0


【JS】纯web端使用ffmpeg实现的视频编辑器

【JS】纯web端使用ffmpeg实现的视频编辑器

废话不多,先上视频。

ffmpeg编辑器

这是一个纯前端实现的视频编辑器,用的ffmpeg的wasm,web框架用的vue3。界面手撸。

界面效果

在这里插入图片描述

开发过程

初始化vue3框架

用vite的vue3模板创建一个就可以。

安装的依赖

package.json


    "@ffmpeg/core": "^0.11.0",
    "@ffmpeg/ffmpeg": "^0.11.5",
    "dayjs": "^1.11.6",
    "less": "^4.1.2",
    "less-loader": "^11.1.0",

创建页面和路由,用的vue-router,简单的添加一下。
router.js

{
     path: "/ffmpeg/app",
       name: "ffmpeg-app",
       component: () => import("../view/ffmpeg/app.vue")
   },

开发编辑器

主要项目结构
在这里插入图片描述

组件代码

progress-dialog.vue

<template>
<div class="dialog-content">
  <div class="dialog-box">
    <div class="header">
      {{ props.title }}
    </div>
    <div class="content">
      <div class="progress">
        <div class="box">
          <div class="value" >
            {{ props.number }}/{{ props.count }}
          </div>
          <div class="percent" :style="progress()">
            {{ props.number }}/{{ props.count }}
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
</template>

<script setup>
const props = defineProps({
  title: { type: String, required: false,default:'加载中' },
  number:{type:Number,required:true,default:0},
  count:{type:Number,required:true,default:100},
})
const progress = () => {
  let percent = props.number / props.count
  percent = percent > 1 ? 1 : percent
  percent = percent * 460
  // console.log('百分比',percent)
  const style = {
    clip:'rect(0px, ' + percent  + 'px, 20px, 0px)'
  }
  // console.log('style',style)
  return style
}
</script>

<style lang="less" scoped>
.dialog-content{
  position: fixed;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  background-color: rgba(0,0,0,0.1);
  backdrop-filter: blur(10px);
  display: flex;
  justify-content: center;
  align-items: center;
}
.dialog-box{
  width: 500px;
  //height: 100px;
  background-color: #fff;
  box-shadow: 0 0 10px #222222;
  border-radius: 5px;
  .header{
    height: 30px;
    line-height: 30px;
    text-align: center;
    font-size: 14px;
    font-weight: bold;
    border-bottom: 1px solid #999;
  }
  .content{
    display: flex;
    align-items: center;
    justify-content: center;
    height: 50px;
  }
}
.progress{
  height: 20px;
  margin-left: 20px;
  margin-right: 20px;
  border:1px solid #222;
  width: 100%;
  position: relative;
  .box{
    position: relative;
  }
  .percent{
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    background-color: #dc3562;
    color:#fff;
    height: 20px;
    transition: all 0.1s;
    width: 100%;
    text-align: center;
    clip:rect(0px,0px,20px,0px);
  }
  .value{
    position: absolute;
    top: 1px;
    left: 0;
    right: 0;
    text-align: center;
    height: 20px;
    line-height: 20px;
    color:#000;
  }
}
</style>

resource-item.vue

<template>
  <div class="line" :title="file.toString()">
    <div class="icon">
      <img v-if="file.cover" :src="file.cover"/>
      <span v-else>无图</span>
    </div>
    <div class="info">
      <div class="play" @click="handlePlay">播</div>
      <div class="del" @click="handleDel">删</div>
      <div class="file-type">
        <div class="mp4" v-if="file.ext === 'mp4'">mp4</div>
        <div class="mp3" v-if="file.ext === 'mp3'">mp3</div>
      </div>
      <div class="filename" :title="file.name">{{ file.name }}</div>
      <div class="k-box-flex">
        <div class="size k-flex-1">{{ file.durationStr }}</div>
<!--        <div class="size k-flex-1">{{ file.sizeStr }}</div>-->
        <div class="date">{{ file.sizeStr }}</div>
<!--        <div class="date">{{ file.lastModifiedDateStr }}</div>-->
      </div>
    </div>
  </div>
</template>

<script setup>
import ResourceFile from '@/view/ffmpeg/app/type/file.js'
import { toRef } from 'vue'
const emit = defineEmits(['del','play'])
const props = defineProps({
  file: { type: Object, required: true,default:() => new ResourceFile() }
})
const file = toRef(props, 'file')
const handleDel = () => {
  console.log("点删除")
  emit('del')
}
const handlePlay = () => {
  console.log("点播放")
  emit('play')
}
</script>

