Mediabunny 中的 读取 和 写入 原语提供了转换媒体文件所需的一切。然而,由于这是一个非常常见的操作且细节可能很棘手,Mediabunny 附带了一个内置的文件转换抽象层。

它具有以下功能:

  • 转复用(在复制媒体数据的同时更改容器格式)
  • 转码(更改轨道的编解码器)
  • 移除轨道
  • 压缩
  • 裁剪
  • 视频调整大小与适配
  • 视频旋转
  • 视频裁剪
  • 视频帧率调整
  • 视频透明度移除/保留
  • 音频重采样
  • 音频上下混音
  • 用户定义的视频和音频处理

转换 API 的设计目标是简单、多功能、可组合且高性能。

基本用法 ​

运行转换 ​

每个转换过程都由一个 Conversion 实例表示。使用 Conversion.init(...) 创建新实例,然后使用 .execute() 运行转换。

这里,我们将其转换为 WebM:

import {
	Input,
	Output,
	WebMOutputFormat,
	BufferTarget,
	Conversion,
} from 'mediabunny';

const input = new Input({ ... });
const output = new Output({
	format: new WebMOutputFormat(),
	target: new BufferTarget(),
});

const conversion = await Conversion.init({ input, output });
if (!conversion.isValid) {
	// 转换无效,无法在不报错的情况下执行。
	// 此字段提供轨道被丢弃的原因:
	conversion.discardedTracks; // => DiscardedTrack[]

	return;
}

await conversion.execute();

// output.target.buffer 包含最终文件

就是这样!Conversion 只需接受一个 Input 和 Output 实例,然后从输入读取数据并写入输出。如果您不熟悉 Input 和 Output,请查看它们各自的指南。

传递给 Conversion 的 Output 必须是新的;也就是说,它不能添加任何轨道或元数据标签,并且必须处于 'pending' 状态(尚未开始)。对于可组合的转换,此要求会放宽,允许您将转换与其他轨道结合。

未配置时,转换过程会自动处理所有细节,例如:

  • 尽可能复制媒体数据,否则进行转码
  • 丢弃输出格式不支持的轨道

在执行 Conversion 之前,您应该考虑检查 isValid 和被丢弃的轨道。

监控进度 ​

要监控 Conversion 的进度,请在调用 execute 之前 设置其 onProgress 属性:

const conversion = await Conversion.init({ input, output });

conversion.onProgress = (progress: number) => {
	// `progress` 是一个介于 0 和 1 之间(含)的数字
};

await conversion.execute();

每当转换进度推进时,都会调用此回调。

警告

进度为 1 并不表示转换已完成;只有当 .execute() 返回的 promise 解析后,转换才算完成。

警告

跟踪转换进度可能会轻微影响性能,因为它需要知道输入文件的总时长。这通常可以忽略不计,但在使用仅追加的输入源(如 ReadableStreamSource)时应避免使用。

如果您想监控转换的输出大小(以字节为单位),只需在 Target 上使用 write 事件:

let currentFileSize = 0;

const stopListening = output.target.on('write', ({ start, end }) => {
	currentFileSize = Math.max(currentFileSize, end);
});

取消转换 ​

有时,您可能想要取消正在进行的转换过程。为此,请使用 cancel 方法:

await conversion.cancel(); // 一旦转换被取消即解析

这将自动释放转换进程使用的所有资源,并导致任何正在进行的 execute 调用抛出 ConversionCanceledError。

如果转换是可组合的,则相应的 Output 不会被取消,并在转换取消后仍可继续使用。

暂停转换 ​

您可以在执行过程中暂停转换,并在稍后恢复。为此,向 execute 方法传递暂停信号:

const controller = new AbortController();
button.onclick = () => controller.abort();

await conversion.execute({
	pauseSignal: controller.signal,
});

if (conversion.state === 'idle') {
	// 在完成前暂停
} else if (conversion.state === 'done') {
	// 运行至完成
}

未完成的转换只需再次调用 execute 即可恢复:

await conversion.execute();

部分执行 ​

您可以不运行完整的转换,而是仅执行到达到特定时间戳为止:

