手写 fMP4 muxer:从 ISO 14496-12 到能播的文件

手写 fMP4 muxer:从 ISO 14496-12 到能播的文件

一、VLC 能播,相册说"无法播放"

如果你决定自己写一个 MP4 封装器,你会经历这样一个过程:

对着 ISO/IEC 14496-12 啃了两天,把 ftypmoovmoofmdat 一个个写出来。第一版文件生成了,拖进 VLC------能播。拖进 ffplay------能播。心里想的是:也就这样嘛。

然后你把它存进 iOS 相册。相册说:无法播放此视频

你打开 QuickTime Player,它说:文件已损坏。没有第二句话。没有哪个 box、哪个字段、哪个字节。

你翻回规范,逐字对了一遍,每个字段都"看起来对"。你开始怀疑是不是签名、是不是扩展名、是不是相册的 bug。都不是。是某一个 32 位字段填了 0 而它应该填 1,或者某个必须存在的 box 你觉得"空的没必要写"就省掉了。

我在做录屏引擎那篇里解释过为什么在 50MB 的 Broadcast Extension 里要自己写 muxer(版本门槛、内存可归因、边录边可播),并且点了三个最容易翻车的字段。那篇是讲"为什么",这篇讲"怎么写":把一个最小可用的 fMP4 muxer 的每一层拆开,按我当时踩坑的顺序,把那些"看起来对但播放器不认"的地方一个个讲清楚。

先摆立场:这是一个成本很高的决定,绝大多数项目不该做。 第十节会说什么情况下你应该直接用 AVAssetWriter。但如果你确实要走这条路,或者你需要理解 MP4 到底是怎么组织的(比如要给服务端做切片、要排查一个"某些设备播不了"的文件),这篇可以省你一两周。


二、先把规模讲清楚:你要写多少东西

MP4 的基本单元是 box:4 字节大端长度 + 4 字节类型码 + 内容。带 version/flags 的叫 FullBox,多 4 字节。整个文件就是 box 的树。

一个最小的、能被 AVFoundation 认的单视频轨 fMP4,头部(ftyp + moov)要写这些:

scss 复制代码
ftyp
moov
 ├─ mvhd
 ├─ trak
 │   ├─ tkhd
 │   └─ mdia
 │       ├─ mdhd
 │       ├─ hdlr
 │       └─ minf
 │           ├─ vmhd
 │           ├─ dinf ─ dref ─ url
 │           └─ stbl
 │               ├─ stsd ─ avc1 ─ avcC
 │               ├─ stts   (空)
 │               ├─ stsc   (空)
 │               ├─ stsz   (空)
 │               └─ stco   (空)
 └─ mvex
     └─ trex

数一下:19 个 box ,其中 4 个是空的但必须存在。加上音频轨再来一份 traksmhd 替换 vmhdmp4a/esds 替换 avc1/avcC),再加一个 trex。头部总共约 700~900 字节。

然后每个分片:

markdown 复制代码
moof
 ├─ mfhd
 └─ traf            (每条轨一个)
     ├─ tfhd
     ├─ tfdt
     └─ trun
mdat

这些 box 里,大部分字段是常量或者可以照抄;真正需要你算对的字段只有大约十个 。但这十个每一个填错,症状都是"文件损坏"这四个字,没有任何进一步提示。这就是手写 muxer 的全部难度所在:信息密度极低的错误反馈

所以先把工具准备好,再动手写。

一个够用的字节写入器

swift 复制代码
struct ByteWriter {
    private(set) var bytes: [UInt8] = []

    mutating func u8(_ v: UInt8)   { bytes.append(v) }
    mutating func u16(_ v: UInt16) { bytes.append(contentsOf: withUnsafeBytes(of: v.bigEndian, Array.init)) }
    mutating func u32(_ v: UInt32) { bytes.append(contentsOf: withUnsafeBytes(of: v.bigEndian, Array.init)) }
    mutating func u64(_ v: UInt64) { bytes.append(contentsOf: withUnsafeBytes(of: v.bigEndian, Array.init)) }
    mutating func i16(_ v: Int16)  { u16(UInt16(bitPattern: v)) }
    mutating func i32(_ v: Int32)  { u32(UInt32(bitPattern: v)) }
    mutating func fourcc(_ s: String) { precondition(s.utf8.count == 4); bytes.append(contentsOf: Array(s.utf8)) }
    mutating func raw(_ b: [UInt8])  { bytes.append(contentsOf: b) }
    mutating func zeros(_ n: Int)    { bytes.append(contentsOf: repeatElement(0, count: n)) }
    mutating func fixed16_16(_ v: Double) { u32(UInt32(v * 65536)) }
}