<style lang="less" scoped>
@import "../index.less";
.line{
  user-select: none;
  box-sizing: border-box;
  border: @resource-border-color solid 1px;
  height: 50px;
  display: flex;
  .icon{
    width: 50px;
    box-sizing: border-box;
    border: 1px dashed #999;
    display: flex;
    justify-content: center;
    align-items: center;
    margin-right: 10px;
    img{
      max-width: 100%;
      max-height: 100%;
    }
  }
  .info{
    flex: 1;
    position: relative;
    padding-top: 5px;
    .del{
      position: absolute;
      right: 5px;
      top: 5px;
      width: 15px;
      height: 15px;
      line-height: 15px;
      text-align: center;
      background-color: palevioletred;
      font-size: 10px;
      color:#fff;
      cursor: pointer;
      border-radius: 2px;
    }
    .play{
      position: absolute;
      right: 25px;
      top: 5px;
      width: 15px;
      height: 15px;
      line-height: 15px;
      text-align: center;
      background-color: palevioletred;
      font-size: 10px;
      color:#fff;
      cursor: pointer;
      border-radius: 2px;
    }
    .file-type{
      position: absolute;
      right: 40px;
      top: 5px;
      height: 15px;
      line-height: 15px;
      text-align: center;
      font-size: 10px;
      border-radius: 2px;
      padding:0 5px;
      .mp4{
        color:#fff;
        background-color: #07b3c9;
      }
      .mp3{
        color:#fff;
        background-color: #d9b608;
      }
    }
    .filename{
      font-size: 6px;
      width: 220px;
      height: 20px;
      overflow: hidden;
      white-space:nowrap;/*不显示的地方用省略号...代替*/
      text-overflow:ellipsis;/* 支持 IE */
    }
    .size{
      font-size: 6px;
      text-align: left;
    }
    .date{
      font-size: 6px;
      text-align: right;
      margin-right: 5px;
    }
  }
}
</style>

time-item.vue

<template>
  <div class="line"
       draggable="true"
       :title="props.name"
       :style="{
      width:props.width + 'px',
      'margin-left':props.left + 'px',
      background:props.color
  }">
    {{ props.name }}
  </div>
</template>

<script setup>
const props = defineProps({
    name: { type: String, required: false,default:'文件名' },
    color: { type: String, required: false,default:'' },
    left:{type:Number,required:true,default:0},
    width:{type:Number,required:true,default:10},
})
</script>

<style lang="less" scoped>
@import "../index.less";
.line{
  cursor: move;
  height: 20px;
  white-space: nowrap; /*不显示的地方用省略号...代替*/
  text-overflow: ellipsis; /* 支持 IE */
  line-height: 20px;
  padding-left: 10px;
  box-sizing: border-box;
  border-bottom: @border-color 1px solid;
  background-color: rgba(248, 235, 174, 0.78);
  user-select: none;
  overflow: hidden;
  &:last-child{
    border-bottom: none;
  }
}
</style>

tool-tab.vue

<template>
  <div class="tab">
    字母
  </div>
  <div class="tool-content">
    <input type="text" v-model="text">
    <button @click="handleCreate">添加</button>
    <button @click="handleRender">渲染</button>
  </div>
</template>

<script setup>
import {ref} from 'vue'
const text = ref('')
const emit = defineEmits(['create','render'])
const handleCreate = () => {
    console.log("添加",text.value)
    emit('create', text.value)
    text.value = ''
}
const handleRender = () =>{
    console.log('渲染')
    emit('render')
}
</script>

<style scoped>

</style>

class代码

file.js

import dayjs from 'dayjs'
export default class ResourceFile {
    constructor(file) {
        this.file = file
        this.key = dayjs().unix() + '_' +file.name
        this.name = file.name
        this.size = file.size
        this.sizeStr = file.size
        this.type = file.type
        this.lastModified = file.lastModified
        this.lastModifiedDate = file.lastModifiedDate
        this.lastModifiedDateStr = file.lastModifiedDate
        this.webkitRelativePath = file.webkitRelativePath
        // 外加
        // 扩展名
        this.ext = ''
        // this.baseName = dayjs().format('YYYYMMDDHHmmss') + '_' + file.name
        this.baseName = this.key
        this.fileType = ''
        this.mime = ''
        this.cover = ''
        this.url = ''
        this.durationStr = ''
        this.duration = ''
        this.bitRate = ''
        this.majorBrand = ''
        this.encoder = ''
        this.resolution = ''
        this.fps = ''
        this.videoInfo = ''
        this.audioType = ''
        this.audioRate = ''
        this.audioInfo = ''
        this.setDate()
    }

