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 segment
- Media playlist
'playlist-2.m3u8'- Init segment
'init-1.m4s'(某些分段格式会发出) - Media segment
'segment-2-1.m4s' - Media segment
'segment-2-2.m4s' - ...
- Init segment
- Media playlist
'playlist-3.m3u8'- Single-file segment
segments-3.ts
- Single-file segment
- ...
- Media playlist
您可以使用以下选项完全自定义这些名称:
分段时长
使用 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 播放列表写入字幕。抱歉!