实现原理 · videoparse

本文说明当前 Demo 如何把 MP4「拆开 → 解码 → 展示」:主线程负责交互与渲染,Web Worker 内用 mp4box 解封装、WebCodecs 解码,并通过 Transferable ImageBitmap 回传帧画面。

1. 为什么这样拆

全帧解码是 CPU / 内存密集操作。若全部放在主线程,页面滚动、按钮响应都会被卡住。因此本项目把 解封装 + 解码放进 Module Worker,主线程只做:

安全上下文:Worker 以 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 = 2MBBlob 切片,每次 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,样本先进入 pendingSamplespumpDecodeQueue 仅在 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. 主线程与消息协议

双方通过结构化消息通信,方向与语义如下:

方向 type 作用
主 → Worker start 携带 MP4 Blob,启动解封装与解码
主 → Worker cancel 置取消标志,清空样本队列,关闭解码器
Worker → 主 input-progress 容器字节读取进度
Worker → 主 progress 阶段文案 + 已解码帧数
Worker → 主 mp4-metadata mp4box info、选中轨道、decoderConfig
Worker → 主 frame index / timestampMs / Transferable bitmap
Worker → 主 done / error 正常结束或失败

5.1 帧展示保序

Worker 可能较快连续投递多帧。主线程用 frameQueue = frameQueue.then(() => appendFrame(...)) 把 PNG 编码与 DOM 插入串起来,保证缩略图按到达顺序稳定追加,并在 done / errorawait frameQueue,避免「状态已完成但图还没画完」。

5.2 任务世代号

activeRunId 在每次开始或停止时递增。过期任务的回调若仍带着旧 runId,会丢弃消息并 close() 残留 bitmap,防止旧 Worker 污染新一轮结果。

5.3 基本信息里的帧率怎么算

页面「帧率」不是浏览器 <video> 直接给出的,也不是逐帧测速得到的。 Worker 解析出视频轨后,通过 mp4-metadataselectedVideoTrack 传回主线程;主线程的 getTrackFrameRate(track) 用 mp4box 轨道字段做平均帧率估算。

用到的三个字段:

字段 含义
track.nb_samples 该视频轨的样本数,近似等于总帧数
track.duration timescale 为单位的轨道时长(非整秒)
track.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 防串扰
                    
  1. 解耦:重计算在 Worker,UI 保持可响应。
  2. 背压:限制 WebCodecs 队列深度,样本与解码速度匹配。
  3. 转移所有权:bitmap 用 Transferable 传到主线程,减少像素拷贝。
  4. 容器边角:显式处理「moov 在尾部」导致的首轮无样本问题。
  5. 可取消:cancel + terminate + runId,保证停止后无幽灵更新。

7. 文件职责

文件 职责
index.html 页面结构、主线程模块脚本、Worker 生命周期与帧渲染
index.css Demo 视觉样式
worker.js mp4box 解封装、WebCodecs 解码、消息上报
README.md 仓库基本信息与启动方式
README.html 本页:实现原理与架构说明

8. MP4Box 解析结果字段说明

主线程元数据面板展示的是一次解析任务汇总后的结构: sourcebrowserMetadata、以及 Worker 回传的 mp4box(内含 mp4boxInfoselectedVideoTrackdecoderConfig)。下面以一份真实样例数值说明常见字段含义。

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),二者含义不同。