/// 普通 box:先占 4 字节 size,写完回填
func box(_ type: String, _ body: (inout ByteWriter) -> Void) -> [UInt8] {
    var w = ByteWriter()
    w.u32(0)
    w.fourcc(type)
    body(&w)
    var out = w.bytes
    let size = UInt32(out.count).bigEndian
    out.replaceSubrange(0..<4, with: withUnsafeBytes(of: size, Array.init))
    return out
}

/// FullBox:多 1 字节 version + 3 字节 flags
func fullBox(_ type: String, version: UInt8 = 0, flags: UInt32 = 0,
             _ body: (inout ByteWriter) -> Void) -> [UInt8] {
    box(type) { w in
        w.u32((UInt32(version) << 24) | (flags & 0x00FF_FFFF))
        body(&w)
    }
}

全部大端。这是第一个会被 Swift 开发者忽略的点------withUnsafeBytes(of: v) 直接写出来的是小端,播放器会把 size = 24 读成 size = 402653184,然后从文件里跳出去,报"损坏"。

有了这两个函数,每个 box 就是一段声明式的代码,规范里的表格可以一行行对着抄。


三、头部:ftypmoov,在第 0 秒就定死的东西

ftyp:不要写 qt

swift 复制代码
func ftyp() -> [UInt8] {
    box("ftyp") { w in
        w.fourcc("iso5")       // major brand
        w.u32(512)             // minor version
        for b in ["isom", "iso5", "iso6", "mp41"] { w.fourcc(b) }   // compatible brands
    }
}

brand 的作用是告诉解析器"我遵守哪个版本的规范"。iso5 之后才有 tfhddefault-base-is-moof 语义(后面第五节会用到),所以至少要声明到 iso5

不要用 qt 那是 QuickTime 文件格式的 brand,解析器会切换到 QuickTime 的语义(部分 box 的字段布局不一样,比如 stsd 里的 sample entry 会多出 QuickTime 特有的扩展)。你按 ISO 写的字节,被按 QuickTime 读,就是"损坏"。

moov:一个只有骨架、没有内容的索引

fMP4 和普通 MP4 最大的区别在这里:普通 MP4 的 moov 里装着全部 sample 的索引表(stts/stsc/stsz/stco,每一帧的时长、大小、位置),所以必须等录完才能写。fMP4 把索引下放到每个 moofmoov 里只剩骨架------所以它可以在第 0 秒就写完落盘

但这里有第一个反直觉的地方:

那四张索引表虽然是空的,但必须存在。

swift 复制代码
func emptyStbl(sampleEntry: [UInt8]) -> [UInt8] {
    box("stbl") { w in
        w.raw(fullBox("stsd") { w in w.u32(1); w.raw(sampleEntry) })   // entry_count = 1
        w.raw(fullBox("stts") { w in w.u32(0) })                       // entry_count = 0
        w.raw(fullBox("stsc") { w in w.u32(0) })                       // entry_count = 0
        w.raw(fullBox("stsz") { w in w.u32(0); w.u32(0) })             // sample_size = 0, sample_count = 0
        w.raw(fullBox("stco") { w in w.u32(0) })                       // entry_count = 0
    }
}

规范里这四个是 stbl 的必选子 box("exactly one")。ffmpeg 的解析器很宽容,缺了照样播;AVFoundation 会直接拒绝。我第一版就是觉得"表是空的写它干嘛"省掉了,VLC 能播、相册不能,查了一整天。

这件事背后的道理值得多说一句:规范定义的是"结构的完整性",不是"信息的有无"。 空表传递的信息是"这条轨在 moov 里没有 sample,去 moof 里找",它本身就是一个信号。省掉它,解析器面对的是"结构不完整",而不是"信息为空"。

mvhd / tkhd / mdhd:三个 duration 都填 0

swift 复制代码
func mvhd(timescale: UInt32, nextTrackID: UInt32) -> [UInt8] {
    fullBox("mvhd") { w in
        w.u32(0); w.u32(0)          // creation_time, modification_time(1904 纪元秒,填 0 合法)
        w.u32(timescale)            // movie timescale,习惯 1000
        w.u32(0)                    // duration ------ fMP4 里在此时未知,填 0
        w.u32(0x0001_0000)          // rate 1.0
        w.u16(0x0100)               // volume 1.0
        w.zeros(2 + 8)              // reserved
        w.raw(unityMatrix)          // 9 × u32:0x10000,0,0, 0,0x10000,0, 0,0,0x40000000
        w.zeros(6 * 4)              // pre_defined
        w.u32(nextTrackID)
    }
}

