记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复

6 小时前(已编辑)
/ ,
2

记一次 HLS 视频流 fMP4 时间戳对齐问题的排查与修复

最近尝试接触前端音视频领域。在开发 HLS(fMP4)播放器的过程中,遇到一个比较棘手的问题: 部分 fmp4 分片的 tfdt.baseMediaDecodeTime 并不是从 0 开始连续递增。在直接通过 mediaSource 播放时,容易出现时间轴错位,甚至播放器卡死的情况。这篇文章记录一下修复过程。

踩坑过程

今天在写 fmp4 player 的时候发现部分 M3U8 播放不出来,这边用 ffprobe 看了下发现它的 start 比较诡异:

ffprobe /Users/suemor/Downloads/xxxx.m4s 2>&1 | grep Duration
Duration: 00:00:08.35, start: 4.189002, bitrate: 2071 kb/s

可以看到 start 直接从第 4 秒开始了,这会导致浏览器 mediaSource 播放出现问题。

开始想了一个比较 hack 方式,但这有些问题,当用户 seek 到 0-4 秒之间会被强制 seek 到第 4 秒,体验上欠妥。

const { buffered } = state.sourceBuffer
const { currentTime } = state.media
const GAP_TOLERANCE = 0.5 // 容忍 0.5s 的误差
const JUMP_OFFSET = 0.1 // 跳入 Buffer 内部 0.1s 以确保安全
if (buffered.length > 0) {
  const bufferStart = buffered.start(0)
  if (
    currentTime < bufferStart &&
    bufferStart - currentTime > GAP_TOLERANCE
  ) {
    state.media.currentTime = bufferStart + JUMP_OFFSET
    return
  }
}

为了解决这个问题,我打算在播放链路中引入了一个简单的 transmuxer,在解封装阶段对 baseMediaDecodeTime 进行修正。

Box 结构

要开发一个 transmuxer 需要先了解下目前手里这个 fmp4 box 结构:

[File] (3s Segment)
 ├── [ftyp]
 ├── [moov]
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -> 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -> 需要修正!
 ├── [mdat] (Media Data 1)
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 ├── [mdat] (Media Data 2)
 └── ... (重复多次)

我查阅资料后发现:TFDT Box 里的 baseMediaDecodeTime 决定了这个片段的绝对解码时间

后续需要读取整个流中第一个 moofbaseMediaDecodeTime 作为基准偏移量,对于分片内的每一个 moof(不仅仅是第一个),都执行 当前时间 - Offset,强行把时间轴整体“平移”回 0 起点。

Box 解析器

要修改二进制数据,首先得能读懂它。JavaScript 的 DataView 是处理二进制数据的神器,它允许我们直接操作内存并控制大端序。

下面实现了一个极简的 MP4Parser 类。它的核心逻辑是:读取 Box 的 Header(大小和类型),并提供访问内容的视图。

// mp4-parser.ts
export type BufferLike = ArrayBuffer | SharedArrayBuffer

export class MP4Parser {
  private readonly buffer: BufferLike
  private readonly fileEnd: number

  public readonly offset: number
  public readonly size: number
  public readonly type: number
  public readonly headerSize: number

