mp4-h264-重编码
本仓库演示了使用网络API对mp4-h264视频文件进行纯重编码的过程,并深入描述了其工作原理。
动机
随着YouTube、TikTok、Reels等平台的普及,视频编辑变得越来越重要。浏览器终于具备了进行适当视频编辑的所有基本要素,如WebCodec、文件系统API、硬件加速的canvas以及Web Workers支持等。
但是……网上几乎没有文档或可用的代码来将这些功能整合在一起。要实现这一点需要150-210行代码,其中大部分都充满了陷阱和不寻常的编码模式。本仓库和README旨在提供一个独立的可工作基线,解释所有步骤,以便您可以在此基础上进行构建(因为仅仅重新编码视频文件并不是温暖您公寓的最有效方式:p)。
在我的非M1 MacBook Pro上使用Chrome重新编码20秒的视频需要10秒。而使用Final Cut Pro完成相同任务需要8秒。因此,性能处于同一水平,应该足以满足生产应用的需求。
注意:到目前为止,本仓库仅实现了视频轨道,音频轨道仍需要解决,但应该是可行的。
仓库内容
您可以在线试用:
仓库包含以下文件:
- 可运行的页面。它们包含重新编码的代码
- mp4box.html是推荐的方法,它使用mp4box.js进行解复用和复用。
- mp4wasm.html作为wasm集成的示例,但速度稍慢,时长处理精度略低。它使用mp4box.js进行解复用,使用mp4wasm进行复用。
- 依赖项。它们是非压缩的,便于编辑和调试以了解其工作原理。
- 示例视频文件。我使用Minecraft Replay Mod录制,并用Quicktime进行裁剪。
- mob_head_farm_5s.mp4
- mob_head_farm_10s.mp4
- mob_head_farm_20s.mp4
工作原理
重新编码视频包括四个步骤:
- 从mp4文件格式中提取样本(解复用),使用mp4box.js完成。
- 将样本解码为像素,使用WebCodec window.VideoDecoder完成。
- 将像素编码为样本,使用WebCodec window.VideoEncoder完成。
- 将样本放入mp4文件(复用)
- 保存文件并显示进度
让我们逐一查看所有步骤:
解复用
我们从一个简单的<input type="file" />
开始,以便从文件系统中选择视频文件。
<p>
选择要重新编码的视频:
<input type="file" onchange="reencode(event.target.files[0])"></input>
<div id="progress"></div>
</p>
我们使用FileReader API将文件转换为ArrayBuffer。
var reader = new FileReader();
reader.onload = function() {
// this.result是ArrayBuffer
};
reader.readAsArrayBuffer(file);
mp4box的API设计为无需一次性将整个视频文件加载到内存中,因为视频文件可能非常大。他们设计了带有appendBuffer方法的API,然后刷新以处理它。
const mp4boxInputFile = MP4Box.createFile();
// ...
reader.onload = function() {
this.result.fileStart = 0; // 缓冲区在文件中的实际位置
mp4boxInputFile.appendBuffer(this.result);
mp4boxInputFile.flush();
};
当第一个缓冲区被刷新后,mp4box解析头部并调用onReady,传递解析后的信息。我们可以设置它来提取第一个视频轨道并开始提取过程。
mp4boxInputFile.onReady = async (info) => {
const track = info.videoTracks[0];
mp4boxInputFile.setExtractionOptions(track.id, null, {nbSamples: Infinity});
mp4boxInputFile.start();
};
这将一次性给我们所有已加载缓冲区的样本。不幸的是,mp4box.js不支持在处理完所有样本时的回调,而是将它们分批处理,每批1000个。因此,我们将使用{nbSamples: Infinity}
来禁用这种任意分组。
mp4boxInputFile.onSamples = async (track_id, ref, samples) => {
for (const sample of samples) {
// 对每个样本进行处理
}
};
解码
VideoDecoder使用类似于mp4box的API,但这次不是因为它不将整个文件加载到内存中。视频压缩的工作原理是,某些帧是完整编码的图像,而某些帧只是另一帧的增量,因为视频中的图像通常是附近帧的小变化。我说"附近"是因为存在所谓的"B帧"(双向帧),它们是前一帧和后一帧的增量。
因此,API的设置方式是,您一次发送多个帧进行解码,它会为所有有足够信息解码的帧同时调用输出函数。最终,它会解码相同数量的帧,顺序相同,但不是一帧输入一帧输出的顺序。
我们首先创建一个带有输出回调的解码器对象。我们可以使用createImageBitmap来获取帧的所有像素。我们需要调用frame.close()来帮助垃圾回收,因为图像非常大,浏览器供应商不想依赖JavaScript引擎的默认垃圾回收。
decoder = new VideoDecoder({
async output(inputFrame) {
const bitmap = await createImageBitmap(inputFrame);
inputFrame.close();
},
error(error) {
console.log(error);
}
});
我们将所有样本包装在EncodedVideoChunk中,并提供一些需要从mp4文件格式调整为浏览器所需通用格式的选项,然后将它们输入解码器。
for (const sample of samples) {
decoder.decode(new EncodedVideoChunk({
type: sample.is_sync ? "key" : "delta",
timestamp: sample.cts * 1_000_000 / sample.timescale,
duration: sample.duration * 1_000_000 / sample.timescale,
data: sample.data
}));
}
到目前为止,这些API虽然有点复杂,但还算通用。现在我们需要进行一些特定于h264的操作。为了解码视频帧,h264有一系列称为PPS(图像参数集)和SPS(序列参数集)的配置选项。它们的内容并不特别有趣,你可以在这里阅读相关内容。我们需要找到它们并将其提供给解码器。
mp4文件的内部结构由许多嵌套的box组成,这些box包含类似JSON的值(都以二进制格式编码)。box trak.mdia.minf.stbl.stsd.avcC
包含我们需要的PPS和SPS配置。因此,我们使用以下代码片段来提取它并将其传递给编码器。
let description;
const trak = mp4boxInputFile.getTrackById(track.id);
for (const entry of trak.mdia.minf.stbl.stsd.entries) {
if (entry.avcC || entry.hvcC) {
const stream = new DataStream(undefined, 0, DataStream.BIG_ENDIAN);
if (entry.avcC) {
entry.avcC.write(stream);
} else {
entry.hvcC.write(stream);
}
description = new Uint8Array(stream.buffer, 8); // 移除box头部
break;
}
}
decoder.configure({
codec: track.codec,
codedHeight: track.video.height,
codedWidth: track.video.width,
description,
});
编码
我们再次创建一个编码器并对其进行配置。我在某处找到了编解码器avc1.4d0034
,它似乎可以正常工作。它的完整描述似乎是MPEG-4 AVC Main Level 5.2
,如果你好奇的话,AVC是h264的同义词。
encoder = new VideoEncoder({
output(chunk) {
// 将编码后的块放入mp4文件
},
error(error) {
console.error(error);
}
});
encoder.configure({
codec: 'avc1.4d0034',
width,
height,
hardwareAcceleration: 'prefer-hardware',
bitrate: 20_000_000,
});
在解码器输出回调中,我们首先用刚解码的位图创建一个VideoFrame对象。
const outputFrame = new VideoFrame(bitmap, { timestamp: inputFrame.timestamp });
如果我们不做任何处理,所有帧都将被编码为增量帧。这在文件大小方面是最优的,但是在视频中快进会非常慢,因为我们需要从头开始,一个个应用增量帧,直到获得位图。另一方面,将每一帧都设为关键帧会大大增加视频大小。在5秒视频的例子中,文件大小从13MB增加到63MB!
实践中人们似乎使用的启发式方法是每隔几秒插入一个关键帧。据报道,YouTube每2秒插入一个关键帧,QuickTime屏幕录制每4秒插入一个,有些人提到可以长达10秒。以下是每2秒插入一个关键帧的实现。
let nextKeyFrameTimestamp = 0;
// ...
const keyFrameEveryHowManySeconds = 2;
let keyFrame = false;
if (inputFrame.timestamp >= nextKeyFrameTimestamp) {
keyFrame = true;
nextKeyFrameTimestamp = inputFrame.timestamp + keyFrameEveryHowManySeconds * 1e6;
}
encoder.encode(outputFrame, { keyFrame });
同样,我们需要进行手动内存管理,我们需要关闭输入和输出帧以释放它们给垃圾收集器回收。幸运的是,我们只是在进行单帧的流式处理,所以内存管理很简单。当我们想要将多个输入视频帧合成为单个输出帧时,这可能会更具挑战性。
inputFrame.close();
outputFrame.close();
封装(MP4Box.js)
我们首先创建一个空的mp4文件。在第一个解码帧期间,我们将获得创建所有元数据所需的信息。
const mp4boxOutputFile = MP4Box.createFile();
当我们编码一帧时收到的块不包含实际的字节,我们首先需要使用copyTo
将它们复制到Uint8Array中。这是我第一次看到这样的API,而不是直接有一个data属性,我不太理解为什么做出这样的选择。
output(chunk, metadata) {
let uint8 = new Uint8Array(chunk.byteLength);
chunk.copyTo(uint8);
如果你还记得之前,我们需要那些SPS和PPS配置,它们又回来了!它们被打包在一个description
对象中。我们可以将其提供给编码器构造函数。或者我们可以省略它,让编码器提供一个。这个原型的想法是重新编码视频,所以我们不会使用原始视频中的那个,而是让编码器给我们一个。
编码器提供description
的方式有两种,如果你使用默认设置,它会为所有关键帧传递一个包含mp4文件格式所需description
的metadata对象作为第二个参数。这在我们的情况下是最简单的,也是我们在这里使用的方式。如果你对另一种方式感兴趣,请向下滚动查看mp4wasm版本。
const description = metadata.decoderConfig.description;
WebCodec API的所有时间都以秒的分数表示。这对人类来说是最自然的表示方式,但对计算机来说并非最佳。视频通常以24、25、30或60帧每秒的速率编码。所以一帧的持续时间分别是1/24 = 0.0416666
、1/25 = 0.04
、1/30 = 0.03333
、1/60 = 0.01666
。它们没有很好的二进制表示。
因此,视频文件格式的创建者提出了时间刻度的概念。他们将一秒重新映射到一个具有更好属性的数字。常见的时间刻度是90000
,它是24 * 25 * 30 * 5
。所以一帧的持续时间现在是90000/24 = 3750
、90000/25 = 3600
、90000/30 = 3000
、90000/60 = 1500
。所有这些数字都可以很容易地用整数表示。
const timescale = 90000;
我们终于有了创建与视频轨道相关的元数据所需的所有信息。我们只需要做一次来初始化mp4文件的头部。
if (trackID === null) {
trackID = mp4boxOutputFile.addTrack({
width,
height,
timescale,
avcDecoderConfigRecord: description,
});
}
为了获取帧的持续时间,我们可以将其从原始持续时间和其时间刻度转换为输出视频的时间刻度。我相信预期的方式是我们将持续时间传递给decode()函数,但在我的测试中,只有一些解码帧的持续时间不为null。然后,当我们对其进行编码时,我们也可以给出持续时间,但这次在另一端它总是被设置为0。这整个流程对时间戳来说工作得很好。我们可以通过在旁边创建一个数组来解决这个问题。由于帧是按顺序处理的,我们可以安全地推入和移出。
let sampleDurations = [];
// ...
for (const sample of samples) {
sampleDurations.push(sample.duration * 1_000_000 / sample.timescale);
// ...
const sampleDuration = sampleDurations.shift() / (1_000_000 / timescale);
最后,我们可以将编码后的帧(也称为Sample)添加到mp4文件中。
mp4boxOutputFile.addSample(trackID, uint8, {
duration: sampleDuration,
is_sync: chunk.type === 'key',
});
一旦所有内容都处理完毕(我将在下一节解释如何检测到这一点),我们就可以让浏览器下载该文件。
mp4boxOutputFile.save("mp4box.mp4");
复用(mp4wasm)
我们是否使用wasm来加快速度?答案是否定的。
视频编码中计算密集型的部分是解码和编码特定帧。所有这些都是在浏览器的WebCodec API内完成的(编码/解码函数)。在实践中,这是如此关键的性能考虑,以至于有专门的硬件来实现这些操作,而WebCodec API让我们可以使用它。
第二个性能考虑是分配和复制数据。由于我们处理的是非常大的文件,每次内存分配和复制都会累加起来。在这方面,WASM增加了开销,每次函数调用都需要数据跨越到wasm并返回。因此,虽然wasm上下文中的单个操作可能更快,但根据我的测试,总体上最终会慢几个百分点。不过,在性能数量级方面,两者是等效的。
- mp4box.html:以118 fps的速度在10.15秒内重新编码了1202帧
- mp4wasm.html:以116 fps的速度在10.36秒内重新编码了1202帧(略微慢一点)
复用和解复用部分在计算上相当便宜,头部只有几千字节,所以不是性能问题,而对于数据部分,它主要是读/写一个很小的头部,然后将其余部分视为与编码器/解码器交互的不透明blob。
你可能好奇代码文件大小是否会影响任何东西。实际上并不会。wasm文件大小为42kb,wasm js包装器未压缩时为37kb,而mp4box.js(包含两者都使用的read和wasm不使用的write)未压缩时为257kb。
那么,如果不是为了性能,我们为什么要使用wasm?答案是代码重用。
有很多用C++编写的高质量且经过实战检验的视频操作软件可以重用。在mp4wasm的情况下,他们重用了minimp4C++库。
现在性能考虑已经解决了,让我们来看看它是如何工作的。首先,我们需要创建一个输出mp4文件。它需要一个类文件对象,具有seek和write功能。我们可以用几行代码实现一个不断增长的Uint8Array。之后,我们可能会想使用文件系统API。
mp4wasmOutputFile = createVirtualFile();
function createVirtualFile(initialCapacity) {
// ...
let contents = new Uint8Array(initialCapacity);
return {
contents: function () { ... },
seek: function (offset) { ... },
write: function (data) { ... },
};
}
为了计算每帧的持续时间,在mp4box.js的实现中,我们重用了现有文件的持续时间,并在两个时间刻度之间进行转换。mp4wasm采用了不同的方法,它要求每秒帧数,并为每帧分配相同的持续时间。这是一个不错的启发式方法,但并不完美。在测试文件中,最后一帧比其他帧稍长一些,所以我们会损失那一小部分持续时间,实际上并不是什么大问题,但确实有所不同。
为了计算fps,你需要小心。在头部框中有5个表示持续时间或时间刻度的值。实际上,只有2个值似乎被视频播放器使用,而其他3个并没有被视频编码器准确写入。我也不敢保证我发现的这两个在我测试的几个视频中有效的值总是可靠的。
- ✗ mvhd.duration
- ✗ mvhd.timescale
- √ trak.samples_duration
- √ trak.mdia.mdhd.timescale
- ✗ trak.mdia.mdhd.duration
一个更可靠的技术可能是读取第一帧的持续时间并以此计算fps。但这是另一天的练习,因为正确的解决方案是重用每帧的持续时间,而不是使用fps。
const duration = (trak.samples_duration / trak.mdia.mdhd.timescale) * 1000;
const fps = Math.round((track.nb_samples / duration) * 1000);
有了这些,我们就有了创建复用器所需的所有信息。我还不了解fragmentation、sequential和hevc的作用。这组配置与mp4box.js默认输出的相同。
mp4wasmMux = mp4wasm.create_muxer(
{
width: track.track_width,
height: track.track_height,
fps,
fragmentation: true,
sequential: false,
hevc: false,
},
调用wasm的方式基本上是在JavaScript中编写C代码。我们首先调用malloc在堆中分配一些内存并获取指向它的指针。我们将编码后的帧复制到wasm堆中,并使用指针和大小调用wasm代码。然后一旦完成,我们就从堆中释放内存。
const p = mp4wasm._malloc(uint8.byteLength);
mp4wasm.HEAPU8.set(uint8, p);
mp4wasm.mux_nal(mp4wasmMux, p, uint8.byteLength);
mp4wasm._free(p);
一旦wasm端完成将编码帧转换为mp4框,它就会调用这个js函数,给出指令来查找和写入数据到我们在开始时创建的文件。
function mux_write(data_ptr, size, offset) {
mp4wasmOutputFile.seek(offset);
const data = mp4wasm.HEAPU8.subarray(data_ptr, data_ptr + size);
return mp4wasmOutputFile.write(data) !== data.byteLength;
}
PPS和SPS的故事继续。这次我们不是从元数据第二个参数读取它。相反,我们将编码器的格式设置为annexb
。它的作用是将PPS和SPS编码在编码帧blob中。它不再只是<size><encoded frame>
,而是现在采用了一种称为NALU(网络抽象层单元)的结构。我花了一个小时试图阅读规范,但并不是很有用。相反,一个例子胜过千言万语。
00 00 00 01 <0b11100000 | 7> <PPS>
00 00 00 01 <0b11100000 | 8> <SPS>
00 00 00 01 <0b11100000 | x> <不相关>
00 00 00 01 <0b11100000 | 1 or 5> <视频>
每个块以00 00 00 01
开头,然后是一个8位标志,接着是内容。可惜没有关于大小的信息,所以我们需要遍历所有字节寻找标记,如果要彻底,还需要正确地对内容进行反转义。一旦提取出PPS和SPS,我们需要将其重新打包成avcC mp4盒子格式。
幸运的是,mp4wasm为我们完成了所有这些工作,我们只需要确保在将数据传递给它之前使用annexb
。我想稍微详细说明一下,因为在意识到可以直接移除这个选项并使用元数据之前,我不得不执行所有相同的步骤才能将其发送给mp4box.js。
avc: {
format: 'annexb',
},
最后,当所有样本都处理完毕后,我们需要完成复用器的工作,并使用一些HTML技巧强制浏览器下载文件。
mp4wasm.finalize_muxer(mp4wasmMux);
const data = mp4wasmOutputFile.contents();
let url = URL.createObjectURL(new Blob([data], { type: "video/mp4" }));
let anchor = document.createElement("a");
anchor.href = url;
anchor.download = "mp4wasm.mp4";
anchor.click();
结束与日志记录
令人惊讶的是,这个练习中最具挑战性的方面之一是知道何时处理完成以保存文件并报告进度。在传统程序中,你按顺序执行操作,文件的最后一行会在上面所有内容完成后执行。当操作是异步的时候,每个操作完成时都会有一个回调。但在这里,你在一端为每帧调用一个函数,而另一端为每帧调用另一个函数,但它们之间没有明确的联系。
我最初尝试的方法是将解码/输出链接转换为Promise,但不幸的是,由于增量编码,我们需要发送大量帧才能解码它们。所以,我不能只等待一个完成就发送下一个。
我发现了一个flush()函数,当队列中的所有元素都处理完毕时,它会返回一个promise。这听起来不错,但有一个问题,如果你在编码增量帧后刷新,它会将其编码为完整帧,导致文件大小膨胀。
所以你需要做的是发送所有解码指令,刷新解码器以确保所有解码步骤都已完成并发送编码指令,然后在编码器上调用flush,以在所有编码指令执行完毕时得到通知。之后,你可以关闭所有内容并保存文件。
for (const sample of samples) {
decoder.decode(/* ... */);
}
await decoder.flush();
await encoder.flush();
encoder.close();
decoder.close();
理想情况下,你希望有几个帧正在解码,然后编码,并保持解码和编码以相同的速度并行进行,这样就不会在内存中积累大量解码后的帧(每个都是完整的位图)。遗憾的是,在当前设置下,解码被优先处理,编码进行得非常缓慢,直到解码结束,然后所有编码一次性完成,正如你在这个视频中看到的。
https://user-images.githubusercontent.com/197597/210186198-be412584-5988-4db5-b936-2a2b84aa0a6e.mov
我还没有一个好的策略来实现理想场景。我们可以将报告分为两个阶段。在这种设置下,要估计剩余时间会很困难,因为两者以不同的速率进行,而且随时间变化不稳定。
// 初始化
const startNow = performance.now();
let decodedFrameIndex = 0;
let encodedFrameIndex = 0;
function displayProgress() {
// 每帧更新DOM会增加约20%的性能开销。
// return; // 取消注释以进行基准测试。
progress.innerText =
"正在解码帧 " + decodedFrameIndex + " (" + Math.round(100 * decodedFrameIndex / track.nb_samples) + "%)\n" +
"正在编码帧 " + encodedFrameIndex + " (" + Math.round(100 * encodedFrameIndex / track.nb_samples) + "%)\n";
}
// VideoDecoder::output
decodedFrameIndex++;
displayProgress();
// VideoEncoder::output
encodedFrameIndex++;
displayProgress();
// 完成
const seconds = (performance.now() - startNow) / 1000;
progress.innerText =
"已编码 " + encodedFrameIndex + " 帧,耗时 " + (Math.round(seconds * 100) / 100) + "秒,速度为 " +
Math.round(encodedFrameIndex / seconds) + " fps";
编码结果
文件名 | 原始大小 | Chrome* | Safari† | Firefox‡ |
---|---|---|---|---|
mob_head_farm_5s.mp4 | 14.6 MB | 16.7 MB | 11.5 MB | 8.9 MB |
mob_head_farm_10s.mp4 | 27.8 MB | 33.5 MB | 22.8 MB | 17.7 MB |
mob_head_farm_20s.mp4 | 49.8 MB | 66.7 MB | 45.6 MB | 35.3 MB |
* Chrome 版本 125.0.6422.142(官方构建)(arm64)
† Safari 版本 17.5 (19618.2.12.11.6)
‡ Firefox Nightly 127.0a1 (2024-04-24) (64位)
未解决问题
- 按时间戳排序
VideoFrame
。VideoDecoder回调output
不保证按呈现顺序调用。这可能导致编码后的输出看起来有些卡顿,因为帧是无序编码的。这在Safari中是一个突出的问题。
有用的工具
- 这个工具显示mp4的所有元数据,是mp4box.js的一部分。这一直是我在这个项目中的首选工具!
- 如果你正在调试NAL、SPS和PPS,这个工具将解析用annexb编码的原始比特流。
- 第一个mp4工具缺少的一个功能是显示每个盒子的二进制数据,这有时很有用。这个工具显示了这些信息,但没有样本视图,这很方便。
- 在某个时候,我想确保两个输出在位级别上完全相同,我使用了这个在线十六进制编辑器,它非常有用