进入卷五的领域部分。这篇拆
spf/src/media/hls/的三个纯函数解析器------multivariant(master playlist)、media playlist、属性字符串。它们是「文本 → 类型化 Presentation/Track/Segment」的转换层,零依赖、可直接测。最有意思的是 multivariant 解析里那个跨音频组的去重算法------HLS 的笛卡尔积坑被它漂亮地解掉了。
解析器的定位
SPF 的解析器在 media/hls/(不是 playback/)------它们是纯领域逻辑,不碰 DOM、不碰网络。输入文本字符串,输出类型化结构:
multivariant 文本 ──parseMultivariantPlaylist──→ Presentation(无 duration/segments)
media playlist 文本 ──parseMediaPlaylist──→ 补上 segments/duration/initialization
两级解析的分工(parse-media-playlist.ts:181-188 的 spread 合并):multivariant 产出 PartiallyResolved*Track(知道 URL、带宽、分辨率,不知道分段),各 track 的 URL 再 fetch 后交给 media playlist 解析器补全。
parse-attributes.ts:属性字符串解析(187 行)
HLS 标签的属性长这样:BANDWIDTH=412000,RESOLUTION=1920x1080,CODECS="avc1.64001f,mp4a.40.2"。核心正则(L9):
ts
/([A-Z0-9-]+)=(?:"([^"]*)"|([^,]*))/g
值优先匹配双引号内整段 ------因为 CODECS="avc1.64001f,mp4a.40.2" 里的逗号是值的一部分,不是分隔符。不先处理引号,CODECS 会被逗号劈开。
几个类型化的解析函数:
parseResolution(L25-33):/^(\d+)x(\d+)$/。parseFrameRate(L38-60):容差匹配 NTSC 分数帧率 ------23.976±0.01 →24000/1001、29.97 →30000/1001、59.94 →60000/1001。NTSC 帧率是分数,直接 parseFloat 会丢精度。parseCodecs(L73-87):按逗号拆分后按前缀分类。视频前缀avc1./hvc1./hev1.,音频前缀mp4a./ac-3/ec-3/ac-4/opus/flac/...(大小写不敏感,fLaC靠 lower 匹配)。L62-67 注释说明了动机:识别不了的 codec 解析为空,能力探测将无法裁剪它------宁可保守。parseExtInfDuration(L92-96):split(',')[0]剥掉可选标题(#EXTINF:6.006,标题)。parseByteRange(L103-121):/^(\d+)(?:@(\d+))?$/------有显式 offset 用之,无 offset 用previousEnd续接,返回闭区间{start, end: start + length - 1}。getBool(L160-162)严格=== 'YES'------HLS 布尔值只有 YES 算真。
matchTag(L182-186)做 #TAG: 前缀匹配,命中就对剩余部分建 AttributeList。
parse-multivariant.ts:master playlist(395 行)
行扫描(L72-151)
按行切分(L33 的 split(/\r?\n/)),跳过空行和 #EXTM3U/#EXT-X-VERSION 等杂项标签。
EXT-X-MEDIA (L89-128)处理音频/字幕 rendition 声明:TYPE/GROUP-ID/NAME 三者必须存在(L92-94)。有个细节我很欣赏------CHANNELS 的解析(L108):HLS 里它是带引号字符串(如 "6" 或 "16/JOC" 空间音频),getInt 只取前导整数(L105-107 注释)。
EXT-X-STREAM-INF (L130-151)用两行式结构 :STREAM-INF 标签在上一行,URI 在下一行。所以 L133-139 先把属性存进 pendingStreamInfo(不立即产出),L144-150 遇到非 # 行且有待定信息时才 resolveUrl 合成完整 StreamInfo。这是 HLS spec 的quirk,解析器必须处理。
跨音频组的去重(L183-236)------本文件最关键的算法
我读这段时先被背景注释(L184-189)教育了:HLS 中同一视频 rendition 会按它可配对的每个音频组重复出现在多个 STREAM-INF 里(笛卡尔积),URI 相同。比如 3 个画质 × 2 个音频组 = 6 行 STREAM-INF,但实际只有 3 个视频 rendition。
解法(L190-236):
ts
const tracksByUrl = new Map<string, PartiallyResolvedVideoTrack>(); // L190 --- key=URI 折叠
// 同 URI 重复出现时,把新的 audioGroupId 追加进 existing.audioGroupIds(L193-195)
但有个陷阱 :STREAM-INF 的 BANDWIDTH 是视频+音频之和 ,跨组重复项只在配对音频上不同。L197-202 的处理:保留最小值作为「最接近纯视频带宽」的代理,ABR 按它排序。而冗余流(不同 CDN 的不同 URI)不会被误合并------key 是 URI,URI 不同就是不同 track。
其余产出
- 纯音频流(L238-256):mimeType
'audio/mp4',sampleRate 48000 / channels 2 占位默认(L251-252,等 media playlist 修正)。 - EXT-X-MEDIA 音频 rendition → track(L258-300):从引用该 groupId 的 stream 的 CODECS 里「偷」出音频 codec(L261-270)------multivariant 不直接给 demuxed 音频的 codec,这是 HLS 规范的空隙,只能这么补。
- 文本 track(L305-334):对齐 hls.js 的行为------仅当
DEFAULT=YES且AUTOSELECT=YES同时成立才置default: true(L322-325)。 - 最终 Presentation(L387-394):
duration有意缺省(L392 注释:至少 fetch+parse 一个 media playlist 之前不可知)。
parse-media-playlist.ts:分段列表(189 行)
状态机式行扫描(L78-158)
状态变量(L78-86):initSegmentUrl/initSegmentByteRange(EXT-X-MAP)、currentDuration(EXTINF 暂存)、currentByteRange、currentTime(时间轴累加器 )、previousByteRangeEnd(BYTERANGE 无偏移时续接)。
关键标签处理:
- EXT-X-MAP(L106-117):init segment 的 URI + 可选 BYTERANGE(偏移从 0 续接,L111-114)。
- EXTINF(L120-123):暂存时长。
- EXT-X-BYTERANGE (L126-129):
parseByteRange(trimmed.slice(17), previousByteRangeEnd)------无显式 offset 时从上一段结束处续接。 - Segment URI (L136-157):非
#且currentDuration > 0时构建 Segment(id: segment-${index}、url、duration、startTime: currentTime),然后currentTime += currentDuration推进时间轴。
三个值得注意的「不处理」:
#EXT-X-TARGETDURATION被显式忽略 (L98)------总时长由 EXTINF 累加得出(L160 的totalDuration = currentTime),不用 targetDuration × n。#EXT-X-ENDLIST只是 continue(L131-133)------解析器对 VOD/live 不做区分,live 判定在别处(streamType feature,21 篇)。- 容器嗅探(L170-177):fMP4 必有 EXT-X-MAP,所以「无 map + 识别到的扩展名」即非 fMP4------
.ts→'video/mp2t'、.aac→'audio/aac'(L23-26)。这些 MIME 标记为当前不可播放,交给能力探测裁剪。
设计观察:解析器的「无知」
读完三个解析器,我最深的印象是它们的克制 ------不知道的就不填(duration 留空)、不该管的就不管(ENDLIST、live 判定)、识别不了的保守处理(codec 解析为空)。解析器只做「忠实转译文本 → 类型」,一切策略判断(能不能播、是 live 吗)都在下游。
这让它们成为理想的纯函数:给文本出结构,Vitest 直接表驱动测试(media/ 目录下 27 个测试文件,大部分是解析器测试)。
小结
- 引号优先的属性正则------CODECS 的逗号是值的一部分。
- 两行式 STREAM-INF------pending 暂存,URI 行才产出。
- URI 折叠去重 + 最小带宽代理------解 HLS 笛卡尔积坑。
- 从 stream CODECS 偷音频 codec------补规范空隙。
- DEFAULT+AUTOSELECT 双条件------对齐 hls.js 行为。
- targetDuration 显式忽略------时长 = EXTINF 累加。
- 容器嗅探------无 MAP + 扩展名 → 非 fMP4 MIME。
下一篇讲 ABR------带宽估计的双 EWMA 和质量选择的滞回算法。