iOS Safari 解码 .mov 失败的根因:藏在容器深处的 2 个字节

原文:https://dev.to/deland/ios-safari-cant-decode-your-mov-and-the-reason-is-2-bytes-deep-in-the-container-386p(作者 @deland)

我们的工具在浏览器里做音频转写——Whisper 通过 transformers.js 在本地运行,不上传任何数据。原本一切正常,直到分析数据显示出一个干净得不像巧合的现象:

移动端上传 `.mov` 文件 100% 失败。不是 90%,是每一个都失败。 而桌面端从未上报过一次 .mov 失败。

以下是我排查出的原因,以及在不引入 ffmpeg.wasm 或 WebCodecs 的前提下修复它的完整过程。

30 秒复现

我拿同一条 AAC 音轨分别装进两个容器——编码器相同,音频本身的字节完全一致,只有外壳不同:

const buf = await file.arrayBuffer();
await new AudioContext().decodeAudioData(buf);


在 iPhone 17 Pro 模拟器(iOS 18.7 / Safari 26.5)上:

| 文件 | iOS Safari | Chromium |

|---|---|---|

| sample.mov(ftyp qt ) | `EncodingError: Decoding failed` | OK |

| sample.mp4(ftyp isom) | OK | OK |

所以问题不在编解码器,而在容器。

那个看似显然却不奏效的修法

第一反应:问题出在 ftyp box 里的 brand。把 qt 打补丁改成 isom,四个字节,收工。

依然失败。 我把这一点写下来,免得有人再为此耗掉一整个下午。ftyp 的 brand 根本不是 Safari 检查的东西,真正的差异藏在 moov 里面。

真正的根因

一路挖到 moov → trak → mdia → minf → stbl → stsd——这个 sample description(采样描述条目)告诉解码器音频是如何编码的。两个文件都带有一个 mp4a 条目,但它们并不是同一个 mp4a 条目:

QuickTime writes:                  MP4 expects:
  version       = 1        <—        version       = 0
  compressionID = -2 (fffe) <—       compressionID = 0
  + 16 bytes of v1 extension <—      (absent)
  esds wrapped in a 'wave' box <—    esds is a direct child
  extra 'chan' channel layout        (absent)


iOS Safari 的 `decodeAudioData` 只接受 version 0 的音频采样描述条目。 Chromium 两种都接受——这正是桌面端从未暴露这个问题、而移动端全军覆没的原因。

那个 version 字段是一个 uint16。两个字节,决定了这个文件能不能播放。

修复方案:重建容器,不动编解码器

既然音频码流本身已经是合法的 AAC,就完全不需要重编码。要做的事纯粹是字节搬运:把音频采样数据抽出来,写一个全新且 stsd 结构规范的纯音频 MP4,再交回给浏览器原生的 decodeAudioData

最后这一点很关键。因为我们从不自己解码音频,所以不需要 WebCodecs——也就不必背上 AudioDecoder 在 iOS 17+ 的最低版本门槛——更不用为了一个容器层的问题打包好几 MB 的 ffmpeg.wasm

.mov ──► scan top-level boxes to find moov (read box headers only, never touch mdat)
      ──► parse the audio track's stsd / stts / stsc / stsz / stco|co64
      ──► sliding window (8 MB) to copy out audio samples
      ──► rebuild ftyp + moov (stsd forced to version 0) + mdat
      ──► decodeAudioData


我们最终生成的采样描述条目是刻意写得非常朴素的:

box('mp4a',
  zeros(6),
  u16(1),  // data_reference_index
  u16(0),  // version —— iOS Safari 唯一接受的版本
  u16(0),  // revision
  u32(0),  // vendor
  u16(channels),
  u16(16), // samplesize
  u16(0),  // compressionID —— QuickTime 写 -2;MP4 规范要求 0
  u16(0),  // packetsize
  u32(Math.min(sampleRate, 65535) * 65536), // 16.16 定点数表示的采样率
  esds     // 必要时从 QuickTime 的 'wave' 包装里取出
)


有一个值得抄走的解析细节:在 MP4 里,esds 描述符是 mp4a 的直接子节点,但 QuickTime 把它埋在 wave box 里。所以查找时必须先扫描所有同级节点、再递归下探——否则嵌在 wave 里的 esds 会遮住你真正想要的那个。