await conversion.execute({
	// 一旦输出时间戳达到 10 秒,暂停执行
	until: 10,
});

然后通过再次调用 execute 可以恢复并继续该转换。此功能对于可组合转换特别有用。

视频选项 ​

您可以在转换选项中设置 video 属性,以配置转换器对视频轨道的处理行为。选项如下:

type ConversionVideoOptions = {
	discard?: boolean;
	width?: number;
	height?: number;
	fit?: 'fill' | 'contain' | 'cover';
	rotate?: 0 | 90 | 180 | 270;
	flip?: boolean;
	allowTransformationMetadata?: boolean;
	crop?: { left: number; top: number; width: number; height: number };
	frameRate?: number;
	codec?: VideoCodec;
	quality?: Quality;
	alpha?: 'discard' | 'keep'; // 默认为 'discard'
	hardwareAcceleration?: 'no-preference' | 'prefer-hardware' | 'prefer-software';
	keyFrameInterval?: number;
	forceTranscode?: boolean;
	process?: (sample: VideoSample) => MaybePromise<
		CanvasImageSource | VideoSample | (CanvasImageSource | VideoSample)[] | null
	>;
	processedWidth?: number;
	processedHeight?: number;
	group?: OutputTrackGroup | OutputTrackGroup[];
};

type MaybePromise<T> = T | Promise<T>;

例如,这里我们将视频轨道调整为 720p:

const conversion = await Conversion.init({
	input,
	output,
	video: {
		width: 1280,
		height: 720,
		fit: 'contain',
	},
});

提供的配置将同样适用于输入的所有视频轨道。如果您想为每个视频轨道应用单独的配置,请查看特定于轨道的选项。

丢弃视频

如果您想移除视频轨道,请使用 discard: true。

调整视频尺寸

width、height 和 fit 属性控制视频的缩放方式。如果仅提供了 width 或 height,另一个值将自动推导以保持视频原始宽高比。如果同时使用了两者,则必须设置 fit 来控制缩放算法:

  • 'fill' 会将图像拉伸以填满整个区域,可能会改变宽高比。
  • 'contain' 会将整个图像包含在区域内,同时保持宽高比。这可能导致黑边(letterboxing)。
  • 'cover' 会将图像缩放直到填满整个区域,同时保持宽高比。

如果将 width 或 height 与 rotate、flip 或 crop 结合使用,它们控制的是旋转、翻转或裁剪后的尺寸。

如果您想对视频的尺寸应用最大/最小约束,请查看特定于轨道的选项。

在极少数情况下,如果输入视频的尺寸随时间变化,可以使用 fit 字段来控制尺寸变化行为(见 VideoEncodingConfig)。如果未设置,行为默认为 'passThrough'。

旋转和翻转视频

rotate 按指定角度顺时针旋转视频。此旋转应用于原始输入文件中的任何旋转元数据之上,并发生在翻转、裁剪和调整尺寸之前。

flip 水平翻转视频(关于垂直轴)。此翻转应用于原始输入文件中的任何翻转元数据之上,并发生在旋转之后但在裁剪和调整尺寸之前。

默认情况下,Mediabunny 会尝试利用输出文件中的旋转和翻转元数据来执行旋转和翻转操作(如可能)。但是,如果您不希望发生这种情况,或者希望使用 Mediabunny 从文件中剥离所有此类元数据,可以将 allowTransformationMetadata 设置为 false。

裁剪视频

crop 可用于从原始视频中提取矩形区域。该矩形由 left、top、width 和 height 指定,并会根据视频尺寸进行限制。裁剪操作在旋转和翻转之后,但在调整尺寸之前应用。

调整帧率

frameRate 属性可用于设置输出视频的帧率(单位:Hz)。如果未指定,将使用原始输入帧率(可能是可变的)。

转码视频

使用 codec 属性控制输出轨道的编解码器。应将其设置为输出文件支持的 编解码器,否则轨道将被丢弃。当未设置且需要转码时,Mediabunny 会自动选择一个环境可以编码且输出格式可以容纳的编解码器。

使用 quality 属性控制输出视频的质量。例如,您可以使用此字段压缩视频轨道。详见 编码质量。如果设置了此属性,将始终进行转码。如果未设置此属性但仍需转码,则将使用 new Quality('high') 作为值。

