Mediabunny 完全支持创建新的 HLS (.m3u8) 播放列表,包括 VOD 和直播。本页面将介绍 HLS 特有的行为和建议。关于在 Mediabunny 中创建新媒体文件(包括 HLS)的一般信息,请参阅 编写媒体文件。

结构与心智模型 ​

当 Mediabunny 写入 HLS 时,它将始终创建以下文件:

  • 一个主播放列表
  • 一个或多个媒体播放列表
  • 零个或多个媒体分段文件

Mediabunny 负责创建播放列表和对媒体数据进行分段。您只需要描述轨道、它们之间的关系以及它们的媒体数据。

媒体分段一旦完成就会立即发出。在 VOD 模式下(默认模式),主播放列表和媒体播放列表将在 完成输出 时发出。在直播模式下,它们将在每次更新时发出。

HLS 输出 ​

HLS 播放列表通过与其他所有 Mediabunny 媒体文件相同的 Output 接口创建。不同之处在于 HLS 会创建多个文件,因此需要 PathedTarget:

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

const output = new Output({
	format: new HlsOutputFormat({
		segmentFormat: new MpegTsOutputFormat(), // 用户定义的分段格式
	}),
	target: new PathedTarget(
		'master.m3u8', // 入口文件的路径
		({ path }) => {
			// 在此返回一个 Target
		},
	),
});

PathedTarget 要求您为 Mediabunny 想要写入的每个文件(由 路径 标识)返回一个 Target。

无主播放列表 ​

如果您只想写入媒体播放列表而不写入主播放列表,请使用 NullTarget:

const output = new Output({
	target: new PathedTarget(
		'',
		({ path, isRoot }) => {
			if (isRoot) {
				return new NullTarget();
			}

			// ...
		},
	),
	// ...
});

请注意,这可能会根据配置的轨道配对性写入多个媒体播放列表;请参见 轨道组与配对。

Target 示例 ​

您可以自由使用任何您想要的 target(参见 targets 列表),但这里有一些示例:

写入内存 ​

const writtenFiles = new Map<string, ArrayBuffer>();

const output = new Output({
	target: new PathedTarget(
		'master.m3u8',
		({ path }) => new BufferTarget({
			onFinalize: buffer => writtenFiles.set(path, buffer),
		}),
	),
	// ...
});

// ...
await output.finalize();

// 最终文件:
console.log(writtenFiles);

写入 OPFS ​

const root = await navigator.storage.getDirectory();
const writePromises: Promise<void>[] = [];

const output = new Output({
	target: new PathedTarget(
		'master.m3u8',
		async ({ path }) => new BufferTarget({
			onFinalize: (buffer) => {
				writePromises.push((async () => {
					const handle = await root.getFileHandle(path, { create: true });
					const writable = await handle.createWritable();
					await writable.write(buffer);
					await writable.close();
				})());
			},
		}),
	),
	onFinalize: () => Promise.all(writePromises),
	// ...
});

// ...
await output.finalize();

// 最终文件现在位于 OPFS 中

上传到服务器 ​

流式上传 ​

此代码模拟流式上传,即文件在创建的同时正在上传:

const promises: Promise<Response>[] = [];

const output = new Output({
	target: new PathedTarget(
		'master.m3u8',
		async ({ path, mimeType }) => {
			const { writable, readable } = new TransformStream<
				Uint8Array, Uint8Array,
			>();

			const url = `/upload?file=${encodeURIComponent(path)}`;
			const promise = fetch(url, {
				method: 'POST',
				body: readable,
				duplex: 'half',
				headers: {
					'Content-Type': mimeType,
				},
			});
			promises.push(promise);

			// 要求所有分段使用仅追加格式
			return new AppendOnlyStreamTarget(writable);
		},
	),
	onFinalize: () => Promise.all(promises),
	// ...
});

// ...
await output.finalize();
// 所有文件已上传到服务器

单体上传 ​

如果无法进行流式上传(例如,通过签名的 PutObject 上传到 S3,这需要已知的 Content-Length),您可以使用 BufferTarget 配合 onFinalize 选项代替。

您可以直接调用 fetch,但这会阻塞 Mediabunny 的内部处理,直到上传完成。相反,使用 ConcurrentRunner 允许 Mediabunny 在上传进行的同时继续在内部产生数据,同时也允许多个并发上传:

import { ConcurrentRunner, ... } from 'mediabunny';

// 此 Mediabunny 工具类用于允许最多两个请求并发运行。
// 当超过此数量时,内部会自动应用背压。
const runner = new ConcurrentRunner(2);

const output = new Output({
	target: new PathedTarget(
		'master.m3u8',
		({ path, mimeType }) =>
			new BufferTarget({
				onFinalize: buffer => runner.run(() =>
					fetch(`/upload?file=${encodeURIComponent(path)}`, {
						method: 'PUT',
						body: buffer,
						headers: {
							'Content-Type': mimeType,
						},
					})
				),
			}),
	),
	onFinalize: () => runner.flush(),
	// ...
});

await output.finalize();
// 所有文件均已上传至服务器

添加轨道与媒体 ​

可以使用 媒体源 通过常规方式注册输出轨道:

const videoSource = new VideoSampleSource(/* ... */);
const audioSource = new AudioSampleSource(/* ... */);

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

await output.start();

然后,像往常一样添加媒体数据。由于 Mediabunny 会自动处理分段,因此需要在内部执行 数据包缓冲。因此,尝试以准交错的方式写入媒体数据,以保持内存使用量在可控范围内。

多分辨率 ​

一种常见模式是提供多种分辨率和比特率的相同内容。这需要多次编码内容,有时还需要额外的缩放。Mediabunny 的 媒体源 使这种模式变得轻而易举。

假设我们有一个 1080p 主视频流。我们希望在 HLS 播放列表中提供 1080p、720p、480p 和 360p 变体。为此,请使用以下模式:

const source1080p = new VideoSampleSource({
	codec: 'avc',
	quality: new Quality('very-high'),
});
const source720p = new VideoSampleSource({
	codec: 'avc',
	quality: new Quality('high'),
	transform: {
		// 帧将在编码前自动调整大小至 720p
		height: 720,
	},
});
const source480p = new VideoSampleSource({
	codec: 'avc',
	quality: new Quality('medium'),
	transform: {
		height: 480,
	},
});
const source360p = new VideoSampleSource({
	codec: 'avc',
	quality: new Quality('low'),
	transform: {
		height: 360,
	},
});

const sources = [source1080p, source720p, source480p, source360p];
for (const source of sources) {
	output.addVideoTrack(source);
}

await output.start();

// 然后,在添加新帧时:
const sample = // ...
for (const source of sources) {
	await source.add(sample);
}

您还可以扩展此模式,以提供多种编解码器的内容。

有关转换选项的完整列表,请参阅 VideoTransformOptions 和 AudioTransformOptions。


如果您正在使用 转换 API,则可以通过 输出轨道扇出 实现相同的功能:

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

轨道元数据 ​

在处理多个轨道时,您通常希望提供额外的 轨道元数据。例如:

output.addVideoTrack(videoSource);
output.addAudioTrack(audioSourceEn, { languageCode: 'en', name: 'English' });
output.addAudioTrack(audioSourceDe, { languageCode: 'de', name: 'German' });
output.addAudioTrack(audioSourceEs, { languageCode: 'es', name: 'Spanish' });

轨道配置 ​

您可以使用 disposition 选项控制播放器如何选择轨道。以下字段映射到 #EXT-X-MEDIA 上的 HLS 属性:

  • disposition.primary → DEFAULT=YES(每个媒体再现组只允许一个;同一组中的后续 primary 轨道将被忽略)
  • disposition.default → AUTOSELECT=YES(当 primary 为 true 时也隐含此设置)
  • disposition.forced → FORCED=YES

例如:

output.addAudioTrack(audioSourceEn, {
	languageCode: 'en',
	name: 'English',
	disposition: { primary: true },
});
output.addAudioTrack(audioSourceDe, {
	languageCode: 'es',
	name: 'Spanish',
	disposition: { default: false },
});

如果未指定,default 默认为 true,而 primary 和 forced 默认为 false。

仅 I 帧轨道 ​

这样注册的轨道将通过 #EXT-X-I-FRAME-STREAM-INF 发出:

output.addVideoTrack(videoSource, { hasOnlyKeyPackets: true });