duration = 0 是合法的,意思是"未知"。AVFoundation 打开这种文件时会自己扫描所有 moof 算出实际时长,所以 AVPlayer 的进度条是对的。但不是所有消费方都这么做------有些第三方播放器和服务端转码工具会把 0 当真,表现为"时长 0:00、进度条拖不动"。

这个问题的解法在第八节,它利用了 fMP4 的一个结构性优势:moov 在文件开头,布局是你自己定的,所以这三个 duration 字段在文件里的字节偏移是已知常量 。录制正常结束时,回头 pwrite 三次、每次 4 字节,就把时长补上了。如果进程被杀没来得及补,文件里还是 0,依然能播。两种结局都可播,只是好坏程度不同------这是整个 fMP4 设计的核心思路在一个字段上的体现。

tkhd 的 flags 要填 0x000007(enabled | in_movie | in_preview)。填 0 的话轨道存在但被标记为"不启用",播放器打开是黑屏无声,而且不报错。

mdhd.timescale 是这条轨的媒体时间基 ,后面所有 tfdttrun 里的 duration 都用它做单位。视频我用 90000(30/60/24/25 fps 都能整除),音频直接用采样率(AAC 每帧固定 1024 个采样点,duration 就是干净的 1024)。

avc1 + avcC:不要自己拼,format description 里有现成的

sample entry 是 stsd 里描述编码格式的那一块。H.264 的 avc1 的字段布局:

swift 复制代码
func avc1(width: UInt16, height: UInt16, avcC: [UInt8]) -> [UInt8] {
    box("avc1") { w in
        w.zeros(6); w.u16(1)             // reserved, data_reference_index
        w.zeros(2 + 2 + 12)              // pre_defined, reserved, pre_defined[3]
        w.u16(width); w.u16(height)
        w.u32(0x0048_0000); w.u32(0x0048_0000)   // 72 dpi × 2
        w.u32(0)                          // reserved
        w.u16(1)                          // frame_count
        w.zeros(32)                       // compressorname(Pascal string,全 0 合法)
        w.u16(0x0018)                     // depth 24
        w.i16(-1)                         // pre_defined
        w.raw(avcC)
    }
}

avcC 里装 SPS/PPS。录屏引擎那篇里我展示了用 CMVideoFormatDescriptionGetH264ParameterSetAtIndex 逐个取出来自己拼------那样能跑,但有个更省事也更不容易错的办法:

VideoToolbox 给你的 CMFormatDescription 里,已经有一个完整的 avcC atom。

swift 复制代码
func avcCAtom(from desc: CMFormatDescription) -> [UInt8]? {
    guard let atoms = CMFormatDescriptionGetExtension(
              desc, extensionKey: kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms
          ) as? [String: Any],
          let data = atoms["avcC"] as? Data else { return nil }
    return box("avcC") { $0.raw([UInt8](data)) }   // data 是 avcC 的 payload,套上 box 头即可
}

这个 payload 由编码器自己生成,profile/level/兼容位/lengthSizeMinusOne 全部正确,High Profile 的扩展字段(chroma format、bit depth)也在里面------自己拼的话这些扩展字段是最容易漏的。HEVC 同理,key 是 "hvcC"

mvex / trex:告诉解析器"后面有分片"

swift 复制代码
func trex(trackID: UInt32) -> [UInt8] {
    fullBox("trex") { w in
        w.u32(trackID)
        w.u32(1)      // default_sample_description_index
        w.u32(0)      // default_sample_duration
        w.u32(0)      // default_sample_size
        w.u32(0)      // default_sample_flags
    }
}

mvex 的存在本身就是信号:有它,解析器才会去找 moof 没有它,解析器把这个文件当普通 MP4,看到四张空表,得出结论"这个视频 0 帧"------不报错,就是播不出东西。

每条轨一个 trex。默认值我全填 0,然后在 trun 里逐 sample 写完整信息。这比用默认值多几个字节,但换来的是每个分片自解释,排查问题时 mp4dump 一眼能看全。

到这里,头部写完了。注意一个事实:这几百字节在第 0 秒落盘之后,就不能再改了 (除了那三个 duration 的补丁)。这意味着所有"录制过程中可能变化的东西"------分辨率、编码参数、轨道数量------要么能在分片层面表达,要么不能变。分辨率变化就属于"不能变":avcCstsd 里,一条轨只有一个 sample entry 在用。用户中途旋转屏幕导致编码分辨率变了,正确的做法是开新文件,而不是试图在同一条轨里切换。这个约束不是 muxer 的限制,是格式的限制,越早在架构里接受它越好。


四、分片:moof 的三个字段,按翻车顺序

tfdt:整数累加,绝不做浮点换算

tfdt.baseMediaDecodeTime 是这个分片第一个 sample 的解码时间,单位是 mdhd.timescale。播放器靠它把分片拼成一条连续的时间轴。

