在浏览器端做音视频处理时,WebCodecs API提供的VideoFrame是绕不开的核心对象。它代表一帧解码后的视频画面,而每个帧都携带两个关键的时间属性:timestamp和duration。TypeScript官方的lib.dom.d.ts里对这两个字段的定义其实非常粗糙,就是一个普通的number。当项目规模变大、时间计算逻辑变复杂时,这种宽泛的类型定义会掩盖很多潜在bug,比如把毫秒当微秒用、把帧时间戳和序列号混用。本文围绕如何为VideoFrame的时间戳定义更精确的TypeScript类型展开讨论。

一、理解WebCodecs规范中时间戳的底层含义
WebCodecs规范明确规定,VideoFrame的timestamp和duration都以微秒(microseconds)为单位,类型为long long,即64位有符号整数。这一点和Web API里常见的毫秒时间戳完全不同,很多开发者在初次接触时会把performance.now()返回的毫秒值直接塞给VideoFrameInit,导致播放速度变成千倍快进。规范同时约定timestamp可以为负数,用于表示早于起始点的帧,而duration在未知时可以是null。
另一个容易忽视的细节是,规范允许timestamp取值为很大的整数。虽然JS的number类型能安全表示到Number.MAX_SAFE_INTEGER(约9千万亿微秒,折合上百年),对于绝大多数场景够用,但如果你用位运算去处理时间戳就会出问题,因为JS位运算会先截断为32位。所以在类型层面标记“这是一个微秒整数,不要做位运算”是有工程价值的。
标准lib.dom.d.ts中的定义大致如下:
interface VideoFrame {
readonly timestamp: number;
readonly duration: number | null;
// 省略其他属性
}
interface VideoFrameInit {
timestamp?: number;
duration?: number;
}可以看到它没有任何单位信息,调用方完全依赖自觉。这正是我们需要补充自定义类型的出发点。
二、从简单别名到品牌类型的定义方案
最简单的做法是定义类型别名,并配合文档注释说明单位:
// 表示微秒单位的时间戳
type TimestampUs = number;
// 表示微秒单位的时长
type DurationUs = number;
interface MyVideoFrameInit {
timestamp?: TimestampUs;
duration?: DurationUs;
}这种写法几乎没有成本,但别名在TypeScript的结构化类型系统里是透明的,TimestampUs和number可以互相赋值,编译器不会帮你拦截把毫秒值传进来的错误。它的价值只在于提升代码可读性,属于最低成本的改进。
想要真正的编译期校验,需要用到品牌类型(Branded Type)技巧。核心思路是给类型附加一个唯一的不可达属性,让不同语义的number之间互不兼容:
declare const TimestampBrand: unique symbol;
declare const DurationBrand: unique symbol;
export type TimestampUs = number & { readonly __brand: typeof TimestampBrand };
export type DurationUs = number & { readonly __brand: typeof DurationBrand };
// 构造函数:唯一允许从裸number进入的类型入口
export function toTimestampUs(us: number): TimestampUs {
if (!Number.isSafeInteger(us)) {
throw new RangeError(`timestamp必须是安全整数,收到 ${us}`);
}
return us as TimestampUs;
}
// 单位转换辅助函数
export const msToUs = (ms: number): TimestampUs => toTimestampUs(Math.round(ms * 1000));
export const secondsToUs = (s: number): TimestampUs => toTimestampUs(Math.round(s * 1_000_000));这样定义之后,下面的代码会在编译期报错:
const frame = new VideoFrame(canvas, {
timestamp: msToUs(1000) // 正确
});
const wrong = new VideoFrame(canvas, {
timestamp: performance.now() // number不能赋给TimestampUs,编译报错
});品牌类型的缺点是所有和number交互的地方都要走构造函数,写起来略繁琐。一个折中方案是只在模块边界(接收外部输入、调用WebCodecs API的位置)做品牌转换,内部计算仍用裸number,这样能兼顾安全性和开发效率。
三、完整的时间戳工具类型与封装实践
除了timestamp和duration,WebCodecs里EncodedVideoChunk、AudioData也都携带微秒时间戳。可以把相关接口统一声明成一套类型,形成项目内部的时间类型体系:
declare const ChunkTimestampBrand: unique symbol;
export type EncodedChunkTimestamp = number & {
readonly __brand: typeof ChunkTimestampBrand;
};
export interface SafeVideoFrameInit {
timestamp: TimestampUs;
duration?: DurationUs | null;
alpha?: AlphaOption;
visibleRect?: DOMRectInit;
}
// 封装VideoFrame创建,收敛类型转换入口
export function createFrame(
source: CanvasImageSource,
init: SafeVideoFrameInit
): VideoFrame {
return new VideoFrame(source, init);
}
// 帧间隔计算:返回普通number,便于后续算术运算
export function frameGapUs(a: VideoFrame, b: VideoFrame): number {
return Math.abs(a.timestamp - b.timestamp);
}还有一个实际开发中常见的坑:从VideoFrame读取timestamp后参与除法运算可能出现小数微秒。由于底层是整数,推荐在计算fps时显式取整:
function estimateFps(frames: VideoFrame[]): number {
if (frames.length < 2) return 0;
const first = frames[0].timestamp;
const last = frames[frames.length - 1].timestamp;
const span = last - first;
if (span <= 0) return 0;
return (frames.length - 1) * 1_000_000 / span;
}最后提醒一点,如果项目使用了较老的TypeScript版本,需要确保版本在4.x以上以支持unique symbol品牌写法。同时建议把所有时间类型和转换函数集中放在一个独立的types或time模块中,避免品牌定义被重复声明导致类型不兼容。通过这套类型定义,VideoFrame的时间戳从裸number变成了带有单位语义和安全校验的类型,能在编译期挡住单位混用这一类最难排查的bug,对音视频工程的可维护性提升非常明显。
TypeScriptWebCodecsVideoFrame修改时间:2026-09-15 12:24:33