返回文章列表

第十一章|音视频同步:时间戳、同步与播放控制

围绕 timestamp、duration、PTS、DTS、timebase、timescale、sampleRate、frameRate、A/V sync、seek 和 WebCodecs timestamp 传递,建立浏览器音视频处理中的时间线心智模型。

第十一章|音视频同步:时间戳、同步与播放控制

1. 本章学习目标

学完这一章,你要能回答一个核心问题:

媒体处理中最容易错的不是“有没有解出来”,而是“什么时候显示、什么时候播放、什么时候丢、什么时候补”。

本章重点不是实现一个完整播放器,而是建立工程里真正需要的时间线思维。你需要掌握:

  1. timestampduration 到底表示什么。
  2. PTSDTS 的区别。
  3. timebase / timescale 为什么存在。
  4. 30fps 视频的每一帧时间戳怎么计算。
  5. 48kHz 音频的 sample 和时间怎么互相换算。
  6. 音视频为什么会不同步。
  7. seek 为什么经常要从关键帧开始。
  8. WebCodecs pipeline 里 timestamp 如何一路传递。
  9. 面试时如何把 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说明
I000关键帧,先显示也先解码
B112显示早,但要等参考帧解出来
B223同上
P331显示晚,但需要提前解码

播放器必须按 DTS 把数据喂给 decoder,但按 PTS 把解码后的帧显示出来。

这就是为什么“解码出来了”不代表“马上显示”。


6. timebase / timescale:时间戳的单位系统

6.1 为什么不用浮点秒?

理论上我们可以用秒:

0.000
0.033333333
0.066666666

但媒体文件里通常不用浮点数,而用整数 tick。

原因是:

  1. 整数更稳定,不容易累计浮点误差。
  2. 容器格式需要精确表达 sample 时间。
  3. 不同轨道可能天然有不同时间单位。

比如 MP4 里经常有这样的概念:

timescale = 一秒钟有多少个时间单位
duration = 持续多少个时间单位

如果:

timescale = 90000
duration = 3000

那么真实时长是:

durationSeconds = 3000 / 90000
                = 0.033333s

MP4 / ISO BMFF 的时间信息会出现在多个 box 里,例如 mvhdtkhdmdhdsttsctts 等;其中 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 indextimestamp 秒duration 秒
00.0000000.033333
10.0333330.033333
20.0666670.033333
30.1000000.033333
40.1333330.033333

7.2 用 MP4 timescale 表示

假设 video track 的 timescale 是 90000。

frameDurationTicks = 90000 / 30
                   = 3000

前几帧:

frame indextimestamp tickduration tickseconds
0030000.000000
1300030000.033333
2600030000.066667
3900030000.100000
41200030000.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 会在 3333333334 之间调整,这是为了避免长期累计误差。

这比每次都写:

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 当绝对时钟

setTimeoutrequestAnimationFrame 适合调度 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 的时间长度
PTSPresentation Timestamp展示时间,按它显示
DTSDecoding Timestamp解码时间,按它喂 decoder
timebase每个 tick 对应多少秒FFmpeg 常见说法
timescale每秒多少 tickMP4 常见说法
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”提升成“像项目的音视频处理系统”。