前言
目前前端视频剪辑的实现方案主要是两种
1. 主流的方案 前端编辑+后端生成视频
前端提供ui界面,可以调整各种参数配置,例如视频大小,视频位置,图片大小位置等根据需要添加相应的配置信息,然后将这些信息提供给后端服务器生成视频最后再返回给前端。需要后端配合,不算纯前端剪辑。
2. ffmpeg + WebAssembly
这算是一种相对比较纯前端的方案
前端通过wasm使用ffmpeg操作视频,可以查看 ffmpeg.wasm 文档学习使用
优点:
- 可以提供更多专业性的操作。
- 可以兼容更多的视频类型
缺点:
- 我觉得最大的缺点就是慢 。当然可能会有一些很快并且方便的操作,像采用copy模式直接截取视频,这种直接对内存的复制速度还是相当快的,但是如果对于某些帧更加精细的操作,比如查找帧这种情况确实是很慢的例如想要获取某一帧
js
console.time('get frame')
await ffmpeg.exec(["-i","input.mp4", "-vf", `select='eq(n,800)'`, "-vframes", "1", , "output.png"])
console.timeEnd('get frame')
我们对命令进行优化,通过帧率可以计算出一个更好的ss时间
js
console.time('get frame')
await ffmpeg.exec(["-ss", "00:00:32","-i","input.mp4", "-vf", `select='eq(n,0)'`, "-vframes", "1", , "output.png"])
console.timeEnd('get frame')
这或许是巨大的提升,可以看出来有20+倍提升,但是,我们知道一个视频的帧数是极大的,如果用这种方式,其实获取所有帧的时间远远大于视频时间,甚至我如果用requestAnimationFrame,去抓取视频图片,这效率其实有没有可能会比ffmpeg更好吧。
- 内存占用问题
如果对于很大的视频,如果我们想要解析视频,我觉得确实是需要加载到内存中的。不过由于ffmpeg.wasm对于前端开发者来说是一个巨大的黑盒 ,想要优化其中的内存问题并不容易,我觉得更多的可能需要开发者自己去优化ffmpeg的实现方案,自己生成wasm。(例如,上文所说的获取帧,为什么最后要存在output.png中,能不能自己去实现,让这个promise可以直接返回一个图片的unit8array,不做过多的资源占用,以及减少存储和读取所带来的时间消耗?)
其实这个对于纯前端剪辑来说,确实是一个巨大的瓶颈,我们能做的只有尽可能优化代码,及时的释放空间去减少带来的内存占用
- 资源大小问题
其实我觉得这个不太算问题,主要是wasm文件体积较大。这个其实可以通过service worker或者indexdb等方案缓存起来。
mp4box+WebCodecs API+mp4-muxer+opfs
mp4box
解析MP4文件的第三方npm库 mp4box - npm 或者风痕大大的@webav/mp4box.js - npm对mp4box进行了ts类型标注,在该方案实现上也看了不少风痕大大的博客Blog List | 风痕 · 術&思以及他的webav开源项目。
WebCodecs API
为 web 开发者提供了对视频流的单个帧和音频数据块的底层访问能力。这对于那些需要完全控制媒体处理方式的 web 应用程序非常有用WebCodecs API - Web API | MDN。 这个
mp4-muxer
mp4-muxer - npm用于视频合成
opfs
源私有文件系统 (OPFS)是作为文件系统 API 的一部分提供的一个存储端点。它是页面所属的源专用的,并且不像常规文件系统那样对用户可见。它提供了对一种特殊类型文件的访问能力,这种文件经过高度性能优化,并提供对其内容的原地写入访问特性。
优点:
- 相比ffmpeg更快。
- 相比ffmpeg有更好的内存控制。
缺点:
- 目前仅支持解析mp4文件
- 部分帧会解析失败
创建muxer
用来合成MP4文件
js
const muxer = new Muxer({
target: fileSystemWritableFileStreamTarget,
video: {
codec: 'avc',
width: clipStore.width,
height: clipStore.height,
frameRate: clipStore.frameRate
},
audio: {
codec: 'aac',
numberOfChannels: audioBuffer?.numberOfChannels ?? 1,
sampleRate: audioBuffer?.sampleRate ?? 48000
},
fastStart: false
})
视频处理
创建一个encoder,用来编码视频
js
const videoEncoder = new VideoEncoder({
output: async (chunk, meta) => {
muxer.addVideoChunk(chunk, meta)
muxNumber++;
if (muxNumber === framesCount) {
muxer.finalize();
await writableStream.close()
console.log('over');
}
},
error: e => console.error(e)
});
videoEncoder.configure({
codec: 'avc1.4D0032',
width: clipStore.width,
height: clipStore.height,
hardwareAcceleration: 'prefer-software'
});
mp4 文件经过 mp4box解析以及onReady获取信息构建decoder
js
// 分块加载mp4文件
const chunkSize = 1024 * 1024 * 10;
let start = 0;
const resolveVideo = async () => {
const end = start + chunkSize;
const chunk = video.source.file.slice(start, end);
const buffer = await chunk.arrayBuffer()
//@ts-ignore
buffer.fileStart = start
decoderFile.appendBuffer(buffer as MP4ArrayBuffer)
start = end;
if (start < video.source.file.size) {
await resolveVideo()
}
};
await resolveVideo()
// 构建decoder
const init: VideoDecoderInit = {
output: (v) => {
// do something 其实就是将帧信息以及编辑信息重组生成新的图片frame
...
// 生成新帧 frame传递给encoder编码
const frame = new VideoFrame(ibmp, { timestamp: cts * count })
videoEncoder.encode(frame, { keyFrame: count % (clipStore.frameRate * 10) === 0 })
frame.close()
},
error: (e) => {
console.log(e);
}
}
videoDecoder = new VideoDecoder(init)
decoderFile.onReady = async (info) => {
const videoTrack = info.videoTracks[0];
const video_track = decoderFile.getTrackById(videoTrack.id);
console.log(info, video_track);
// 获取帧率
const frameRate = Math.ceil(1000 / ((info.duration / info.timescale) * 1000 / videoTrack.nb_samples))
let description
for (const entry of video_track.mdia.minf.stbl.stsd.entries) {
// @ts-ignore
const box = entry.avcC || entry.hvcC || entry.vpcC || entry.av1C;
if (box) {
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
box.write(stream);
description = new Uint8Array(stream.buffer.slice(8)); // Remove the box header.
}
}
config = {
codec: videoTrack.codec.startsWith("vp08") ? "vp8" : videoTrack.codec,
codedWidth: info.videoTracks[0].track_width,
codedHeight: info.videoTracks[0].track_height,
description,
}
videoDecoder.configure(config)
}
js
// 解析sample
const sample = file.getTrackSample(tid, j)
const type = sample.is_sync ? "key" : "delta";
needAddMux = false
// sample转换成EncodedVideoChunk,进而可以被VideoDecoder进行解码
const chunk = new EncodedVideoChunk({
type: type,
timestamp: sample.cts,
duration: sample.duration,
data: sample.data
});
decoder.decode(chunk)
音频处理
其实对前端来说通过canvas或者OffscreenCanvas 处理图像生成帧还是比较简单的,对于音频就感觉比较抽象了,这边也是通过crunker库crunker - npm进行一些音频的操作,(这里有优化空间)
将音频数据转化成AudioBuffer
js
// 目前没看到什么比较好的处理mp4音频的方案,或许可以用ffmpeg提取音频?
// 提取MP4音频
const audioContext = new AudioContext()
const buffer = await video.source.file.arrayBuffer()
const info = await audioContext.decodeAudioData(buffer)
// 提取MP3音频
crunker.fetchAudio('...')
AudioBuffer转化成AudioData
js
// 注意双声道处理
const audioData = new AudioData({
// 当前音频片段的时间偏移
timestamp: 0,
// 双声道
numberOfChannels: audioBuffer.numberOfChannels,
// 帧数,就是多少个数据点,因为双声道,前一半左声道后一半右声道,所以帧数需要除以 2
numberOfFrames: length / audioBuffer.numberOfChannels,
// 48KHz 采样率
sampleRate: audioBuffer.sampleRate,
// 通常 32位 左右声道并排的意思,更多 format 看 AudioData 文档
format: 'f32-planar',
data: combinedData,
});
创建AudioEncoder并且解析AudioData
js
const encoder = new AudioEncoder({
output: (chunk) => {
muxer.addAudioChunk(chunk)
},
error: console.error,
});
encoder.configure({
// AAC 编码格式
codec: 'mp4a.40.2',
sampleRate: audioBuffer.sampleRate,
numberOfChannels: audioBuffer.numberOfChannels,
});
// // 编码原始数据对应的 AudioData
encoder.encode(audioData);
// 这里要用flush不然解析不全
await encoder.flush()
完整流程图
存在问题
主要问题,对于不同帧率的视频混剪,可能会存在音画不同步问题,这需要一定抽帧和补帧的操作。
目前demo的git地址
只是个玩具,跑通了流程,存在许多功能需要补足 github.com/ChenNianYa/...