m3u8在线可视化编辑解决方案(视频在线编辑工具)

505 阅读8分钟

写在前面

最近遇到一个场景,给你一段视频的URL,需要对视频进行剪切,拼接,重新生成一个新的视频。 其实之前是有一套视频编辑方案的, 但是比(shi)较(zai)粗(la)糙(kua)。就想着优化一套纯前端的展示方案。

回放编辑历史形态.png 由于业务里的视频是腾讯云生成的回放,后端可以借助腾讯云的服务来生成新的视频,简单的说,这个场景需要前端提供给用户可视化的编辑能力,告诉后端剪切、拼接的时间段,排列顺序。

鉴于这个场景比较有趣,所以用原生js写了一套,第三方库用到了lodash和腾讯TCPlayer sdk,可放心食用

npm i m3u8-clip

,有类似场景的朋友可以在任意框架中体验。(我们项目是react,附react版demo,其他框架同理)

下面会对这个场景进行结构拆分,详解0-1的过程,及代码如何安装使用。

demo.gif

以上是成品录屏, 在线体验DEMO -> 点我跳转 github 地址 偶尔需要科学

场景抽象

image.png

结构分析

以绘声绘影,Imovie设计来看, 一个视频编辑需要包括预览区和用户编辑区,这里我们的预览区要完成播放原始视频,和编辑后的视频功能。编辑区, 是生成用户对视频切分合并的结果。

也就是说, 参与剪切和预览的, 是同一段视频, 这里剪切并不是真正的剪切,而是处理新老视频的时间映射, 让用户觉得自己在剪切,真正的剪切是在提交给服务端,调用腾讯云的服务。

这里由于是在线编辑,拿到的不是具体的xxx.mp4, 而是视频流的索引和内容, 不能使用常用的视频处理库,比如ffmpeg.js等,去操作文件本身,所以想逐帧展示视频不显示, 这里的解决方案是截取部分时刻的视频截图,来让用户对视频的把握更加精准。所以还需要打开一个看不见的video标签, 来播放无声视频,这个看不到的video的功能, 就是跳转到对应时刻, 完成截图,以实现用户拖拽指针时候, 实时展示视频的对应帧截图。

区域功能
预览区1.能播放原始视频
2.根据用户编辑的结果, 展示剪切后的视频
3.展示剪切后视频的播放进度
编辑区1.展示指针,用户通过指针选取某一帧视频方便剪切
2.剪切,删除,顺序调整
3.撤回,重做
4.编辑提交
隐藏区1.承载一个无声 不可见的video
2.用户拖动指针时,实时生成视频的截图, 方便用户选取帧

image.png

分步实现, 贴代码

image.png

整体代码虽然撸了600行,但是不要慌,这里面大部分还是注释

首先所有 _ 开头的函数一般是供内部使用的, 可以不看, 有bug那是我的问题。

面向过程,来看这600行做了写啥。

init = () => {
        // 检查是否存在TCPlayer
        if (!this._checkTcPlayer()) {
            console.error("=======Video Clip init fail========")
            console.error("=======there is no TCPlayer in window==========")
            return false
        }
       
        // 初始化一个透明静音的video
        this._generateBlindVideo()
        this._initBlindVideo()
        // 检查是否需要UI
        if (this._checkNeedUI()) {
            // UI相关准备
            this._generateUI()
        } 
    }

第一步检测环境, 环境不行那是真的不行。这里如果没有TCPlayer,去看提供的react-demo, 找public里的TCPlayer的引入, 就几段script沾一沾, 这里坑都踩过了,我们项目跟开源这套的TCPlayer版本不一样, 腾讯在维护过程中没有向下兼容, 导致脱敏抽离时,用最新版TCPlayer全是问题, 反正腾讯也不给打钱, 安心吐槽。

    _generateBlindVideo = () => {
        const { blindVideoElId } = this.state;
        if (document.getElementById(blindVideoElId)) {
            return document.getElementById(blindVideoElId)
        } else {
            const _blindVideoContainer = document.createElement('video')
            _blindVideoContainer.id = blindVideoElId
            _blindVideoContainer.style.width = '940px'
            _blindVideoContainer.style.height = '528px'
            _blindVideoContainer.style.zIndex = '-999'
            if (!this.state.showVideo) {
                _blindVideoContainer.style.opacity = '0'
                _blindVideoContainer.style.pointerEvents = 'none'
            }
            document.body.appendChild(_blindVideoContainer)
            return _blindVideoContainer
        }
    }

