实现原理 · videoparse
本文说明当前 Demo 如何把 MP4「拆开 → 解码 → 展示」:主线程负责交互与渲染,Web Worker
内用 mp4box 解封装、WebCodecs 解码,并通过 Transferable ImageBitmap 回传帧画面。
1. 为什么这样拆
全帧解码是 CPU / 内存密集操作。若全部放在主线程,页面滚动、按钮响应都会被卡住。因此本项目把 解封装 + 解码放进 Module Worker,主线程只做:
- 读取本地文件或 CORS 拉取远程 MP4,得到可结构化克隆的
Blob - 用
<video>展示预览与浏览器侧元数据 - 接收 Worker 消息:进度、容器信息、逐帧 bitmap、完成 / 错误
- 把 bitmap 画到 Canvas 再导出 PNG 缩略图,并维护帧数与耗时
type: "module" 加载
worker.js,并从 CDN ESM 引入 mp4box;页面必须通过
localhost 或 HTTPS 访问。
2. 总体架构
一次解析任务对应一个独立的 Module Worker 实例。停止或结束时主线程会
postMessage({ type: "cancel" }) 并 terminate(),避免残留解码占用内存。
组件关系
flowchart LR
subgraph Main["主线程 · index.html"]
UI["控件 / 状态 / 耗时"]
Preview["<video> 预览"]
Frames["帧缩略图网格"]
Queue["frameQueue 串行渲染"]
end
subgraph Worker["Module Worker · worker.js"]
Feed["分片喂入 Blob"]
MP4["mp4box 解封装"]
Pump["pendingSamples 队列"]
VD["VideoDecoder"]
BM["createImageBitmap"]
end
CDN["mp4box@2.3.0 CDN ESM"] --> Worker
UI -->|start + Blob| Feed
Feed --> MP4
MP4 -->|Encoded samples| Pump
Pump --> VD
VD -->|VideoFrame| BM
BM -->|frame + Transferable| Queue
Queue --> Frames
MP4 -->|mp4-metadata / progress| UI
VD -.->|done / error| UI
3. 端到端时序
从点击「解析全部帧」到展示缩略图,大致经过以下阶段:
时序概览
sequenceDiagram
actor User as 用户
participant Main as 主线程
participant W as worker.js
participant M as mp4box
participant D as VideoDecoder
User->>Main: 选择文件 / URL 并开始
Main->>Main: loadSource → Blob
loadPreviewMetadata
Main->>W: start + Blob
W->>M: 按 2MB 分片 appendBuffer
M-->>W: onReady(info)
W->>D: configure(codec, description…)
W-->>Main: mp4-metadata
M-->>W: onSamples(samples)
loop 背压泵送
W->>D: decode(EncodedVideoChunk)
D-->>W: output(VideoFrame)
W->>W: createImageBitmap
W-->>Main: frame + ImageBitmap
Main->>Main: Canvas → PNG → DOM
end
W->>D: flush
W-->>Main: done
4. Worker 内部流水线
4.1 分片喂入容器
Worker 不会一次性把整个文件读进 ArrayBuffer。它按 CHUNK_SIZE = 2MB 对
Blob 切片,每次 arrayBuffer() 后写入
buffer.fileStart = offset,再交给 mp4File.appendBuffer。
偏移必须正确,mp4box 才能把离散块拼回完整 ISO BMFF 结构。
4.2 选轨与配置解码器
onReady 时在 info.tracks 中找第一条带
video 的轨道,提取 codec / 宽高,并从 sample entry 写出
AVC/HVC 等初始化数据作为 WebCodecs 的 description,然后
VideoDecoder.configure。
4.3 样本提取与解码背压
mp4box 按批(EXTRACTION_BATCH_SIZE = 24)回调
onSamples,样本先进入 pendingSamples。
pumpDecodeQueue 仅在 decodeQueueSize < 8 时继续
decode,避免把编码器队列撑爆。解码输出后先
createImageBitmap(frame),再以 Transferable 发出
frame 消息,并立刻 frame.close()。
Worker 数据变换
flowchart TB
Blob["source Blob"] --> Slice["slice(offset, offset+2MB)"]
Slice --> Append["appendBuffer + fileStart"]
Append --> Ready{"onReady?"}
Ready -->|是| Track["选视频轨 + description"]
Track --> Config["VideoDecoder.configure"]
Ready --> Samples["onSamples → pendingSamples"]
Config --> Pump["pumpDecodeQueue"]
Samples --> Pump
Pump --> Chunk["EncodedVideoChunk
key / delta"]
Chunk --> Decode["videoDecoder.decode"]
Decode --> VF["VideoFrame"]
VF --> IBM["ImageBitmap"]
IBM --> Post["postMessage frame
transfer bitmap"]
4.4 moov 在文件尾部时的二次喂入
不少 MP4 把 moov 放在文件末尾。首轮从偏移 0 读完并
flush 后,可能已经 onReady,但还没有真正抽出媒体样本。
此时用 mp4File.seek(0, true) 得到建议的媒体数据起始偏移,再从该偏移
feedBlob 一次,才能继续 onSamples。
maybeFinish 要求输入喂完、样本队列为空、解码队列为空,再
flush 解码器,最后发送 done。
5. 主线程与消息协议
双方通过结构化消息通信,方向与语义如下:
5.1 帧展示保序
Worker 可能较快连续投递多帧。主线程用 frameQueue = frameQueue.then(() =>
appendFrame(...)) 把 PNG 编码与 DOM 插入串起来,保证缩略图按到达顺序稳定追加,并在
done / error 前 await frameQueue,避免「状态已完成但图还没画完」。
5.2 任务世代号
activeRunId 在每次开始或停止时递增。过期任务的回调若仍带着旧
runId,会丢弃消息并 close() 残留 bitmap,防止旧 Worker 污染新一轮结果。
5.3 基本信息里的帧率怎么算
页面「帧率」不是浏览器 <video> 直接给出的,也不是逐帧测速得到的。
Worker 解析出视频轨后,通过 mp4-metadata 把
selectedVideoTrack 传回主线程;主线程的
getTrackFrameRate(track) 用 mp4box 轨道字段做平均帧率估算。
用到的三个字段:
| 字段 | 含义 |
|---|---|
track.nb_samples |
该视频轨的样本数,近似等于总帧数 |
track.duration |
以 timescale 为单位的轨道时长(非整秒) |
track.timescale |
时间基,表示每秒对应多少个 duration 刻度 |
换算关系:
- 真实时长(秒)=
duration / timescale - 平均帧率(fps)=
nb_samples / 真实时长=(nb_samples × timescale) / duration
帧率推导
flowchart LR
Meta["mp4-metadata
selectedVideoTrack"] --> Fields["nb_samples
duration
timescale"]
Fields --> Check{"三者均有限
且 > 0?"}
Check -->|否| Unknown["显示「未知」"]
Check -->|是| Formula["fps = nb_samples × timescale / duration"]
Formula --> Display["toFixed(3) +「fps(平均)」"]
fps 字段,也无法反映 VFR(可变帧率)视频中每一段的瞬时帧率。任一字段缺失或
≤ 0 时,界面显示「未知」。展示精度为 3 位小数(FRAME_RATE_DECIMAL_DIGITS)。
6. 关键设计点小结
设计对照
mindmap
root((videoparse))
隔离
Module Worker
主线程只渲染
吞吐
2MB 分片喂入
decodeQueue 背压
批提取 samples
零拷贝倾向
ImageBitmap Transferable
及时 close VideoFrame
容器兼容
moov 尾部二次 feed
fileStart 偏移
UI 正确性
frameQueue 保序
activeRunId 防串扰
- 解耦:重计算在 Worker,UI 保持可响应。
- 背压:限制 WebCodecs 队列深度,样本与解码速度匹配。
- 转移所有权:bitmap 用 Transferable 传到主线程,减少像素拷贝。
- 容器边角:显式处理「moov 在尾部」导致的首轮无样本问题。
- 可取消:cancel + terminate + runId,保证停止后无幽灵更新。
7. 文件职责
| 文件 | 职责 |
|---|---|
index.html |
页面结构、主线程模块脚本、Worker 生命周期与帧渲染 |
index.css |
Demo 视觉样式 |
worker.js |
mp4box 解封装、WebCodecs 解码、消息上报 |
README.md |
仓库基本信息与启动方式 |
README.html |
本页:实现原理与架构说明 |
8. MP4Box 解析结果字段说明
主线程元数据面板展示的是一次解析任务汇总后的结构:
source、browserMetadata、以及 Worker 回传的
mp4box(内含 mp4boxInfo、selectedVideoTrack、
decoderConfig)。下面以一份真实样例数值说明常见字段含义。
8.1 顶层结构
| 字段 | 含义 |
|---|---|
source |
源文件信息,包含文件名、MIME 类型、文件大小 |
browserMetadata |
浏览器播放时的元数据,如时长、分辨率、网络/就绪状态、丢帧统计 |
mp4box |
MP4Box 解析出的完整 MP4 文件内部结构信息 |
8.2 mp4boxInfo · 文件级信息
| 字段 | 示例值 | 含义 |
|---|---|---|
hasMoov |
true |
包含 moov box(元数据),可以随机访问和播放 |
duration |
246960 |
文件时长(以 timescale 为单位) |
timescale |
44100 |
时间刻度 / 时间基,每秒 44100 个时间单位 |
isFragmented |
false |
不是分段 MP4(fMP4) |
isProgressive |
false |
不是渐进式下载优化 |
hasIOD |
false |
没有初始对象描述符(MPEG-4 系统) |
brands |
["qt ", "qt "] |
兼容性标识,表示 QuickTime 格式 |
created / modified |
时间戳 | 文件创建 / 修改时间(UTC) |
mime |
video/mp4; codecs=... |
MIME 类型和编解码器信息 |
8.3 tracks · 轨道信息
视频轨道(track id: 1)
| 字段 | 示例值 | 含义 |
|---|---|---|
id |
1 |
轨道唯一 ID |
type |
video |
轨道类型:视频 |
codec |
avc1.640015 |
视频编解码器(H.264/AVC,Profile High Level 5.1) |
timescale |
600 |
该轨道的时间刻度,每秒 600 个时间单位 |
duration |
3360 |
轨道时长(以该轨道 timescale 为单位)= 3360/600 = 5.6 秒 |
movie_duration |
246960 |
电影的时长(以 movie_timescale 为单位) |
movie_timescale |
44100 |
电影的全局时间刻度 |
nb_samples |
168 |
该轨道包含的样本(帧)总数 = 168 帧 |
samples_duration |
3360 |
所有样本累计时长 = 3360 个时间单位 |
size |
344188 |
该轨道数据总大小(字节)≈ 336 KB |
bitrate |
491697 |
码率 ≈ 491.7 kbps |
track_width / track_height |
458 × 280 |
轨道像素尺寸 |
video.width / video.height |
458 × 280 |
视频实际分辨率 |
edits |
数组 | 编辑列表,定义该轨道在影片中的播放映射 |
volume |
0 |
音量(0 = 视频轨道无音频) |
layer |
0 |
图层编号(用于合成) |
alternate_group |
0 |
备用组 ID(0 = 无备用) |
language |
und |
语言代码(und = 未定义) |
matrix |
Int32Array(36) |
3×3 变换矩阵(用于视频旋转 / 缩放 / 裁剪) |
references |
[] |
轨道依赖引用(空 = 无依赖) |
nb_samples / (duration / timescale) = 168 / (3360/600) = 168 / 5.6 = 30 fps。
这也是 Demo 基本信息卡片里「平均帧率」的来源口径。
音频轨道(track id: 2)
| 字段 | 示例值 | 含义 |
|---|---|---|
id |
2 |
轨道唯一 ID |
type |
audio |
轨道类型:音频 |
codec |
mp4a |
音频编解码器(AAC) |
timescale |
44100 |
采样率 = 44.1 kHz(音频轨常直接以采样率为刻度) |
duration |
247808 |
轨道时长 ≈ 5.62 秒(247808/44100) |
movie_duration |
244736 |
电影时长(有编辑偏移时可能不同) |
nb_samples |
242 |
该轨道包含的音频样本(帧)总数 = 242 帧 |
samples_duration |
247808 |
所有样本累计时长 |
size |
68661 |
音频数据总大小 ≈ 67 KB |
bitrate |
97751 |
码率 ≈ 97.8 kbps |
audio.sample_rate |
44100 |
音频采样率 44.1 kHz |
audio.channel_count |
2 |
双声道(立体声) |
audio.sample_size |
16 |
每个采样点 16 位深度 |
volume |
1 |
音量(1 = 满音量) |
edits.media_time |
2112 |
音频从 2112/44100 ≈ 0.048 秒处开始(起始偏移) |
edits.segment_duration |
244736 |
编辑段时长(播放时取用) |
8.4 关键差异对比
| 对比项 | 视频轨道 | 音频轨道 |
|---|---|---|
nb_samples |
168 帧 | 242 帧(AAC 帧数) |
| 时长 | 5.6 秒(3360/600) | ≈ 5.62 秒(247808/44100) |
timescale |
600(时间刻度) | 44100(采样率作为刻度) |
| 每样本代表 | 1 帧画面 | 1 个 AAC 音频帧(约 1024 个 PCM 采样点) |
nb_samples 计算 |
30 fps × 5.6s = 168 | 编码器输出的 AAC 帧数 |
8.5 nb_samples 字段总结
| 轨道类型 | nb_samples 含义 |
|---|---|
| 视频轨 | 视频总帧数 ≈ 帧率 × 时长 |
| 音频轨 | 音频压缩帧总数(非 PCM 采样点数),AAC 等编码器输出的帧数 |
nb_samples 是轨道样本总数;
Worker 调用 setExtractionOptions 时还有参数 nbSamples,
用于控制提取时的批次大小(本项目为 EXTRACTION_BATCH_SIZE = 24),二者含义不同。