使用 keyFrameInterval 属性控制输出视频中关键帧之间的最大间隔(秒)。设置此字段会强制进行转码。如果您想阻止媒体数据的直接复制并强制进行转码步骤,请使用 forceTranscode: true。

使用 hardwareAcceleration 属性控制视频转码时使用硬件加速还是软件加速。

处理视频

process 属性可用于定义自定义视频样本处理函数,例如用于 添加覆盖层、颜色变换或时间戳修改。您需要自行执行此处理,例如使用 Canvas API。

示例:

let ctx: CanvasRenderingContext2D | null = null;
const conversion = await Conversion.init({
	video: {
		process: (sample) => {
			if (!ctx) {
				const canvas = new OffscreenCanvas(
					sample.displayWidth,
					sample.displayHeight,
				);
				ctx = canvas.getContext('2d')!;

				// Convert the video to grayscale
				ctx.filter = 'saturate(0)';
			}
			
			sample.draw(ctx, 0, 0);

			return ctx.canvas;
		},
	},
});

该函数在转换和帧率校正后,对每个输入视频样本进行调用。它必须返回一个 VideoSample、可转换为 VideoSample 的对象、它们的数组,或者为丢弃帧而返回 null。

此函数也可用于手动调整帧的大小。在此情况下,您应使用 processedWidth 和 processedHeight 字段来指示后处理尺寸,以便编码器能更好地预期输出内容。

音频选项 ​

您可以在转换选项中设置 audio 属性,以配置转换器对音频轨道的行为。可用选项如下:

type ConversionAudioOptions = {
	discard?: boolean;
	codec?: AudioCodec;
	quality?: Quality;
	numberOfChannels?: number;
	sampleRate?: number;
	sampleFormat?: 'u8' | 's16' | 's32' | 'f32';
	forceTranscode?: boolean;
	process?: (sample: AudioSample) => MaybePromise<
		AudioSample | AudioSample[] | null
	>;
	processedNumberOfChannels?: number;
	processedSampleRate?: number;
};

type MaybePromise<T> = T | Promise<T>;

例如,此处我们将音频轨道转换为单声道并设置特定的采样率:

const conversion = await Conversion.init({
	input,
	output,
	audio: {
		numberOfChannels: 1,
		sampleRate: 48000,
	},
});

提供的配置将同等应用于输入的所有音频轨道。如果您希望为每个音频轨道应用单独的配置,请查看特定于轨道的选项。

丢弃音频 ​

如果您想移除音频轨道,请使用 discard: true。

重采样音频 ​

numberOfChannels 属性控制输出音频的声道数(例如,1 表示单声道,2 表示立体声)。如果此值与输入轨道的声道数不同,Mediabunny 将使用 与 Web Audio API 相同的算法 对声道数据进行上混或下混。

sampleRate 属性控制以 Hz 为单位的采样率(例如,44100、48000)。如果此值与输入轨道的采样率不同,Mediabunny 将对音频进行重采样。

转码音频 ​

使用 codec 属性控制输出轨道的编解码器。该值应设置为输出文件支持的 编解码器,否则该轨道将被丢弃。当未设置且需要转码时,Mediabunny 将自动选择一个环境可以编码且输出格式可以容纳的编解码器。

使用 quality 属性控制输出音频的质量。例如,您可以使用该字段压缩音频轨道。更多信息请参阅 编码质量。如果设置了此属性,则始终会发生转码。如果未设置此属性但仍需转码,则将使用 new Quality('high') 作为值。

如果您希望防止媒体数据的直接复制并强制进行转码步骤,请使用 forceTranscode: true。

处理音频 ​

process 属性可用于定义自定义音频样本处理函数,例如应用音频效果、变换或时间戳修改。您需要自行执行此处理。

该函数在重新混音和重采样后,对每个输入音频样本进行调用。它必须返回一个 AudioSample、它们的数组,或者为丢弃样本而返回 null。

此函数也可用于手动执行重新混音或重采样。在此情况下,您应使用 processedNumberOfChannels 和 processedSampleRate 字段来指示后处理参数,以便编码器能更好地预期输出内容。