  constructor(buffer: BufferLike, offset: number, fileEnd?: number) {
    this.buffer = buffer
    this.offset = offset
    this.fileEnd = fileEnd ?? buffer.byteLength
    
    // 读取前 16 字节来解析 Header
    const view = new DataView(
      buffer,
      offset,
      Math.min(16, this.fileEnd - offset),
    )
    
    const size32 = view.getUint32(0)
    this.type = view.getUint32(4) // Box 类型

    // 处理 size 的不同情况 (标准 MP4 协议)
    if (size32 === 1) {
      // size 为 1 表示这是个 Large Box,真实大小在后面 8 字节
      this.size = Number(view.getBigUint64(8))
      this.headerSize = 16
    } else if (size32 === 0) {
      // size 为 0 表示一直到文件末尾
      this.size = this.fileEnd - offset
      this.headerSize = 8
    } else {
      this.size = size32
      this.headerSize = 8
    }
  }

/**
   * 获取 Box 的内容部分(不包含 Header)
   * 返回 DataView,适合读取内部的具体数值(如 TrackID, TimeStamp)
   */
  public getContentDataView(): DataView {
    return new DataView(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }

    /**
   * 获取 Box 的内容部分(不包含 Header)
   * 返回 Uint8Array,适合作为下一次 findBoxes 的输入,或者进行字节级操作
   */
  public getContentView(): Uint8Array {
    return new Uint8Array(
      this.buffer,
      this.offset + this.headerSize,
      this.size - this.headerSize,
    )
  }
}

查找 Box

有了解析器,我们还需要开发一个快速查找指定 Box 的工具。

在下方 BoxUtils 中,实现了一个 findBoxes 函数。为了性能最大化,它采用了跳跃式遍历:读取一个 Box 的 header 拿到 size,如果不匹配,直接跳过 size 长度的字节,而不是逐字节扫描。

// box-utils.ts
import { MP4Parser } from './mp4-parser'

// 辅助函数:统一转为 Uint8Array
function asU8(data: BytesLike): Uint8Array {
  return data instanceof Uint8Array ? data : new Uint8Array(data)
}

export const BoxUtils = {
  /**
   * 在给定的数据范围内查找指定类型的 Box
   */
  findBoxes(
    data: BytesLike,
    type: number,
    start = 0,
    end?: number,
  ): MP4Parser[] {
    const u8 = asU8(data)
    const buf = u8.buffer
    const base = u8.byteOffset
    const limit = end ?? u8.byteLength

    const boxes: MP4Parser[] = []
    let offset = start
    
    // 循环遍历,直到范围结束
    while (offset < limit) {
      // 剩余数据不足 header 长度,退出
      if (offset + 8 > limit) {
        break
      }
      
      const box = new MP4Parser(buf, base + offset, base + limit)
      const boxSize = box.size === 0 ? limit - offset : box.size

      // 异常检查
      if (boxSize < box.headerSize || offset + boxSize > limit) {
        break
      }

      // 找到目标 Box,加入结果列表
      if (box.type === type) {
        boxes.push(box)
      }
      
      // 关键:直接跳过整个 Box 的大小,进入下一个 Box
      offset += boxSize
    }
    return boxes
  },
}

完成修正

我们先梳理接下来查找 tfdt box 的链路。

根据下方链路图片可以发现:我们需要对 moof -> traf -> tfhd -> tfdt 进行解析。

[File] (3s Segment)
 ├── [moof] (Fragment 1)
 │    ├── [mfhd]
 │    ├── [traf] (Track Fragment - 视频轨?)
 │    │    ├── [tfhd] (Track ID: 1)
 │    │    └── [tfdt] (Decode Time) -> 需要修正!
 │    └── [traf] (Track Fragment - 音频轨?)
 │         ├── [tfhd] (Track ID: 2)
 │         └── [tfdt] (Decode Time) -> 需要修正!
 ├── [moof] (Fragment 2)
 │    ├── [mfhd]
 │    ├── [traf] ...
 │    └── [traf] ...
 └── ... (重复多次)
 
// 对应的 16 进制表示
MOOF: 0x6d6f6f66
TRAF: 0x74726166
TFHD: 0x74666864
TFDT: 0x74666474

解析 tfhd trackId

对于 traf 的 trackId 它位于 tfhd box body 的第 4 个字节处,它占据 4 个字节。

[TFHD Body Layout]
+---------+---------+-----------+
| Version |  Flags  | Track ID  | ...
+---------+---------+-----------+
|  1 byte | 3 bytes |  4 bytes  |
+---------+---------+-----------+
^                   ^
offset 0            offset 4 (读取位置)
parseTfhdTrackId(tfhdBox: MP4Parser): number {
  const view = tfhdBox.getContentDataView()
  // 偏移量 4 = Version(1) + Flags(3)
  // 紧接着就是 TrackID (4 bytes)
  return view.getUint32(4)
},

解析 tfdt baseMediaDecodeTime

tfdt (Track Fragment Decode Time) 存储了该分片的绝对解码时间。这里有一个必须注意的版本兼容性问题:

