简介 ​

媒体接收器(Media sinks) 提供了从 InputTrack 提取媒体数据的方法。不同的媒体接收器提供不同级别的抽象,并适用于不同的用例。

有关如何获取输入轨道或如何一般性地从媒体文件中读取数据的详细信息,请参阅 读取媒体文件。

通用用法 ​

媒体接收器的通用使用模式将使用虚构的 FooSink 进行演示。

媒体接收器类似于用于检索媒体数据的微型“命名空间”,其作用域限定在特定的轨道上。这意味着通常只需要为每个轨道每种类型构建一个接收器。

const track = await input.getPrimaryVideoTrack();
const sink = new FooSink(track);

构建接收器几乎不消耗资源,且不会执行任何媒体数据读取。

要读取媒体数据,每个接收器都提供一组不同的方法。您可以按需多次调用这些方法;由于媒体接收器是无状态的[1],它们的调用将是独立的。

await sink.getFoo(1);
await sink.getFoo(2);
await sink.getFoo(3);

异步迭代器 ​

媒体接收器大量使用 异步迭代器。它们允许您高效地遍历一组媒体数据(如视频轨道中的所有帧),只需在任意时刻读取文件的小部分。

异步迭代器与 for await...of 循环配合使用时非常便捷:

for await (const foo of sink.foos()) {
	console.log(foo.timestamp);
}

就像在常规 for 循环中一样,可以使用 break 语句提前退出循环。这将自动清理异步迭代器使用的任何内部资源(如解码器):

// 仅遍历前 5 个 foos
let count = 0;
for await (const foo of sink.foos()) {
	console.log(foo.timestamp);
	if (++count === 5) break;
}

异步迭代器在 for 循环之外也很有用。此处使用 next 方法来检索迭代中的下一项:

const foos = sink.foos();

const foo1Result = await foos.next();
const foo2Result = await foos.next();

const foo1 = foo1Result.value; // 如果迭代已完成,可能为 `undefined`

警告

当您手动使用异步迭代器时,确保在完成操作后调用 return:

await foos.return();

这确保了所有内部持有的资源都会被释放。

解码顺序与呈现顺序 ​

数据包在文件中可能出现乱序,这意味着它们的解码顺序并不对应于解码数据的显示顺序(参见 B 帧)。媒体接收器上的方法在查询和检索数据包时所使用的排序方式有所不同。因此,请牢记以下定义:

  • 呈现顺序(Presentation order): 数据应被呈现的顺序;按时间戳排序。
  • 解码顺序(Decode order): 数据包必须被解码的顺序;不一定按时间戳排序。

实时轨道 ​

某些轨道(如 HLS 中的轨道)可能是 实时(live) 的,意味着新的媒体数据仍在生成。检查方法如下:

await track.isLive();

Mediabunny 对实时轨道的心理模型很简单:将其视为与其他轨道相同的轨道,只是其末尾的媒体数据尚未可用。

这意味着任何尝试从末尾“不可用”部分读取媒体数据的读取操作,都将等待媒体数据变为可用或直播流结束。Mediabunny 将此称为“实时等待”。

有时,您可能希望 跳过 实时等待,假装媒体数据在当前已知的最新点处结束。为此,可以向所有媒体接收器传递 skipLiveWait: true 选项。例如:

import { EncodedPacketSink } from 'mediabunny';

const packetSink = new EncodedPacketSink(track);
const lastPacket = await packetSink.getPacket(Infinity, { skipLiveWait: true });

通用接收器 ​

有一种媒体接收器可用于任何 InputTrack:

EncodedPacketSink ​

此接收器可用于从媒体文件中提取原始的、编码数据包(encoded packets),是最基础的媒体接收器。如果您不关心解码后的媒体数据(例如,只关心时间戳),或者想要自行实现解码逻辑,则 EncodedPacketSink 非常有用。

首先从任何 InputTrack 构建接收器:

import { EncodedPacketSink } from 'mediabunny';