特定于轨道的选项 ​

您可能需要根据输入轨道的具体情况分别配置视频和音频选项。或者,如果媒体文件包含多个视频或音频轨道,您可能希望仅丢弃特定轨道或分别配置每个轨道。

为此,您可以不传递 video 和 audio 的对象,而是传递一个函数:

const conversion = await Conversion.init({
	input,
	output,

	// Function gets invoked for each video track:
	video: async (videoTrack) => {
		if (videoTrack.number > 1) {
			// Keep only the first video track
			return { discard: true };
		}

		return {
			// Shrink width to 640 only if the track is wider
			width: Math.min(await videoTrack.getDisplayWidth(), 640),
		};
	},

	// Async functions work too:
	audio: async (audioTrack) => {
		if (await audioTrack.getLanguageCode() !== 'rus') {
			// Keep only Russian audio tracks
			return { discard: true };
		}

		return {
			codec: 'aac',
		};
	},
});

有关视频和音频轨道属性的文档,请参考 读取轨道元数据。

轨道扇出 ​

您还可以为单个输入轨道设置(或返回)一个选项数组,这将导致 Mediabunny 为每个条目创建一个输出轨道,换句话说,即从一个输入轨道生成多个输出轨道(扇出)。

这对于以不同质量生成多个版本非常有用,例如用于 HLS:

const conversion = await Conversion.init({
	input,
	output,
	video: [
		{ height: 1080, quality: new Quality('high') },
		{ height: 720, quality: new Quality('medium') },
		{ height: 480, quality: new Quality('low') },
	],
});

裁剪 ​

使用转换选项中的 trim 属性,仅将输入文件的一部分提取到输出文件中:

type ConversionOptions = {
	// ...
	trim?: {
		start: number; // 秒数
		end: number; // 秒数
	};
	// ...
};

例如,这里我们提取从 10 秒到 25 秒的片段:

const conversion = await Conversion.init({
	input,
	output,
	trim: {
		start: 10,
		end: 25,
	},
});

在这种情况下,输出时长将为 15 秒。

如果仅设置了 start,则剪辑将持续到输入文件的末尾。如果仅设置了 end,则剪辑将从输入文件的开头开始。

注意,当使用裁剪默认值时,生成的媒体文件将始终从时间戳 0 开始。如果您的输入文件具有起始时间偏移(MPEG-TS 文件常见),并且您希望保留该偏移,请使用 trim: { start: 0 } 以确保时间戳不会发生移位。


您甚至可以使用负裁剪值来偏移媒体的起始位置:

const conversion = await Conversion.init({
	// ...
	trim: {
		start: -2, // 起始处有两秒无媒体数据(冻结画面 / 静音)
	},
	// ...
});

警告

将 start 设置为非默认值目前将强制对视频和音频进行 转码。

在 Mediabunny 的未来版本中,当前通过不设置 start 即可访问的快速“包复制路径”,可能会针对任意裁剪范围提供。

复制媒体数据 ​

在媒体转换中,媒体数据可以直接从旧文件复制到新文件,或者经过转码步骤。如果您来自 FFmpeg,您可能知道通过 -c copy 实现的复制路径。Mediabunny 与 FFmpeg 的不同之处在于,如果配置允许,它将默认尝试执行复制转换。复制转换通常更受欢迎,因为它们快速且无损。

有几个主要因素决定了是否可以进行复制转换:

  • 没有强制转码的操作(调整大小、旋转、重采样、编解码器更改等)
  • 输出格式必须能够包含输入编解码器
  • 输出格式必须支持足够灵活的时间戳

如果您转换为 MP4,由于该格式支持广泛的编解码器以及通过编辑列表支持负时间戳(允许您模拟任意裁剪范围),通常可以进行复制转换。较简单的格式如 MP3 限制更多,可能需要转码或时间戳偏移才能实现复制转换。


您可以通过 copy 转换选项完全配置 Mediabunny 的行为:

type ConversionCopyOptions = {
	mode?: 'forced' | 'preferred';
	shiftTolerance?: number;
	boundaryPolicy?: 'expand' | 'shrink';
	boundaryTolerance?: number;
};
  • mode
    控制是首选还是要求媒体复制。默认为 'preferred'。

    • 'forced':尽可能复制编码媒体,并丢弃无法复制的轨道。
    • 'preferred':在可能时复制编码媒体,并对无法复制的轨道进行转码。
  • shiftTolerance
    为了能够将媒体复制到输出格式而可应用的最大绝对偏移量(秒)。默认为 0,表示不允许任何额外偏移。设置为 Infinity 以允许任何偏移。

    0 的偏移为您提供完美的 时间线同步:输出时间戳将与输入时间戳完全匹配(仅由裁剪区域产生偏移)。任何非零偏移都将破坏此属性,但在所有情况下仍保持完美的跨轨道和音视频同步。

  • boundaryPolicy
    控制为满足请求的裁剪范围将复制哪个媒体区域。默认为 'expand'。

    • 'expand':至少包含请求范围内的所有媒体。这可能需要因关键帧和包边界而扩展媒体区域,因此可能包含裁剪范围之外的媒体。该区域始终最小化扩展以满足复制标准。
    • 'shrink':仅包含完全位于请求裁剪范围内的媒体。这可能需要因关键帧和包边界而缩小媒体区域,因此可能排除裁剪范围内的媒体。该区域始终最小化缩小以满足复制标准。

    如果您不希望丢失任何媒体,请使用 expand;如果您希望从不暴露裁剪区域之外的任何媒体,请使用 shrink。

  • boundaryTolerance
    由于 boundaryPolicy 导致的被复制媒体区域在其起始处偏离请求裁剪范围的最大量(秒)。默认为 Infinity,表示允许任何偏差。

    换句话说,此字段设置转换允许添加/移除多少秒媒体以实现复制路径。


您可以通过将字段设置为 false 来完全禁用复制转换:

await Conversion.init({
	copy: false,
	// ...
});

元数据标签 ​

默认情况下,输入的任意 描述性元数据标签 都会复制到输出中。如果您想进一步控制写入输出的元数据标签,可以使用 tags 选项:

// Set your own metadata:
const conversion = await Conversion.init({
	// ...
	tags: {
		title: 're:Turning',
		artist: 'Alexander Panos',
	},
	// ...
});

// Or, augment the input's metadata:
const conversion = await Conversion.init({
	// ...
	tags: (inputTags) => ({
		...inputTags, // Keep the existing metadata
		images: [{ // And add cover art
			data: new Uint8Array(...),
			mimeType: 'image/jpeg',
			kind: 'coverFront',
		}],
		comment: undefined, // And remove any comments
	}),
	// ...
});

// Or, remove all metadata
const conversion = await Conversion.init({
	// ...
	tags: {},
	// ...
});

选择音轨 ​

使用 tracks 选项来控制哪些输入音轨被视为进行转换:

const conversion = await Conversion.init({
	input,
	output,
	tracks: 'primary', // Only the primary video and audio tracks
});

接受的值为 'all' 或 'primary'。默认为 'all',除非输入格式为 HLS,则默认为 'primary'。这是因为 HLS 通常具有多个音轨的变体,转换所有变体(通常)是不需要的。

对于更细粒度的选择,请使用音轨上的 discard 字段。

丢弃的音轨 ​

如果输入音轨被排除在输出文件之外,则视为 已丢弃。初始化 Conversion 后可以访问已丢弃音轨的列表:

const conversion = await Conversion.init({ input, output });
conversion.discardedTracks; // => DiscardedTrack[]

type DiscardedTrack = {
	// The track that was discarded
	track: InputTrack;
	// The reason for discarding the track
	reason:
		| 'discarded_by_user'
		| 'max_track_count_reached'
		| 'max_track_count_of_type_reached'
		| 'unknown_source_codec'
		| 'undecodable_source_codec'
		| 'no_encodable_target_codec';
};

由于您可以在执行 Conversion 之前检查此列表,因此您可以决定是否继续转换过程。

如果 isValid 为 false,则已丢弃的音轨导致 Conversion 无效。例如,当格式需要特定编解码器但该编解码器无法编码时,就会发生这种情况。


