简介 ​

Mediabunny 中的媒体数据以两种不同的形式存在:

  • 包(Packet):编码后的媒体数据,是编码过程的结果
  • 样本(Sample):原始的、未压缩的、可呈现的媒体数据

除了数据本身,包和样本还携带额外的元数据,例如时间戳、持续时间、宽度等。

包由 EncodedPacket 类表示,该类同时用于视频和音频包。样本由 VideoSample 和 AudioSample 类表示:

  • VideoSample:代表视频的单帧。
  • AudioSample:代表(通常较短的)音频片段。

样本可以编码为包,包可以解码为样本:

与 WebCodecs 的关系 ​

Mediabunny 中的包和样本直接对应于 WebCodecs API 中的概念:

  • EncodedPacket
    -> 视频包对应 EncodedVideoChunk
    -> 音频包对应 EncodedAudioChunk
  • VideoSample -> VideoFrame
  • AudioSample -> AudioData

由于 Mediabunny 大量使用 WebCodecs API,其自身的类通常作为 WebCodecs 类的包装器。然而,这种包装带来了几个好处:

  1. 独立性:即使没有 WebCodecs API,该库也能正常运行。编码器和解码器可以使用 自定义编码器 进行多态填充,并且库可以在 Node.js 等非浏览器环境中运行。
  2. 可扩展性:这些包装器充当额外操作的命名空间,例如 AudioSample 上的 toAudioBuffer() 或 VideoSample 上的 draw()。
  3. 一致性:虽然 WebCodecs 使用整数微秒时间戳,但 Mediabunny 在所有地方都使用浮点秒时间戳。通过这些包装器,所有时间信息始终以秒为单位,用户无需考虑单位转换。

转换很简单:

import { EncodedPacket, VideoSample, AudioSample } from 'mediabunny';

// EncodedPacket 转换为 WebCodecs 块:
encodedPacket.toEncodedVideoChunk(); // => EncodedVideoChunk
encodedPacket.toEncodedAudioChunk(); // => EncodedAudioChunk
// WebCodecs 块转换为 EncodedPacket:
EncodedPacket.fromEncodedChunk(videoChunk); // => EncodedPacket
EncodedPacket.fromEncodedChunk(audioChunk); // => EncodedPacket

// VideoSample 转换为 VideoFrame:
videoSample.toVideoFrame(); // => VideoFrame
// VideoFrame 转换为 VideoSample:
new VideoSample(videoFrame); // => VideoSample

// AudioSample 转换为 AudioData:
audioSample.toAudioData(); // => AudioData
// AudioData 转换为 AudioSample:
new AudioSample(audioData); // => AudioSample

从 WebCodecs API 对应物创建的 VideoSample/AudioSample 实例非常高效;它们仅维护对底层 WebCodecs API 实例的引用,且不执行任何不必要的复制。

负时间戳 ​

虽然包和样本的持续时间不能为负,但它们的时间戳可以为负。

负时间戳表示在合成开始之前就开始播放的样本(合成始终从 0 开始)。负时间戳通常是轨道在开头被修剪的结果,要么是为了裁剪媒体的一部分,要么是为了与其他轨道同步。因此,您应该避免呈现任何具有负时间戳的样本。

EncodedPacket ​

编码包代表任意类型(视频或音频)的编码媒体数据。它们是编码过程的结果,您可以使用解码过程将编码包转换为实际的媒体数据。

创建包 ​

要创建 EncodedPacket,可以使用其构造函数:

constructor(
    data: Uint8Array,
    type: 'key' | 'delta',
    timestamp: number, // 单位为秒
    duration: number, // 单位为秒
    sequenceNumber?: number,
    byteLength?: number,
);

当为给定编解码器创建包时,您必须遵守 Mediabunny 编解码器注册表 中指定的数据格式。

您可能永远不需要在构造函数中设置 sequenceNumber 或 byteLength。

例如,这里我们根据一些编码后的视频数据创建一个包:

import { EncodedPacket } from 'mediabunny';

const encodedVideoData = new Uint8Array([...]);
const encodedPacket = new EncodedPacket(encodedVideoData, 'key', 5, 1/24);

或者,如果您是从 WebCodecs 编码块而来,可以从它们创建 EncodedPacket:

import { EncodedPacket } from 'mediabunny';

// 从 EncodedVideoChunk:
const encodedPacket = EncodedPacket.fromEncodedChunk(encodedVideoChunk);

// 从 EncodedAudioChunk:
const encodedPacket = EncodedPacket.fromEncodedChunk(encodedAudioChunk);

检查包 ​

编码包拥有一系列只读数据可供检查。您可以这样获取编码数据:

encodedPacket.data; // => Uint8Array

您可以查询包的类型:

encodedPacket.type; // => PacketType ('key' | 'delta')
  • 关键包(key packet)可以直接解码,独立于其他包。
  • 增量包(delta packet)只能在它之前的包被解码后才能解码。

例如,在视频轨道中,每隔几秒通常会有一个关键帧。当用户进行跳转时,如果跳转到关键帧稍后的位置,解码数据可以迅速显示;如果跳转位置远离关键帧,解码器必须先处理许多差分帧才能显示任何内容。

确定数据包的类型 ​

type 字段源自包含文件中的元数据,有时(在罕见情况下)可能不正确。要确切地确定数据包的实际类型,您可以执行以下操作:

// `packet` 必须来自 InputTrack `track`
const type = await track.determinePacketType(packet); // => PacketType | null

这通过检查其比特流来确定数据包的类型。当无法确定类型时,将返回 null。


您可以查询数据包的时间信息:

encodedPacket.timestamp; // => 以秒为单位的展示时间戳
encodedPacket.duration; // => 以秒为单位的持续时间

// 这些属性也存在整数微秒版本:
encodedPacket.microsecondTimestamp;
encodedPacket.microsecondDuration;

timestamp 和 duration 均以浮点数形式给出。

警告

时间戳可能为负数。


数据包还有一个称为 序列号 的数量:

encodedPacket.sequenceNumber; // => number

当从 输入文件读取数据包 时,该数字指定数据包的相对顺序。如果数据包 A 的序列号低于数据包 B,则数据包 A 排在前面(在 解码顺序 中)。如果两个数据包具有相同的序列号,则它们代表同一个媒体样本。

序列号本身没有意义,只有在与其他序列号比较时才有意义。如果数据包具有序列号 n,并不意味着它是轨道中的第 n 个数据包。

负序列号表示数据包的顺序未定义。创建 EncodedPacket 时,序列号默认为 -1。

克隆数据包 ​

使用 clone 方法从现有数据包创建一个新数据包。在此过程中,您可以部分更改其数据。

// 创建一个与原始数据包完全相同的克隆:
packet.clone();

// 创建一个时间戳设置为 10 秒的克隆:
packet.clone({ timestamp: 10 });

仅元数据数据包 ​

EncodedPacketSink 可以创建 仅元数据 数据包:

await sink.getFirstPacket({ metadataOnly: true });

仅元数据数据包包含完整数据包的所有元数据,但不包含任何数据:

packet.data; // => Uint8Array([])

您仍然可以检索数据应有的 大小:

packet.byteLength; // => number

给定一个数据包,您可以按以下方式检查它是否为仅元数据数据包:

packet.isMetadataOnly; // => boolean

VideoSample ​

视频样本代表单个视频帧。它可以由图像源直接创建,也可以是解码过程的结果。其 API 基于 VideoFrame。

创建视频样本 ​

视频样本具有图像源构造器和原始构造器。

VideoSample 的构造器与 VideoFrame's 构造器 非常相似,但使用秒级时间戳而不是微秒时间戳。

图像源构造器 ​

此构造器从 CanvasImageSource 创建 VideoSample:

import { VideoSample } from 'mediabunny';

// 从 canvas 元素创建样本
const sample = new VideoSample(canvas, {
	timestamp: 3, // 以秒为单位
	duration: 1/24, // 以秒为单位
});

// 从 image 元素创建样本,并添加一些旋转和翻转
const sample = new VideoSample(imageElement, {
	timestamp: 5, // 以秒为单位
	rotation: 90, // 顺时针角度
	flip: true, // 水平翻转,在旋转后应用
});

// 从 VideoFrame 创建样本(时间戳将被复制)
const sample = new VideoSample(videoFrame);

原始构造器 ​

此构造器从 ArrayBuffer 中给出的原始像素数据创建 VideoSample:

import { VideoSample } from 'mediabunny';

// 从 RGBX 格式的像素数据创建样本
const sample = new VideoSample(buffer, {
	format: 'RGBX',
	codedWidth: 1280,
	codedHeight: 720,
	timestamp: 0,
});

// 从 YUV 4:2:0 格式的像素数据创建样本
const sample = new VideoSample(buffer, {
	format: 'I420',
	codedWidth: 1280,
	codedHeight: 720,
	timestamp: 0,
});

有关 WebCodecs 支持的像素格式列表,请参阅 VideoPixelFormat。

自定义资源构造器 ​

对于高级用例(自定义解码器、GPU 支持的帧等),您可以使用自己对 VideoSampleResource 的实现来支持 VideoSample:

import { VideoSample, VideoSampleResource } from 'mediabunny';

class MyResource extends VideoSampleResource {
	// Implement getFormat(), getCodedWidth(), getCodedHeight(),
	// getSquarePixelWidth(), getSquarePixelHeight(), getColorSpace(),
	// getDataPlanes(), toRgbSample(), and close().
}

const sample = new VideoSample(new MyResource(), {
	timestamp: 0,
});

这允许您用自己的数据来支撑一个 VideoSample,而无需进行复制。

检查视频样本 ​

VideoSample 具有多个只读属性:

// 帧存储的内部像素格式
videoSample.format; // => VideoPixelFormat | null

// 样本的原始尺寸
videoSample.codedWidth; // => number
videoSample.codedHeight; // => number

// 经像素宽高比校正后的样本尺寸
videoSample.squarePixelWidth;
videoSample.squarePixelHeight;

// 样本的变换显示尺寸(旋转和翻转后)
videoSample.displayWidth; // => number	
videoSample.displayHeight; // => number	

// 样本的顺时针旋转角度(度)。在呈现时,原始样本应按此量进行旋转。
videoSample.rotation; // => 0 | 90 | 180 | 270

// 在旋转后,呈现时原始样本是否应水平翻转。
videoSample.flip; // => boolean

// 样本的像素宽高比
videoSample.pixelAspectRatio; // => { num: number, den: number }

// 时间信息
videoSample.timestamp; // => 呈现时间戳(秒)
videoSample.duration; // => 持续时间(秒)
videoSample.microsecondTimestamp; // => 呈现时间戳(微秒)
videoSample.microsecondDuration; // => 持续时间(微秒)

// 样本的颜色空间
videoSample.colorSpace; // => VideoColorSpace

videoSample.visibleRect; // Rectangle

// 将此样本传递给编码器时使用的编码选项
videoSample.encodeOptions; // => VideoEncoderEncodeOptions (默认为 {})

虽然所有这些属性都是只读的,但您可以使用 setTimestamp、setDuration、setRotation、setFlip 和 setEncodeOptions 方法来修改视频样本的一些元数据。

警告

时间戳可以为负数。

使用视频样本 ​

视频样本提供了几种访问其帧数据的方法。

您可以将视频样本转换为 WebCodecs VideoFrame 以访问额外数据或将其传递给 VideoEncoder:

videoSample.toVideoFrame(); // => VideoFrame

如果视频样本是使用 VideoFrame 构建的,则此方法几乎是零开销的。

警告

此方法返回的 VideoFrame 必须 与视频样本分开关闭。


将视频样本绘制到 <canvas> 元素或 OffscreenCanvas 也很常见。为此,您可以使用以下方法:

draw(
	context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
	dx: number,
	dy: number,
	dWidth?: number, // 默认为 displayWidth
	dHeight?: number, // 默认为 displayHeight
): void;

draw(
	context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
	sx: number,
	sy: number,
	sWidth: number,
	sHeight: number,
	dx: number,
	dy: number,
	dWidth?: number, // 默认为 sWidth
	dHeight?: number, // 默认为 sHeight
): void;

这些方法的行为类似于 drawImage,并在给定位置以给定尺寸绘制视频帧。此方法将根据其 rotation 和 flip 属性自动以正确的旋转和翻转绘制帧。

可以使用 drawWithFit 方法将视频样本绘制到整个画布上,并指定拟合算法:

drawWithFit(
	context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
	options: {
		fit: 'fill' | 'contain' | 'cover';
		rotation?: Rotation; // 覆盖样本的旋转
		flip?: boolean; // 覆盖样本的翻转
		crop?: CropRectangle;
	},
): void;

如果您想直接将底层原始图像绘制到画布上(而不尊重旋转和翻转元数据),则可以使用以下方法:

videoSample.toCanvasImageSource(); // => VideoFrame | OffscreenCanvas;

此方法返回一个有效的 CanvasImageSource,您可以在 drawImage 中使用它。

警告

如果此方法返回一个 VideoFrame,您应立即使用该帧。这是因为任何内部创建的 video frames 将在下一个微任务中自动关闭。


有时您可能需要直接访问底层像素数据。为此,VideoSample 允许您将此数据复制到 ArrayBuffer 中。

使用 allocationSize 确定需要多少字节:

const bytesNeeded = videoSample.allocationSize(); // => number

然后,使用 copyTo 将像素数据复制到目标缓冲区:

const bytes = new Uint8Array(bytesNeeded);
const planeLayout = await videoSample.copyTo(bytes);

您可以向 allocationSize 和 copyTo 传递其他选项,以提取不同像素格式的数据。


您可以将 VideoSample 转换为调整大小、旋转、翻转和/或裁剪后的新 VideoSample:

const transformed = await videoSample.transform({
	width: 640,
	height: 360,
	fit: 'cover',
	rotate: 90,
	flip: true,
	crop: { left: 0, top: 0, width: 1920, height: 1000 },
	alpha: 'discard',
});

操作按以下顺序应用:像素宽高比归一化、旋转、翻转、裁剪、调整大小。旋转和翻转是叠加在样本自身的 rotation 和 flip 元数据之上的。

在浏览器环境中,转换使用 canvas 执行。在没有 OffscreenCanvas 或 HTMLCanvasElement 的非浏览器环境中,除非您注册自定义转换器,否则此方法会抛出异常:

import { registerVideoSampleTransformer } from 'mediabunny';

registerVideoSampleTransformer((sample, description) => {
	// 返回转换后的 VideoSample,或返回 null 以交给下一个转换器处理。
});

@mediabunny/server 扩展已注册此类转换器。


您也可以克隆一个 VideoSample:

const clonedSample = videoSample.clone(); // => VideoSample

克隆的样本必须与原始样本分开关闭。

关闭视频样本 ​

在使用完 VideoSample 后,您必须手动关闭它以释放内部持有的资源。请调用 close 方法来完成此操作:

videoSample.close();

请在不再需要 VideoSample 时尽快将其关闭。未关闭的视频样本可能导致 VRAM 占用过高和解码器停滞。

VideoSample 关闭后,其数据将不可用,且其大多数方法将抛出错误。

AudioSample ​

音频样本表示一段音频数据。它可以直接从原始音频数据创建,也可以是解码过程的结果。其 API 设计模仿了 AudioData。

创建音频样本 ​

音频样本可以通过 AudioData 实例、初始化对象或 AudioBuffer 进行构造:

import { AudioSample } from 'mediabunny';

// 来自 AudioData:
const sample = new AudioSample(audioData);

// 来自原始数据:
const sample = new AudioSample({
    data: new Float32Array([...]),
    format: 'f32-planar', // 音频样本格式
    numberOfChannels: 2,
    sampleRate: 44100, // 单位为 Hz
    timestamp: 0, // 单位为秒
});

// 来自 AudioBuffer:
const timestamp = 0; // 单位为秒
const samples = AudioSample.fromAudioBuffer(audioBuffer, timestamp);
// => 如果 AudioBuffer 非常长,则返回多个 AudioSamples

支持以下音频样本格式:

  • 'u8': 8 位无符号整数(交错)
  • 'u8-planar': 8 位无符号整数(平面)
  • 's16': 16 位有符号整数(交错)
  • 's16-planar': 16 位有符号整数(平面)
  • 's32': 32 位有符号整数(交错)
  • 's32-planar': 32 位有符号整数(平面)
  • 'f32': 32 位浮点数(交错)
  • 'f32-planar': 32 位浮点数(平面)

平面格式将每个通道的数据连续存储,而交错格式将各通道的数据交错存储:

平面格式与交错格式

自定义资源构造函数 ​

