Mediabunny 使您能够以非常精细的控制级别创建媒体文件。您可以向媒体文件添加任意数量的视频、音频和字幕轨道,并精确控制媒体数据的时间。该库支持多种输出文件格式。使用输出目标,您可以决定是将整个文件构建在内存中,还是在创建过程中分块流式传输——从而允许您创建非常大的文件。

Mediabunny 提供了多种向输出轨道提供媒体数据的方式,与 WebCodecs API 良好集成,同时也允许您使用自己的编码堆栈(如果您愿意)。这些媒体源具有多个抽象层级,既便于常见用例的使用,又能在需要时为您提供细粒度的控制。

创建输出 ​

Mediabunny 中的媒体文件创建围绕一个核心类 Output 展开。Output 的一个实例代表您想要创建的一个媒体文件。

首先,使用您想要创建的文件的所需配置创建一个新的 Output 实例:

import { Output, Mp4OutputFormat, BufferTarget } from 'mediabunny';

// 在本示例中,我们将在内存中创建一个 MP4 文件:
const output = new Output({
	format: new Mp4OutputFormat(),
	target: new BufferTarget(),
});

有关可用输出格式的完整列表,请参见输出格式。
有关可用输出目标的完整列表,请参见输出目标。

您始终可以访问输出上的 format 和 target:

output.format; // => Mp4OutputFormat	
output.target; // => BufferTarget

添加轨道 ​

Output 上有几个方法可用于向其添加轨道:

output.addVideoTrack(videoSource);
output.addAudioTrack(audioSource);
output.addSubtitleTrack(subtitleSource);

对于您要添加的每个轨道,都需要为其创建一个唯一的媒体源。您将能够通过这些媒体源向输出添加媒体数据。一个媒体源只能用于一个输出轨道。

这些方法返回新创建的 OutputTrack 实例。

可选地,您可以在添加轨道时指定其他轨道元数据:

// 这指定视频轨道应在被视频播放器显示前顺时针旋转 90 度,
// 并且预期帧率为 30 FPS。
output.addVideoTrack(videoSource, {
	// 顺时针旋转角度(度)
	rotation: 90,
	// 预期帧率(赫兹)
	frameRate: 30,
});

// 这添加了两个音频轨道;一个为英语,一个为德语。
output.addAudioTrack(audioSourceEng, {
	languageCode: 'eng', // ISO 639-2/T 语言代码
	name: 'Developer Commentary', // 设置用户定义的轨道名称
	disposition: { commentary: true }, // 设置文件中的额外标志
});
output.addAudioTrack(audioSourceGer, {
	languageCode: 'ger',
});

// 这添加了多个字幕轨道,均为不同语言。
output.addSubtitleTrack(subtitleSourceEng, { languageCode: 'eng' });
output.addSubtitleTrack(subtitleSourceGer, { languageCode: 'ger' });
output.addSubtitleTrack(subtitleSourceSpa, { languageCode: 'spa' });
output.addSubtitleTrack(subtitleSourceFre, { languageCode: 'fre' });
output.addSubtitleTrack(subtitleSourceIta, { languageCode: 'ita' });

可选的 frameRate 视频轨道元数据选项指定视频的预期帧率。所有将添加到该轨道的帧的时间戳和持续时间都将对齐到指定的帧率。应避免比允许的速率更频繁地添加帧,因为这会导致多个帧具有相同的时间戳。

要精确实现常见的分数帧率,请务必使用其确切的分数形式:23.976→24000/1001
29.97→30000/1001
59.94→60000/1001

作为示例,让我们向输出中添加两个轨道:

  • 由 <canvas> 元素的内容驱动的视频轨道,使用 AVC 进行编码
  • 由用户麦克风输入驱动的音频轨道,使用 AAC 进行编码
import { CanvasSource, MediaStreamAudioTrackSource, Quality } from 'mediabunny';

// 假设 `canvasElement` 已存在
const videoSource = new CanvasSource(canvasElement, {
	codec: 'avc',
	quality: new Quality({ bitrate: 1e6 }), // 1 Mbps
});