  • Version 0:使用 32 位整数(UInt32)。
  • Version 1:使用 64 位整数(UInt64)。

视频时间一长,时间戳很容易超过 32 位整数的范围(约 42 亿),因此现代 HLS 流大多使用 Version 1。为了防止精度丢失,我在代码中统一将其转换为 JavaScript 的 BigInt

parseTfdtBaseMediaDecodeTime(tfdtBox: MP4Parser): bigint {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0) // 读取第1个字节:version 用于判断是否 32 位溢出
  
  if (version === 0) {
    // Version 0: 32位,转为 BigInt 统一处理
    return BigInt(view.getUint32(4))
  }
  // Version 1: 64位,必须用 getBigUint64
  return view.getBigUint64(4)
},

修正 tfdt baseMediaDecodeTime

同理,这里也需要注意 32 位溢出的问题,需要用 version 进行额外判断

updateTfdtBaseMediaDecodeTime(
  tfdtBox: MP4Parser,
  newBaseMediaDecodeTime: bigint,
) {
  const view = tfdtBox.getContentDataView()
  const version = view.getUint8(0)
  
  if (version === 0) {
    // 32位写入:需要将 BigInt 转回 Number
    view.setUint32(4, Number(newBaseMediaDecodeTime))
  } else {
    // 64位写入:直接写入 BigInt
    view.setBigUint64(4, newBaseMediaDecodeTime)
  }
},

调用

最后,我们需要在主类中调用上述方法。由于我的流结构包含多个 moof 且每个 moof 包含多个 traf,代码采用了双层循环结构。

这里需要注意,我的 fmp4 包含两个 traf 分别对于视频和音频,他们的 decodeTime 并不是一致的,所以需要解析 tfhd box 获取当前的 track id,来区分当前 traf 属于哪个轨道,从而能够使用到对于轨道的 decodeTime。

// transmuxer.ts
import { BoxUtils } from './box-utils'

export class Transmuxer {
  private baseTimestampOffset: Record<number, bigint> = {} // TrackID -> Offset
  private fmp4: Fmp4 

  constructor(fmp4: Fmp4) { this.fmp4 = fmp4 }

  processTimeOffset(data: ArrayBuffer, isFirstSegment: boolean) {
    // 第一层循环:遍历分片内所有的 MOOF
    BoxUtils.findBoxes(data, BoxUtils.types.MOOF).forEach((moofBoxes) => {
      
      const trafBoxes = BoxUtils.findBoxes(moofBoxes.getContentView(), BoxUtils.types.TRAF)
      
      // 第二层循环:遍历 MOOF 内所有的 TRAF (通常是 Video 和 Audio)
      for (const trafBox of trafBoxes) {
        const trafContent = trafBox.getContentView()
        
        // 1. 获取 Track ID (音频/视频分开处理)
        const tfhdBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFHD)
        if (tfhdBoxes.length === 0) continue
        const trackId = BoxUtils.parseTfhdTrackId(tfhdBoxes[0])

        // 2. 找到 TFDT 读取时间
        const tfdtBoxes = BoxUtils.findBoxes(trafContent, BoxUtils.types.TFDT)
        if (tfdtBoxes.length === 0) continue
        const tfdtBox = tfdtBoxes[0]
        
        // 使用 BigInt 读取,防止大整数溢出
        const baseMediaDecodeTime = BoxUtils.parseTfdtBaseMediaDecodeTime(tfdtBox)
          
        // 3. 仅在全流的第一个分片记录基准 Offset
        if (isFirstSegment && this.baseTimestampOffset[trackId] === undefined) {
          this.fmp4.logger.log(`[Transmuxer] Track ${trackId} set offset: ${baseMediaDecodeTime}`)
          this.baseTimestampOffset[trackId] = baseMediaDecodeTime
        }

        // 4. 修正时间:当前时间 - Offset
        const offset = this.baseTimestampOffset[trackId] ?? 0n
        const newBaseMediaDecodeTime = baseMediaDecodeTime - offset
        
        if (newBaseMediaDecodeTime < 0n) continue 
        
        // 5. 原地修改 Buffer
        BoxUtils.updateTfdtBaseMediaDecodeTime(tfdtBox, newBaseMediaDecodeTime)
      }
    })
    return data
  }
}

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...