这里是生成不可见无声视频的代码, 原生js操作dom不再赘述。唯一需要注意的是, 视频的长宽高影响到截图的比例,需要特殊处理的改这里的代码。

// 初始化提取截图的视频容器
    _initBlindVideo = () => {
        const that = this;
        const blindVideoEl = document.getElementById(this.state.blindVideoElId)
        this.blindVideo = new window.TCPlayer(blindVideoEl, {
            ...this.state.videoSrc,
            autoplay: false,
            live: false,
            controls: true,
            volume: 0,
            width: that.state.videoWidth, //视频的显示宽度,请尽量使用视频分辨率宽度
            height: that.state.videoHeight, //视频的显示高度,请尽量使用视频分辨率高度
        })
        this.blindVideo.on('seeked', () => {
            that.seekedTime++
        })
        this.blindVideo.on('loadedmetadata', () => {
            that.state.fullTime = this.blindVideo.duration()
            that._setEndTag(that.state.fullTime)
            // 视频的元数据加载完成 只会执行一次
            let { result } = that.state
            if (!result.length) {
                let resultData = [{
                    start: 0,
                    end: that.state.fullTime,
                    content: '',
                    startPic: '',
                    endPic: '',
                }]
                that.addStep(resultData)
                result = that.state.result
            // 先加入4个缓冲的图片, 丰富分段内容
            let sectionLong = Math.floor((result[0].end - result[0].start) / 5)
            const extraSnapList = []
            for (let i = 1; i <= this.state.initSnapNumber; i++) {
                extraSnapList.push({
                    time: that.state.result[0].start + (sectionLong * i) + "",
                    cb: function() {
                        that.snapBuffer[that.state.result[0].start + (sectionLong * i) + ""] = that._screenshotVideo()
                    }
                })
            }

            let waitSnapList = [].concat(extraSnapList, [{
                    time: result[0].start,
                    cb: function() {
                        that.state.result[0].startPic = that._screenshotVideo()
                    }
                }, {
                    time: result[0].end,
                    cb: function() {
                        that.state.result[0].endPic = that._screenshotVideo()
                        if (that._checkNeedUI())  {
                            that._generateVideoPieces()
                        }
                        that.setPreview()
                    }
                }])
                that._initBlindVideoScreenSnap(waitSnapList)
            }
        })
        this.blindVideo.src(this.state.videoSrc)
    }

为了方便的操作video,这里初始化了TCPlayer, loadedmetadata钩子是视频加载好的意思, waitSnapList是需要截图的时间和截好图之后的回调,因为截图本身是个异步的过程, 需要1.跳转对应时刻, 2.等待视频加载好 3.完成截图,用base64触发回调。

下面详细说说操作视频跳转到对应时刻并截图,这个流程怎么写。

    getScreenByTs = throttle((time, cb) => {
        this._getScreenByTs(time)
        .then(res => {
            cb && cb(this._screenshotVideo())
        })
        .catch(err => {
            cb && cb("err")
        })
    }, 300)
     _getScreenByTs = (time) => {
        return new Promise((resolve, reject) => {
            const _seekedTime = this.seekedTime
            const videoEl = document.querySelector(`#${this.state.blindVideoElId} video`)
            if (videoEl) { videoEl.currentTime = Number(time) }
            let exec = true
            // 超时逻辑
            setTimeout(() => {
                exec = false
                this.seeking = false
                reject('超时...')
            }, 5000)
            // 持续监听seeked变化
            let listenSeekedTimeChange = () => {
                if (this.seekedTime > _seekedTime) {
                    // 跳转视频time完成, 开始截图
                    this.seeking = false
                    resolve(this.seekedTime)
                } else {
                    exec && window.requestAnimationFrame(listenSeekedTimeChange)
                }
            }
            this.seeking = true
            listenSeekedTimeChange()
        })
    }