注意,向此类轨道添加非关键数据包是错误的。

DATE-TIME 标签 ​

为了在媒体播放列表中发出 #EXT-X-PROGRAM-DATE-TIME 标签(将每个媒体时间映射到确定的时间点),请在轨道元数据中设置 isRelativeToUnixEpoch:

output.addVideoTrack(videoSource, { isRelativeToUnixEpoch: true });

这声明您添加的时间戳不是相对于合成开始(0),而是相对于 Unix 纪元(1970 年 1 月 1 日,UTC 午夜):

const source = new EncodedVideoPacketSource('avc');
output.addVideoTrack(source, { isRelativeToUnixEpoch: true });

await output.start();

// 从 2024 年 1 月 1 日 UTC 午夜开始,帧率为 10 FPS。
// 时间戳 *是* Unix 时间戳:
await source.add(new EncodedPacket(data, type, 1704067200.0, 0.1));
await source.add(new EncodedPacket(data, type, 1704067200.1, 0.1));
await source.add(new EncodedPacket(data, type, 1704067200.2, 0.1));
// ...

上述代码将生成如下媒体播放列表:

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:2
#EXT-X-INDEPENDENT-SEGMENTS

#EXTINF:2,
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00.000Z
segments-1-1.ts

#EXTINF:2,
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:02.000Z
segments-1-2.ts

...

音轨组与配对 ​

Mediabunny 允许您以扁平化的方式向 HLS 播放列表添加音轨,但真实的 HLS 播放列表具有结构化特征:音轨被分组为变体和媒体再现(media renditions),这隐含了一个音轨可配对图:某些音轨可以相互配对(可以同时呈现),而另一些则不能。

您可以使用音轨组(track groups)来控制这些音轨配对:音轨可以被分配到零个或多个组中,且组之间可以进行配对:

import { OutputTrackGroup } from 'mediabunny';

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

// 将 A 与 C 配对,并将 C 与 A 配对
groupA.pairWith(groupC);

// 音轨属于一个组
output.addVideoTrack(source, { group: groupA });

// 音轨属于多个组
output.addVideoTrack(source, { group: [groupB, groupC] });

// 音轨不属于任何组
output.addVideoTrack(source, { group: [] });

配对规则 ​

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

  • 两个音轨属于同一个组 且 类型不同(视频、音频、字幕)
  • 它们属于彼此已配对的独立组

若未指定,所有音轨将自动分配给 Output.defaultTrackGroup。这意味着所有音轨都可以与任何其他类型的音轨配对,但相同类型的两个音轨无法配对。

示例 ​

默认配对 (n:k) ​

设想以下四音轨配置:

output.addVideoTrack(source1);
output.addVideoTrack(source2);

output.addAudioTrack(source3);
output.addAudioTrack(source4);

由于未指定音轨组,所有音轨都将分配给 Output.defaultTrackGroup。假设仅有一种视频和一种音频编解码器,这将生成如下主播放列表:

#EXTM3U

#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-1",NAME="audio-1",URI="audio-1.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-1",NAME="audio-1",URI="audio-2.m3u8"

#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio-1"
video-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x480,AUDIO="audio-1"
video-2.m3u8

为了保持“每个视频音轨可与每个音频音轨配对”的配置,音频音轨已通过 #EXT-X-MEDIA 提取到媒体再现组中。

注意,媒体再现组中的音轨应在元数据上有所区别。因此,在此配置下创建音轨时,请确保提供如 language 或 name 等元数据选项以区分音轨。

编解码器备选方案 ​

设想以下配置,我们为音频提供了两种不同的编解码器以获得更广泛的支持:

output.addVideoTrack(source1);
output.addVideoTrack(source2);

// 不同的音频编解码器:
output.addAudioTrack(sourceAac);
output.addAudioTrack(sourceAc3);

这将生成如下主播放列表,涵盖所有视频 - 音频音轨组合:

#EXTM3U

#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-1",NAME="audio-1",URI="audio-1.m3u8"

#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio-2",NAME="audio-2",URI="audio-2.m3u8"

#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio-1"
video-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,ac-3",RESOLUTION=1280x720,AUDIO="audio-2"
video-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x480,AUDIO="audio-1"
video-2.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,ac-3",RESOLUTION=640x480,AUDIO="audio-2"
video-2.m3u8

