第十一章|音视频同步:时间戳、同步与播放控制
围绕 timestamp、duration、PTS、DTS、timebase、timescale、sampleRate、frameRate、A/V sync、seek 和 WebCodecs timestamp 传递,建立浏览器音视频处理中的时间线心智模型。
第十一章|音视频同步:时间戳、同步与播放控制
1. 本章学习目标
学完这一章,你要能回答一个核心问题:
媒体处理中最容易错的不是“有没有解出来”,而是“什么时候显示、什么时候播放、什么时候丢、什么时候补”。
本章重点不是实现一个完整播放器,而是建立工程里真正需要的时间线思维。你需要掌握:
timestamp、duration到底表示什么。PTS和DTS的区别。timebase / timescale为什么存在。- 30fps 视频的每一帧时间戳怎么计算。
- 48kHz 音频的 sample 和时间怎么互相换算。
- 音视频为什么会不同步。
- seek 为什么经常要从关键帧开始。
- WebCodecs pipeline 里 timestamp 如何一路传递。
- 面试时如何把 A/V sync 讲清楚。
2. 先建立一个总模型:媒体文件不是“连续播放”,而是“按时间表执行”
很多新人会以为:
MP4 文件
↓
一帧一帧读出来
↓
一帧一帧显示
但真实播放器更像这样:
MP4 文件
↓
demuxer 解析容器
↓
得到 video sample / audio sample
↓
每个 sample 都带有时间信息
↓
decoder 解码
↓
scheduler 根据时间戳决定何时播放 / 显示
也就是说,播放器不是单纯地“读到哪播到哪”,而是看每个音频块、视频帧的 时间戳。
一个非常简化的媒体时间线:
media timeline, seconds
0.000s 0.033s 0.066s 0.100s
|-------------|-------------|-------------|
video frame 0 video frame 1 video frame 2 video frame 3
0.000s 0.021s 0.043s 0.064s 0.085s
|-------|-------|-------|-------|-------|
audio packet 0 audio packet 1 audio packet 2 audio packet 3
视频可能是 30fps,每帧约 33.333ms;音频可能是 48kHz,每秒 48000 个采样点。它们的节奏完全不同,所以必须通过统一的时间线对齐。
3. timestamp 是什么
timestamp 可以理解为:
某个音频块或视频帧在媒体时间线上的开始时间。
比如一个视频帧的 timestamp 是 2.000s,意思是:
这帧应该在视频播放到 2 秒时显示。
一个音频块的 timestamp 是 2.000s,意思是:
这段音频应该从播放到 2 秒时开始发声。
在工程里,timestamp 不一定直接用秒表示。它可能是:
MP4 timescale tick
WebCodecs microseconds
FFmpeg time_base unit
Web Audio seconds
所以时间同步的第一件事就是:
不要只看 timestamp 数字本身,要知道它的单位。
例如:
timestamp = 3000
它可能表示:
3000 / 90000 = 0.033333s
也可能表示:
3000 microseconds = 0.003s
数字一样,含义差十倍以上。音视频 bug 里这种“单位错了”的坑,堪称祖传老坑。
4. duration 是什么
duration 表示:
这一帧或这一段音频在时间线上持续多久。
比如 30fps 的视频,如果是恒定帧率:
每秒 30 帧
每帧 duration = 1 / 30s = 0.033333s
时间线可以写成:
frame 0: timestamp = 0.000s, duration = 0.033333s
frame 1: timestamp = 0.033333s, duration = 0.033333s
frame 2: timestamp = 0.066667s, duration = 0.033333s
frame 3: timestamp = 0.100000s, duration = 0.033333s
对于音频,假设一个 AAC packet 对应 1024 个 PCM samples,采样率是 48000Hz:
duration = 1024 / 48000s
≈ 0.021333s
≈ 21.333ms
所以音频 packet 的时间线可能是:
packet 0: timestamp = 0.000000s, duration = 0.021333s
packet 1: timestamp = 0.021333s, duration = 0.021333s
packet 2: timestamp = 0.042667s, duration = 0.021333s
packet 3: timestamp = 0.064000s, duration = 0.021333s
5. PTS 和 DTS
这是本章最重要的概念之一。
5.1 PTS:Presentation Timestamp
PTS 全称是 Presentation Timestamp。
它表示:
这一帧应该什么时候展示。
对视频来说,是“什么时候显示到屏幕上”。
对音频来说,是“什么时候播放出来”。
所以 PTS 关心的是:
用户看到 / 听到的时间顺序
5.2 DTS:Decoding Timestamp
DTS 全称是 Decoding Timestamp。
它表示:
这一帧应该什么时候送进解码器解码。
DTS 关心的是:
解码器处理数据的顺序
5.3 为什么 PTS 和 DTS 会不一样?
如果视频里只有 I 帧和 P 帧,很多时候显示顺序和解码顺序可以一致。
但如果有 B 帧,情况就变了。
先回忆一下:
I 帧:关键帧,可以独立解码
P 帧:参考前面的帧
B 帧:可能参考前后两边的帧
假设显示顺序是:
显示顺序 / PTS order:
I0 B1 B2 P3
B1、B2 可能需要参考 P3 才能解码。因此解码器必须先拿到 P3:
解码顺序 / DTS order:
I0 P3 B1 B2
这时候:
PTS != DTS
用表格看更清楚:
| 帧 | 显示顺序 PTS | 解码顺序 DTS | 说明 |
|---|---|---|---|
| I0 | 0 | 0 | 关键帧,先显示也先解码 |
| B1 | 1 | 2 | 显示早,但要等参考帧解出来 |
| B2 | 2 | 3 | 同上 |
| P3 | 3 | 1 | 显示晚,但需要提前解码 |
播放器必须按 DTS 把数据喂给 decoder,但按 PTS 把解码后的帧显示出来。
这就是为什么“解码出来了”不代表“马上显示”。
6. timebase / timescale:时间戳的单位系统
6.1 为什么不用浮点秒?
理论上我们可以用秒:
0.000
0.033333333
0.066666666
但媒体文件里通常不用浮点数,而用整数 tick。
原因是:
- 整数更稳定,不容易累计浮点误差。
- 容器格式需要精确表达 sample 时间。
- 不同轨道可能天然有不同时间单位。
比如 MP4 里经常有这样的概念:
timescale = 一秒钟有多少个时间单位
duration = 持续多少个时间单位
如果:
timescale = 90000
duration = 3000
那么真实时长是:
durationSeconds = 3000 / 90000
= 0.033333s
MP4 / ISO BMFF 的时间信息会出现在多个 box 里,例如 mvhd、tkhd、mdhd、stts、ctts 等;其中 mdhd 的 timescale 是 track 自己的时间单位,stts 保存 sample duration,ctts 可以保存 composition time 和 decoding time 的偏移关系。(SWGDE - SWGDE)
6.2 timescale 和 timebase 的两种常见说法
工程里你会看到两种表达:
说法一:MP4 风格
timescale = 90000
seconds = timestamp / timescale
例如:
timestamp = 180000
timescale = 90000
seconds = 180000 / 90000
= 2s
说法二:FFmpeg 风格
time_base = 1 / 90000
seconds = timestamp * time_base
例如:
timestamp = 180000
time_base = 1 / 90000
seconds = 180000 * 1 / 90000
= 2s
本质一样,只是说法不同:
timescale = 每秒多少 tick
time_base = 每个 tick 是多少秒
7. 例子:30fps 视频每帧 timestamp 如何变化
假设视频是 30fps。
7.1 用秒表示
frameDuration = 1 / 30
= 0.033333333s
前几帧:
| frame index | timestamp 秒 | duration 秒 |
|---|---|---|
| 0 | 0.000000 | 0.033333 |
| 1 | 0.033333 | 0.033333 |
| 2 | 0.066667 | 0.033333 |
| 3 | 0.100000 | 0.033333 |
| 4 | 0.133333 | 0.033333 |
7.2 用 MP4 timescale 表示
假设 video track 的 timescale 是 90000。
frameDurationTicks = 90000 / 30
= 3000
前几帧:
| frame index | timestamp tick | duration tick | seconds |
|---|---|---|---|
| 0 | 0 | 3000 | 0.000000 |
| 1 | 3000 | 3000 | 0.033333 |
| 2 | 6000 | 3000 | 0.066667 |
| 3 | 9000 | 3000 | 0.100000 |
| 4 | 12000 | 3000 | 0.133333 |
7.3 用 WebCodecs 表示
WebCodecs 的 EncodedVideoChunk.timestamp 是以 microseconds,微秒 为单位的整数。(MDN Web Docs)
1 second = 1_000_000 microseconds
30fps 每帧 duration = 1_000_000 / 30
≈ 33333.333 microseconds
但 timestamp 要用整数,所以不能每帧都简单写死 33333,否则会慢慢积累误差。
更稳的写法是:
function videoTimestampUs(frameIndex: number, fps: number): number {
return Math.round((frameIndex * 1_000_000) / fps);
}
function videoDurationUs(frameIndex: number, fps: number): number {
return videoTimestampUs(frameIndex + 1, fps) - videoTimestampUs(frameIndex, fps);
}
for (let i = 0; i < 5; i++) {
console.log({
frame: i,
timestamp: videoTimestampUs(i, 30),
duration: videoDurationUs(i, 30),
});
}
输出大概是:
frame 0: timestamp 0, duration 33333
frame 1: timestamp 33333, duration 33334
frame 2: timestamp 66667, duration 33333
frame 3: timestamp 100000, duration 33333
frame 4: timestamp 133333, duration 33334
你会发现 duration 会在 33333 和 33334 之间调整,这是为了避免长期累计误差。
这比每次都写:
timestamp += 33333;
更靠谱。
8. 例子:48kHz 音频 sample 和时间的关系
采样率 sampleRate = 48000 的意思是:
每秒 48000 个 sample
Web Audio 的 AudioBuffer.sampleRate 表示 PCM 数据每秒的 sample 数,length 表示 sample-frame 数,duration 表示秒数。(MDN Web Docs)
8.1 单个 sample 对应多久?
1 sample duration = 1 / 48000s
≈ 0.0000208333s
≈ 20.833 microseconds
注意:
sample 很密
video frame 很稀
48kHz 音频一秒有 48000 个 sample,而 30fps 视频一秒只有 30 帧。
这就是为什么播放器通常会用音频时钟作为主时钟。
因为音频对时间漂移更敏感:
视频晚一帧:可能只是轻微卡顿
音频断一下:人耳立刻觉得不对劲
8.2 一个音频 packet 的 duration
假设一个 AAC packet 解码后有 1024 个 samples。
sampleRate = 48000
samplesPerPacket = 1024
durationSeconds = 1024 / 48000
≈ 0.021333333s
durationUs = 1024 * 1_000_000 / 48000
≈ 21333.333us
同样,微秒需要整数,所以可以这样算:
function audioTimestampUs(sampleIndex: number, sampleRate: number): number {
return Math.round((sampleIndex * 1_000_000) / sampleRate);
}
const sampleRate = 48000;
const samplesPerPacket = 1024;
for (let packetIndex = 0; packetIndex < 5; packetIndex++) {
const startSample = packetIndex * samplesPerPacket;
const endSample = startSample + samplesPerPacket;
const timestamp = audioTimestampUs(startSample, sampleRate);
const duration = audioTimestampUs(endSample, sampleRate) - timestamp;
console.log({
packetIndex,
timestamp,
duration,
});
}
大概输出:
packet 0: timestamp 0, duration 21333
packet 1: timestamp 21333, duration 21334
packet 2: timestamp 42667, duration 21333
packet 3: timestamp 64000, duration 21333
packet 4: timestamp 85333, duration 21334
还是同一个原则:
根据 sample index 计算绝对 timestamp,不要靠不断累加小数。
9. 音频 sample rate 和视频 fps 如何对齐
音频和视频不是靠“数量一样”对齐,而是靠“时间戳一样”对齐。
假设:
video: 30fps
audio: 48000Hz
1 帧视频的时长:
1 / 30s ≈ 0.033333s
对应多少音频 samples?
48000 / 30 = 1600 samples
所以在 30fps + 48kHz 的理想情况下:
1 video frame ≈ 1600 audio samples
但现实里不总是这么整齐。
例如 29.97fps:
48000 / 29.97 ≈ 1601.6 samples
不能说“一帧视频永远对应固定整数个音频 samples”。
更稳的做法是:统一换算到时间线。
video frame timestamp = frameIndex / fps
audio timestamp = sampleIndex / sampleRate
然后判断:
当前视频帧 timestamp 是否接近当前音频播放时间
10. A/V sync:音视频同步到底同步什么
A/V sync 指的是:
Audio 和 Video 在同一个媒体时间线上保持一致。
通俗讲:
嘴巴张开时,声音也应该出来。
鼓槌敲到鼓面时,鼓声也应该响。
从播放器角度看:
audio current time ≈ video current time
常见策略是:
以音频为主时钟
视频追音频
因为音频播放由声卡稳定推进,不适合频繁“暂停、跳跃、拉扯”。视频则可以通过延迟显示、丢帧、补帧来追上音频。
11. 一个简化播放器的同步逻辑
假设 audio clock 当前是:
audioTime = 10.000s
现在有一个视频帧:
frame.pts = 9.900s
说明这帧已经晚了 100ms:
frame.pts - audioTime = -0.100s
它可能应该被丢掉。
如果视频帧:
frame.pts = 10.020s
说明这帧应该在 20ms 后显示:
frame.pts - audioTime = 0.020s
可以稍微等一下再绘制。
简化逻辑:
type DecodedVideoFrame = {
frame: VideoFrame;
timestampUs: number;
};
function shouldDropFrame(frameTimeUs: number, audioTimeUs: number): boolean {
const diffUs = frameTimeUs - audioTimeUs;
// 视频已经落后音频超过 100ms,通常可以考虑丢帧追赶
return diffUs < -100_000;
}
function delayBeforeRenderMs(frameTimeUs: number, audioTimeUs: number): number {
const diffUs = frameTimeUs - audioTimeUs;
// 如果帧在未来,就等到它该显示的时候
return Math.max(0, diffUs / 1000);
}
实际播放器会复杂得多,但核心思路就是:
拿当前主时钟
↓
比较 video frame PTS
↓
早了就等
↓
晚太多就丢
↓
差不多就显示
12. 为什么音视频会漂?
音视频不同步,常见原因有这些。
12.1 timestamp 单位换错
比如 MP4 track 的 timescale 是 90000,你却当成微秒:
正确:
90000 tick = 1s
错误:
90000 us = 0.09s
这种会导致播放速度严重不对。
12.2 忽略 timebase / timescale
只拿到:
sample.pts = 45000;
却不知道:
sample.timescale = 90000;
那这个 45000 没有实际意义。
必须转换:
const seconds = sample.pts / sample.timescale;
const timestampUs = Math.round((sample.pts * 1_000_000) / sample.timescale);
12.3 把 DTS 当 PTS 用
如果视频有 B 帧,DTS 和 PTS 可能不一样。
错误做法:
按解码顺序显示视频帧
结果:
画面顺序错乱
正确做法:
按 DTS 喂 decoder
按 PTS 排序和显示
12.4 忽略 B 帧导致的重排序
有些 decoder 输出的帧已经带着正确的 presentation timestamp。
但如果你在 pipeline 里丢掉、重建、覆盖 timestamp,就可能破坏顺序。
WebCodecs 规范中,VideoDecoder 输出 VideoFrame 时,会从输入的 EncodedVideoChunk 继承 timestamp 和 duration;VideoEncoder 输出 EncodedVideoChunk 时,也会从输入的 VideoFrame 继承 timestamp 和 duration。(W3C)
所以你的任务通常是:
demuxer 给 chunk 正确 timestamp
↓
decoder 输出 frame 保留 timestamp
↓
处理 frame 时不要乱改 timestamp
↓
encoder 输出 chunk 继续保留 timestamp
↓
muxer 根据 timestamp 写回容器
12.5 处理耗时导致视频落后
比如你对每帧做水印、滤镜、AI 分割:
每帧处理 50ms
视频本身 30fps,每帧预算 33ms
结果必然越来越慢。
解决方向:
降低处理复杂度
降低分辨率
使用 Worker
使用 WebGL / WebGPU
丢帧追赶
降低输出 fps
12.6 音频重采样导致长度微变
比如输入音频是:
44100Hz
输出音频项目是:
48000Hz
如果重采样处理不好,可能会产生轻微时长差异。
短视频可能看不出来,长视频就会慢慢漂。
12.7 使用 setTimeout / requestAnimationFrame 当绝对时钟
setTimeout 和 requestAnimationFrame 适合调度 UI,但不适合当作媒体真时钟。
更合理的是:
AudioContext.currentTime
HTMLMediaElement.currentTime
MediaStream 时间
高精度 monotonic clock
然后用 requestAnimationFrame 只是周期性检查:
现在该显示哪一帧?
而不是:
每 33ms 强行显示下一帧。
13. 丢帧、补帧、缓冲
13.1 丢帧 drop frame
如果视频落后音频太多,播放器可能丢掉部分视频帧。
例如:
audioTime = 10.000s
videoFramePTS = 9.700s
已经落后 300ms,这一帧显示出来也没意义,用户只会看到旧画面。
这时候可以:
丢掉旧帧
找更接近当前 audioTime 的帧显示
丢帧的代价:
画面可能跳
但音画同步能追回来
13.2 补帧 / 重复帧
如果视频暂时没有新帧,但音频还在继续,可以短暂重复上一帧。
例如直播、网络抖动时:
音频继续播
视频卡住一瞬
重复帧的代价:
画面卡顿
但比黑屏好
13.3 缓冲 buffer
缓冲是为了抵抗:
网络抖动
解码抖动
磁盘读取抖动
处理耗时波动
播放器通常不会拿到一个 packet 就立刻播,而是保留一点缓冲。
简化模型:
demux queue
↓
decode queue
↓
render queue
同步调度器从 render queue 里挑合适时间的帧显示。
14. seek 的实现思路
seek 指的是:
用户拖动进度条,跳到某个时间点播放。
比如用户要跳到:
targetTime = 60.000s
天真的想法是:
直接找到 timestamp = 60s 的帧,然后解码显示
但视频压缩不是每一帧都能独立解码。
15. 为什么视频 seek 通常要找 keyframe?
因为大部分视频帧依赖其他帧。
比如:
I frame:可独立解码
P frame:依赖前面的帧
B frame:依赖前后参考帧
如果用户要 seek 到 60s,而 60s 正好是一个 P 帧:
P frame at 60s
它可能依赖 58s 的 I 帧之后的一系列参考帧。
所以播放器通常要:
1. 找到 targetTime 之前最近的 keyframe
2. 从 keyframe 开始解码
3. 解码但丢弃 targetTime 之前的帧
4. 到 targetTime 附近开始显示
流程图:
user seek to 60.000s
↓
find nearest previous keyframe, e.g. 58.000s
↓
demux samples from 58.000s
↓
decode 58.000s → 60.000s
↓
drop decoded frames before 60.000s
↓
render from 60.000s
这就是为什么有些视频 seek 慢:
keyframe 间隔太长
↓
需要从很早的关键帧开始解码
↓
用户感觉拖动后卡一下
16. keyframe 间隔和 seek 体验
假设 GOP 是 2 秒:
keyframe: 0s, 2s, 4s, 6s, ...
用户 seek 到 61s:
从 60s keyframe 开始解码
只需要解码约 1s
体验不错。
如果 GOP 是 10 秒:
keyframe: 0s, 10s, 20s, ...
用户 seek 到 69s:
从 60s keyframe 开始解码
需要解码约 9s
seek 可能明显变慢。
所以编码时 keyframe 间隔会影响:
压缩效率
seek 速度
首帧速度
直播延迟
编辑体验
GOP 越长,压缩效率可能更好,但随机访问更差。
17. WebCodecs pipeline 中 timestamp 如何传递
WebCodecs 只负责:
EncodedVideoChunk → VideoFrame
VideoFrame → EncodedVideoChunk
EncodedAudioChunk → AudioData
AudioData → EncodedAudioChunk
MDN 对 WebCodecs 的定位也是低层媒体处理 API:它提供 raw video frame、encoded video frame、raw audio、encoded audio 的表示,以及对应的 encoder / decoder。(MDN Web Docs)
但 WebCodecs 不负责:
解析 MP4
生成 MP4
管理完整播放器时间线
自动做音视频同步
所以 timestamp 通常来自 demuxer。
完整链路:
MP4 file
↓
demuxer
↓
video sample:
- data
- pts
- dts
- duration
- timescale
- isKeyframe
↓
convert pts/duration to WebCodecs microseconds
↓
new EncodedVideoChunk({
type: isKeyframe ? "key" : "delta",
timestamp: ptsUs,
duration: durationUs,
data
})
↓
VideoDecoder.decode(chunk)
↓
output VideoFrame
- frame.timestamp
- frame.duration
↓
Canvas / WebGL / WebGPU processing
↓
new VideoFrame(processedCanvas, {
timestamp: oldFrame.timestamp,
duration: oldFrame.duration
})
↓
VideoEncoder.encode(processedFrame)
↓
output EncodedVideoChunk
- chunk.timestamp
- chunk.duration
↓
muxer
↓
write MP4 sample timing tables
重点是这句:
处理帧时,不要把 timestamp 弄丢。
18. WebCodecs 时间戳转换工具函数
在浏览器端项目里,建议统一写一个时间工具模块。
export type Timescale = number;
/**
* MP4 / container tick → WebCodecs microseconds
*/
export function ticksToUs(ticks: number, timescale: Timescale): number {
if (!Number.isFinite(ticks) || !Number.isFinite(timescale)) {
throw new Error("Invalid ticks or timescale");
}
if (timescale <= 0) {
throw new Error("timescale must be positive");
}
return Math.round((ticks * 1_000_000) / timescale);
}
/**
* WebCodecs microseconds → MP4 / container tick
*/
export function usToTicks(us: number, timescale: Timescale): number {
if (!Number.isFinite(us) || !Number.isFinite(timescale)) {
throw new Error("Invalid microseconds or timescale");
}
if (timescale <= 0) {
throw new Error("timescale must be positive");
}
return Math.round((us * timescale) / 1_000_000);
}
/**
* sample index → microseconds
*/
export function audioSampleIndexToUs(
sampleIndex: number,
sampleRate: number,
): number {
if (sampleRate <= 0) {
throw new Error("sampleRate must be positive");
}
return Math.round((sampleIndex * 1_000_000) / sampleRate);
}
/**
* frame index → microseconds
*/
export function videoFrameIndexToUs(frameIndex: number, fps: number): number {
if (fps <= 0) {
throw new Error("fps must be positive");
}
return Math.round((frameIndex * 1_000_000) / fps);
}
19. WebCodecs 解码时保留 timestamp 的伪代码
假设 demuxer 给你这样的 sample:
type DemuxedVideoSample = {
data: Uint8Array;
pts: number;
dts: number;
duration: number;
timescale: number;
isKeyframe: boolean;
};
喂给 WebCodecs:
function sampleToEncodedVideoChunk(
sample: DemuxedVideoSample,
): EncodedVideoChunk {
return new EncodedVideoChunk({
type: sample.isKeyframe ? "key" : "delta",
timestamp: ticksToUs(sample.pts, sample.timescale),
duration: ticksToUs(sample.duration, sample.timescale),
data: sample.data,
});
}
解码:
const decodedFrames: VideoFrame[] = [];
const decoder = new VideoDecoder({
output(frame) {
// frame.timestamp 来自 EncodedVideoChunk.timestamp
decodedFrames.push(frame);
},
error(error) {
console.error("VideoDecoder error:", error);
},
});
decoder.configure({
codec: "avc1.42E01E",
codedWidth: 1280,
codedHeight: 720,
});
for (const sample of samplesInDtsOrder) {
const chunk = sampleToEncodedVideoChunk(sample);
decoder.decode(chunk);
}
await decoder.flush();
注意这里的 samplesInDtsOrder。
通常:
decode 输入按 DTS 顺序
render 输出按 PTS / timestamp 调度
20. 处理 VideoFrame 时不要丢 timestamp
如果你把帧画到 canvas 上,再创建新 VideoFrame,必须把原始 timestamp 带过去。
错误写法:
const processedFrame = new VideoFrame(canvas, {
timestamp: 0,
});
这会导致所有帧 timestamp 都变成 0。
正确写法:
function createProcessedFrame(
sourceFrame: VideoFrame,
canvas: HTMLCanvasElement | OffscreenCanvas,
): VideoFrame {
return new VideoFrame(canvas, {
timestamp: sourceFrame.timestamp,
duration: sourceFrame.duration ?? undefined,
});
}
然后记得关闭旧帧:
const processed = createProcessedFrame(frame, canvas);
encoder.encode(processed);
frame.close();
processed.close();
21. 一个简化的视频渲染调度器
这个例子展示“以音频时间为主时钟,视频帧追音频”。
type RenderableFrame = {
frame: VideoFrame;
timestampUs: number;
};
class VideoScheduler {
private queue: RenderableFrame[] = [];
private rafId: number | null = null;
constructor(
private readonly getAudioTimeUs: () => number,
private readonly render: (frame: VideoFrame) => void,
) {}
push(frame: VideoFrame) {
if (frame.timestamp == null) {
frame.close();
throw new Error("VideoFrame has no timestamp");
}
this.queue.push({
frame,
timestampUs: frame.timestamp,
});
this.queue.sort((a, b) => a.timestampUs - b.timestampUs);
if (this.rafId == null) {
this.tick();
}
}
private tick = () => {
const audioTimeUs = this.getAudioTimeUs();
while (this.queue.length > 0) {
const next = this.queue[0];
const diffUs = next.timestampUs - audioTimeUs;
// 还没到显示时间,退出等待下一次 RAF
if (diffUs > 10_000) {
break;
}
this.queue.shift();
// 落后太多,丢帧追赶
if (diffUs < -100_000) {
next.frame.close();
continue;
}
this.render(next.frame);
next.frame.close();
break;
}
this.rafId = requestAnimationFrame(this.tick);
};
stop() {
if (this.rafId != null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
for (const item of this.queue) {
item.frame.close();
}
this.queue = [];
}
}
使用方式示意:
const audioContext = new AudioContext();
const startMediaTimeUs = 0;
const startAudioContextTime = audioContext.currentTime;
function getAudioTimeUs() {
const elapsedSeconds = audioContext.currentTime - startAudioContextTime;
return startMediaTimeUs + Math.round(elapsedSeconds * 1_000_000);
}
const scheduler = new VideoScheduler(getAudioTimeUs, (frame) => {
canvasContext.drawImage(frame, 0, 0);
});
这个例子不是完整播放器,但它抓住了同步的核心:
不要按照“解出来一帧就画一帧”
而是按照“当前媒体时间该画哪一帧”
22. seek 的 TypeScript 伪代码
假设我们已经从 MP4 sample table 里拿到了视频 sample 列表:
type SampleIndexEntry = {
sampleIndex: number;
ptsUs: number;
dtsUs: number;
durationUs: number;
fileOffset: number;
size: number;
isKeyframe: boolean;
};
找到 seek 起点:
function findSeekStartSample(
samples: SampleIndexEntry[],
targetTimeUs: number,
): SampleIndexEntry {
let candidate: SampleIndexEntry | null = null;
for (const sample of samples) {
if (sample.ptsUs > targetTimeUs) {
break;
}
if (sample.isKeyframe) {
candidate = sample;
}
}
if (!candidate) {
throw new Error("No keyframe found before target time");
}
return candidate;
}
seek 解码流程:
async function seekAndDecodeToTarget(
samples: SampleIndexEntry[],
targetTimeUs: number,
decoder: VideoDecoder,
) {
const startSample = findSeekStartSample(samples, targetTimeUs);
const samplesToDecode = samples
.filter((sample) => sample.dtsUs >= startSample.dtsUs)
.sort((a, b) => a.dtsUs - b.dtsUs);
for (const sample of samplesToDecode) {
const data = await readSampleData(sample.fileOffset, sample.size);
const chunk = new EncodedVideoChunk({
type: sample.isKeyframe ? "key" : "delta",
timestamp: sample.ptsUs,
duration: sample.durationUs,
data,
});
decoder.decode(chunk);
if (sample.ptsUs >= targetTimeUs + 1_000_000) {
// 示例:最多预解 1 秒
break;
}
}
await decoder.flush();
}
真正项目里还需要处理:
音频 seek
decoder reset
队列清空
当前播放 clock 重置
B 帧重排序
视频和音频共同 seek
但核心就是:
seek 到目标时间
↓
找目标时间之前最近 keyframe
↓
从 keyframe 开始按 DTS 解码
↓
按 PTS 丢弃目标时间之前的输出帧
23. 真实工程中的同步策略
23.1 点播播放器
点播播放器通常有完整文件或可请求区间:
有 sample table
可以查 keyframe
可以预缓冲
可以做相对准确的 seek
同步策略:
以音频时钟为主
视频根据 PTS 调度
落后太多就丢帧
23.2 直播
直播的麻烦是:
网络抖动
数据乱序
延迟控制
缓冲不能无限大
同步策略:
维护 jitter buffer
音频作为主时钟
根据网络情况动态调整缓冲
必要时丢帧降低延迟
23.3 浏览器端编辑器
编辑器更关注:
时间线准确
导出准确
seek 准确
多轨对齐
例如多轨音频混音:
track A starts at 0.000s
track B starts at 2.500s
track C starts at 4.200s
每条轨道都要映射到同一个 timeline 上。
23.4 WebCodecs 视频处理
浏览器端视频处理常见问题:
解码 timestamp 正确
处理后 timestamp 丢失
编码后 chunk 顺序错乱
mux 时 sample duration 错误
建议:
从 demuxer 开始就保存原始 pts/dts/duration
内部统一转成 microseconds
编码后 mux 时再转回目标容器 timescale
24. 常见误区
误区一:fps 就等于每帧时间戳
不完全对。
对于恒定帧率视频,可以用 fps 推导 timestamp。
但现实里存在:
VFR,可变帧率
丢帧
重复帧
非均匀 frame duration
所以更可靠的是读取容器里的 sample timing 信息,而不是只相信 fps。
误区二:timestamp 从 0 开始
不一定。
有些媒体轨道的起始 timestamp 可能不是 0。
工程里经常要做归一化:
normalizedPts = pts - firstPts;
但要注意音频和视频都要用同一套起点规则。
误区三:音频和视频 sample 数量应该能对上
不需要。
音频是 sample-based,视频是 frame-based。
应该对齐的是:
时间
不是:
数量
误区四:解码顺序就是播放顺序
遇到 B 帧就可能错。
应该区分:
DTS:解码顺序
PTS:展示顺序
误区五:WebCodecs 会自动帮你同步音视频
不会。
WebCodecs 是底层编解码 API,不是播放器引擎。
它不会帮你:
解析 MP4
找 keyframe
做 seek
维护 A/V sync
mux 输出文件
这些都要你的 pipeline 自己处理,或者依赖 demuxer / muxer / 播放框架。
误区六:每帧 timestamp += 固定 duration 就够了
短 demo 可能可以。
长视频、29.97fps、VFR、音频 packet duration 不整除时,就容易累计误差。
更好的方式:
根据 frame index / sample index / container timing 计算绝对 timestamp
25. 必须掌握的术语表
| 术语 | 含义 | 面试回答关键词 |
|---|---|---|
| timestamp | 媒体对象在时间线上的时间点 | 决定何时播放/显示 |
| duration | 媒体对象持续时间 | frame/sample 的时间长度 |
| PTS | Presentation Timestamp | 展示时间,按它显示 |
| DTS | Decoding Timestamp | 解码时间,按它喂 decoder |
| timebase | 每个 tick 对应多少秒 | FFmpeg 常见说法 |
| timescale | 每秒多少 tick | MP4 常见说法 |
| fps | 每秒视频帧数 | 不等于绝对可靠的 timing |
| sample rate | 每秒音频采样数 | 48000Hz = 每秒 48000 sample |
| keyframe | 可独立解码的帧 | seek 通常从 keyframe 开始 |
| GOP | 关键帧之间的一组帧 | 影响压缩率和 seek |
| A/V sync | 音视频同步 | audio clock + video scheduling |
| drift | 音画逐渐漂移 | 单位、重采样、时钟误差 |
| jitter | 到达/处理时间抖动 | 需要 buffer |
| VFR | 可变帧率 | 不能只靠 fps 推 timestamp |
| CFR | 恒定帧率 | frame duration 通常固定 |
26. 和真实工程的关系
这一章的知识会直接影响下面这些功能。
26.1 视频剪辑
剪切 10s ~ 20s 片段时,你要处理:
从哪个 keyframe 开始
目标时间之前的帧是否丢弃
音频是否也从对应时间开始
导出时 timestamp 是否归零
26.2 多段视频拼接
拼接时最常见的 bug:
第二段视频 timestamp 还是从 0 开始
结果 mux 后可能:
播放顺序错乱
时长错误
音画不同步
正确做法是给后续片段加 offset:
newPts = oldPts + timelineOffset;
26.3 加水印 / 滤镜
处理每一帧后,必须保留:
timestamp
duration
color metadata,如果需要
rotation / display size,如果需要
否则输出视频可能变成:
所有帧堆在 0 秒
播放速度异常
音视频不同步
26.4 多轨混音
每条音轨都有自己的开始时间:
track A start = 0s
track B start = 3.5s
track C start = 8.2s
离线渲染时要把每条轨道放到共同时间线上。
26.5 抽帧生成缩略图
如果你要“每秒抽一帧”,不能简单理解成:
每 30 帧抽一帧
因为视频可能是 VFR。
更合理:
抽 timestamp 接近 0s、1s、2s、3s 的帧
27. 面试可能怎么问
问题 1:PTS 和 DTS 有什么区别?
简洁回答:
PTS 是展示时间,决定帧什么时候显示;DTS 是解码时间,决定帧什么时候送进解码器。没有 B 帧时二者可能相同,有 B 帧时显示顺序和解码顺序可能不同。
深入回答:
视频压缩里 B 帧可能依赖未来的参考帧,所以解码器需要先解码后面的 P 帧,再解码中间的 B 帧。此时解码顺序和展示顺序不同。播放器应该按 DTS 顺序喂 decoder,按 PTS 调度显示。如果把 DTS 当 PTS 用,画面可能乱序。
问题 2:timebase / timescale 是什么?
简洁回答:
它们是时间戳的单位系统。MP4 里常说 timescale,表示一秒有多少 tick;FFmpeg 常说 time_base,表示一个 tick 是多少秒。
深入回答:
比如 timescale 是 90000,timestamp 是 180000,那么真实时间就是 180000 / 90000 = 2 秒。媒体容器常用整数 tick 表示时间,避免浮点误差。不同 track 可能有不同 timescale,所以跨音视频同步时要先转换到统一时间单位,比如秒或微秒。
问题 3:30fps 视频每帧 timestamp 怎么算?
简洁回答:
恒定 30fps 下,每帧间隔是 1/30 秒,约 33.333ms。第 n 帧 timestamp 是 n / 30 秒。
深入回答:
如果用 WebCodecs 微秒单位,可以用 round(n * 1_000_000 / 30) 计算第 n 帧 timestamp,而不是每次累加 33333 微秒,因为累加会产生长期误差。如果是 MP4 timescale 90000,则每帧 duration 是 90000 / 30 = 3000 tick。
问题 4:48kHz 音频和时间是什么关系?
简洁回答:
48kHz 表示每秒 48000 个 sample。第 n 个 sample 的时间是 n / 48000 秒。
深入回答:
如果一个 AAC packet 解码后有 1024 个 samples,那么它的 duration 是 1024 / 48000 秒,约 21.333ms。做音频同步时应基于 sample index 或 timestamp 换算,而不是靠 packet 数量猜时间。
问题 5:为什么 seek 通常要找关键帧?
简洁回答:
因为非关键帧通常依赖其他帧,不能独立解码。seek 到目标时间时,播放器通常找目标时间之前最近的 keyframe,从那里开始解码,再丢掉目标时间之前的帧。
深入回答:
比如用户 seek 到 60s,如果 60s 是 P 帧,它可能依赖前面的 I 帧和中间的参考帧。播放器不能直接解码 60s 的 P 帧,所以要回退到最近 keyframe。GOP 越长,seek 可能越慢;GOP 越短,seek 更快但压缩效率可能下降。
问题 6:音视频不同步有哪些常见原因?
简洁回答:
常见原因包括 timestamp 单位换错、PTS/DTS 用错、处理耗时导致视频落后、音频重采样误差、丢帧策略不合理、seek 后没有重置 clock 或队列。
深入回答:
真实 pipeline 里最容易出错的是时间单位。比如 MP4 的 tick 被误当成 WebCodecs 的微秒,就会导致播放速度异常。另一个常见问题是处理帧后新建 VideoFrame 时丢了 timestamp,导致编码后的 chunk 时间线错误。长视频里还要注意小数累计误差和音频重采样导致的 drift。
问题 7:WebCodecs 会自动处理 A/V sync 吗?
简洁回答:
不会。WebCodecs 只负责按 chunk/frame 粒度进行编码和解码,不负责容器解析、播放调度、seek、音视频同步或 mux 输出。
深入回答:
输入 MP4 时需要 demuxer 解析出 samples,并把 PTS/DTS/duration 转成 WebCodecs 需要的 timestamp/duration。解码后你要根据 VideoFrame 的 timestamp 做显示调度。重新编码后,还需要 muxer 根据 chunk timestamp 写回容器。
28. 实践任务
任务 1:写时间戳转换工具
要求:
输入:
- pts
- duration
- timescale
输出:
- timestampUs
- durationUs
参考代码:
type MediaSampleTiming = {
pts: number;
dts: number;
duration: number;
timescale: number;
};
type WebCodecsTiming = {
timestampUs: number;
decodeTimeUs: number;
durationUs: number;
};
function convertSampleTiming(sample: MediaSampleTiming): WebCodecsTiming {
return {
timestampUs: Math.round((sample.pts * 1_000_000) / sample.timescale),
decodeTimeUs: Math.round((sample.dts * 1_000_000) / sample.timescale),
durationUs: Math.round((sample.duration * 1_000_000) / sample.timescale),
};
}
你要验证:
timescale = 90000
pts = 180000
duration = 3000
timestampUs 应该是 2000000
durationUs 应该约是 33333
任务 2:模拟 30fps 视频时间线
要求输出前 10 帧:
frameIndex
timestampUs
durationUs
timestampSeconds
参考代码:
function buildVideoTimeline(fps: number, frameCount: number) {
return Array.from({ length: frameCount }, (_, frameIndex) => {
const timestampUs = Math.round((frameIndex * 1_000_000) / fps);
const nextTimestampUs = Math.round(((frameIndex + 1) * 1_000_000) / fps);
return {
frameIndex,
timestampUs,
durationUs: nextTimestampUs - timestampUs,
timestampSeconds: timestampUs / 1_000_000,
};
});
}
console.table(buildVideoTimeline(30, 10));
重点观察:
durationUs 不一定每帧完全一样
但长期平均是准确的
任务 3:模拟 48kHz 音频 packet 时间线
假设:
sampleRate = 48000
samplesPerPacket = 1024
packetCount = 10
参考代码:
function buildAudioPacketTimeline(
sampleRate: number,
samplesPerPacket: number,
packetCount: number,
) {
return Array.from({ length: packetCount }, (_, packetIndex) => {
const startSample = packetIndex * samplesPerPacket;
const endSample = startSample + samplesPerPacket;
const timestampUs = Math.round((startSample * 1_000_000) / sampleRate);
const nextTimestampUs = Math.round((endSample * 1_000_000) / sampleRate);
return {
packetIndex,
startSample,
timestampUs,
durationUs: nextTimestampUs - timestampUs,
timestampSeconds: timestampUs / 1_000_000,
};
});
}
console.table(buildAudioPacketTimeline(48000, 1024, 10));
任务 4:实现 seek 起点查找
输入 sample table:
const samples = [
{ sampleIndex: 0, ptsUs: 0, isKeyframe: true },
{ sampleIndex: 1, ptsUs: 33_333, isKeyframe: false },
{ sampleIndex: 2, ptsUs: 66_667, isKeyframe: false },
// ...
];
实现:
findNearestPreviousKeyframe(samples, targetTimeUs)
参考代码:
type SimpleSample = {
sampleIndex: number;
ptsUs: number;
isKeyframe: boolean;
};
function findNearestPreviousKeyframe(
samples: SimpleSample[],
targetTimeUs: number,
): SimpleSample {
let result: SimpleSample | null = null;
for (const sample of samples) {
if (sample.ptsUs > targetTimeUs) {
break;
}
if (sample.isKeyframe) {
result = sample;
}
}
if (!result) {
throw new Error("No keyframe before target time");
}
return result;
}
任务 5:WebCodecs 处理后保留 timestamp
要求:
输入 VideoFrame
绘制到 canvas
创建新的 VideoFrame
保留 timestamp/duration
关闭旧帧和新帧
参考代码:
function processFrameWithTimestamp(
sourceFrame: VideoFrame,
canvas: OffscreenCanvas,
ctx: OffscreenCanvasRenderingContext2D,
): VideoFrame {
ctx.drawImage(sourceFrame, 0, 0, canvas.width, canvas.height);
// 示例:画一个水印
ctx.font = "24px sans-serif";
ctx.fillText("Demo Watermark", 24, 40);
return new VideoFrame(canvas, {
timestamp: sourceFrame.timestamp,
duration: sourceFrame.duration ?? undefined,
});
}
使用:
const processedFrame = processFrameWithTimestamp(frame, canvas, ctx);
videoEncoder.encode(processedFrame);
frame.close();
processedFrame.close();
29. 自测题
题 1
30fps 视频,第 10 帧的 timestamp 是多少秒?
答案:
如果从第 0 帧开始计数:
timestamp = 10 / 30
= 0.333333s
题 2
video track timescale 是 90000,某帧 PTS 是 270000。它对应多少秒?
答案:
seconds = 270000 / 90000
= 3s
题 3
48kHz 音频,第 96000 个 sample 对应多少秒?
答案:
seconds = 96000 / 48000
= 2s
题 4
AAC packet 包含 1024 个 samples,sample rate 是 48000Hz,这个 packet 的 duration 是多少毫秒?
答案:
duration = 1024 / 48000s
≈ 0.021333s
≈ 21.333ms
题 5
为什么有 B 帧时 PTS 和 DTS 可能不同?
答案:
因为 B 帧可能依赖未来的参考帧。为了能解码 B 帧,解码器可能要先解码显示时间更晚的 P 帧。因此解码顺序和显示顺序不同,DTS 和 PTS 就可能不同。
题 6
为什么 seek 到 10s 时,播放器可能从 8s 开始解码?
答案:
因为 10s 的帧可能不是 keyframe,不能独立解码。播放器需要找到 10s 之前最近的 keyframe,比如 8s,从 8s 开始解码,然后丢弃 10s 之前的解码结果。
题 7
WebCodecs 的 EncodedVideoChunk.timestamp 使用什么单位?
答案:
使用微秒,microseconds。(MDN Web Docs)
题 8
为什么不能简单用 timestamp += 33333 表示 30fps?
答案:
因为 30fps 每帧真实 duration 是:
1_000_000 / 30 = 33333.333...
如果每帧都加 33333,长期会累计误差。更好的方式是根据 frame index 计算绝对 timestamp:
Math.round(frameIndex * 1_000_000 / fps)
题 9
音视频同步通常以音频为主时钟,为什么?
答案:
因为音频播放对时间连续性更敏感,声音卡顿或跳跃人耳很容易察觉。而视频可以通过丢帧、延迟显示、重复帧来追赶音频,所以常见策略是 audio master clock,video follows audio。
题 10
WebCodecs 处理视频帧后,重新创建 VideoFrame 时最容易忘记什么?
答案:
最容易忘记保留:
timestamp
duration
如果 timestamp 丢了,后续编码和 mux 的时间线就会错,可能导致播放速度异常、帧堆叠、音视频不同步。
30. 本章总结
本章你要记住这条主线:
媒体文件不是按“读取顺序”播放
而是按“时间戳”播放
更具体一点:
PTS 决定什么时候展示
DTS 决定什么时候解码
timescale / timebase 决定 timestamp 的单位
duration 决定这一帧或音频块持续多久
keyframe 决定 seek 从哪里开始
audio clock 常被用作同步主时钟
WebCodecs 不帮你做同步,但会沿着 chunk/frame 传递 timestamp
如果你做浏览器端音视频处理,最稳的工程习惯是:
1. demux 后保存 pts / dts / duration / timescale
2. 内部统一转成 microseconds
3. 解码、处理、编码过程中保留 timestamp
4. mux 输出时再转换成目标容器 timescale
5. 播放时以主时钟调度视频帧
一句话版:
音视频同步的本质,是把不同来源、不同单位、不同节奏的媒体数据,映射到同一条时间线上。
31. 下一章衔接:为什么第十二章要讲性能、内存、Worker 与兼容性
学完这一章,你已经知道:
音视频处理要维护时间线
要保留 timestamp
要按顺序解码
要按时间调度
seek 还要清空队列和重建状态
但真实浏览器项目里还有一堆工程问题:
大文件不能一次性乱读
VideoFrame 不 close 会爆内存
主线程处理帧会卡 UI
WebCodecs 队列需要背压
ArrayBuffer 复制会浪费性能
不同浏览器支持情况不同
所以第十二章要进入工程化部分:
Chapter 12:性能、内存、Worker 与兼容性
下一章会把“能跑的 Demo”提升成“像项目的音视频处理系统”。