录屏引擎那篇说了"用整数累加,不要用浮点"。这里把"怎么累加"讲清楚,因为即使全用整数,也有一种算法是错的

ReplayKit 给你的 PTS 是 CMTime,timescale 通常是 10 亿(纳秒)。你要换算到 90000。两种写法:

swift 复制代码
// 写法 A:先换算每个 sample 的 PTS,再用相邻差做 duration
let ptsTS = CMTimeConvertScale(pts - t0, timescale: 90000, method: .roundHalfAwayFromZero).value
let duration = nextPtsTS - ptsTS

// 写法 B:先做相邻差,再换算 duration
let deltaNs = nextPts - pts
let duration = CMTimeConvertScale(deltaNs, timescale: 90000, method: .roundHalfAwayFromZero).value

写法 B 每个 sample 引入至多半个 tick 的舍入误差,误差同向时会累积 。30fps 录 10 分钟是 18000 帧,最坏情况偏差 9000 tick = 0.1 秒。音画同步那篇讲过:漂移不是对齐一次的事,是持续纠偏的事------而这里的正确做法是根本不让它漂

写法 A 是对的 。所有 duration 之和恒等于最后一个 sample 换算后的 PTS,误差不累积。tfdt 就是 ptsTS(无 B 帧时 DTS = PTS)。

swift 复制代码
final class TrackState {
    let timescale: Int32
    var t0: CMTime?                    // 第一个 sample 的 PTS,作为零点
    var decodeTime: UInt64 = 0         // 下一个分片的 tfdt

    func tick(_ pts: CMTime) -> Int64 {
        if t0 == nil { t0 = pts }
        return CMTimeConvertScale(pts - t0!, timescale: timescale,
                                  method: .roundHalfAwayFromZero).value
    }
}

trun:最后一个 sample 的 duration 在写它的时候还不知道

这是这篇最反直觉的一条,也是录屏引擎那篇没有讲的。

trun 里每个 sample 要写 duration。duration 是这个 sample 到下一个 sample 的时间差。所以当你收到第 N 帧、准备把它写进分片时,它的 duration 取决于第 N+1 帧的 PTS------而第 N+1 帧还没来

普通 MP4 没这个问题(录完才写索引,什么都知道了)。fMP4 每秒就要落一个分片,分片的最后一个 sample 永远面临这个问题。

第一版的自然写法是"最后一个用前一个的 duration"。在恒定帧率下没事;在 ReplayKit 的可变帧率下会翻车:画面静止时 ReplayKit 可能几秒不送一帧,于是"前一帧的 duration"可能是 33ms 而真实值是 3 秒,或者反过来。

症状取决于你的 tfdt 是怎么算的。如果 tfdt 是把 duration 一路累加出来的,这个错误就永久地 混进了时间轴:之后所有视频都比音频早 3 秒,直到录制结束。如果 tfdt 是按上一节的写法从 PTS 独立换算的,错误被限制在分片边界:时间轴上出现一个 3 秒的空洞,不同播放器处理不一样------有的冻结在 F4 上(碰巧看起来是对的),有的黑屏,有的直接跳过。三种表现都是间歇性的、跟用户当时在屏幕上做什么有关,极难复现。

正确的解法是滞后一帧

swift 复制代码
final class FragmentBuilder {
    private var pending: Sample?          // 最后一个尚未知道 duration 的 sample
    private var ready: [Sample] = []      // duration 已确定,可以进分片的

    func push(_ s: Sample) {
        if var p = pending {
            p.duration = UInt32(s.decodeTick - p.decodeTick)
            ready.append(p)
        }
        pending = s
    }

    /// 只把 ready 的写进分片;pending 留到下一个分片
    func flush() -> (samples: [Sample], coveredUntil: Int64)? {
        guard !ready.isEmpty else { return nil }
        defer { ready.removeAll(keepingCapacity: true) }
        return (ready, pending!.decodeTick)
    }

    /// 正常结束:pending 的 duration 无从得知,用最后一个已知 duration 兜底
    func finish() -> [Sample] {
        if var p = pending {
            p.duration = ready.last?.duration ?? 1
            ready.append(p)
        }
        pending = nil
        return ready
    }
}

代价是:进程被杀时,除了未完成的分片,还会多丢那一个 pending 帧。在 1 秒分片的量级下这是可以忽略的。收益是:每一个写进文件的 duration 都是真实值,没有任何估算。

这个问题反过来揭示了一件事:fMP4 的每个分片在结构上是"过去完成时"的,但 muxer 收到数据的时候只知道"现在进行时"。 一切"流式写入一个需要前瞻信息的格式"的系统都有同样的形状,解法也都一样------留一个的余量。