每视频一音频 ​

为了实现视频音轨与音频音轨之间的 1:1 配对,我们可以按如下方式对音轨进行分组:

const a = new OutputTrackGroup();
const b = new OutputTrackGroup();

output.addVideoTrack(source1, { group: a });
output.addVideoTrack(source2, { group: b });

output.addAudioTrack(source3, { group: a });
output.addAudioTrack(source4, { group: b });

这将生成如下主播放列表:

#EXTM3U

#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720
video-and-audio-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x480
video-and-audio-2.m3u8

独立音轨 ​

如果所有音轨都旨在作为独立音轨,请确保其中任意两个音轨不可相互配对。一种表达方式如下:

output.addVideoTrack(source1, { group: [] });
output.addVideoTrack(source2, { group: [] });

output.addAudioTrack(source3, { group: [] });
output.addAudioTrack(source4, { group: [] });

这将生成如下主播放列表:

#EXTM3U

#EXT-X-STREAM-INF:CODECS="avc1.64001f",RESOLUTION=1280x720
video-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f",RESOLUTION=640x480
video-2.m3u8
#EXT-X-STREAM-INF:CODECS="mp4a.40.2"
audio-1.m3u8
#EXT-X-STREAM-INF:CODECS="mp4a.40.2"
audio-2.m3u8

任意配对 ​

任何对称的轨道可配对图都可以使用轨道组进行建模。构建方式如下:

  • 为每个轨道创建一个组
  • 将每个轨道 Ti 分配给 Gi
  • 如果 Ti 和 Tj 应该可以配对,则将 Gi 与 Gj 配对

非法配对 ​

并非所有轨道配对都能在 HLS master playlists 中表示。具体来说,禁止将相同类型的轨道彼此配对:

const a = new OutputTrackGroup();
const b = new OutputTrackGroup();
a.pairWith(b);

output.addVideoTrack(source1, { group: a });
output.addVideoTrack(source2, { group: b });

这将导致 Mediabunny 发出警告并将这两个轨道视为未配对。

输出配置 ​

Mediabunny 提供了多种通过 HlsOutputFormatOptions 配置 HLS 输出的方法。

分段格式 ​

为了进行树摇(tree-shaking),您必须自行声明每个媒体分段的格式。任何 输出格式 均可工作,尽管并非所有格式都符合 HLS 规范。您应坚持使用以下支持良好的选项:

MPEG-TS ​

经过验证且可靠:

new HlsOutputFormat({
	segmentFormat: new MpegTsOutputFormat(),
});

CMAF ​

new HlsOutputFormat({
	segmentFormat: new CmafOutputFormat(),
});

注意,此格式除了生成所有媒体分段外,还会生成一个初始化分段(init segment)。

MP3/ADTS/WAV ​

当将单个轨道打包到一个分段中时,这些格式运行良好:

// MP3:
new HlsOutputFormat({
	segmentFormat: new Mp3OutputFormat(),
});

// ADTS:
new HlsOutputFormat({
	segmentFormat: new AdtsOutputFormat(),
});

// WAV:
new HlsOutputFormat({
	segmentFormat: new WavOutputFormat(),
});

混合格式 ​

您可能希望将音频数据打包到 .aac 文件中,将视频数据打包到 .ts 文件中。为此,您可以指定一个输出格式数组:

new HlsOutputFormat({
	segmentFormat: [
		new AdtsOutputFormat(), // 用于音频分段
		new MpegTsOutputFormat(), // 用于视频分段
	],
});

Mediabunny 随后将使用第一个能够包含该分段中所有轨道的格式。

文件名 ​

默认情况下,Mediabunny 将创建以下文件名:

  • Master playlist pathedTarget.rootPath(由您指定的名称)
    • Media playlist 'playlist-1.m3u8'
      • Media segment 'segment-1-1.ts'
      • Media segment 'segment-1-2.ts'
      • ...
    • Media playlist 'playlist-2.m3u8'
      • Init segment 'init-1.m4s'(某些分段格式会发出)
      • Media segment 'segment-2-1.m4s'
      • Media segment 'segment-2-2.m4s'
      • ...
    • Media playlist 'playlist-3.m3u8'
      • Single-file segment segments-3.ts
    • ...