存在以下原因:

  • discarded_by_user
    您通过设置 discard: true 丢弃了该音轨。
  • max_track_count_reached
    输出中没有更多空间容纳另一个音轨。
  • max_track_count_of_type_reached
    输出中没有更多空间容纳另一种类型的音轨,或者输出根本不支持该音轨类型。
  • unknown_source_codec
    我们不知道输入音轨的编解码器,因此不知道该怎么做。
  • undecodable_source_codec
    已知输入音轨的编解码器,但我们无法对其进行解码。
  • no_encodable_target_codec
    我们无法找到能够编码且可包含在输出格式中的编解码器。如果环境不支持必要的编码器,或者您请求的编解码器无法包含在输出格式中,可能会出现此原因。

另一方面,您可以随时查询哪些输入音轨成功进入了输出:

const conversion = await Conversion.init({ input, output });
conversion.utilizedTracks; // => InputTrack[]

当扇出(fan-out)从单个音轨产生多个输出音轨时,该音轨可能会在此列表中多次出现。

可组合转换 ​

默认情况下,Conversion 完全拥有其 Output:它需要一个全新的输出,然后为您启动、添加数据并完成最终化。然而,有时您希望转换只是贡献于单个输出文件的多个组件之一——例如,保留输入的音轨同时附加您自己外部制作的音频音轨。为此,请设置 composable: true。

可组合转换仅将其自身的音轨添加到输出中,并在 execute() 运行时泵送其媒体数据。输出生命周期的其余部分由您掌控:您可以添加任何其他音轨,设置任何元数据标签,并自行调用 start() 和 finalize()。这使您能够在转换之外添加其他音轨,甚至让多个转换针对单个 Output。

要使用它,请先初始化所有内容,然后启动 Output,再执行转换:

import {
	Input,
	Output,
	Mp4OutputFormat,
	BufferTarget,
	Conversion,
	AudioBufferSource,
} from 'mediabunny';

const input = new Input({ ... });
const output = new Output({
	format: new Mp4OutputFormat(),
	target: new BufferTarget(),
});

// 仅使用转换来复制视频
const conversion = await Conversion.init({
	input,
	output,
	audio: { discard: true },
	composable: true,
});

// 直接添加我们自己的音频轨道
const audioSource = new AudioBufferSource({
	codec: 'aac',
	quality: new Quality({ bitrate: 128e3 }),
});
output.addAudioTrack(audioSource);

// 启动输出
await output.start();

// 并发运行转换并馈送我们自己的音频
await Promise.all([
	conversion.execute(),
	audioSource.add(myAudioBuffer).then(() => audioSource.close()),
]);

// 完成输出
await output.finalize();

同步运行 ​

为了避免因缓冲需求导致的高内存占用,重要的是在所有轨道上以大致相同的速度添加媒体数据。为此,您可以通过多次调用 execute 来刻意步进转换:

await output.start();

for (using sample of generateAudioSamples()) {
	await audioSource.add(sample);
	await conversion.execute({ until: sample.timestamp });
}

// 转换剩余部分
await conversion.execute();

await output.finalize();

当运行多个针对同一输出的可组合转换时,您可以使用以下模式:

await output.start();

for (let until = 1; true; until += 1) {
	await Promise.all([
		conversion1.execute({ until }),
		conversion2.execute({ until }),
	]);

	if (conversion1.state === 'done' && conversion2.state === 'done') {
		break;
	}
}

await output.finalize();

转换直播流 ​

直播输入(如 HLS 直播流)也可与 Conversion API 配合使用。在这种情况下,默认情况下,转换将一直运行直到直播流结束。

如果您只想转换直播流的一部分而不是等待其结束,可以对转换进行裁剪。例如,这里我们捕获接下来 60 秒的直播流:

// 获取直播边缘
const currentDuration = await input.getDurationFromMetadata(undefined, {
	skipLiveWait: true,
});

const conversion = await Conversion.init({
	input,
	output,
	trim: {
		// 从当前直播边缘开始
		start: currentDuration,
		// 最晚在 60 秒后结束
		end: currentDuration + 60,
	},
});