在浏览器里出片:帧 → 音轨 → mp4
WebCodecsExporter(packages/infra/src/export/webcodecs/)实现 Exporter 端口(packages/contracts/src/export/ports.ts),把推演交出的帧编码成 H.264、把 AudioClock 混出的 PCM 编码成 AAC,再由 Mp4Muxer 封装成 Blob。
要 webm 走的是另一条路:WebmExporter(packages/infra/src/export/webm/)用 VP8/VP9 + Opus,容器由本仓自写 EBML 段/簇/索引。两条路径互不复用封装代码,共用的只是背压与音频块上限。本页只讲 mp4 这条。
何时用 / 何时不用
- 用:要在浏览器里拿到一个可下载的 mp4;需要音轨进片子;需要控制码率或分辨率。
- 不用:只想看画面 → 预览路径(
PacedPreviewExporter+CanvasBlitExporter);只想拿到像素数组 →FrameSequenceExporter,它不引入编码器;只想验证不推演 →debug-with-compute.md;产物要带透明 →export-alpha.md(H.264 没有 alpha 通道;要带音轨的透明成片换WebmExporter)。
断言表
| 断言 | 判据 | 钉子 |
|---|---|---|
| 顺序固定:帧全部写完 → 写音频块 → finalize | 调用序列为 ["frames","audio","finalize"] | tests/export-audio.test.ts「帧全部写完后才写音频块,音频写完才 finalize」 |
无声音时不调用 writeAudio,纯视频导出仍成立 | 调用序列为 ["frames","finalize"] | tests/export-audio.test.ts「无声音时不调用 writeAudio(纯视频导出)」 |
有声音、导出器不支持音轨 → spec_violation,不静默丢音 | 抛错文案指明导出器 impl | tests/export-audio.test.ts「有声音但导出器不支持音轨 → spec_violation」 |
有声音、有音轨出口、但没注入 AudioClock → spec_violation | 抛错文案要求给出混音时钟 | tests/export-audio.test.ts「有声音但没有 AudioClock → spec_violation」 |
| 位图尺寸与导出尺寸不一致:写帧前就报错 | writeFrame 抛 spec_violation | tests/webcodecs-exporter.test.ts「尺寸与导出尺寸不一致:写帧前报 spec_violation」 |
| 位图数据长度不符:写帧前就报错 | writeFrame 抛 spec_violation | tests/webcodecs-exporter.test.ts「位图数据长度不符:写帧前报 spec_violation」 |
| 编码器报错后,后续写入按同一错误失败,不静默继续 | 同一错误重复抛出 | tests/webcodecs-exporter.test.ts「error 回调之后:后续写入按同一错误失败,而不是静默继续」 |
| 音频块声道布局由首块定下,之后的坏块不污染布局 | 坏块不占位,后续单声道块正常写入 | tests/webcodecs-exporter.test.ts「样本数不是声道整数倍的坏块不占布局:之后单声道块正常写入」 |
| 收尾失败不锁死实例,可重试 | 同一实例再次 finalize 成功 | tests/webcodecs-exporter.test.ts「收尾失败后同一实例仍可再次收尾与写入」 |
不注入 painter、不给 2D 画布又不开 headless → 首帧 spec_violation,不静默交白帧 | 报错文案要求注入 painter 或声明 allow-blank | tests/frame-source.test.ts「无 painter 且无 2D 上下文 → spec_violation(不静默交空白帧)」 |
容器结构可解析:纯视频 ftyp → moov → mdat,带音轨两个 trak | 解析出对应 box 序列 | tests/mp4-muxer.test.ts「纯视频:ftyp → moov → mdat」「带音轨:两个 trak」 |
用法
最小可用出片
import { render } from "@lingfan-video/core";
import { WebCodecsExporter } from "@lingfan-video/infra";
const WIDTH = 1920;
const HEIGHT = 1080;
const FPS = 30;
if (typeof VideoEncoder === "undefined" || typeof AudioEncoder === "undefined") {
throw new Error("当前浏览器不支持 WebCodecs 编码(需要 Chrome/Edge 94+)");
}
// 导出用一块独立画布:预览画布上留着上一帧与 2D 状态,
// 复用会让「先预览再导出」与「直接导出」得到不同像素。
const exportCanvas = document.createElement("canvas");
const host = makeStageHost(exportCanvas, true, WIDTH, HEIGHT); // 见下节
const exporter = new WebCodecsExporter({ width: WIDTH, height: HEIGHT, fps: FPS, bitrate: 7_000_000 });
const result = await render(program.picture, {
fps: FPS,
width: WIDTH,
height: HEIGHT,
canvas: exportCanvas,
exporter,
headless: false,
painter: host,
onProgress: (done) => console.log(`已交帧 ${done}`),
});
const blob = result.output.output;
if (!(blob instanceof Blob)) throw new Error("导出器未返回 Blob");
const url = URL.createObjectURL(blob);
// 用完后 60_000ms 撤销
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);frames 两个取值的差别:
frames | 缺省? | 行为 |
|---|---|---|
"required" | 是 | 既无 painter 又无 2D 画布上下文 → 首帧即 spec_violation。适合出片:宁可报错也不交白帧 |
"allow-blank" | 否 | 接受全 0 占位位图。适合预览或宿主自绘的 sink。不豁免回读:仍会每帧从画布回读整幅像素 |
出片用 "required"(即省略不写)并确保 painter 或可 2D 的 canvas 至少有一个。
音频时钟
AudioClock 是导出端的混音端口。缺省不注入 = 纯视频导出;注入了才写音轨。
import { OfflineAudioMixer } from "@lingfan-video/infra";
const audio = new OfflineAudioMixer(makeSynthDecoder(48_000), { sampleRate: 48_000, channels: 2 });
await render(program.picture, { fps: FPS, width: WIDTH, height: HEIGHT, canvas: exportCanvas, exporter, audio, headless: false, painter: host });sampleRate缺省 48000Hz,channels缺省 2。renderProgram(program)返回从 0ms 起的单段交错 Float32 样本;同一程序重复调用逐样本一致。- 样本是时间线的纯函数,与帧率无关;所以改
fps不会改声音。
canvas 与 painter 的分工
两者解决不同问题,缺一会走不同的兜底:
painter(FrameSource | FrameHost):直接交帧。给了它引擎就不回读绘制目标,像素只由它决定。合成宿主(createCompositeHost)走这条:它按层声明把底图与各层的面叠成一张FrameHandle或位图。canvas:绘制目标。没给painter时,引擎从这个画布的 2D 上下文回读像素;canvas与painter都不给且不开headless时才落到「没有帧来源」的报错。
浏览器出片的典型装配是两个都给:canvas 当绘制目标,painter 是合成宿主(它自己也可能画到这块画布上)。用 createCompositeHost 时注意 GPU 档交的是设备句柄,必须让导出器能零拷贝吃下 VideoFrame;交位图的路径会让 WebCodecsExporter 走 CPU 上传,慢但仍正确。
帧 → 音轨 → finalize 的次序
这个次序由组合根固定,不可重排:
engine.run逐帧交帧,导出器逐帧writeFrame(内部await drain()做背压)。- 帧全部交完后,
writeAudioTrack调AudioClock.renderProgram,把样本按块交给writeAudio。 - 最后
exporter.finalize():冲刷编码器、取 AVC 参数集、封装 mp4、返回ExportResult。
违反次序会被明确挡住:无帧就 finalize 抛「无已编码帧(finalize 前需 writeFrame)」;finalize 之后再写抛 spec_violation,说的是「finalize 之后不能再写入」。
反例
✗ 预览画布复用为导出画布
伪代码
await render(picture, { canvas: previewCanvas, painter: previewHost, exporter });为什么错:预览画布上留着上一次播放的最后一帧与 2D 状态,复用会让「先预览再导出」和「直接导出」得到不同像素,跨设备比对直接失效。
✓ 每次导出新建一块画布与对应的宿主:
伪代码
const exportCanvas = document.createElement("canvas");
const host = makeStageHost(exportCanvas, true, WIDTH, HEIGHT);✗ 给了 canvas 就以为出片一定对
伪代码
await render(picture, { canvas, exporter }); // 缺 painter,也未开 headless为什么错:能回读不等于回读到想要的像素。没注入 painter 时引擎从 canvas.getContext("2d") 回读——Node 下通常没有 2D 上下文,直接首帧 spec_violation;浏览器里则可能回读到一块没被画过的画布。
✓ 显式注入 painter:
伪代码
await render(picture, { canvas, painter: host, exporter });✗ 只想拿像素却用 WebCodecsExporter
为什么错:它是编码器适配器,成本在编码与封装,而且依赖浏览器能力探测。要像素数组用 FrameSequenceExporter(frames / timestamps 两个 getter 直接给出)。
边界与陷阱
- 能力探测必须做:
VideoEncoder/AudioEncoder任一缺失时构造编码器会失败,先探测再走导出分支。 fps只是编码器提示:真实时间戳来自帧本身(Math.round(timestampMs * 1000)微秒)。首帧时长取1000000/fps微秒,其后取相邻时间戳之差;所以变帧率剧本不会被抹平。- 关键帧间隔固定:每 60 帧一个关键帧,不可配。
bitrate缺省 8Mbps,audioBitrate缺省 128kbps。画面大面积暗部时 7Mbps 足够,再高只是把文件撑大。writeFrame不可并发:内部drain()是背压,队列上限MAX_ENCODE_QUEUE = 16,超了就在writeFrame上等。writeAudio的声道布局由首块定死:后续块与首块不一致会抛spec_violation;单块上限AUDIO_BLOCK_FRAMES = 4096采样帧,内部自动切块并转f32-planar。WebCodecsExporter只接两种帧载体:CPU 位图与设备句柄。FrameHandle的所有权随writeFrame转移,接收端要异步保留必须自己clone()。- 确定性是分级的:CPU 2D 路径承诺跨设备逐字节相同;GPU / 第三方框架只承诺同设备、同代码、同数据相同。做跨设备比对前先确认走的是哪一级。
finalize不幂等:成功后重复调用会抛spec_violation;封装阶段失败时finalized不置位,同一实例可重试。- 长片不要用
FrameSequenceExporter:像素全留在内存里,内存随帧数线性增长。
相关
- 端口源码:
packages/contracts/src/export/ports.ts(Exporter/ExportResult/ExportFormat/ExporterImpl)、packages/contracts/src/capture/ports.ts(FrameCarrier/FrameBitmap/FrameHandle/Painter)、packages/contracts/src/entry/ports.ts(RenderOptions/RenderResult)、packages/contracts/src/audio/ports.ts(AudioClock/AudioOutput);实现见packages/infra/src/export/webcodecs/(exporter.ts/video-track.ts/audio-track.ts/encoder-gate.ts/types.ts)、packages/infra/src/export/frame-sequence-exporter.ts、packages/infra/src/audio/offline-mixer.ts、packages/infra/src/frames/composite-host.ts。 - 示例:
examples/browser/export.ts(能力探测 + 独立画布 + 音频时钟 + Blob 下载)、examples/browser/host.ts(预览 CPU 档与导出 GPU 档两套合成器)。 - 相邻页:
compose-acts.md(多幕与重叠窗)、add-audio.md(声音登记与总线)、debug-with-compute.md(不整片出片的验证手段)、first-clip.md(单幕入口)。