Mediabunny 完全支持读取 HLS (.m3u8) 播放列表,包括 VOD 和直播内容。本页面将深入介绍 HLS 特有的行为和建议。关于如何在 Mediabunny 中读取任何媒体文件(包括 HLS)的通用信息,请参阅 读取媒体文件。
心智模型
Mediabunny 将 HLS 播放列表暴露为仿佛是一个巨大的单一输入文件。与其他任何格式一样,它从不暴露底层结构(播放列表、片段、解密密钥),而是提供一个抽象的、以媒体数据为中心的视图。任何用于处理其他 Mediabunny 输入的代码都会自动适用于 HLS 输入:它们也只是轨道的集合,每个轨道包含线性序列的媒体数据。HLS 的多文件、多播放列表和多片段特性已被抽象化并完全对用户隐藏。
HLS 输入
HLS 播放列表(主播放列表和媒体播放列表)通过 Mediabunny 中所有其他媒体文件相同的 Input 接口进行读取。HLS 需要读取多个文件,因此必须使用 PathedSource(例如 UrlSource):
import { Input, UrlSource, HLS_FORMATS } from 'mediabunny';
const input = new Input({
source: new UrlSource('https://example.com/master.m3u8'),
formats: HLS_FORMATS, // HLS_FORMATS 包含 HLS 以及常用的片段格式
});您可以使用 CustomPathedSource 提供任何自定义的“数据路径”解析逻辑。
读取轨道
要获取所有轨道:
const tracks = await input.getTracks();Mediabunny 将所有变体中的所有轨道展平为一个单一的扁平数组。如果多个变体引用相同的底层媒体播放列表,重复的轨道将被去重。
由于 Mediabunny 采用惰性加载机制,获取所有轨道列表是一个廉价的操作,因为它只需要获取主播放列表。
如果您直接将 Mediabunny 指向媒体播放列表,则轨道列表是根据播放列表中第一个片段存在的轨道推导出来的。
轨道查询
Mediabunny 提供了一个 InputTrackQuery 系统,以帮助在复杂的拥有多轨道的输入(如 HLS 中常见的情况)中找到轨道。
下面是一个构建 HLS 质量阶梯并根据语言选择音频轨道的整体示例:
import { desc, prefer } from 'mediabunny';
// 按分辨率对视频轨道进行排序(最高优先)
const videoTracks = await input.getVideoTracks({
sortBy: async track => [
desc(await track.getDisplayHeight()),
// 分辨率匹配的轨道按比特率排序
desc(await track.getBitrate()),
],
// 过滤掉 #EXT-X-I-FRAME-STREAM-INF 轨道
filter: async track => !(await track.hasOnlyKeyPackets()),
});
const availableQualities = videoTracks.length;
// 获取最佳质量的视频轨道
const video = videoTracks[0];
// 获取与该视频轨道配对的音频轨道
const matchingAudioTrack = await video.getPrimaryPairableAudioTrack();
// 获取所有音频轨道
const availableAudioTracks = await video.getPairableAudioTracks();
const availableLanguages = await Promise.all(
availableAudioTracks.map(track => track.getLanguageCode()),
);
// 获取西班牙语音频轨道
const matchingSpanishAudio = await video.getPrimaryPairableAudioTrack({
filter: async track => await track.getLanguageCode() === 'es',
});
// 如果存在西班牙语音频轨道则获取它,否则获取次优选项
const matchingAudioPreferSpanish = await video.getPrimaryPairableAudioTrack({
sortBy: async track => prefer(await track.getLanguageCode() === 'es'),
});读取轨道元数据
有关一般性的轨道元数据读取,请参阅 读取媒体文件。以下是 HLS 的一些 notable 元数据映射:
// 返回 BANDWIDTH 属性的值(峰值比特率)。
// 如果该轨道用于多个变体中,则返回这些比特率的最大值。
await track.getBitrate();
// 同上,但是针对 AVERAGE-BANDWIDTH
await track.getAverageBitrate();
// LANGUAGE 属性
await track.getLanguageCode();
// NAME 属性
await track.getName();
const disposition = await track.getDisposition();
disposition.primary; // => DEFAULT 属性 (= Mediabunny 中的 primary)
disposition.default; // => AUTOSELECT 属性
// 表示一个 #EXT-X-I-FRAME-STREAM-INF 轨道
await track.hasOnlyKeyPackets();轨道配对
在 Mediabunny 中,如果两个轨道可以一起呈现,则认为它们是 可配对的。HLS 主播放列表通过变体流和媒体再现明确建模了配对性。尽管 Mediabunny 不暴露这些结构并将所有轨道展平,但由主播放列表隐含的配对图仍然可以通过以下方式访问:
trackA.canBePairedWith(trackB);还有许多其他方法可用于查询配对性和可配对轨道;请参见 InputTrack API 文档。
示例
考虑以下两个主播放列表:
播放列表 #1:
#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播放列表 #2:
#EXTM3U
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio",URI="audio-1.m3u8"
#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio",URI="audio-2.m3u8"
#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio"
video-1.m3u8
#EXT-X-STREAM-INF:CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=640x480,AUDIO="audio"
video-2.m3u8这两个播放列表都精确定义了四个轨道:两个视频轨道,两个音频轨道。它们的区别仅在于配对能力:
- 第一个播放列表具有视频 - 音频对。视频轨道 #1 只能与音频轨道 #1 配对,视频轨道 #2 只能与音频轨道 #2 配对。
- 第二个播放列表则更加开放。任何视频轨道都可以与任何音频轨道配对。
这意味着,对于播放列表 #1:
const videoTrack = (await input.getVideoTracks())[0];
(await videoTrack.getPairableTracks()).length; // => 1对于播放列表 #2:
const videoTrack = (await input.getVideoTracks())[0];
(await videoTrack.getPairableTracks()).length; // => 2懒加载
轨道信息是懒加载的。这意味着此操作仅加载主播放列表:
const track = await input.getPrimaryVideoTrack();然后,仅需媒体播放列表的操作将只加载它:
const duration = await track.getDurationFromMetadata();最后,某些操作还需要第一个分片:
const decoderConfig = await track.getDecoderConfig();访问媒体数据的操作可能会加载满足该请求所需的任何分片,但不会更多:
const sink = new EncodedPacketSink(videoTrack);
// 获取流中 300s 处的某个数据包
const middlePacket = await sink.getPacket(300);某些操作加载的数据量可能因底层文件而异。例如,如果存在可选的 RESOLUTION 属性,此代码只需要主播放列表;但如果不存在,Mediabunny 别无选择,必须深入底层媒体数据以确定分辨率:
const width = await track.getDisplayWidth();
const height = await track.getDisplayHeight();播放列表在内部被缓存,因此永远不会读取超过一次。分片仅在仍在使用时才被缓存;未使用的分片会被驱逐以保持内存使用量有界。
Unix 时间戳媒体
某些 HLS 媒体播放列表(尤其是直播播放列表)可能如下所示:
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXTINF:10,
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:00Z
segment-1.ts
#EXTINF:10,
#EXT-X-PROGRAM-DATE-TIME:2024-01-01T00:00:10Z
segment-2.ts
#EXT-X-ENDLIST在这里,每个分片都被分配了一个特定的 Unix 时间戳,这对于跨轨道同步至关重要。
Mediabunny 不将这些时间戳作为带外元数据暴露。相反,它将整个轨道的时间线进行偏移,使其时间戳就是Unix 时间戳。
具体来说,这意味着对于上述播放列表:
await track.getFirstTimestamp(); // => 1704067200 (2024-01-01T00:00:00Z 的 Unix 时间戳)
await track.computeDuration(); // => 1704067220 (2024-01-01T00:00:20Z 的 Unix 时间戳)
const sink = new EncodedPacketSink(track);
(await sink.getPacket(1704067210))!.timestamp; // => 1704067210 (2024-01-01T00:00:10Z 的 Unix 时间戳)这使得跨轨道同步变得微不足道。要确定轨道的时间戳是否相对于 Unix 纪元,请使用:
await track.isRelativeToUnixEpoch(); // => boolean禁用 Unix 偏移
如果您不希望 Mediabunny 将数据包时间戳偏移为 Unix 时间,可以在输入格式选项中设置 offsetTimestampsByDateTime 为 false:
const input = new Input({
// ...
formatOptions: {
hls: {
offsetTimestampsByDateTime: false,
},
},
});这样,轨道和数据包的时间戳行为就好像不存在 #EXT-X-PROGRAM-DATE-TIME 标签一样。这也意味着任何日期时间间隙都会被完全压缩。
您仍然可以通过 InputTrack 上的映射函数查询 Unix 时间元数据:
const firstTimestamp = await inputTrack.getFirstTimestamp(); // => 0
await inputTrack.getUnixTimeForTimestamp(firstTimestamp); // => 1704067200 (2024-01-01T00:00:00Z 的 Unix 时间戳)此函数将时间戳空间分段连续地映射到 Unix 时间空间。
如果没有可用墙钟时间信息,getUnixTimeForTimestamp() 将返回 null。您可以通过以下方式检查是否存在 Unix 时间元数据:
await inputTrack.hasUnixTimeMapping(); // boolean直播 HLS
HLS 播放列表可能是直播的。您可以通过以下方式检查轨道是否为直播:
await track.isLive(); // => boolean每个直播轨道还有一个“刷新间隔”,即多少秒后可能有新的媒体数据:
await track.getLiveRefreshInterval(); // => number | null心智模型
Mediabunny 对直播轨道有一个非常简单的心智模型:它们被视为媒体数据不断添加到末尾的轨道。这也意味着末尾的媒体数据尚未可知,因为末尾本身尚未可知。
另一种思考方式是将其视为尚未完全写入的输入文件,其中仍在向末尾添加更多数据。
当前已知最后一段媒体数据的点被称为直播边缘。
读取直播媒体数据
此心智模型意味着默认情况下,Mediabunny 会等待直到任何读取操作可被解析,这被称为“实时等待”(live wait)。例如,以下代码仅在直播流结束时才会完成:
const duration = await track.getDurationFromMetadata();这是因为只有在直播流结束后才能获知时长。
类似地,只要直播流在运行,以下代码就会持续执行:
const sink = new EncodedPacketSink(track);
const iterator = sink.packets();
for await (const packet of iterator) {
console.log(packet);
}
// Live stream has ended, all packets iterated!有时,这种行为并不理想。假设我们想读取当前已知数据末尾的帧。
Mediabunny 允许你将直播轨道视为所有媒体数据都已知的状态。为此,使用 skipLiveWait 标志:
// Get the duration up until the current point in the live stream
const currentDuration = await track.getDurationFromMetadata({ skipLiveWait: true });
// Loop over all currently known packets
const sink = new EncodedPacketSink(track);
const iterator = sink.packets(undefined, undefined, { skipLiveWait: true });
for await (const packet of iterator) {
console.log(packet);
}你可以将 skipLiveWait 与轨道的直播刷新周期结合使用来进行轮询。例如,以下代码会持续刷新当前已知的时长:
let knownDuration: number | null = null;
const poll = async () => {
knownDuration = await track.getDurationFromMetadata({
skipLiveWait: true,
});
const refreshInterval = await track.getLiveRefreshInterval();
if (refreshInterval === null) {
return; // The track is no longer live
}
setTimeout(poll, 1000 * refreshInterval);
};
await poll();实时播放
任何媒体播放都需要以持续递增的时间戳对媒体数据进行采样。对于实时播放,必须找到正确的时间戳,以确保能够持续播放且无中断。一个经验法则是使用以下内容:
const currentDuration = await track.getDurationFromMetadata({
skipLiveWait: true,
});
const refreshInterval = await track.getLiveRefreshInterval();
const fac = 2;
const playbackStartTime = currentDuration! - fac * refreshInterval!;你可以降低 fac 以使播放更接近直播边缘(1.5 也可以),但不应将其降至 1 以下。
你可以增大 fac 以提高对网络不稳定或不可靠的媒体生产者的容错能力。
加密内容
某些 HLS 播放列表携带加密的媒体数据。Mediabunny 可以读取通过以下加密方案编码的数据:
'AES-128''SAMPLE-AES''SAMPLE-AES-CTR'
'SAMPLE-AES' 和 'SAMPLE-AES-CTR' 目前仅支持 ISOBMFF 媒体文件。它们最常见于 DRM 加密内容(如 Widevine、FairPlay 等),而 Mediabunny 无法自行获取这些内容的解密密钥,因为商业 CDM 不会向用户态暴露密钥(这正是 DRM 的核心目的)。
如果你直接使用 InputOptions.formatOptions.isobmff.resolveKeyId 提供解密密钥,Mediabunny 仍然能够解密数据。示例如下:
// Maps each key ID to a concrete decryption key
const keyMap = new Map([
['4d97930a3d7b55fa81d0028653f5e499', '429ec76475e7a952d224d8ef867f12b6'],
['d21373c0b8ab5ba9954742bcdfb5f48b', '150a6c7d7dee6a91b74dccfce5b31928'],
['6f1729072b4a5cd288c916e11846b89e', 'a84b4bd66901874556093454c075e2c6'],
['800aacaa522958ae888062b5695db6bf', '775dbf7289c4cc5847becd571f536ff2'],
['67b30c86756f57c5a0a38a23ac8c9178', 'efa2878c2ccf6dd47ab349fcf90e6259'],
]);
using input = new Input({
source: new UrlSource(
'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine-hls/hls.m3u8',
),
formats: ALL_FORMATS,
formatOptions: {
isobmff: {
resolveKeyId: ({ keyId, psshBoxes }) => {
// psshBoxes contains Protection System Specific Header boxes
// relevant to this key ID. They can be used to obtain a
// decryption key from a DRM license server.
const key = keyMap.get(keyId);
if (!key) {
throw new Error('Unknown key ID.');
}
return key;
},
},
},
});字幕
目前不支持从 HLS 播放列表中读取字幕。抱歉!