const sink = new EncodedPacketSink(track);

您可以给定以秒为单位的时间戳来检索特定数据包:

await sink.getPacket(5); // => EncodedPacket | null

// 或者,仅检索类型为 'key' 的数据包:
await sink.getKeyPacket(5); // => EncodedPacket | null

当使用时间戳检索数据包时,将返回呈现顺序中时间戳小于或等于搜索时间戳的最后一个数据包。如果不存在这样的数据包,这些方法将返回 null。

有专门的方法用于检索第一个数据包(按解码顺序):

await sink.getFirstPacket(); // => EncodedPacket | null

// 第一个数据包通常是关键帧,但这不是强制要求的。
// 此方法返回第一个关键帧:
await sink.getFirstKeyPacket(); // => EncodedPacket | null

最后一个数据包(按呈现顺序)可以像这样检索:

await sink.getPacket(Infinity); // => EncodedPacket | null

一旦你获取了一个数据包,你可以像这样检索该数据包的后续包(按解码顺序):

await sink.getNextPacket(packet); // => EncodedPacket | null

// 或者直接跳转到类型为 'key' 的下一个数据包:
await sink.getNextKeyPacket(packet); // => EncodedPacket | null

如果没有下一个数据包,这些方法将返回 null。

这些方法可以组合使用以遍历一系列数据包。从初始数据包开始,在循环中调用 getNextPacket 来遍历数据包:

let currentPacket = await sink.getFirstPacket();
while (currentPacket) {
	console.log('Packet:', currentPacket);
	// 对数据包执行某些操作

	currentPacket = await sink.getNextPacket(currentPacket);
}

虽然这种方法有效,但 EncodedPacketSink 也提供了一个专用的 packets 迭代器函数,它按解码顺序遍历数据包:

for await (const packet of sink.packets()) {
	// ...
}

你还可以使用数据包范围来限制迭代,迭代将从起始数据包开始,直到(但不包括)结束数据包:

const start = await sink.getPacket(5);
const end = await sink.getPacket(10, { metadataOnly: true });

for await (const packet of sink.packets(start, end)) {
	// ...
}

packets 方法比手动迭代更具性能优势,因为它会在需要之前智能地预加载未来的数据包。

验证关键数据包 ​

默认情况下,数据包类型是使用包含文件提供的元数据确定的。某些文件可能会错误地将一些 delta 数据包标记为关键数据包,从而导致潜在的解码器错误。为了确保关键数据包确实是关键数据包,你可以启用 verifyKeyPackets 选项:

// 如果此方法返回的数据包类型为 'key',则保证是关键数据包。
await sink.getPacket(5, { verifyKeyPackets: true });

// 返回的数据包保证是关键数据包
await sink.getKeyPacket(10, { verifyKeyPackets: true });
await sink.getNextKeyPacket(packet, { verifyKeyPackets: true });

// 对迭代器也有效:
for await (const packet of sink.packets(
	undefined,
	undefined,
	{ verifyKeyPackets: true },
)) {
	// ...
}

verifyKeyPackets 仅在未同时启用 metadataOnly 时有效。

仅元数据的数据包检索 ​

有时,你只关心数据包的元数据(时间戳、持续时间、类型等),而不关心其编码媒体数据。EncodedPacketSink 上的所有方法都接受一个最终的 options 参数,你可以用它来检索 仅元数据的数据包:

const packet = await sink.getPacket(5, { metadataOnly: true });

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

对于某些输入格式,检索仅元数据的数据包效率更高:只需读取文件的元数据部分,而无需读取媒体数据部分。

视频数据接收器 ​

这些接收器只能与 InputVideoTrack 一起使用。

VideoSampleSink ​

使用此接收器从视频轨道中提取解码后的 视频样本(帧)。接收器将自动在内部处理解码。

此接收器的所有操作均使用呈现顺序。

创建接收器如下所示:

import { VideoSampleSink } from 'mediabunny';

const sink = new VideoSampleSink(videoTrack);