const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioStreamTrack = stream.getAudioTracks()[0];
const audioSource = new MediaStreamAudioTrackSource(audioStreamTrack, {
	codec: 'aac',
	quality: new Quality({ bitrate: 128e3 }), // 128 kbps
});

output.addVideoTrack(videoSource, { frameRate: 30 });
output.addAudioTrack(audioSource);

警告

如果轨道与输出格式不兼容,向 Output 添加轨道将抛出异常。在添加轨道时,请务必遵守输出格式的属性。

轨道组与配对能力 ​

为了控制输出轨道的配对能力(即哪些轨道可以与哪些轨道一起呈现),Mediabunny 使用了一个称为“轨道组”的概念。轨道被分配到一个或多个组,其组成员资格决定了它们可以与其他哪些轨道配对。该系统使得描述常见的配对模式变得容易。

对于典型文件格式,配置轨道组不是必需的,也不会产生任何效果。它适用于配置多轨道格式(如 HLS),其中轨道组会影响主播放列表的结构。

如果满足以下任一条件,则两个输出轨道被视为可配对:

  • 它们属于同一组但类型不同(视频、音频、字幕)
  • 它们位于两个已相互配对的组中

按如下方式创建轨道组:

import { OutputTrackGroup } from 'mediabunny';

const groupA = new OutputTrackGroup();
const groupB = new OutputTrackGroup();

您可以按如下方式选择性地配对组:

// 在此之后,group A 中的任何轨道都可以与 group B 中的任何轨道配对
groupA.pairWith(groupB); // 自动将 B 与 A 配对(对称操作)

在注册轨道期间将轨道分配到组:

output.addVideoTrack(videoSource, { group: groupA });
output.addAudioTrack(audioSource, { group: [groupA, groupB] });

默认情况下,当未指定时,每个轨道都会被分配给 Output.defaultTrackGroup。这反过来意味着默认的轨道配对规则是:

  • 不同类型的轨道(视频、音频、字幕)始终可以互相配对
  • 相同类型的两个轨道(例如两个音频轨道)不能互相配对

设置元数据标签 ​

Mediabunny 允许您向输出文件写入额外的描述性元数据标签,例如标题、艺术家或封面艺术:

output.setMetadataTags({
	title: 'Big Buck Bunny',
	artist: 'Blender Foundation',
	date: new Date('2008-05-20'),
	images: [{
		data: new Uint8Array([...]),
		mimeType: 'image/jpeg',
		kind: 'coverFront',
	}],
});

如果您添加了元数据标签,则必须在启动 Output 之前调用此方法。

有关您可以写入哪些标签的更多信息,请参阅 MetadataTags。

启动输出 ​

在向 Output 添加所有轨道后,您需要 启动 它。启动输出会启动写入进程,允许您现在向输出文件发送媒体数据。它还防止您向其添加任何新轨道。

await output.start(); // 一旦输出准备好接收媒体数据即解析

添加媒体数据 ​

启动 Output 后,您可以使用用于添加轨道的媒体源将媒体数据管道传输到输出文件。此 API 对于每个 媒体源 都不同,但通常看起来像这样:

mediaSource.add(...);

在我们的示例中,一旦我们调用了 start,用户的麦克风输入就会被管道传输到输出文件。然而,我们仍然需要添加来自画布的数据。我们可以这样做:

let framesAdded = 0;
const intervalId = setInterval(() => {
	const timestampInSeconds = framesAdded / 30;
	const durationInSeconds = 1 / 30;

	// 捕获调用 `add` 时的画布状态:
	videoSource.add(timestampInSeconds, durationInSeconds);
	framesAdded++;
}, 1000 / 30);

然后我们将让此运行任意长时间以捕获媒体数据。

完成输出 ​

一旦添加了所有媒体数据,就需要对 Output 进行 完成。完成操作会完成所有剩余的编码工作并将剩余数据写入以创建最终的可播放媒体文件。

await output.finalize(); // 一旦输出完成即解析

警告

调用 finalize 后,向输出添加更多媒体数据会导致错误。

在我们的示例中,我们需要这样做:

clearInterval(intervalId); // 停止画布循环
audioStreamTrack.stop(); // 停止捕获用户的麦克风

await output.finalize();

const file = output.target.buffer; // => Uint8Array

可以在输出选项中提供可选的 onFinalize 回调函数。该函数将在 .finalize() 结束时被调用,可用于在输出完成后执行工作。如果它返回一个 Promise,输出将等待其解析。

取消输出 ​

有时,您可能想要取消正在进行的输出文件创建。为此,请使用 cancel 方法:

await output.cancel(); // 一旦输出被取消即解析

这将自动释放输出进程使用的所有资源,例如关闭所有编码器或释放写入器。

警告

调用 cancel 后,向输出添加更多媒体数据会导致错误。

在我们的示例中,我们会这样做:

clearInterval(intervalId); // 停止画布循环
audioStreamTrack.stop(); // 停止捕获用户的麦克风

await output.cancel();

// 输出已被取消

检查输出状态 ​

您可以随时使用其 state 属性检查输出当前所处的状态:

output.state; // => 'pending' | 'started' | 'canceled' | 'finalizing' | 'finalized'
  • 'pending' - 输出尚未启动或取消;可以添加新轨道。
  • 'started' - 输出已启动并准备好接收媒体数据;无法再添加轨道。
  • 'finalizing' - 已调用 finalize 但尚未解析;无法再添加任何媒体数据。
  • 'finalized' - 输出已完成并完成文件写入。
  • 'canceled' - 输出已被取消。

输出目标 ​

输出目标决定了由 Output 创建的数据将被写入的位置。本库提供了一些目标选项。


所有目标都会发出一个 write 事件,您可以监听该事件以监控哪些字节区域正在被写入:

const stopListening = target.on('write', ({ start, end }) => {
	// ...
});

您可以利用此功能跟踪输出文件在增长过程中的大小。但请注意,该事件非常频繁地触发(极其频繁)。

BufferTarget ​

此目标将所有数据写入单个、连续的内存 ArrayBuffer。随着文件变大,此缓冲区会自动扩展。用法十分直接:

import { Output, BufferTarget } from 'mediabunny';

const output = new Output({
	target: new BufferTarget(),
	// ...
});

// ...

output.target.buffer; // => null
await output.finalize();
output.target.buffer; // => ArrayBuffer

对于较小的文件(< 100 MB),此目标是不错的选择,但由于所有数据都保留在内存中,用于大文件并非最优。如果输出变得非常大,页面可能会因内存耗尽而崩溃。针对这些情况,建议使用 StreamTarget。

onFinalize 回调函数 ​

BufferTarget 接受一个 onFinalize 选项,当目标完成时,该选项会被传入完整的缓冲区并执行。这可用于将最终缓冲区上传到服务器或对象存储(例如 S3 PutObject,它需要一个已知的 Content-Length):

const output = new Output({
	target: new BufferTarget({
		onFinalize: async (buffer) => {
			await fetch('/upload', { method: 'PUT', body: buffer });
		},
	}),
	// ...
});

await output.finalize();
// 当 finalize 解析时,上传已完成。

StreamTarget ​

此目标将 Output 写入的数据以小块形式传递给您,您需要将这些数据管道传输到其他位置以手动组装最终文件。典型用例包括直接将文件写入磁盘,或通过网络将其上传到服务器。

StreamTarget 利用了 Streams API,因此您需要向其传递一个 WritableStream 实例:

import { Output, StreamTarget, StreamTargetChunk } from 'mediabunny';

const writable = new WritableStream({
	write(chunk: StreamTargetChunk) {
		chunk.data; // => Uint8Array
		chunk.position; // => number

		// 对数据进行操作...
	}
});

const output = new Output({
	target: new StreamTarget(writable),
	// ...
});

写入 WritableStream 的每个块代表输出文件中一段连续的字节数据 data,预期将在给定的字节偏移量 position 处写入。当在 Output 上调用 finalize 或 cancel 时,WritableStream 将自动关闭。

警告