主要看_getScreenByTs, 这里的promise要配合video的seeked钩子使用,video的seeked钩子是跳转对应时刻后会触发回调,在上面初始化时,监听了这个钩子,并且seekedTime++

        this.blindVideo.on('seeked', () => {
            that.seekedTime++
        })

这样截图promise只需要在触发时,记录一下当前seekedTime, 然后轮训seekedTime的变化, 这里我没有用setTimeout, 用了requestAnimationFrame, 带了私货, 最近琢磨写游戏, requestAnimationFrame是h5游戏的基石了。调用间隔一般是16毫秒,高刷屏会更低... 只要5秒之内, 轮训到seekedTime的变化,那就是视频跳转并且加载好, 可以截图了。 下面说说截图。

    _screenshotVideo = () => {
        const videoEl = document.querySelector(`#${this.state.blindVideoElId} video`)
        let _canvas = document.createElement('canvas')
        _canvas.width = this.state.videoWidth
        _canvas.height = this.state.videoHeight
        let ctx = _canvas.getContext('2d')
        ctx.drawImage(videoEl, 0, 0, _canvas.width, _canvas.height)
        let _screen = _canvas.toDataURL()
        return _screen
    }

截图非常简单, 创建个canvas, video搁进去,咔嚓, toDataURL就出来了。

以上步骤, 我们完成了一个看不见的video, 任意时刻都能吐一个截图出去。

下面说说用户的编辑区。

image.png

generateUI就是用原生js写了一个dom区域, 用来渲染切分好的视频区段,同时这里要渲染一个指针,用来让用户拖拽,选取视频的时刻,同时展示视频在这个时刻的截图。

这里我是用原生JS写的,方便后端同学或者做毕设的朋友使用。定制化的业务需求,needUI=false,就可以关闭UI功能, 只使用主要逻辑功能。

这里对JS和CSS基本功要求比较高,比如指针滑动到某个位置,要计算出滑动的百分比计算视频时间,需要知道他滑动到了哪个元素上。注释已经很努力了,如果想改造加群问我也行。

 /**
                 * 回归计算视频的真实时间,相对麻烦一些, 需要考虑红色针所在的位置, 计算offset 红色指针, 再计算隐藏区域的宽度, 相加
                 *                               指针
                 *  ┏━━━━━━━━━━━━━━━━━━━┓┃┏━━━━━━━┃━━━━━━━━━━━━┓
                 *  ┃ scroll hide area  ┃┃┃ video ┃  visible   ┃
                 *  ┗━━━━━━━━━━━━━━━━━━━┛┃┗━━━━━━━┃━━━━━━━━━━━━┛
                 *  author by murongqimiao@live.cn 有问题可以沟通
                 */

总之我们要渲染出一个编辑区, 让用户能够拖拽指针,选择时刻。

下面开始处理视频

    // 从xx:xx分割视频
    slipVideo = (ts) => {
        this._saveScrollArea()
        // console.log(ts, this.state)
        let aimVideoIndex = this.state.result.findIndex(v => v.start < Number(ts) && v.end > Number(ts) )
        // console.log("aimVideoIndex", aimVideoIndex)
        if (aimVideoIndex < 0) return
        let newResultData = [].concat(this.state.result.slice(0, aimVideoIndex),
        {
            ...this.state.result[aimVideoIndex],
            end: ts,
            endPic: this.curPreviewSrc
        },
        {
            ...this.state.result[aimVideoIndex],
            start: ts,
            startPic: this.curPreviewSrc
        }, 
        this.state.result.slice(aimVideoIndex + 1, this.state.result.length))
        this.addStep(newResultData)
        this._generateVideoPieces()
    }

