写在前面
最近遇到一个场景,给你一段视频的URL,需要对视频进行剪切,拼接,重新生成一个新的视频。 其实之前是有一套视频编辑方案的, 但是比(shi)较(zai)粗(la)糙(kua)。就想着优化一套纯前端的展示方案。
由于业务里的视频是腾讯云生成的回放,后端可以借助腾讯云的服务来生成新的视频,简单的说,这个场景需要前端提供给用户可视化的编辑能力,告诉后端剪切、拼接的时间段,排列顺序。
鉴于这个场景比较有趣,所以用原生js写了一套,第三方库用到了lodash和腾讯TCPlayer sdk,可放心食用
npm i m3u8-clip
,有类似场景的朋友可以在任意框架中体验。(我们项目是react,附react版demo,其他框架同理)
下面会对这个场景进行结构拆分,详解0-1的过程,及代码如何安装使用。
以上是成品录屏, 在线体验DEMO -> 点我跳转 github 地址 偶尔需要科学
场景抽象
结构分析
以绘声绘影,Imovie设计来看, 一个视频编辑需要包括预览区和用户编辑区,这里我们的预览区要完成播放原始视频,和编辑后的视频功能。编辑区, 是生成用户对视频切分合并的结果。
也就是说, 参与剪切和预览的, 是同一段视频, 这里剪切并不是真正的剪切,而是处理新老视频的时间映射, 让用户觉得自己在剪切,真正的剪切是在提交给服务端,调用腾讯云的服务。
这里由于是在线编辑,拿到的不是具体的xxx.mp4, 而是视频流的索引和内容, 不能使用常用的视频处理库,比如ffmpeg.js等,去操作文件本身,所以想逐帧展示视频不显示, 这里的解决方案是截取部分时刻的视频截图,来让用户对视频的把握更加精准。所以还需要打开一个看不见的video标签, 来播放无声视频,这个看不到的video的功能, 就是跳转到对应时刻, 完成截图,以实现用户拖拽指针时候, 实时展示视频的对应帧截图。
区域 | 功能 |
---|---|
预览区 | 1.能播放原始视频 2.根据用户编辑的结果, 展示剪切后的视频 3.展示剪切后视频的播放进度 |
编辑区 | 1.展示指针,用户通过指针选取某一帧视频方便剪切 2.剪切,删除,顺序调整 3.撤回,重做 4.编辑提交 |
隐藏区 | 1.承载一个无声 不可见的video 2.用户拖动指针时,实时生成视频的截图, 方便用户选取帧 |
分步实现, 贴代码
整体代码虽然撸了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, 任意时刻都能吐一个截图出去。
下面说说用户的编辑区。
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无框架。