// 可选:配置解码器:
const sink = new VideoSampleSink(videoTrack, {
	hardwareAcceleration: 'prefer-software',
});

单次检索 ​

你可以检索给定秒数时间戳处的样本:

await sink.getSample(5);

// 提取第一个样本:
await sink.getSample(await videoTrack.getFirstTimestamp());

// 提取最后一个样本:
await sink.getSample(Infinity);

此方法返回时间戳小于或等于搜索时间戳的最后一个样本,或者如果没有这样的样本则返回 null。

范围迭代 ​

你可以使用 samples 迭代器方法来遍历连续范围的样本:

// 遍历所有样本:
for await (const sample of sink.samples()) {
	console.log('Sample:', sample);
	// 对样本执行某些操作

	sample.close();
}

// 遍历特定时间范围内的所有样本:
for await (const sample of sink.samples(5, 10)) {
	// ...
	sample.close();
}

samples 迭代器按呈现顺序(按时间戳排序)产生样本。

稀疏迭代 ​

有时,你可能希望一次性检索多个时间戳的样本(例如,用于生成缩略图)。虽然你可以多次调用 getSample,但 samplesAtTimestamps 方法提供了更高效的方式:

for await (const sample of sink.samplesAtTimestamps([0, 1, 2, 3, 4, 5])) {
	// `sample` 要么是 VideoSample,要么是 null
	sample.close();
}

// 任何时间戳序列都是允许的:
sink.samplesAtTimestamps([1, 2, 3]);
sink.samplesAtTimestamps([4, 5, 5, 5]);
sink.samplesAtTimestamps([10, -2, 3]);

此方法比多次调用 getSample 更高效,因为它避免了重复解码同一个数据包。

除了数组之外,你还可以向此方法传递任何可迭代对象:

sink.samplesAtTimestamps(new Set([2, 3, 3, 4]));

sink.samplesAtTimestamps((function* () {
	for (let i = 0; i < 5; i++) {
		yield i;
	}
})());

sink.samplesAtTimestamps((async function* () {
	const firstTimestamp = await videoTrack.getFirstTimestamp();
	const lastTimestamp = await videoTrack.computeDuration();

	for (let i = 0; i <= 100; i++) {
		yield firstTimestamp + (lastTimestamp - firstTimestamp) * i / 100;
	}
})());

传递异步可迭代对象与 EncodedPacketSink 配合使用时特别有用。假设你想检索所有关键帧,一个粗略的实现可能如下所示:

// 粗略的、不推荐的实现:
const packetSink = new EncodedPacketSink(videoTrack);
const keyFrameTimestamps: number[] = [];

let currentPacket = await packetSink.getFirstPacket();
while (currentPacket) {
	keyFrameTimestamps.push(currentPacket.timestamp);
	currentPacket = await packetSink.getNextKeyPacket(currentPacket);
}

const sampleSink = new VideoSampleSink(videoTrack);
const keyFrameSamples = sampleSink.samplesAtTimestamps(keyFrameTimestamps);

for await (const sample of keyFrameSamples) {
	// ...
	sample.close();
}

该实现的问题在于它在生成第一个样本之前会先遍历所有关键包。更好的实现方式如下:

// 更好的实现:
const packetSink = new EncodedPacketSink(videoTrack);
const sampleSink = new VideoSampleSink(videoTrack);	

const keyFrameSamples = sampleSink.samplesAtTimestamps((async function* () {
	let currentPacket = await packetSink.getFirstPacket();

	while (currentPacket) {
		yield currentPacket.timestamp;
		currentPacket = await packetSink.getNextKeyPacket(currentPacket);
	}
})());

for await (const sample of keyFrameSamples) {
	// ...
	sample.close();
}

CanvasSink ​

虽然 VideoSampleSink 提取原始解码的视频样本,但你可以使用 CanvasSink 将这些样本作为画布提取出来。在此过程中,某些操作(如缩放、旋转和裁剪)也可以由 Sink 处理。缺点是画布帧缓冲区需要额外的 VRAM。