另外,version 1 的条目在子 box 之前还多出 16 个字节,version 2 则多出 36 个字节,其中真正的采样率存放在一个 float64 里。这些偏移量要是算错,你就会把垃圾数据当成 box 头去解析:

const version = view.getUint16(base + 8);
let childStart = base + 28;
if (version === 1) childStart = base + 28 + 16;
else if (version === 2) { sampleRate = view.getFloat64(base + 32); childStart = base + 64; }


我们原本没在找的 bug

旧代码是这样开头的:

const buf = await file.arrayBuffer();  // 移动端平均 161 MB


我们日志里的移动端视频文件平均大小为 161.9 MB,而其中 99% 的字节是转写根本用不到的视频帧。把整个文件读进内存,再解码成一个等长的 PCM 缓冲区,正是被 iOS Safari 内存限制干掉的最佳方式。

只读取 box 的 8–16 字节头来定位它们,再通过 8 MB 的滑动窗口复制采样数据,就把内存峰值从整个文件降到了8 MB + 音频本身

| 文件 | 原生解码 | 提取后 | 提取耗时 | 提取后解码 |

|---|---|---|---|---|

| sample.mov(0.10 MB / 5 秒) | 失败 | 0.08 MB | 16 ms | 成功 |

| hevc.mov(HEVC 视频 + AAC) | 失败 | 0.12 MB | 4 ms | 成功 |

| `huge5.mov`(158.6 MB / 900 秒) | 失败 | 13.88 MB | 378 ms | 成功(476 ms) |

| audio.m4a | 成功 | 0.08 MB | 3 ms | 成功(无回归) |

| sample.mp4 | 成功 | 0.08 MB | 3 ms | 成功(无回归) |

真正要紧的是 huge5.mov 那一行:158.6 MB 正是移动端的真实平均水平,旧路径把整个文件读完仍然抛出 EncodingError,而新路径总共只用 854 ms 就得到了解码完毕的 15 分钟音频缓冲区。解码输出经过了非静音验证(peak > 0)和时长精确性验证(900.023s → 900.1s),因为“返回了一个缓冲区”和“真的能用”根本不是一回事。

失败就回退,绝不横生枝节

一旦实际情况不再符合假设,每一步都会立刻返回 null,调用方随之回退到最初的直接解码路径:

| 输入 | 结果 |

|---|---|

| 分片 MP4(empty_moov) | null——采样表位于 moof 中,假设不成立 |

| .mov 中的 PCM 音轨 | null——只处理 mp4a |

| .webm | 根本不会进入这条路径 |

| 没有音轨的 .mov | null——找不到 soun 轨道 |

正是这条规则让这次改动可以安全上线:一个为兜底必然失败场景而存在的模块,绝不能把本来正常的场景搞坏。每一个 null 都对应着一个反正旧路径也会照常执行的场景。

这次的几点心得

  • 当一种格式在一个浏览器能正常工作、在另一个浏览器却不行时,先怀疑容器,再怀疑编解码器。编解码器的支持情况有详尽文档可查,容器的容忍度却没有。
  • `ffmpeg.wasm` 不是默认答案。如果码流本身已经有效,你要解决的只是管道问题,而管道问题用几百行 DataView 就能搞定。
  • 在移动端,为了解码而读取整个文件本身就是 bug,哪怕它今天还没崩溃。
  • 还有修完之后的事:删掉警告横幅。我们过去会告诉移动端用户“较大的 .mov 文件可能失败”。修复之后还留着这句话,比从未显示过更糟,文件大小已经和任何真实风险都不再相关,因为我们现在只会搬运音频的字节。

这些改动已随 [TranscriptSnap](https://transcriptsnap.com) 上线——这是一款浏览器本地转写工具,Whisper 通过 `transformers.js` 在你自己的机器上运行,文件永远不会离开设备,这也正是 Safari 的这个容器怪癖最终成了我们的问题、而不是服务器的问题。包含真机测量数据的完整报告见[这里](https://transcriptsnap.com/guides/mov-decode-ios-safari)

原文:https://dev.to/deland/ios-safari-cant-decode-your-mov-and-the-reason-is-2-bytes-deep-in-the-container-386p(作者 @deland)

发布评论
全部评论(0)