本仓库演示了使用网络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秒。因此,性能处于同一水平,应该足以满足生产应用的需求。
注意:到目前为止,本仓库仅实现了视频轨道,音频轨道仍需要解决,但应该是可行的。
您可以在线试用:
仓库包含以下文件:
重新编码视频包括四个步骤:
让我们逐一查看所有步骤:
我们从一个简单的<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();
我们首先创建一个空的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");
视频编码中计算密集型的部分是解码和编码特定帧。所有这些都是在浏览器的WebCodec API内完成的(编码/解码函数)。在实践中,这是如此关键的性能考虑,以至于有专门的硬件来实现这些操作,而WebCodec API让我们可以使用它。
第二个性能考虑是分配和复制数据。由于我们处理的是非常大的文件,每次内存分配和复制都会累加起来。在这方面,WASM增加了开销,每次函数调用都需要数据跨越到wasm并返回。因此,虽然wasm上下文中的单个操作可能更快,但根据我的测试,总体上最终会慢几个百分点。不过,在性能数量级方面,两者是等效的。
复用和解复用部分在计算上相当便宜,头部只有几千字节,所以不是性能问题,而对于数据部分,它主要是读/写一个很小的头部,然后将其余部分视为与编码器/解码器交互的不透明blob。
你可能好奇代码文件大小是否会影响任何东西。实际上并不会。wasm文件大小为42kb,wasm js包装器未压缩时为37kb,而mp4box.js(包含两者都使用的read和wasm不使用的write)未压缩时为257kb。
有很多用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个并没有被视频编码器准确写入。我也不敢保证我发现的这两个在我测试的几个视频中有效的值总是可靠的。
一个更可靠的技术可能是读取第一帧的持续时间并以此计算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中是一个突出的问题。一键生成PPT和Word,让学习生活更轻松
讯飞智文是一个利用 AI 技术的项目,能够帮助用户生成 PPT 以及各类文档。无论是商业领域的市场分析报告、年度目标制定,还是学生群体的职业生涯规划、实习避坑指南,亦或是活动策划、旅游攻略等内容,它都能提供支持,帮助用户精准表达,轻松呈现各种信息。
深度推理能力全新升级,全面对标OpenAI o1
科大讯飞的星火大模型,支持语言理解、知识问答和文本创作等多功能,适用于多种文件和业务场景,提升办公和日常生活的效率。讯飞星火是一个提供丰富智能服务的平台,涵盖科技资讯、图像创作、写作辅助、编程解答、科研文献解读等功能,能为不同需求的用户提供便捷高效的帮助,助力用户轻松获取信息、解决问题,满足多样化使用场景。
一种基于大语言模型的高效单流解耦语音令牌文本到语音合成模型
Spark-TTS 是一个基于 PyTorch 的开源文本到语音合成项目,由多个知名机构联合参与。该项目提供了高效的 LLM(大语言模型 )驱动的语音合成方案,支持语音克隆和语音创建功能,可通过命令行界面(CLI)和 Web UI 两种方式使用。用户可以根据需求调整语音的性别、音高、速度等参数,生成高质量的语音。该项目适用于多种场景,如有声读物制作、智能语音助手开发等。
字节跳动发布的AI编程神器IDE
Trae是一种自适应的集成开发环境(IDE),通过自动化和多元协作改变开发流程。利用Trae,团队能够更快速、精确地编写和部署代码,从而提高编程效率和项目交付速度。Trae具备上下文感知和代码自动完成功能,是提升开发效率的理想工具。
AI助力,做PPT更简单!
咔片是一款轻量化在线演示设计工具,借助 AI 技术,实现从内容生成到智能设计的一站式 PPT 制作服务。支持多种文档格式导入生成 PPT,提供海量模板、智能美化、素材替换等功能,适用于销售、教师 、学生等各类人群,能高效制作出高品质 PPT,满足不同场景演示需求。
选题、配图、成文,一站式创作,让内容运营更高效
讯飞绘文,一个AI集成平台,支持写作、选题、配图、排版和发布。高效生成适用于各类媒体的定制内容,加速品牌传播,提升内容营销效果。
专业的AI公文写作平台,公文写作神器
AI 材料星,专业的 AI 公文写作辅助平台,为体制内工作人员提供高效的公文写作解决方案。拥有海量公文文库、9 大核心 AI 功能,支持 30 + 文稿类型生成,助力快速完成领导讲话、工作总结、述职报告等材料,提升办公效率,是体制打工人的得力写作神器。
OpenAI Agents SDK,助力开发者便捷使用 OpenAI 相关功能。
openai-agents-python 是 OpenAI 推出的一款强大 Python SDK,它为开发者提供了与 OpenAI 模型交互的高效工具,支持工具调用、结果处理、追踪等功能,涵盖多种应用场景,如研究助手、财务研究等,能显著提升开发效率,让开发者更轻松地利用 OpenAI 的技术优势。
高分辨率纹理 3D 资产生成
Hunyuan3D-2 是腾讯开发的用于 3D 资产生成的强大工具,支持从文本描述、单张图片或多视角图片生成 3D 模型,具备快速形状生成能力,可生成带纹理的高质量 3D 模型,适用于多个领域,为 3D 创作提供了高效解决方案。
一个具备存储、管理和客户端操作等多种功能的分布式文件系统相关项目。
3FS 是一个功能强大的分布式文件系统项目,涵盖了存储引擎、元数据管理、客户端工具等多个模块。它支持多种文件操作,如创建文件和目录、设置布局等,同时具备高效的事件循环、节点选择和协程池管理等特性。适用于需要大规模数据存储和管理的场景,能够提高系统的性能和可靠性,是分布式存储领域的优质解决方案。
最新AI工具、AI资讯
独家AI资源、AI项目落地
微信扫一扫关注公众号