简介
Mediabunny 中的媒体数据以两种不同的形式存在:
- 包(Packet):编码后的媒体数据,是编码过程的结果
- 样本(Sample):原始的、未压缩的、可呈现的媒体数据
除了数据本身,包和样本还携带额外的元数据,例如时间戳、持续时间、宽度等。
包由 EncodedPacket 类表示,该类同时用于视频和音频包。样本由 VideoSample 和 AudioSample 类表示:
VideoSample:代表视频的单帧。AudioSample:代表(通常较短的)音频片段。
样本可以编码为包,包可以解码为样本:
与 WebCodecs 的关系
Mediabunny 中的包和样本直接对应于 WebCodecs API 中的概念:
EncodedPacket
-> 视频包对应EncodedVideoChunk
-> 音频包对应EncodedAudioChunkVideoSample->VideoFrameAudioSample->AudioData
由于 Mediabunny 大量使用 WebCodecs API,其自身的类通常作为 WebCodecs 类的包装器。然而,这种包装带来了几个好处:
- 独立性:即使没有 WebCodecs API,该库也能正常运行。编码器和解码器可以使用 自定义编码器 进行多态填充,并且库可以在 Node.js 等非浏览器环境中运行。
- 可扩展性:这些包装器充当额外操作的命名空间,例如
AudioSample上的toAudioBuffer()或VideoSample上的draw()。 - 一致性:虽然 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; // => booleanVideoSample
视频样本代表单个视频帧。它可以由图像源直接创建,也可以是解码过程的结果。其 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 关闭后,其数据将不可用,且大多数方法将抛出错误。