其实处理视频反而非常简单, 就是生成一个数组, 这个数组要包括这个视频区段的开始时间,结束时间,和开始结束的截图。

同理,更改视频顺序也非常简单

    // 修改video位置或删除恢复
    changeVideo = (handle, index) => {
        this._saveScrollArea()
        let index1 = index
        let index2 = 0
        switch (handle) {
            case 'remove':
                this.state.result[index].delete = !this.state.result[index].delete 
                const fullTime = this.state.result.filter(v => !v.delete).reduce((a, b) => {return a + (b.end - b.start) }, 0)
                this._setEndTag(fullTime)
                break;
            case 'left':
                if (index1 === 0) { return false }
                index2 = index - 1
                this.state.result.splice(index2,1, ...this.state.result.splice(index1, 1, this.state.result[index2])) // 交换位置
                break;
            case 'right':
                if (index1 === this.state.result.length -1) { return false }
                index2 = index + 1
                this.state.result.splice(index2,1, ...this.state.result.splice(index1, 1, this.state.result[index2])) // 交换位置
                break
            default:
        }
        this._generateVideoPieces()
        this.setPreview()
    }

这里交换位置我用的是

ARR.splice(index2,1, ....ARR.splice(index1, 1, ARR[index2]))

反正就是能交换, 看不懂的话自己跑一遍试试就行。

对了, 还有撤回, 编辑了半天不能撤回那可太寂寞了。

    // 写入覆盖
    _coverStore = (newArr) => {
        const { updateCallback } = this.state
        let { save, step } = this.store
        let needSave = save.slice(0, step + 1)
        needSave.push(newArr)
        this.store.save = needSave
        this.store.step++
        updateCallback && updateCallback()
    }

    // 读取某一步
    _loadStore = (index) => {
        const { updateCallback } = this.state
        if (!index && index !== 0) {
            index = this.store.step
        }
        this.store.step = index
        this.state.result = this.store.save[index]
        this._generateVideoPieces()
        this.setPreview()
        updateCallback && updateCallback()
        return this.store.save[index]
    }

思路是来两个私有方法记录和读取某一步骤的数据结构, 写入, 步骤++

敏感操作的时候, 执行一下写入方法就好。

现在编辑区整体搞定了, 用户可以生成一个JSON结构,这个结构长这个样子

[{
    start: 200,
    end: 300,
}, {
    start: 20,
    end: 100
},
... 
]

这个数据结构就是编辑好的结构, 告诉我们从哪个时刻播放到哪个时刻,再从哪个时刻播放到哪个时刻

下面我们来搞定预览功能。

预览功能的思路是, 播放时, 记录用户播放了哪些区段, 监听timeUpdate, 只要超过了这个区段, 马上跳转到下个区段给用户看。

首先, 把预览视频播放的timeUpdate时间绑定好, 每次timeUpdate,一定要执行onPreview函数,绑定好视频的停止跟跳转

   // 初始化编辑区域的video
    _videoClip = new VideoClip({
      src: videoInfo.url, // 视频地址
      showVideo: false,
      el: document.getElementById('video-control'), // 编辑区的dom容器id
      updateCallback: this.whenClipVideoUpdate // 每次裁剪的回调
    })
    _videoClip.init()
    _videoClip.onPreview(globalPlayer.currentTime(), (handle, time) => {
          if (handle === 'playNext') {
            ctrlPlayer('jump', time)
            // 跳播
          } else if (handle === 'playFinish') {
            // 停止
            ctrlPlayer('play')
          }
        })

然后计算什么时候跳转什么时候暂停,接着展示虚假的播放时间来迷惑用户(不是)