该 Sink 在可能的情况下生成 HTMLCanvasElement,否则回退到 OffscreenCanvas(例如在 Worker 上下文中)。

按以下方式创建 Sink:

import { CanvasSink } from 'mediabunny';

const sink = new CanvasSink(videoTrack, options);

此处,options 的类型如下:

type CanvasSinkOptions = {
	width?: number;
	height?: number;
	fit?: 'fill' | 'contain' | 'cover';
	rotation?: 0 | 90 | 180 | 270;
	flip?: boolean;
	crop?: { left: number; top: number; width: number; height: number };
	poolSize?: number;
	alpha?: boolean;
	decoderOptions?: VideoSinkDecoderOptions;
};
  • width
    输出画布的宽度(像素)。如果省略但未设置 height,则宽度将自动计算以保持原始宽高比。否则,宽度将设置为视频的原始宽度。
  • height
    输出画布的高度(像素)。如果省略但未设置 width,则高度将自动计算以保持原始宽高比。否则,高度将设置为视频的原始高度。
  • fit
    当同时设置了 width 和 height 时必需,此选项设置要使用的适配算法。
    • 'fill' 会将图像拉伸以填满整个区域,可能会改变宽高比。
    • 'contain' 会将整个图像包含在区域内,同时保持宽高比。这可能导致黑边。
    • 'cover' 会将图像缩放直到填满整个区域,同时保持宽高比。
  • rotation
    顺时针旋转原始视频帧的角度。默认为文件元数据中设置的旋转角度。旋转在翻转、裁剪和调整大小之前应用。
  • flip
    是否水平翻转原始视频帧(关于垂直轴)。默认为文件元数据中设置的翻转状态。翻转在旋转之后但在裁剪和调整大小之前应用。
  • crop
    指定要裁剪的输入视频矩形区域。裁剪区域将自动限制为输入视频轨道的尺寸。裁剪在旋转和翻转之后但在调整大小之前执行。裁剪区域位于底层视频数据的显示像素空间中。
  • poolSize
    参见 Canvas pool。
  • alpha
    输出画布是否具有透明度而不是黑色背景。默认为 false。在使用此 Sink 读取透明视频时将其设置为 true。
  • decoderOptions
    底层视频解码器的其他偏好设置。

一些示例:

// 此 Sink 生成的画布具有未改变的轨道显示尺寸,
// 并尊重轨道的旋转和翻转元数据。
new CanvasSink(videoTrack);

// 此 Sink 生成的画布宽度为 1280,高度保持
// 原始显示宽高比。
new CanvasSink(videoTrack, {
	width: 1280,
});

// 此 Sink 生成的画布为正方形,视频帧被缩放以完全
// 覆盖画布。
new CanvasSink(videoTrack, {
	width: 512,
	height: 512,
	fit: 'cover',
});

// 此 Sink 生成的画布具有未旋转的轨道尺寸,
// 且不应用任何旋转或翻转。
new CanvasSink(videoTrack, {
	rotation: 0,
	flip: false,
});

检索画布的方法与 VideoSampleSink 上的方法类似:

  • getCanvas
    获取给定时间戳的画布;参见单次检索。
  • canvases
    遍历一系列画布;参见范围迭代。
  • canvasesAtTimestamps
    遍历特定时间戳下的画布;参见稀疏迭代。

这些方法返回 WrappedCanvas 实例:

type WrappedCanvas = {
	// 画布元素或离屏画布。
	canvas: HTMLCanvasElement | OffscreenCanvas;
	// 对应视频样本的时间戳,单位为秒。
	timestamp: number;
	// 对应视频样本的持续时间,单位为秒。
	duration: number;
};

画布池

默认情况下,此 sink 每次产出画布时都会创建一个新的画布。如果您知道在任意时刻只保留少量画布,应使用 poolSize 选项。该整数值指定池中画布的数量;这些画布随后会以环形缓冲区 / 轮询方式复用。这能保持已分配 VRAM 量的恒定,并减轻浏览器不断分配/释放画布的负担。将池大小设置为 0 或 undefined 可禁用池功能。