trun.data_offset:一趟半,不是两趟

录屏引擎那篇里说"先算 moof 大小再回填"要两趟。实际上更简单:data_offset 是一个固定 4 字节的字段,它的值不影响 moof 的大小。所以构建一次、记下它的偏移、回填,一趟半就够了。

swift 复制代码
func moof(sequence: UInt32, tracks: [(id: UInt32, tfdt: UInt64, samples: [Sample])]) -> [UInt8] {
    var out = box("moof") { w in
        w.raw(fullBox("mfhd") { $0.u32(sequence) })
        for t in tracks {
            w.raw(box("traf") { w in
                w.raw(fullBox("tfhd", flags: 0x020000) { $0.u32(t.id) })        // default-base-is-moof
                w.raw(fullBox("tfdt", version: 1) { $0.u64(t.tfdt) })
                let trunFlags: UInt32 = 0x000001 | 0x000100 | 0x000200 | 0x000400  // offset|dur|size|flags
                w.raw(fullBox("trun", flags: trunFlags) { w in
                    w.u32(UInt32(t.samples.count))
                    w.i32(0)                                         // data_offset 占位,稍后回填
                    for s in t.samples { w.u32(s.duration); w.u32(s.size); w.u32(s.flags) }
                })
            })
        }
    }
    // 上面拿不到绝对偏移(嵌套 box 各自独立构建),实际实现里我用一个带"绝对光标"的 writer;
    // 这里为了可读性,用扫描的方式定位每个 trun 的 data_offset 字段:
    var cursor = 0
    var dataOffset = Int32(out.count + 8)        // moof 大小 + mdat 的 8 字节 box 头
    for t in tracks {
        cursor = findTrunDataOffsetField(in: out, after: cursor)
        out.replaceSubrange(cursor..<cursor+4, with: withUnsafeBytes(of: dataOffset.bigEndian, Array.init))
        dataOffset += Int32(t.samples.reduce(0) { $0 + Int($1.size) })
    }
    return out
}

(示例用扫描定位是为了可读;工程里应该让 writer 在写入占位时就记录绝对偏移,避免扫描误匹配。)

第一条轨的 data_offset = moof.size + 8,也就是 mdat 的第一个字节。第二条轨的 data_offset 要再加上第一条轨所有 sample 的字节数------因为 mdat 里是按轨顺序连续放的。

而这引出下一个坑。


五、单轨能播,加上音频就坏了:tfhd 的隐式基址

这是我踩过最莫名其妙的一个。视频轨单独跑一切正常,加上 AAC 音频轨之后,视频正常、音频从第二个分片开始变成噪声

根因在 tfhd 的 flags。data_offset 是一个相对偏移 ,相对于"基址"。而基址是什么,取决于 tfhd 的两个 flag:

  • 设了 base-data-offset-present(0x000001):基址是你显式写的那个 64 位绝对偏移。
  • 设了 default-base-is-moof(0x020000):基址是这个 moof 的第一个字节
  • 两个都没设 :规范说,第一条 traf 的基址是 moof 起始;后续每条 traf 的基址是前一条 traf 的数据结束位置

第三种就是默认行为,也是我第一版的行为。我按"都相对 moof 起始"算的 data_offset,对第一条轨(视频)恰好是对的;对第二条轨(音频),播放器把它加在了视频数据的结束位置上------于是音频的读取位置被推到了 mdat 之外,读到的是下一个 moof 的字节。单轨的时候这个 bug 完全不可见。

解法就是显式设 default-base-is-moof,让两条轨的基址都是 moof 起始,然后按上一节的方式给第二条轨的偏移加上第一条轨的数据长度。前面代码里 flags: 0x020000 就是这个。

这条 flag 是 iso5 引入的,这就是 ftyp 要声明 iso5 的原因。

这个坑的形状值得记住:规范里的"默认行为"往往是为最简单的情况优化的,而你的第一版恰好就是最简单的情况,所以默认行为看起来"就是对的"。 加第二条轨、第二个分片、第二种 sample 类型的时候,那些你没显式指定的东西才开始暴露。写格式的时候,凡是有 flag 可以显式指定的,都显式指定。


六、sample_flags:全标关键帧和全不标,各错一半

trun 里每个 sample 的 flags 是个 32 位位域,最要紧的两个位:

ini 复制代码
bit 24-25  sample_depends_on   : 2 = 不依赖其他帧(I 帧), 1 = 依赖(P/B 帧)
bit 16     sample_is_non_sync  : 0 = 同步点(可从此帧开始解码), 1 = 不是

所以:

swift 复制代码
let keyframeFlags: UInt32    = 0x0200_0000   // depends_on = 2, non_sync = 0
let nonKeyframeFlags: UInt32 = 0x0101_0000   // depends_on = 1, non_sync = 1

判断关键帧看 CMSampleBuffer 的 attachment:

swift 复制代码
func isKeyframe(_ sb: CMSampleBuffer) -> Bool {
    guard let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: false) as? [[CFString: Any]],
          let first = attachments.first else { return true }   // 无 attachment 视为关键帧(VT 的约定)
    return (first[kCMSampleAttachmentKey_NotSync] as? Bool) != true
}

两个方向的错误症状不一样,都不报错:

  • 全标成同步点 (第一版最常见,因为 flags 填 0 就是这个意思):拖动进度条时,播放器以为任何一帧都能作为起点,seek 到一个 P 帧直接解码------画面是灰的、带块状残影,几秒后下一个 I 帧到了才恢复
  • 全标成非同步:seek 永远找不到落点,进度条一拖就回到 0;某些播放器干脆不起播。

顺带说分片的切点。分片边界应该落在关键帧上------每个分片从 I 帧开始,才是"独立可解码"的单位,进程被杀后最后一个完整分片才真的能播。结构上规范并不强制这一点(P 帧开头的分片是合法的),所以 muxer 不会报错,但录屏引擎那篇承诺的"被杀后已落盘的每个分片都可播"就不成立了:最后一个分片开头几帧是灰的。

正确做法是让分片的切分由关键帧驱动 而不是由时间驱动:编码器 GOP 设为 1 秒,muxer 收到关键帧时才切分片。时间只是用来决定"下一个关键帧要不要强制"(kVTEncodeFrameOptionKey_ForceKeyFrame),不直接决定切点。


七、音频轨:esds 的描述符和那两个字节

音频轨的骨架跟视频轨对称:smhdvmhdmp4aavc1esdsavcC

mp4a 的字段:

swift 复制代码
func mp4a(channels: UInt16, sampleRate: UInt32, esds: [UInt8]) -> [UInt8] {
    box("mp4a") { w in
        w.zeros(6); w.u16(1)              // reserved, data_reference_index
        w.zeros(8)                        // reserved
        w.u16(channels)
        w.u16(16)                         // samplesize,固定 16
        w.zeros(4)                        // pre_defined, reserved
        w.u32(sampleRate << 16)           // 16.16 定点
        w.raw(esds)
    }
}

esds 是 MPEG-4 Systems 的描述符嵌套,用的是另一套编码规则(tag + 变长 length + payload)。最关键的 payload 是 DecoderSpecificInfo 里的 AudioSpecificConfig,只有两个字节

arduino 复制代码
5 bit  audioObjectType     : 2 = AAC-LC
4 bit  samplingFrequencyIndex : 3 = 48000, 4 = 44100
4 bit  channelConfiguration
3 bit  0
swift 复制代码
func audioSpecificConfig(sampleRate: Int, channels: Int) -> [UInt8] {
    let freqIndex: [Int: UInt16] = [96000:0, 88200:1, 64000:2, 48000:3, 44100:4, 32000:5,
                                    24000:6, 22050:7, 16000:8, 12000:9, 11025:10, 8000:11]
    let v: UInt16 = (2 << 11) | (freqIndex[sampleRate]! << 7) | (UInt16(channels) << 3)
    return [UInt8(v >> 8), UInt8(v & 0xFF)]
}

func esds(asc: [UInt8]) -> [UInt8] {
    // 描述符 length 用 1 字节短编码即可(payload 都很小)
    let dsi: [UInt8]  = [0x05, UInt8(asc.count)] + asc                              // DecoderSpecificInfo
    let dcd: [UInt8]  = [0x04, UInt8(13 + dsi.count),
                         0x40,                    // objectTypeIndication: MPEG-4 Audio
                         0x15,                    // streamType audio(5)<<2 | reserved 1
                         0, 0, 0,                 // bufferSizeDB
                         0, 0, 0, 0,              // maxBitrate(填 0 合法)
                         0, 0, 0, 0] + dsi        // avgBitrate
    let sl: [UInt8]   = [0x06, 0x01, 0x02]                                            // SLConfigDescriptor
    let es: [UInt8]   = [0x03, UInt8(3 + dcd.count + sl.count),
                         0, 0,                    // ES_ID
                         0] + dcd + sl            // flags
    return fullBox("esds") { $0.raw(es) }
}

这两个字节错一位(比如 44100 和 48000 的 index 写反),音频是变调的------能播,但音高不对,而且你要听得够仔细才发现。这是我见过 muxer 里最不动声色的一类错误。