    setUrl(url) {
        this.url = url
    }
    setCover(url){
        this.cover = url
    }
    isVideo() {
        return this.mime.indexOf('video') !== -1
    }
    isAudio() {
        return this.mime.indexOf('audio') !== -1
    }
    setMedia() {
        this.fileType ='media'
        this.mime = this.file.type.split(',')[0]
        this.ext = this.name.split('.')[this.name.split('.').length - 1]
    }
    setFont() {
        this.fileType ='font'
        this.mime = 'font'
        this.ext = this.name.split('.')[this.name.split('.').length - 1]
    }
    getFile() {
        return this.file
    }
    getFSName() {
        return this.baseName
    }
    setDate() {
        this.lastModifiedDateStr = dayjs(this.lastModifiedDate).format('YYYY-MM-DD HH:mm:ss')
    }
    setInfo(info) {
        this.durationStr = info.durationStr
        this.duration = info.duration
        this.bitRate = info.bitRate
        this.majorBrand = info.majorBrand
        this.encoder = info.encoder
        this.resolution = info.resolution
        this.fps = info.fps
        this.videoInfo = info.videoInfo
        this.audioType = info.audioType
        this.audioRate = info.audioRate
        this.audioInfo = info.audioInfo
    }
    setSize(type = ''){
        let str = ''
        console.log('size',type,this.size,this.size/1024)
        switch (type) {
            case 'AUTO':
                let G = this.size/1024/1024/1024
                let M = this.size/1024/1024
                let K = this.size/1024
                console.log(G,M,K)
                if(G > 1){
                    str = G.toFixed(2) + 'GB'
                }else if(M >1) {
                    str = M.toFixed(2) + 'MB'
                }else if(K > 1) {
                    str = K.toFixed(2) + 'KB'
                }else{
                    str = this.size + 'B'
                }
                break
            case 'B':
                str = this.size + 'B'
                break
            case 'KB':
                str = (this.size/1024).toFixed(2) + 'KB'
                break
            case 'MB':
                str = (this.size/1024/1024).toFixed(2) + 'MB'
                break
            case 'GB':
                str = (this.size/1024/1024/1024).toFixed(2) + 'GB'
                break
            default:
                str = this.size + 'B'
        }
        this.sizeStr = str
    }

    toString() {
        let str = ''
        str += '文件名:' + this.name +'\r\n'
        str += '时长:' + this.durationStr +'\r\n'
        str += '时长:' + this.duration +'\r\n'
        str += '比特率:' + this.bitRate +'\r\n'
        str += '格式:' + this.majorBrand +'\r\n'
        str += '编码器:' + this.encoder +'\r\n'
        str += '分辨率:' + this.resolution +'\r\n'
        str += '帧率:' + this.fps +'\r\n'
        str += '视频信息:' + this.videoInfo +'\r\n'
        str += '音频类型:' + this.audioType +'\r\n'
        str += '采样率:' + this.audioRate +'\r\n'
        str += '音频信息:' + this.audioInfo +'\r\n'
        str += '文件唯一标识:' + this.key +'\r\n'
        str += '文件大小:' + this.size +'\r\n'
        str += '文件大小:' + this.sizeStr +'\r\n'
        str += '文件类型:' + this.type +'\r\n'
        str += '最后修改:' + this.lastModified +'\r\n'
        str += '最后修改时间:' + this.lastModifiedDate +'\r\n'
        str += '最后修改时间:' + this.lastModifiedDateStr +'\r\n'
        str += 'webkit路径:' + this.webkitRelativePath +'\r\n'
        // 外加
        str += '基本名:' + this.baseName +'\r\n'
        str += '文件类型:' + this.fileType +'\r\n'
        str += 'mime信息:' + this.mime +'\r\n'
        str += '扩展名:' + this.ext +'\r\n'
        str += '封面:' + this.cover +'\r\n'
        return str
    }
}

line.js

import { randColor } from '@/utils/color.js'
import { uuid } from '@/utils/key.js'
/**
 * 时间揍单个数据
 */
export default class Line{
    leftTime = 2
    constructor(file) {
        // 时间轴唯一
        this.key = uuid()
        this.name = file.name
        this.type = ''
        this.duration = file.duration
        this.left = 0
        this.width = file.duration * this.leftTime
        this.color = randColor()
        // 原始资源文件名
        this.fileKey = file.key
        this.font = ''
    }

    setMedia() {
        this.type = 'media'
    }
    setText() {
        this.type = 'text'
    }
    setFont(path) {
        this.font = path
    }
    getFont() {
        return this.font
    }
    getLeftSecond() {
        return parseInt((this.left/this.leftTime))
    }
    getFile() {
        return '/'+this.fileKey
    }
}

主要的代码

ffmpeg.js

import { clearEmpty } from '@/utils/string.js'
import { createFFmpeg , fetchFile } from '@ffmpeg/ffmpeg'
import dayjs from 'dayjs'
/**
 * ======================================
 * 说明:需要用到的ffmpeg操作封装一下
 * 作者: YYDS
 * 文件: ffmpeg.js
 * 日期: 2023/3/29 11:08
 * ======================================
 */