您可以使用以下选项完全自定义这些名称:

分段时长 ​

使用 targetDuration 设置每个分段期望的最大时长:

new HlsOutputFormat({
	// ...
	targetDuration: 10, // 默认为 2(秒)
});

Mediabunny 随后将尝试最大化每个分段的长度,同时确保不超过目标时长。

由于 Mediabunny 仅在关键帧边界处创建新分段,请确保至少每 targetDuration 秒提供一个关键帧。如果您不提供,它别无选择,只能创建更长的分段。您可以使用 VideoEncodingConfig.keyFrameInterval 来控制关键帧速率。

单文件分段 ​

由于 HLS 支持字节子范围请求,您可能希望将所有媒体数据打包到一个巨大的单体分段文件中:

new HlsOutputFormat({
	// ...
	singleFilePerPlaylist: true,
});

这将生成如下所示的媒体播放列表:

#EXTM3U
#EXT-X-VERSION:4
#EXT-X-PLAYLIST-TYPE:VOD
#EXT-X-TARGETDURATION:2
#EXT-X-INDEPENDENT-SEGMENTS

#EXTINF:2,
#EXT-X-BYTERANGE:12345@0
segments-1.ts

#EXTINF:2,
#EXT-X-BYTERANGE:67890@12345
segments-1.ts

...

当与分片化 MP4 分段格式配合使用时,此选项具有特殊处理:生成的分段文件将是一个独立的、有效的 fMP4 文件。这样,您既能获得 HLS 播放列表,又能获得媒体数据的单文件表示形式。

直播 HLS ​

默认情况下,创建的 HLS 播放列表被视为 VOD。如果您想要创建在生成过程中即可被消费的 HLS 直播流,可以使用直播模式:

new HlsOutputFormat({
	// ...
	live: true,
});

与 VOD 模式相比,直播模式具有以下主要行为差异:

  • 媒体播放列表中将省略 #EXT-X-PLAYLIST-TYPE:VOD 标签
  • 一旦媒体播放列表拥有一个完成的片段,就会立即输出该播放列表
  • 主播放列表将在每个媒体播放列表至少被输出一次后立即输出
  • 每次媒体播放列表更新(新增片段或流结束)时,都会重新输出该播放列表
  • 每次媒体播放列表更新(以细化比特率值)时,都会重新输出主播放列表
  • 仅当流结束时,媒体播放列表才会包含 #EXT-X-ENDLIST 标签

由于主播放列表和媒体播放列表会被写入多次,因此需要对同一路径请求多个目标。请确保每次都返回不同的 Target 实例。

为了保证音视频同步,在使用直播模式时,请务必使用 DATE-TIME 标签。

结束流 ​

完成输出将结束所有媒体播放列表的流,并向其中每一个追加 #EXT-X-ENDLIST:

await output.finalize();

您可以通过单独关闭轨道来结束部分轨道的流。当任何媒体播放列表的最后一个打开轨道被关闭(意味着无法再添加任何媒体数据)时,该媒体播放列表的流将会结束:

// 假设一个包含视频 & 音频的流:

videoSource.close();
// 音频仍在运行,直播流继续
audioSource.close();
// 不再有媒体数据输入,直播流已结束

移除旧片段 ​

默认情况下,Mediabunny 会不断向媒体播放列表追加新片段,而永远不会删除旧片段。这意味着媒体播放列表的大小会持续增长,直到流结束。

要设置每个播放列表的最大片段数量上限,并告知 Mediabunny 从播放列表中弹出旧片段,请使用 maxLiveSegmentCount:

new HlsOutputFormat({
	// ...
	maxLiveSegmentCount: 500, // 默认为 Infinity
	onSegmentPopped: (path, segmentInfo) => // ...
});

每当以这种方式从播放列表中弹出一个片段时,将调用 onSegmentPopped 回调函数。您可以利用此回调函数获知片段已被移除并采取相应操作;例如,删除该片段文件。该回调函数将接收片段的路径以及 HlsOutputSegmentInfo。

当启用 singleFilePerPlaylist 时,不会调用 onSegmentPopped。

字幕 ​

目前不支持向 HLS 播放列表写入字幕。抱歉!