记一次 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 决定了这个片段的绝对解码时间。
后续需要读取整个流中第一个 moof 的 baseMediaDecodeTime 作为基准偏移量,对于分片内的每一个 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
}
}