export default class Ffmpeg {
    static ffmpeg = ''
    // 进度输出
    static progress = {
        /*
         * ratio is a float number between 0 to 1.
         */
        ratio:0,
        time:0
    }
    // 日志输出
    static message = []
    // 资源目录
    static resourceDir = 'resource'
    // 缓存目录
    static tmpDir = 'mediaTmp'
    // 渲染完的文件名
    static renderFileName = 'render.mp4'
    static async instance  () {
        this.ffmpeg = createFFmpeg( {
            log: true
        })
        await this.ffmpeg.load();
        this.ffmpeg.FS('mkdir',this.resourceDir)
        this.ffmpeg.FS('mkdir',this.tmpDir)
        // 设置日志
        this.ffmpeg.setLogger(({ type, message }) => {
            // console.log('日志',type, message);
            /*
             * type can be one of following:
             *
             * info: internal workflow debug messages
             * fferr: ffmpeg native stderr output
             * ffout: ffmpeg native stdout output
             */
            if(type === 'fferr') {
                this.message.push(clearEmpty(message))
            }
        });
        // 设置进度
        this.ffmpeg.setProgress((progress) => {
            this.progress.ratio = progress.ratio * 100
            this.progress.time = progress.time
            console.log('进度',progress);
            console.log('进度',this.progress);
            this.updateProgress(this.progress)
        })
    }
    static updateProgress(progress) {
        console.log('进度更新了',progress)
    }
    static clearMessage() {
        this.message = []
    }
    static  loadFile(file){
        // console.log('加载的文件',file)
        return new Promise(async (resolve) => {
            const filePath = '/' + this.resourceDir + '/' + file.getFSName()
            const fileData = await fetchFile(file.getFile())
            console.log('fileData',fileData)
            this.ffmpeg.FS( 'writeFile' , filePath , fileData );
            if(file.mime){
                let url = URL.createObjectURL( new Blob( [fileData.buffer] , { type: file.mime } ) );
                file.setUrl(url)
            }
            if(file.isVideo()) {
                this.readCover(filePath).then(url => {
                    file.setCover(url)
                    // console.log('file',file)
                    console.log('全部日志',this.message)
                    file.setInfo(this.fileInfoFilter(this.message))
                    this.clearMessage()
                    resolve()
                })
            }else if(file.isAudio()) {
                this.readInfo(filePath).then(() => {
                    console.log('全部日志',this.message)
                    file.setInfo(this.fileInfoFilter(this.message))
                    this.clearMessage()
                    resolve()
                })
            }else{
                resolve()
            }
        })

    }

    static readFile(filePath) {
        return new Promise(async (resolve) => {
            const data = this.ffmpeg.FS( 'readFile' , filePath );
            let url = URL.createObjectURL( new Blob( [data.buffer] , { type: 'video/mp4' } ) );
            resolve(url)
        })
    }