对于高级用例(如自定义解码器等),您可以使用自己对 AudioSampleResource 的实现来支撑 AudioSample:

import { AudioSample, AudioSampleResource } from 'mediabunny';

class MyResource extends AudioSampleResource {
	// 实现 getFormat(), getSampleRate(), getNumberOfFrames(),
	// getNumberOfChannels(), getTimestamp(), getDataPlane() 和 close()。
}

const sample = new AudioSample(new MyResource());

这使得您可以使用自己的数据支撑 AudioSample,而无需复制数据。

检查音频样本 ​

AudioSample 具有多个只读属性:

type AudioSampleFormat = 'u8' | 'u8-planar' | 's16' | 's16-planar' | 's32' | 's32-planar' | 'f32' | 'f32-planar';

audioSample.format; // => AudioSampleFormat
audioSample.sampleRate; // => 采样率,单位为 Hz
audioSample.numberOfFrames; // => 每通道的帧数
audioSample.numberOfChannels; // => 通道数
audioSample.timestamp; // => 显示时间戳,单位为秒
audioSample.duration; // => 持续时间,单位为秒 (= numberOfFrames / sampleRate)

// 此外还存在时间信息的微秒级整数版本:
audioSample.microsecondTimestamp;
audioSample.microsecondDuration;

虽然所有这些属性都是只读的,但您可以使用 setTimestamp 方法来修改音频样本的时间戳。

警告

时间戳可以为负数。

使用音频样本 ​

音频样本提供了几种访问其音频数据的方式。

您可以将音频样本转换为 WebCodecs AudioData,以便传递给 AudioEncoder:

audioSample.toAudioData(); // => AudioData

如果音频样本是使用 AudioData 构建的,且未使用 setTimestamp 修改其时间戳,则此方法几乎无开销。

警告

此方法返回的 AudioData 必须 与音频样本分开关闭。

您还可以轻松地将音频样本转换为 AudioBuffer 以用于 Web Audio API:

audioSample.toAudioBuffer(); // => AudioBuffer

您还可以直接将原始音频数据从 AudioSample 复制到 ArrayBuffer。为此,可以使用 allocationSize 和 copyTo 方法。

复制过程由以下配置对象控制:

type AudioSampleCopyToOptions = {
	planeIndex: number;
	format?: AudioSampleFormat;
	frameOffset?: number;
	frameCount?: number;
};
  • planeIndex (必需)
    标识要从中复制的平面的索引。如果使用非平面(交错)输出格式,此值必须为 0。
  • format
    目标数据的输出格式。默认为 AudioSample 的格式。
  • frameOffset
    源平面数据中的偏移量,指示从哪一帧开始复制。默认为 0。
  • frameCount
    要复制的帧数。如果未提供,复制将包含从 frameOffset 开始的平面中的所有帧。

由于 planeIndex 的含义取决于提取时使用的格式,因此最好始终显式指定格式。

例如,这里我们将整个音频数据提取为 f32:

const options = { planeIndex: 0, format: 'f32' };
const bytesNeeded = audioSample.allocationSize(options);
const data = new Float32Array(bytesNeeded / 4);
audioSample.copyTo(data, options);

这里,我们遍历 s16 格式的每个平面:

// 第一个平面的大小等于所有平面的大小
const bytesNeeded = audioSample.allocationSize({
	planeIndex: 0,
	format: 's16-planar',
});
const data = new Int16Array(bytesNeeded / 2);

for (let i = 0; i < audioSample.numberOfChannels; i++) {
	audioSample.copyTo(data, { planeIndex: i, format: 's16-planar' });
	// 对数据进行操作
}

allocationSize 和 copyTo 的行为与 WebCodecs API 完全一致。然而,WebCodecs API 规范仅规定支持转换为 f32-planar,而 Mediabunny 的实现支持转换为所有格式。因此,Mediabunny 的方法功能更强大。

关闭音频样本 ​

在使用完 AudioSample 后,您必须手动调用 close 方法来关闭它,以释放内部持有的资源:

audioSample.close();

一旦不再需要 AudioSample,请尽量立即关闭它。未关闭的音频样本可能导致高内存使用和解码器停滞。

AudioSample 关闭后,其数据将不可用,且大多数方法将抛出错误。