使用池大小为 3 的示意图:

const sink = new CanvasSink(videoTrack, { poolSize: 3 });

const a = await sink.getCanvas(42);
const b = await sink.getCanvas(42);
const c = await sink.getCanvas(42);
const d = await sink.getCanvas(42);
const e = await sink.getCanvas(42);
const f = await sink.getCanvas(42);

assert(a.canvas === d.canvas);
assert(b.canvas === e.canvas);
assert(c.canvas === f.canvas);
assert(a.canvas !== b.canvas);
assert(a.canvas !== c.canvas);

对于已关闭的迭代器,池大小为 1 即可:

const sink = new CanvasSink(videoTrack, { poolSize: 1 });
const canvases = sink.canvases();

for await (const { canvas, timestamp } of canvases) {
	// ...
}

音频数据 sinks

这些 sinks 仅可与 InputAudioTrack 配合使用。

AudioSampleSink

使用此 sink 从音频轨道中提取解码后的 音频样本。sink 将在内部自动处理解码。

按以下方式创建 sink:

import { AudioSampleSink } from 'mediabunny';

const sink = new AudioSampleSink(audioTrack);

检索样本的方法与 VideoSampleSink 上的方法类似:

  • getSample
    获取给定时间戳的样本;参见单次检索。
  • samples
    遍历一系列样本;参见范围迭代。
  • samplesAtTimestamps
    遍历特定时间戳下的样本;参见稀疏迭代。

这些方法返回 AudioSample 实例。

例如,让我们使用此 sink 通过 均方根 计算音频轨道的平均响度:

const sink = new AudioSampleSink(audioTrack);

let sumOfSquares = 0;
let totalSampleCount = 0;

for await (const sample of sink.samples()) {
	const bytesNeeded = sample.allocationSize({ format: 'f32', planeIndex: 0 });
	const floats = new Float32Array(bytesNeeded / 4);
	sample.copyTo(floats, { format: 'f32', planeIndex: 0 });

	for (let i = 0; i < floats.length; i++) {
		sumOfSquares += floats[i] ** 2;
	}

	totalSampleCount += floats.length;
}

const averageLoudness = Math.sqrt(sumOfSquares / totalSampleCount);

AudioBufferSink

虽然 AudioSampleSink 提取原始解码的音频样本,但您可以使用 AudioBufferSink 直接提取 AudioBuffer 实例。这在处理 Web Audio API 时特别有用。

按以下方式创建 sink:

import { AudioBufferSink } from 'mediabunny';

const sink = new AudioBufferSink(audioTrack);

检索音频缓冲的方法与 VideoSampleSink 上的方法类似:

  • getBuffer
    获取给定时间戳的缓冲区;参见单次检索。
  • buffers
    遍历一系列缓冲区;参见范围迭代。
  • buffersAtTimestamps
    遍历特定时间戳下的缓冲区;参见稀疏迭代。

这些方法返回 WrappedAudioBuffer 实例:

type WrappedAudioBuffer = {
	// 可用于 Web Audio API 的 AudioBuffer。
	buffer: AudioBuffer;
	// 对应音频样本的时间戳,单位为秒。
	timestamp: number;
	// 对应音频样本的持续时间,单位为秒。
	duration: number;
};

例如,让我们使用此 sink 播放音频轨道的最后 10 秒:

const sink = new AudioBufferSink(audioTrack);
const audioContext = new AudioContext();
const lastTimestamp = await audioTrack.computeDuration();
const baseTime = audioContext.currentTime;

for await (const { buffer, timestamp } of sink.buffers(lastTimestamp - 10)) {
	const source = audioContext.createBufferSource();
	source.buffer = buffer;
	source.connect(audioContext.destination);
	source.start(baseTime + timestamp);
}

  1. 几乎正确:CanvasSink 在使用画布池时会变为有状态。