Apple 的工具写出来的 esds 里 length 用的是 4 字节扩展编码(0x80 0x80 0x80 0xNN),跟上面的 1 字节短编码都合法。看到别人文件里的 0x80 0x80 0x80 不要以为是魔数。

AAC 每帧 1024 个采样点,所以音频轨的 mdhd.timescale 设成采样率之后,每个 sample 的 duration 恒为 1024,tfdt 就是 1024 × 已写帧数------音频轨是整个 muxer 里唯一不需要"滞后一帧"的地方,因为它的 duration 是先验已知的。


八、收尾:把 duration 补回去

正常结束时做三件事:finish() 把 pending 的那一帧收进最后一个分片并写盘;然后回头补 duration。

因为 moov 是你自己构建的,每个字段的偏移都能在构建时记录下来:

swift 复制代码
struct HeaderLayout {
    var mvhdDurationOffset: Int      // 相对文件开头
    var tkhdDurationOffsets: [Int]   // 每条轨
    var mdhdDurationOffsets: [Int]
}

func patchDurations(fd: Int32, layout: HeaderLayout,
                    movieTimescale: UInt32, tracks: [TrackState]) {
    let movieDur = tracks.map { UInt32($0.decodeTime * UInt64(movieTimescale) / UInt64($0.timescale)) }.max() ?? 0
    write32(fd, at: layout.mvhdDurationOffset, movieDur)
    for (i, t) in tracks.enumerated() {
        write32(fd, at: layout.tkhdDurationOffsets[i], movieDur)             // tkhd 用 movie timescale
        write32(fd, at: layout.mdhdDurationOffsets[i], UInt32(t.decodeTime)) // mdhd 用 media timescale
    }
}

func write32(_ fd: Int32, at offset: Int, _ v: UInt32) {
    var be = v.bigEndian
    pwrite(fd, &be, 4, off_t(offset))
}

注意 tkhd.duration 用的是 movie timescale,mdhd.duration 用的是 media timescale,两个不一样。填反了的症状是"时长显示成 90 倍或者 1/90",倒是很容易发现。

三次 pwrite,12 字节,不需要移动任何数据。这就是把 moov 放在文件开头、并且自己控制其布局的直接回报。


九、调试:宽容的解析器是最差的测试预言机

手写 muxer 最痛苦的是错误信息为零。这几件工具按顺序用:

1. Bento4 的 mp4dump ------ 把 box 树和每个字段打出来。这是第一道,对着规范一个个核。它对结构错误的容忍度很低,能发现大部分 box 层面的问题。

2. ffprobe -show_packets ------ 逐 packet 打印 pts/dts/duration/size/flags(K 表示关键帧)。用来核 tfdt、duration 累加、sample flags:

csharp 复制代码
ffprobe -v error -show_packets -select_streams v out.mp4 | grep -E "pts=|duration=|flags="

如果 pts 序列不单调,或者相邻差跟你预期的帧间隔对不上,就是第四节的问题。

3. AVFoundation ------ 最后也是最重要的一道:

swift 复制代码
let asset = AVURLAsset(url: url)
let playable = try await asset.load(.isPlayable)
let tracks = try await asset.load(.tracks)
let duration = try await asset.load(.duration)

这里引出这节标题那句话。ffmpeg 系的解析器(ffplay、VLC、大部分 Android 播放器的底层)设计目标是"尽可能播出来",对结构缺陷极其宽容:缺空表照播、brand 不认照播、tfhd 基址算错了它还会猜。这让它成为一个很差的测试预言机------它说"能播"几乎不提供任何信息。

AVFoundation 相反,它按规范严格解析,不猜。能被 AVFoundation 播的文件,基本在哪里都能播;反过来不成立。 所以调试顺序应该是先过 AVFoundation,最后才拿 VLC 验一下兼容性,而不是反过来。我第一版就是反过来的:VLC 能播就以为完事了,浪费了一整天。

一个更极端的说法:测试一个格式写出器,要用最严格的读者,而不是最宽容的读者。 这句话对 JSON、对协议 buffer、对任何有 schema 的东西都成立。


十、诚实对比:你大概率不该写这个

AVAssetWriter 从 iOS 14 起可以输出 fMP4:

swift 复制代码
writer.outputFileTypeProfile = .mpeg4AppleHLS
writer.preferredOutputSegmentInterval = CMTime(seconds: 1, preferredTimescale: 1)
writer.initialSegmentStartTime = .zero
writer.delegate = self   // assetWriter(_:didOutputSegmentData:segmentType:) 里拿到每个分片的 Data

它帮你处理了这篇讲的所有东西,而且是 Apple 自己的实现,跟 AVFoundation 的解析器天然对齐。如果你能接受 iOS 14+ 且不在 Extension 的内存红线下,用它。这篇文章对你就是背景知识,不是操作手册。