请注意,输出文件中的某些字节区域可能会被多次写入。因此,简单地连接所有 Uint8Array 来构建最终文件是错误的——您必须按照块到达的顺序,在每个指定的字节偏移量位置写入每个数据块。如果您不这样做,您的输出文件很可能会无效或损坏。

一些 输出格式 具有 仅追加 写入模式,在这种模式下,写入块的字节偏移量将始终等于之前所有写入块的总字节数。换句话说,当写入为仅追加模式时,简单连接所有 Uint8Array 即可得到正确结果。某些 API(如 Media Source Extensions 的 appendBuffer)要求这样做,因此请确保在这些情况下相应地配置您的输出格式。

分块模式 ​

默认情况下,一旦数据可用,StreamTarget 就会立即发出。在某些格式中,这可能导致每秒数百次写入事件。如果您想减少写入频率,StreamTarget 提供了一种替代的“分块模式”,在该模式下,数据首先会在内存中累积成给定大小的较大块,然后仅在块完全填满时才发出。

new StreamTarget(writable, {
	chunked: true,
	chunkSize: 2 ** 20, // 可选;默认为 16 MiB
});

应用背压 ​

有时,Output 产生新数据的速度可能快于您写入数据的速度。在这种情况下,您需要告知 Output “冷静下来”,减慢速度以匹配 WritableStream 能够处理的速度。使用 StreamTarget 时,Output 将自动尊重 WritableStream 施加的背压。为此,了解如何应用背压的 Stream API 概念 很有用。

例如,可写流可以通过在 write 中返回 Promise 来施加背压:

const writable = new WritableStream({
	write(chunk: StreamTargetChunk) {
		// 假设写入数据需要 10 毫秒:
		return new Promise(resolve => setTimeout(resolve, 10));
	}
});

为了让可写流的背压在整个管道中传播,您必须确保正确遵守 媒体源施加的背压。

与文件系统 API 配合使用 ​

StreamTargetChunk 的设计使其与文件系统 API 的 FileSystemWritableFileStream 兼容。这意味着,如果您想直接将数据写入磁盘,只需执行以下操作:

const handle = await window.showSaveFilePicker();
const writableStream = await handle.createWritable();

const output = new Output({
	target: new StreamTarget(writableStream),
	// ...
});

// ...

await output.finalize(); // Will automatically close the writable stream

AppendOnlyStreamTarget ​

类似于 StreamTarget,但用于以纯追加方式写入文件:

import { Output, AppendOnlyStreamTarget } from 'mediabunny';

const writable = new WritableStream({
	write(data: Uint8Array) {
		// Do something with the data...
	},
});

const output = new Output({
	target: new AppendOnlyStreamTarget(writable),
	// ...
});

适用于只能顺序读取的消费者,例如处理传入上传的 HTTP 服务器。

警告

底层数据源并不会仅仅因为您使用了此源而神奇地变为追加模式。相反,只有当底层格式为追加模式时,才能使用此源。请参阅输出格式以查看哪些格式是追加模式的。

FilePathTarget ​

此目标将数据写入指定路径的文件。它旨在用于 Node、Bun 或 Deno 中的服务端场景,当您只想直接写入文件路径时,其提供的 API 比 StreamTarget 更简单。

import { Output, FilePathTarget } from 'mediabunny';

const output = new Output({
	target: new FilePathTarget('/path/to/output.mp4'),
	// ...
});

// ...

await output.finalize(); // Will automatically close the file handle

当在 Output 上调用 finalize 或 cancel 时,内部持有的文件句柄将被关闭。

默认情况下,为了性能,写入是分块的。与 StreamTarget 一样,您可以配置分块模式的选项:

new FilePathTarget('/path/to/output.mp4', {
	chunked: false, // Disable chunking (slower)
	chunkSize: 2 ** 20, // Optional; defaults to 16 MiB
});

NullTarget ​

此目标会简单地丢弃所有传入的数据。当您需要一个 Output 但需要通过其他方式提取数据时(例如通过特定于输出格式的回调或编码器事件),这将非常有用。

作为一个示例,这里我们创建一个分段的 MP4 文件并直接处理各个片段:

import { Output, NullTarget, Mp4OutputFormat } from 'mediabunny';

let ftyp: Uint8Array;
let lastMoof: Uint8Array;

const output = new Output({
	target: new NullTarget(),
	format: new Mp4OutputFormat({
		fastStart: 'fragmented',
		onFtyp: (data) => {
			ftyp = data;
		},
		onMoov: (data) => {
			const header = new Uint8Array(ftyp.length + data.length);
			header.set(ftyp, 0);
			header.set(data, ftyp.length);

			// Do something with the header...
		},
		onMoof: (data) => {
			lastMoof = data;
		},
		onMdat: (data) => {
			const segment = new Uint8Array(lastMoof.length + data.length);
			segment.set(lastMoof, 0);
			segment.set(data, lastMoof.length);

			// Do something with the segment...
		},
	}),
});

带路径的多文件目标 ​

某些输出格式会写入多个文件。例如,一个HLS输出会在生成主播放列表的同时,生成一个或多个媒体播放列表以及许多媒体分段文件。要写入此类多文件输出,Mediabunny 需要一种方式来解析其希望写入输出目标的路径。您可以为此使用 PathedTarget。

PathedTarget 包装了一个根路径(媒体的入口文件)以及一个回调函数,该函数为每个请求的文件路径生成一个 Target:

import { Output, PathedTarget, FilePathTarget, HlsOutputFormat } from 'mediabunny';

const output = new Output({
	format: new HlsOutputFormat({ /* ... */ }),
	target: new PathedTarget(
		'master.m3u8',
		({ path, isRoot }) => new FilePathTarget(`/output/${path}`),
	),
});

该回调会为格式想要写入的每个文件调用一次,并接收一个 TargetRequest:

type TargetRequest = {
	path: FilePath; // The requested file path
	isRoot: boolean; // Whether the requested file is the root file
	mimeType: string; // The MIME type of the file being requested
};

您在回调中创建的 Target 类型由您决定——对于磁盘上的文件使用 FilePathTarget,对于将分块上传到服务器的情况使用 StreamTarget,或者使用任何其他适合的目标类型。

数据包缓冲 ​

某些输出格式需要数据包缓冲来处理多轨道输出。发生数据包缓冲是因为 Output 必须等待给定时间戳的所有轨道数据后,才能继续写入数据。例如,如果您先编码所有视频帧,然后再编码音频,那么 Output 必须在内存中保留所有视频帧,直到音频数据包开始到达。如果您的视频非常长,这可能会导致内存耗尽。如果只有一个媒体轨道,则不会出现此问题。

请检查输出格式页面,以查看哪些格式配置需要数据包缓冲。


如果您的输出格式配置需要数据包缓冲,请确保以某种交错的方式添加媒体数据,以保持内存使用量较低。例如,如果您正在创建一个 5 分钟的影片,请以块为单位添加数据——10 秒视频,然后是 10 秒音频,然后重复——而不是先添加所有 300 秒的视频,再添加所有 300 秒的音频。

如果您的用例无法实现这种分块方式,尝试首先添加整体数据占用较小的媒体:先添加 300 秒的音频,然后再添加 300 秒的视频。

输出 MIME 类型 ​

有时您可能希望获取由 Output 创建的文件的 MIME 类型。例如,在使用 Media Source Extensions 时,addSourceBuffer 需要文件的完整 MIME 类型,包括编解码器字符串。

为此,请使用以下方法:

output.getMimeType(); // => Promise<string>

该方法的返回值可能是一个如下所示的字符串:

video/mp4; codecs="avc1.42c032, mp4a.40.2"

警告

getMimeType 返回的 Promise 仅在已知 Output 所有轨道的确切编解码器字符串后才会解析——这意味着它可能需要等待所有编码器完全初始化。因此,请确保不要陷入死锁:在向轨道添加媒体数据之前等待此方法会导致 Promise 永远无法解析。

如果您不关心特定轨道的编解码器,可以使用 Output 格式上更简单的 mimeType 属性:

output.format.mimeType; // => string