    static async readCover (path)  {
        return new Promise(async (resolve, reject) => {
            const fileName = dayjs().valueOf()+'.jpg'
            const tmpPath = '/'+this.tmpDir +'/'+ fileName
            let cmd = '-i ' + path + ' -ss 1 -f image2 ' + tmpPath
            let args = cmd.split(' ')
            console.log('args',args)
            this.ffmpeg.run(...args).then(() => {
                // console.log(this.readDir(this.tmpDir))
                const data = this.ffmpeg.FS( 'readFile' , tmpPath );
                // console.log("文件数据",data)
                const fileUrl = URL.createObjectURL( new Blob( [data.buffer] , { type: 'image/jpeg' } ) );
                // console.log('文件url',fileUrl)
                resolve(fileUrl)
            })
        })
    }
    static async readInfo (path)  {
        return new Promise(async (resolve, reject) => {
            const fileName = dayjs().valueOf()+'.jpg'
            let cmd = '-i ' + path
            let args = cmd.split(' ')
            console.log('args',args)
            this.ffmpeg.run(...args).then(() => {
                resolve()
            })
        })
    }
    static  readDir (path = '')  {
        let list = this.ffmpeg.FS( 'readdir' , '/' + path )
        console.log('list',list)
        return list
    }
    static messageGetDataCutLastR(message,key) {
        let str = message.substring(message.indexOf(key) + key.length)
        return str.replace(':','')
    }
    static  fileInfoFilter (messageList) {
        const data = {
            durationStr:'',
            duration:'',
            bitRate:'',
            majorBrand:'',
            encoder:'',
            resolution:'',
            fps:'',
            videoInfo:'',
            audioType:'',
            audioRate:'',
            audioInfo:''
        }
        messageList.forEach(message => {
            if(message.indexOf('Duration') !== -1) {
                let duration = message.substring(message.indexOf('Duration:') + 'Duration:'.length ,message.indexOf('Duration:')+ 'Duration:'.length + '00:00:20.48'.length)
                console.log("时长",duration)
                let time = duration.split(':')
                console.log('time',time)
                data.durationStr = duration
                data.duration = parseInt(time[0])*120 + parseInt(time[1]) *60 +parseFloat(time[2])
            }
            if(message.indexOf('Duration') !== -1 && message.indexOf('bitrate') !== -1) {
                let bitRate = this.messageGetDataCutLastR(message,'bitrate')
                console.log("比特率",bitRate)
                data.bitRate = bitRate
            }
            if(message.indexOf('major_brand') !== -1) {
                let majorBrand = this.messageGetDataCutLastR(message,'major_brand')
                console.log("格式",majorBrand)
                data.majorBrand = majorBrand
            }
            if(message.indexOf('encoder') !== -1) {
                let encoder = this.messageGetDataCutLastR(message,'encoder')
                console.log("编码器",encoder)
                data.encoder = encoder
            }
            if(message.indexOf('Video:') !== -1) {
                let key = 'Video:'
                let arr = message.substring(message.indexOf(key) + key.length)
                let arrList =  arr.split(',')
                console.log("视频信息",arr)
                console.log("分辨率",arrList[2].substring(0,arrList[2].indexOf('[')))
                data.resolution=arrList[2].substring(0,arrList[2].indexOf('['))
                arrList.forEach(v=>{
                    if(v.indexOf('fps') !== -1) {
                        console.log("帧率",v)
                        data.fps=v
                    }
                })
                data.videoInfo=arr
            }
            if(message.indexOf('Audio:') !== -1) {
                let key = 'Audio:'
                let arr = message.substring(message.indexOf(key) + key.length)
                let arrList =  arr.split(',')
                console.log("音频信息",arr,)
                console.log("音频格式",arrList[0])
                console.log("音频采样率",arrList[1])
                data.audioType=arrList[0]
                data.audioRate=arrList[1]
                data.audioInfo=arr
            }
        })
        console.log('信息',data)
        return data
    }
    static generateArgs(timelineList) {
        const cmd = []
        console.log('时间轴数据',timelineList)
        console.log("文件1",this.readDir())
        console.log("文件2",this.readDir(this.resourceDir))
        let textCmdList = []
        timelineList.forEach(time => {
            console.log('time',time,time.getLeftSecond())
            if(time.type === 'media') {
                cmd.push('-i /' + this.resourceDir  + time.getFile())
            }
            if(time.type === 'text') {
                // 阶段切换
                // cmd.push('-vf drawtext=fontsize=60:fontfile=\'/' + this.resourceDir +'/' +time.getFont() + '\':text=' + time.name + ':fontcolor=green:enable=lt(mod(t\\,3)\\,1):box=1:boxcolor=yellow')
                // 显示
                cmd.push('-vf drawtext=fontsize=60:fontfile=\'/' + this.resourceDir +'/' +time.getFont() + '\':text=' + time.name + ':fontcolor=green:enable=\'between(t,' + time.getLeftSecond() +','+(time.getLeftSecond() + 6)+')\':box=1:boxcolor=yellow ')
                // 多条
                // textCmdList.push('drawtext=fontsize=60:fontfile=\'/' + this.resourceDir +'/' +time.getFont() + '\':text=' + time.name + ':fontcolor=green:enable=\'between(t,' + time.getLeftSecond() +','+(time.getLeftSecond() + 6)+')\':box=1:boxcolor=yellow')
            }
        })
        // const textCmd = '-vf "' + textCmdList.join(',') + '"'
        // console.log('文字命令',textCmd)
        // cmd.push(textCmd)
        // 添加最后输出文明
        cmd.push(this.renderFileName)
        // 命令生成
        let args = cmd.join(' ')
        args = args.split(' ')
        console.log('命令',args)
        // const cmd = '-i infile -vf movie=watermark.png,colorkey=white:0.01:1.0[wm];[in][wm]overlay=30:10[out] outfile.mp4'
        // const cmd = '-re -i infile -vf drawtext=fontsize=60:fontfile=\'font\':text=\'%{localtime\\:%Y\\-%m\\-%d%H-%M-%S}\':fontcolor=green:box=1:boxcolor=yellow outfile.mp4'
        // let args = cmd.split(' ')
        // console.log('args',args)
        return args
    }

    static async run(args) {
        console.log("运行命令",args)
        await this.ffmpeg.run(...args)
    }
}

index.less

@border-color:#222;
@resource-border-color:#999;
@resource-width:300px;
::-webkit-scrollbar {
  width: 5px;
  height: 10px;
  background-color: #ebeef5;
}
::-webkit-scrollbar-thumb {
  box-shadow: inset 0 0 6px rgba(0, 0, 0, .3);
  -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, .3);
  background-color: #ccc;
}
::-webkit-scrollbar-track{
  box-shadow: inset 0 0 5px rgba(0, 0, 0, 0.2);
  border-radius: 3px;
  background: rgba(255, 255, 255, 1);
}

index.vue