自己写的理由,录屏引擎那篇讲过,只有三条站得住:最低版本压不到 iOS 14、内存必须逐字节可归因、要在被杀时保证每个落盘分片自洽。三条一条都不占,就别写。

另一个折中:只写 muxer,不写解析器。 你不需要一个通用的 MP4 库,你需要的是"把这两条轨的数据按固定结构落盘"。固定结构意味着大量字段是常量,整个实现几百行。把它限定在"只服务我自己的编码器输出"这个范围内,复杂度是可控的;一旦想做通用,复杂度是另一个量级。


十一、能带走的三条

第一,格式是"过去完成时"的,流是"现在进行时"的,中间永远差一个。 trun 要 duration,而 duration 依赖下一帧;moofdata_offset,而它依赖 moof 自己的大小。凡是流式地写一个"自描述"格式,都会遇到这种前瞻依赖。解法只有两种:留一个的余量(滞后一帧),或者把依赖变成常量(data_offset 不影响大小、moov 布局固定所以偏移已知)。设计格式的人早就知道这个,所以 fMP4 才把索引拆进分片;写 muxer 的人要在自己的代码里再做一次同样的事。

第二,默认行为是为最简单的情况准备的,而你的第一版就是最简单的情况。 tfhd 不设 flag,单轨完全正确;空表省略,VLC 完全正确;sample_flags 填 0,顺序播放完全正确。这些"正确"全是假象,是最简单场景恰好和默认值重合。写任何有 flag 的格式,凡是能显式指定的都显式指定,不要依赖默认。

第三,测试写出器要用最严格的读者。 宽容的解析器是产品层面的美德、测试层面的缺陷。它把你的错误吞掉、修好、播出来,然后你在真正严格的地方(相册、QuickTime、服务端转码)翻车,而且这时离你写下那行错误代码已经过去很久了。

回到开头那个场景:VLC 能播、相册不能。现在你知道应该做什么了------不是去怀疑相册,是去拿 mp4dump 看一眼那四张空表在不在。


如果你正在排查一个"某些播放器能播、某些不能"的 MP4,最快的路径是 mp4dump 看结构 → ffprobe -show_packets 看时间轴 → AVFoundation 的 isPlayable 做终审。三步下来,问题基本能定位到具体 box。


关于本文代码

文中的 Swift 示例用于说明 box 的字段布局、构建顺序和几个关键字段的计算方式,未逐行编译验证 。直接用于工程需要补全:findTrunDataOffsetField 应替换为构建时记录绝对偏移的 writer(示例里的扫描定位只为可读);HeaderLayout 的偏移需要在构建 moov 时实际记录;Sample 类型、文件句柄的生命周期与错误处理、moof/mdat 写盘的原子性(建议一次 writev 或先拼成一段连续内存再写)、以及 B 帧场景下 trunversion = 1 与有符号 composition offset 均未展开。

ftyp brand、tfhd/trun 的 flag 数值、sample_flags 位域、AudioSpecificConfig 的位布局、esds 各描述符的 tag 均按 ISO/IEC 14496-12 与 14496-14 公开规范给出,可对照规范文本复核。涉及的播放器行为(ffmpeg 系解析器的宽容度、AVFoundation 的严格性、duration = 0 的处理差异)以实际调试观察为准,不同版本可能存在差异。

相关推荐
深念Y3 小时前
iOS模拟器在无Metal的NVIDIA显卡环境下的可行性研究报告
服务器·ios·unix·pve·freebsd
2501_915909064 小时前
SwiftUI 和 UIKit 怎么选?两代 UI 框架的适用范围
vscode·ios·objective-c·个人开发·swift·敏捷流程
恋猫de小郭6 小时前
Shopify 从 React Native 回到 Swift/Kotlin,但是你以为有手就行??
android·前端·ios
for_ever_love__6 小时前
iOS: GCD高级API
macos·ios·objective-c·cocoa·多线程·gcd
2501_915918416 小时前
在Windows10上使用VSCode和Code Runner搭建Swift开发环境详细步骤
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
秋枫要学习7 小时前
iPhone 18 Pro顶配涨3500
ios·iphone
parade岁月7 小时前
倒反天罡!押注 React Native 6 年后,Shopify 又回到了原生开发
android·前端·ios
深念Y8 小时前
黑苹果P400-卡顿排查与优化记录
linux·macos·ios·pve·黑苹果·英伟达·n卡
DQQzero9 小时前
折叠屏的“最后一块拼图“:iPhone Duo的4:3形态对iOS多任务的底层重构
ios·iphone·折叠屏