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

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

废话不多,先上视频。

ffmpeg编辑器


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

界面效果

【JS】纯web端使用ffmpeg实现的视频编辑器_第1张图片

开发过程

初始化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")
   },

开发编辑器

主要项目结构
【JS】纯web端使用ffmpeg实现的视频编辑器_第2张图片

组件代码

progress-dialog.vue







resource-item.vue







time-item.vue







tool-tab.vue







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







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})`
}

你可能感兴趣的:(视频编辑器,wasm,前端,ffmpeg,前端,javascript)