<template>
  <div class="app-container">
    <div class="resource-list">
      <div class="btn-bar">
        <input ref="uploadInput" v-show="false" type="file" multiple @change="changeFile"/>
        <button @click="addFile('media')">添加媒体</button>
        <button @click="addFile('font')">添加字体</button>
        <button @click="addDirectory">添加文件夹</button>
      </div>
      <div class="file-list">
        <resource-item
                draggable="true"
                v-for="(item,index) in mediaList"
                :file="item"
                :key="item.key"
                @play="handlePlay(item)"
                @del="handleDel(index)"
                @dragstart.private="fileDragStart($event,item)"
                @dragend.private="fileDragEnd($event,item)"
                @dblclick="appendFile(item)"
        />
      </div>
    </div>
    <div class="view">
      <div class="window">
        <div class="screen">
          <video :src="previewSrc" controls autoplay></video>
        </div>
        <div class="screen">
          <video :src="renderSrc" controls autoplay></video>
        </div>
      </div>
      <div class="time-line"
           @dragenter="lineDragEnter"
           @dragleave="lineDragLeave"
           @dragover="lineDragOver"
           @drop="lineDropFile">
        <div
          class="line"
          draggable="true"
          v-for="(file,index) in timeLineList"
          :title="file.name"
          :style="{
              width:file.width + 'px',
              'margin-left':file.left + 'px',
              background:file.color
          }"
          :key="'timeLine' + file.key"
          @dragstart="lineDragStart($event,index)"
          @dragend="lineDragEnd"
          @dragenter="lineItemDragEnter($event,index)"
          @dragleave="lineItemDragLeave"
          @dragover="lineItemDragMove"
          @drop.prevent.stop="lineItemDropFile(index)"
        >{{ file.name }}
        </div>
      </div>
      <div class="tool-bar">
        <tool-tab @create="handleCreateText" @render="handleRender"/>
      </div>
    </div>
    <!--    加载弹窗-->
    <progress-dialog :title="progressTitle" v-if="progressVisible" :number="progressNumber" :count="progressCount"/>
    <!--    时间轴示例-->
    <div class="hidden">
        <div id="move" ref="moveBlock">
            {{ nowFile.name }}
        </div>
    </div>
  </div>
</template>

<script setup>
import ResourceFile from '@/view/ffmpeg/app/type/file.js'
import { checkFontFile , checkMediaFile } from '@/view/ffmpeg/app/util.js'
import ft from './ffmpeg.js'
import { reactive , ref } from 'vue'
import ResourceItem from './component/resource-item.vue'
import ToolTab from './component/tool-tab.vue'
import ProgressDialog from '@/view/ffmpeg/app/component/progress-dialog.vue'
ft.instance()
const uploadInput = ref(null)
const previewSrc = ref('')
const renderSrc = ref('')
// 进度条
const progressTitle = ref('')
const progressNumber = ref(0)
const progressCount = ref(100)
const progressVisible = ref(false)
// 媒体资源 图片 视频 音频
const mediaList = reactive([])
// 字体资源
const fontList = reactive([])
// 添加文件
let addType = ''

const addDirectory = () => {
    console.log("未实现")
    alert('未实现')
}
const addFile = (type) => {
  addType = type
  uploadInput.value.click()
}
// 选择文件
const changeFile = function (e) {
  const files = e.target.files
  const mediaLoadList = []
  const fontLoadList = []
  console.log('文件列表',files)
  for ( let i = 0 ; i < files.length ; i++ ) {
    const file = new ResourceFile(files[i])
    console.log('文件',file)
    file.setSize('AUTO')
    if(addType === 'media'){
      if(checkMediaFile(file)){
        file.setMedia()
        mediaLoadList.push(file)
        // mediaList.push(file)
        continue
      }
    }
    if(addType === 'font'){
      if(checkFontFile(file)){
        file.setFont()
        fontLoadList.push(file)
        // fontList.push(file)
      }
    }
  }
    if(addType === 'media'){
        loadMediaFile(mediaLoadList)
    }
    if(addType === 'font'){
        loadFontFile(fontLoadList)
    }

}
// 加载文件
const loadMediaFile = async (list) => {
  console.log('加载文件',list)
  openLoadProgress(list.length,'加载资源文件')
  let i = 0
  for ( const file of list ) {
    await ft.loadFile(file)
    mediaList.push(file)
    i++
    setLoadProgressNumber(i)
  }
  setTimeout(() => {
    closeLoadProgress()
  },100)
}
const loadFontFile = async (list) => {
  console.log('加载文件',list)
  openLoadProgress(list.length,'加载字体文件')
  let i = 0
  for ( const file of list ) {
    await ft.loadFile(file)
    fontList.push(file)
    i++
    setLoadProgressNumber(i)
  }
  setTimeout(() => {
    closeLoadProgress()
  },100)
}

const handlePlay = (file) => {
  console.log("播放文件",file)
  previewSrc.value=file.url
}
const handleDel = (index) => {
  console.log("删除文件",index)
  mediaList.splice(index,1)
}
// 打开进度条
const openLoadProgress = (count,title = '加载中') => {
  progressVisible.value = true
  progressCount.value = count
  progressNumber.value = 0
  progressTitle.value = title
}
// 设置进度条值
const setLoadProgressNumber = (val) => {
  progressNumber.value = val
}
// 关闭进度条
const closeLoadProgress = () => {
  progressVisible.value = false
  progressCount.value = 0
  progressNumber.value = 0
  progressTitle.value = ''
}