按照用户播放的时间展示, 而不是真实视频的时间

    // listen video play 播放视频时候, 根据视频的真实时间,计算什么时候需要跳转
    onPreview = (currentTime, cb) => {
        const { currentIndex } = this.previewInfo
        this.previewInfo.realTime = currentTime
        let timeUserPlayed = 0
        let canPlayResult = this.state.result.filter(v => !v.delete)
        if (!canPlayResult[currentIndex] || !canPlayResult[currentIndex].end) { 
            // console.log("超出可播放的区间")
            cb("playFinish")
        }
        // 计算之前播放过的区块, 来计算当前用户展示的播放时间
        timeUserPlayed = canPlayResult.filter((v, index) => currentIndex > index).reduce((a, b) => { return a + (Math.round((b.end - b.start) * 100) / 100) }, 0)
        
        const { start, end } = canPlayResult[currentIndex]
        if (currentTime >= start && currentTime < end) {
            // 当前区段还未播放完毕 不需要进行切换
            this.previewInfo.currentTime = timeUserPlayed + Math.round(currentTime - start)
        } else {
            // console.log("=========检测区段===========", this.previewInfo, canPlayResult, currentIndex)
            if (canPlayResult[currentIndex + 1]) {
                // 存在下个可播放的区段,可以跳转播放
                cb("playNext",canPlayResult[currentIndex + 1].start)
                this.previewInfo.currentIndex = this.previewInfo.currentIndex + 1
            } else {
                // 不存在下个区段意味着播放完成
                // console.log("=========不存在下个区段===========", this.previewInfo, canPlayResult, currentIndex)
                cb("playFinish")
            }
        }
    }

当然还需要一些回归算法, 来计算用户播放的时间,和实际视频的时间, 做好映射关系, 方便用户拖拽播放时间或者前进后退时候, 让预览视频播放指定的位置。

    // 用户显示时间回归真实视频时间
    _getRealTimeByUserTime = (userTime) => {
        let sum = 0
        let realTime = 0
        let currentIndex = 0
        this.state.result.filter(v => !v.delete).forEach((item, index) => {
            let itemDuration = item.end - item.start
            if (userTime > (sum + itemDuration)) {  // 已经播放过了这个区间
                sum += itemDuration
            } else { // 还未播放到这个区间
                realTime = (userTime - sum) + item.start
                currentIndex = index
            }
        })
        return {
            currentIndex,
            realTime
        }
    }

剩下就没有了, 只需要在用户保存的时候, 把产出的数据结构扔给后端, 让后端找腾讯云视频合成API勾兑去吧!

使用流程

有框架的话, 啥框架无所谓

// 引入m3u8-clip的videoClip使用
import { VideoClip } from "m3u8-clip"
// 样式同样需要引入, 如果你需要展示编辑区的话, 编辑区使用原生js实现, 不需要考虑框架问题
import "m3u8-clip/index.css"

然后初始化编辑模块

    // 初始化编辑区域的video
    _videoClip = new VideoClip({
      src: videoInfo.url, // 视频地址
      showVideo: false,
      el: document.getElementById('video-control'), // 编辑区的dom容器id
      updateCallback: this.whenClipVideoUpdate // 每次裁剪的回调
    })
    _videoClip.init()

预览区自己写, 但是要绑定播放的时间

globalPlayer.on('timeupdate', () => {
      console.log("---------timeupdate-----------")
       if (_videoClip) {
          that.toChangeKey({'currentTime': _videoClip._getTime(_videoClip.previewInfo.currentTime)})
          that.toChangeKey({'realTime': _videoClip._getTime(_videoClip.previewInfo.realTime)})
          that.toChangeKey({'fullTime': _videoClip._getTime(_videoClip.previewInfo.fullTime)})
        }

        _videoClip.onPreview(globalPlayer.currentTime(), (handle, time) => {
          if (handle === 'playNext') {
            ctrlPlayer('jump', time)
            // 跳播
          } else if (handle === 'playFinish') {
            // 停止
            ctrlPlayer('play')
          }
        })
    })

如果这些都不想写, react版本demo直接抄那也行。github.com/murongqimia…

结语

整体开发时间在3PD, 如果加上PM跟UE,工时翻倍,如果UI验收,那还要再翻 [doge]

本人计划在年内写完一个前端游戏,放一个目前进展在这里,有兴趣的同学欢迎关注。

纯JS无框架。

QQ20220502-215451-HD_.gif