import Line from './type/line.js'
// 时间轴
const timeLineList = ref([])
const moveStartPosition = ref({x:0,y:0})
let moveIndex = ''
let moveIn = ''
// 拖动类型
let dragType = 'create'
// 拖动的当前文件
const nowFile = ref({})
// 拖动的dom
const moveBlock = ref( null )
// 是否拖入时间揍
const lineIn = ref( false )
/**
 * 文件列表拖拽开始
 * @param $event
 * @param file
 */
const fileDragStart = ( $event , file ) => {
    console.log( '文件列表拖拽开始' , $event , file )
    dragType = 'create'
    nowFile.value = file
    let width = nowFile.value.duration * 2
    moveBlock.value.style.width = (width > 270 ? 270 : width) + 'px'
    $event.dataTransfer.setDragImage( moveBlock.value , 0 , 0 )
}
/**
 * 文件列表拖拽结束
 * @param $event
 * @param file
 */
const fileDragEnd = ( $event , file ) => {
    console.log( '文件列表拖拽结束' , $event , file )
    lineIn.value = false
}
/**
 * 添加到时间轴最后
 * @param item
 */
const appendFile = (item)=> {
    console.log('双击添加',item)
    const file = new Line(item)
    file.setMedia()
    console.log( 'file' , file )
    timeLineList.value.push( file )
}

/**
 * 时间轴放入
 * @param index
 */
const lineItemDropFile = ( index ) => {
    console.log( '时间轴放入' , index )
    // 放在某个轴上
    if ( dragType === 'create' ) {
        const file = new Line(mediaList[index])
        file.setMedia()
        console.log( 'file' , file )
        timeLineList.value.splice(index,0,file)
    }
    // 移动
    if ( dragType === 'move' ) {
        console.log( '移动' ,moveIndex,index)
        let list = timeLineList.value
        if(moveIndex > index){
            const item = timeLineList.value[moveIndex]
            list.splice(moveIndex,1)
            list.splice(index,0,item)
        }else{
            const item = timeLineList.value[moveIndex]
            console.log('移动',item,list)
            list.splice(moveIndex,1)
            list.splice(index,0,item)
            console.log("移动到后面",list)
        }
        timeLineList.value = list
    }
}
const lineDragStart = ( $event , index ) => {
    console.log( '时间轴拖动开始' , index,$event )
    dragType = 'move'
    moveIndex = index
    moveStartPosition.value.x = $event.pageX
    moveStartPosition.value.y = $event.pageY
}
/**
 * 时间轴拖动结束
 * @param $event
 * @constructor
 */
const lineDragEnd = ( $event ) => {
    console.log( '时间轴拖动结束' , $event )
    console.log('移动了',$event.pageX - moveStartPosition.value.x,$event.pageY-moveStartPosition.value.y)
    timeLineList.value[moveIndex].left+=$event.pageX - moveStartPosition.value.x
    moveStartPosition.value.x = 0
    moveStartPosition.value.y = 0
    moveIndex = ''
}
/**
 * 时间轴内容进入
 * @param $event
 * @constructor
 */
const lineItemDragEnter = ( $event,index ) => {
    console.log( '时间轴进入' , $event )
    $event.preventDefault(); //阻止默认事件
    moveIn = index
}
/**
 * 时间轴内容离开
 * @param $event
 * @constructor
 */
const lineItemDragLeave = ( $event  ) => {
    console.log( '时间轴离开' , $event )
    $event.preventDefault(); //阻止默认事件
    moveIn = ''
}
/**
 * 时间轴内容移动
 * @param $event
 */
const lineItemDragMove = ($event) => {
    console.log("拖拽移动",$event)
    console.log('移动了',$event.pageX - moveStartPosition.value.x,$event.pageY-moveStartPosition.value.y)
}
/**
 * 时间轴进入
 * @param $event
 * @constructor
 */
const lineDragEnter = ( $event ) => {
    console.log( '时间列表进入' , $event )
    $event.preventDefault(); //阻止默认事件
    lineIn.value = true
}

/**
 * 时间轴离开
 * @param $event
 * @param file
 * @constructor
 */
const lineDragLeave = ( $event , file ) => {
    console.log( '时间列表离开' , $event )
    $event.preventDefault(); //阻止默认事件
    lineIn.value = false
}

/**
 * 时间轴阻止默认
 * @param $event
 * @constructor
 */
const lineDragOver = ( $event ) => {
    $event.preventDefault(); //阻止默认事件
}

/**
 * 时间轴放入
 * @param $event
 */
const lineDropFile = ( $event ) => {
    console.log( '时间列表放入' ,dragType, $event )
    // 放在空的地方
    if ( dragType === 'create' ) {
        let file = new Line(nowFile.value)
        file.setMedia()
        console.log( 'file' , file )
        timeLineList.value.push( file )
    }
}

const handleCreateText = (text) => {
    let data = {
        key : '',
        name : text,
        duration : 30,
        left : 0
    }
    const item = new Line(data)
    item.setText()
    item.setFont(fontList[0].getFSName())
    console.log('添加',item,fontList[0].getFSName())
    timeLineList.value.push(item)
}
const handleRender = () => {
    console.log("渲染视频")
    let args = ft.generateArgs(timeLineList.value)
    ft.run( args )
    console.log('ft.progress',ft.progress)
    openLoadProgress(100,'渲染中')
    ft.updateProgress = updateRender
}
const updateRender = (progress) => {
    setLoadProgressNumber(parseInt(progress.ratio))
    if(progress.ratio >= 100) {
        setTimeout(() => {
            closeLoadProgress()
            previewRender()
        },1000)

    }
}
const previewRender = () => {
    ft.readFile(ft.renderFileName).then(res => {
        console.log("文件",res)
        renderSrc.value = res
    })
}
window.ft = ft
</script>

<style lang="less" scoped>
@import "index.less";
.app-container{
  width: 100vw;
  height: 100vh;
  display: flex;
  position: relative;
}
.resource-list{
  display: flex;
  flex-direction:column;
  width: @resource-width;
  height: 100%;
  box-sizing: border-box;
  border-right:  @border-color 1px solid;
  .btn-bar{
    display: flex;
    border-bottom: @border-color 1px solid;
    button{
      flex: 1;
      margin:5px;
    }
  }
  .file-list{
    width: 300px;
    height: 100%;
    overflow-y: auto;
    overflow-x: hidden;
  }
}
.view{
  flex: 1;
  display: flex;
  flex-direction:column;
  .window{
    flex:1;
    box-sizing: border-box;
    border-bottom: @border-color 1px solid;
    display: flex;
    .screen{
      flex: 1;
      box-sizing: border-box;
      display: flex;
      justify-content: center;
      align-items: center;
      &:first-child{
        border-right: @border-color 1px solid;
      }
      video{
        width: 100%;
        max-height: 100%;
        //max-width: 1024px;
        //max-height: 768px;
      }
    }
  }
  .time-line{
    width: calc(100vw - @resource-width);
    height: 300px;
    box-sizing: border-box;
    border-bottom: @border-color 1px solid;
    overflow-x: scroll;
    .line{
      cursor: move;
      height: 20px;
      white-space: nowrap; /*不显示的地方用省略号...代替*/
      text-overflow: ellipsis; /* 支持 IE */
      line-height: 20px;
      padding-left: 10px;
      box-sizing: border-box;
      border-bottom: @border-color 1px solid;
      background-color: rgba(248, 235, 174, 0.78);
      user-select: none;
      overflow: hidden;
      &:last-child{
        border-bottom: none;
      }
    }
  }
  .tool-bar{
    height: 100px;
    box-sizing: border-box;
  }
}

.hidden {
  position: fixed;
  left: 0;
  top: -100px;

  #move {
    min-width: 20px;
    height: 20px;
    background: red;
    border: 1px solid #07b3c9;
    overflow: hidden;
  }
}
</style>

util.js

const filterType = ['audio','video','image']
const fontExt = ['ttc','ttf','fon']
export function checkMediaFile(file) {
    let status = false
    filterType.forEach(type => {
        if(file.type.toLowerCase().indexOf(type) !== -1) {
            status = true
        }
    })
    return status
}

export function checkFontFile(file) {
    if(file.type){
        return false
    }
    let status = false
    let nameSplit = file.name.split('.')
    let fileExt = nameSplit[nameSplit.length-1].toLowerCase()
    fontExt.forEach(type => {
        if(fileExt.indexOf(type) !== -1) {
            status = true
        }
    })
    return status
}

string.js

/**
 * ======================================
 * 说明:string处理
 * 作者:SKY
 * 文件:string.js
 * 日期:2022/11/22 16:30
 * ======================================
 */
export function clearEmpty(val) {
    val = val.replace(' ','')
    if(val.indexOf(' ') !== -1) {
        return clearEmpty( val )
    }else{
        return val
    }
}

key.js

/**
 * 生成UUID
 * @return {string}
 */
export function uuid() {
    return +new Date() + Math.random()*10+ Math.random()*10+ Math.random()*10+ Math.random()*10 + 'a'
}

color.js

/**
 * 随机生成颜色
 */
export function randColor() {
    const r = parseInt(Math.random() * 255)
    const g = parseInt(Math.random() * 255)
    const b = parseInt(Math.random() * 255)
    return `rgb(${r},${g},${b})`
}

本文转载自: https://blog.csdn.net/sky529063865/article/details/129954456
版权归原作者 飘逸者打瞌睡 所有, 如有侵权,请联系我们删除。

“【JS】纯web端使用ffmpeg实现的视频编辑器”的评论:

还没有评论