- 音视频
- 直播
- 移动开发
【免费下载链接】pure_live
纯粹直播:哔哩哔哩/虎牙/斗鱼/快手/抖音/网易cc/YY直播/Twitch直播/SOOP直播/M38自定义源应有尽有。
本篇技术指南围绕 pure_live 开源直播录播项目在 HLS 音视频预取(prefetch)链路中引入的"原子准入"机制展开,聚焦HlsPrefetchScheduler.selectAll与HlsPrefetchPlan.fromMaster两个核心组件的设计动机、实现细节与验证结果。读者将掌握:为什么双路(视频 + 音频)预取必须"全体先校验、再一次性准入",无歧义 master 清单如何被安全识别,以及从源码到原生 FFmpeg 实验验证的完整闭环方法,可直接复用于自己项目中的 HLS 录制/预取系统设计。
该审计文档(docs/HLS_PREFETCH_PLAN_AUDIT_2026_09_09.md)承接此前的停止冻结审计,本轮的核心目标是把"完整选择集合"的准入变成一个不可拆分的原子操作:要么整条 A/V 组合全部通过校验并一次性进入 feed 表,要么一条都不进入、调用方保持原有路径。
一、背景:为什么双路预取需要"原子准入"
HLS 直播录制通常涉及两路媒体流:一路视频(variant)、一路音频(独立的 AUDIO 组或伴随流)。如果调度器逐路 select——先准入视频、再准入音频——就会产生一个危险的中间态:视频已经进入活动状态、开始下载与轮询,而音频随后因"未支持标签、空/重复 ID、非法来源或集合超限"被拒绝。此时视频一路已经"先跑起来",整个 A/V 集合被拆成了残缺状态,录制输入端将丢失音轨。
文档明确指出这里的"原子"语义边界:
这里的"原子"是初始元数据与选择状态的原子准入,不是承诺未来两路网络请求同时成功。网络失败、运行中清单变化、续签及来源切换仍由后续运行期规则负责。
也就是说,原子性只覆盖"准入决策"这个瞬间:所有校验都在分配任何下载器或刷新定时器之前完成。网络层面的不确定性(断流、清单漂移、密钥续签)不属于准入合同的承诺范围,由已有的运行期刷新、覆盖缺口检测(onCoverageGap)与重试策略负责。
二、核心实现一:HlsPrefetchScheduler.selectAll的原子准入路径
源码位于 lib/domains/recorder/data/services/hls_prefetch_scheduler.dart。调度器的设计注释写得很清楚:一份录制源代(one recording source generation)、一个共享池、显式选中的媒体 feed;这个 owner 永远不自行切换清晰度或追随 master——选择权完全在调用方。
2.1 单路 select 复用同一准入路径
原有单路调用通过同一个准入路径继续工作:
bool select(String id, Uri fetchSource, HlsMediaSnapshot initial) => selectAll([(id: id, source: fetchSource, snapshot: initial)]);select只是selectAll的退化形式,单路调用方无需改动。
2.2 selectAll 的完整校验序列
bool selectAll(Iterable<HlsPrefetchSelection> selections) { if (_closed || _finishing) return false; final pending = <String, _Feed>{}; try { for (final selection in selections) { if (pending.length + _feeds.length >= maximumFeeds || selection.id.isEmpty || selection.id.length > 256 || pending.containsKey(selection.id) || _feeds.containsKey(selection.id) || !_validSource(selection.source) || !_validSource(selection.snapshot.source)) { return false; } final window = HlsRetainedWindow(selection.snapshot.source, maximumSegments: maximumSegments); if (window.merge(selection.snapshot).isNotEmpty) return false; renderHlsRetainedManifest(window, localUri: (uri) => uri); final feed = _Feed(selection.id, selection.source, window, selection.snapshot.reloadFingerprint); _rebuild(feed); pending[selection.id] = feed; } } on FormatException { return false; } if (pending.isEmpty || _closed || _finishing) return false; _feeds.addAll(pending); _pump(); for (final feed in pending.values) { _schedule(feed); } return true; }关键点在于:所有校验在构建pending临时表阶段完成,任何一条不满足就整体返回false,已经构建的 feed 不会被写入_feeds。只有整个集合通过后,才执行_feeds.addAll(pending)一次性提交,随后才启动_pump()(下载泵)和_schedule(feed)(清单轮询定时器)。这从代码结构上保证了"第二路被拒时,前一路不会先进入活动状态"。
逐一拆解校验项:
| 校验项 | 源码位置 | 说明 |
|---|---|---|
| 集合总容量 | pending.length + _feeds.length >= maximumFeeds | maximumFeeds默认 2,构造时被约束在1..2,超出即整体拒绝 |
| feed ID 合法性 | selection.id.isEmpty \|\| selection.id.length > 256 | 拒绝空 ID 与超长 ID |
| ID 冲突 | pending.containsKey \|\| _feeds.containsKey | 同一集合内重复 ID、或与既有 feed 冲突均拒绝 |
| 来源合法性 | _validSource(selection.source)与_validSource(selection.snapshot.source) | 同时校验"选择来源"与"快照来源" |
| 保留窗口完整性 | window.merge(selection.snapshot).isNotEmpty | 有片段被逐出即拒绝——准入必须保留初始前缀,包括有限媒体;窗口太小而静默丢首段不算选择 |
| 渲染合同 | renderHlsRetainedManifest(window, localUri: (uri) => uri) | 初始清单必须能通过保留清单渲染器的全部约束 |
| 属性解析 | on FormatException | 解析失败整体回退 |
其中_validSource的实现为:
static bool _validSource(Uri uri) => const {'http', 'https'}.contains(uri.scheme) && uri.host.isNotEmpty && uri.userInfo.isEmpty && !uri.hasFragment && uri.toString().length <= 65536;来源必须显式 http/https、主机非空、无内嵌凭据(userInfo 为空)、无 fragment,且序列化长度不超过 64 KiB。
2.3 有界迭代输入:不无界复制外部 iterable
文档特别强调:迭代输入按剩余最多两个 feed 的容量检查,超限即退出,不把外部 iterable 无界复制进内存。selectAll直接消费调用方传入的Iterable,在循环内即时检查pending.length + _feeds.length >= maximumFeeds并return false,因此外部集合再大也只会被有界遍历,符合录制端对内存安全的一贯约束。
2.4 准入后的运行期生命周期
准入只是起点。一旦提交,调度器进入运行期管理:
_pump():按 feed 轮询(round-robin)准入下载条目,预留pool.maximumEntries / maximumFeeds的配额(quota = (pool.maximumEntries ~/ maximumFeeds).clamp(1, 32)),防止第一条长清单吃光配额而饿死第二路;条目总数上限 512,池满时返回null形成有界背压而非隐式重试;_schedule(feed):按 RFC 8216 6.3.4 的节奏刷新——首次/变化加载用完整 target duration,未变化加载用半个 target duration(见 hls_prefetch_scheduler.dart 的_schedule);_refresh(feed):每次刷新拉取新快照、合并进HlsRetainedWindow、重建 wanted 集合;刷新失败则置feed.failed = true并上报onRefreshFailure,绝不在死循环里反复重试陈旧清单;freeze()/stopFetching()/drainPublished():停止阶段先把已发布代冻结出 ENDLIST,再在有限时间内排空已准入的缓存体,超时则标记"输入尾部被丢弃"(inputTailDiscarded)。
三、核心实现二:HlsPrefetchPlan.fromMaster的无歧义 master 识别
源码位于 lib/domains/recorder/data/services/hls_prefetch_plan.dart。它的类注释定义了自己的边界:
Only describes an unambiguous master; it never chooses a quality, language, subtitle or camera for native. Other masters retain their existing path.
它不是一个通用 HLS 解析器,也不猜测 native 会选择哪档。生产调用者应整体保留现有路径,而不是"选第一档"或"只启用其中一路"。
3.1 什么 master 会被识别为"无歧义"
fromMaster(String text, Uri source)只接受两种形态:
- 唯一视频流(无 AUDIO 组引用):
#EXT-X-STREAM-INF恰好一个,且其AUDIO属性缺失,返回[video]; - 唯一视频 + 其唯一明确 AUDIO 组:恰好一个
#EXT-X-STREAM-INF引用了一个#EXT-X-MEDIA的 AUDIO 组,且组内恰好一个成员,返回[video, audioUri]。
两种形态都要求:URI 使用 master 最终地址(source.resolve(value))解析;来源与媒体协议的 scheme 必须 http/https、host 非空、无 userInfo、无 fragment;HTTPS 引用降级(master 是 https 而子 URI 不是)会直接抛FormatException使整个计划返回null。返回的sources是List.unmodifiable,即不可变集合。
3.2 明确的拒绝清单
以下任一情形都会让fromMaster返回null(无预取计划),由调用方回退到原有路径:
- 多清晰度(多个
#EXT-X-STREAM-INF); - 多个语言/音频候选(多个
#EXT-X-MEDIAAUDIO 组); - 字幕/隐藏字幕组(
CLOSED-CAPTIONS存在且不为NONE); - session key、未知状态标签/属性(
#EXT开头的未知标签、STREAM-INF 出现白名单外的属性); - 组关联不完整(STREAM-INF 引用 AUDIO 组但组缺失,或反之);
- 重复字段(同一属性名出现两次,
_attributes解析时直接抛FormatException); - 悬空 STREAM-INF(
pendingVariant为 true 时没有跟随的媒体 URI 行); - 相同 URI 同时作为两路(
audioUri == video); - master 超过 4 MiB(
text.length > 4 * 1024 * 1024); - 首行不是
#EXTM3U。
其中#EXT-X-STREAM-INF的属性白名单为BANDWIDTH / AVERAGE-BANDWIDTH / RESOLUTION / CODECS / FRAME-RATE / AUDIO / CLOSED-CAPTIONS,且BANDWIDTH必须匹配^[1-9]\d*$(正整数);#EXT-X-MEDIA的属性白名单为TYPE / GROUP-ID / NAME / URI / DEFAULT / AUTOSELECT / LANGUAGE / CHANNELS / CHARACTERISTICS,其中TYPE必须为AUDIO,GROUP-ID、NAME、URI均非空,DEFAULT/AUTOSELECT只能是YES/NO。这些严格约束正是"不猜测、只识别唯一确定组合"的体现。
3.3 属性解析器
_attributes使用([A-Z0-9-]+)=("[^"]*"|[^,\s"]+)正则逐段解析逗号分隔的属性列表,遇到重复键、非法分隔符(非逗号或结尾悬空逗号)都抛出FormatException,因此"重复字段"会被同一机制拦截。
四、原生实验适配层:从 master 到 selectAll 的一次性接入
文档指出,原生实验适配层已经使用该计划:读取实际源站 master → 获取两份初始媒体 snapshot → selectAll 一次性准入 → 返回 master;随后 native 请求子清单时复用已选 feed,不重复选择、不重复加载。此前由 native 顺序访问两路后逐个 select 的实验接线已替换。
在源码中,这一流程对应 lib/domains/recorder/data/services/hls_relay_prefetch.dart 的_preparePrefetch:
HlsPrefetchPlan.fromMaster(master, source)解析 master;- 若
plan == null,回退为单路root快照(HlsMediaSnapshot.parse(master, source)),走原有路径; - 若计划有效,
Future.wait(plan.sources.map(load))并发拉取两份初始媒体快照——注意文档与代码都强调"Serial initial reads age the first short live window while waiting for the second",因此共享同一个取消令牌(cancellation),任一加载失败都会cancellation.cancel()使整个准备过程作废; - 为每路生成本地资源 ID(取自
_localResource的路径末段文件名),构建HlsPrefetchSelection列表; - 创建
HlsPrefetchPool(maximumEntries: 32, maximumConcurrent: 16, memoryBytesPerBody: 512 * 1024)与HlsPrefetchScheduler(maximumFeeds: selections.length); candidate.selectAll(selections)一次性准入,失败则整个候选调度器被close(),不产生任何部分启用的 A/V 集合;- 准入成功后,
_prefetch = candidate,后续请求通过_servePrefetchManifest/_servePrefetchBody复用已选 feed。
_servePrefetchBody中的关键语义还包括:acquire超时或失败时按失败状态码回 503/410(停止阶段未排空则置_inputTailDiscarded = true);BYTERANGE 缓存只服务于其声明的切片——若请求的 Range 与资源声明的requestHeader不一致,返回 416,绝不把缓存切片冒充完整对象。
这段适配同时保留了原始 master 和媒体字节:不改质量、音轨或时间戳。切换/停止阶段,_endedManifest只为媒体清单补#EXT-X-ENDLIST,master 目录清单原样返回。
五、验证范围:从定向测试到双场景原生实验
5.1 master 计划测试
文档列出新增 master 计划测试的覆盖面:唯一组合、不可变集合、组错配、多变体/多音轨、重复属性、空/凭据/降级/fragment URI、未知会话/字幕合同等。与之配套的批量测试断言了原子性的可观测结果:
- 第二路被拒时:
feed=0、download=0、pool 条目=0——没有任何一路进入活动状态; - 有效组合:第一次 loader 回调必须已观察到
feed=2——两路在准入瞬间同时可用; - 失败后的重复准入:保持既有状态,不破坏已经准入的组合。
5.2 双场景原生实验
原生实验目录controls/scheduled-1788957891514939/使用同一 12 秒视频 body 或 headers、6 秒滚动窗口、34 秒曝光,验证"真实 TCP + native"链路:
| 场景 | TS 字节 | 本地完整视频序号 | 视频/音频包 | 探针停止耗时 ms | 观察峰值条目/下载 |
|---|---|---|---|---|---|
| body 12 秒 | 3,329,668 | 0–13,连续 | 840 / 1407 | 2165 | 20 / 8 |
| headers 12 秒 | 3,300,340 | 0–13,连续 | 840 / 1313 | 2180 | 19 / 8 |
两场景均code=0、正常排空、无强制取消、无完整性错误、无活动覆盖缺口;尾部丢弃仍为 true。两路合计 66 次独立刷新,关闭后池条目和字节均归 0。视频最大排序包步长 0.033334 秒、音频 0.021334 秒;整体仍是约 28 秒视频,body 场景音频约 30 秒、headers 场景约 28 秒——文档谨慎声明"不据此宣布所有尾部对齐"。
5.3 全量解码与固定哈希校验
CLI 实际以-v error -xerror -map 0:v:0 -map 0:a:0 -f null解码两份 TS,均退出 0、无错误文本。body TS SHA-256 为ED27EFDAB0D4DD3944F6AACC10E88B4725B7B8BEFFA0F08AA5CCFE1F1772AFA3,headers TS 为54ED2AC7B02BE31D0D09BA81F3372FB8FB163D7C8EFFD1E3057CD1BBFC372098。本地夹具、FFmpegKit DLL、ffprobe 和 CLI 的固定哈希由脚本验证;本轮 hook 未取得远端 ZIP SHA,故不称作"远端包哈希通过"——这一表述边界体现了审计对证据等级的严格区分。
5.4 资源守卫与构建记录
重型验证经共享资源守卫排队,未停止其他任务的 Java 进程,排队期间没有继续编辑源码。证据根目录local-artifacts/hls-prefetch-plan-20260909/,资源记录均位于local-artifacts/build-records/:
| 文件前缀 / 阶段 | 秒(含排队) | 观察峰值 CPU % | 峰值工作集 B | 阶段结束活跃重型进程 |
|---|---|---|---|---|
| 20260909T124040828Z / atomic-plan | 188.077 | 15.57 | 11725209600 | 0 |
| 20260909T124630392Z / atomic-fixed | 295.791 | 75.39 | 14921601024 | 1 |
| 20260909T124702156Z / decode | 19.718 | 0.11 | 11030073344 | 0 |
一个值得注意的工程细节:首次atomic-plan在分析阶段因三个多行 if 缺少花括号失败,未运行测试;补齐花括号后运行atomic-fixed。同时让实验 master 响应头和 body共用同一个响应预算,不在读取 body 时重新计时。所有失败、排队与成功记录均被保留。第二阶段结束时另有 Java 进程活跃,解码阶段继续在资源守卫中排队,该进程随后退出;监控值包含可见重型进程,不把 75.39% 全部归因于预取代码,也未声称第二阶段结束立即全机空闲。最终解码记录为 0,两个本任务执行句柄都已收到终态。
六、生产接入状态与剩余工作
文档明确了当前所处的阶段与边界:
本轮把完整 A/V 选择接通到真实 TCP + native 实验,但尚未把预取默认启用于应用的
FFmpegHlsInputRelay。下一步复用这个全体先校验的入口,接通生产资源 ID/HTTP Range 和失败状态、统一资源预算与停止生命周期,而非再叠一层实验 HTTP。
结合源码看,生产侧的接入点已经就位:FFmpegHlsInputRelay在_handleRequest中当resourceId == 'root' && prefetchEnabled && !_finishing时触发_preparePrefetch(见 lib/domains/recorder/data/services/ffmpeg_hls_input_relay.dart),且enablePrefetch仅在drainOnStop同时开启时才生效(prefetchEnabled: enablePrefetch && drainOnStop)。此外,manifestMediaTransform != null && enablePrefetch会被显式拒绝——预取缓存尚未承接 manifest 级媒体前缀变换的所有权,因此两者不能同时启用。探针 tool/probes/hls_scheduled_delivery_probe.dart 通过环境变量PURELIVE_HLS_PRODUCTION_PREFETCH_PROBE=1选择直接生产预取或历史实验适配器,两者均为 opt-in 探针,不是默认的应用程序激活。
其他明确未完成项:
- 多变体等返回无计划,不等于这些平台验收通过;
- 真实 LL-HLS、尾部完整排空、所有平台/界面功能、稳定版 3.2.0 发布仍属于原目标;
- 宏观仍为20 PASS / 32 RUN / 10 NOT RUN,42 个验收大项未闭环;
- 没有手机操作、构建、版本修改、上游合并或发布。
七、相关源码与文档索引
| 关注点 | 路径 |
|---|---|
| 原子准入调度器(selectAll / select / 运行期泵与刷新) | lib/domains/recorder/data/services/hls_prefetch_scheduler.dart |
| 无歧义 master 计划(fromMaster) | lib/domains/recorder/data/services/hls_prefetch_plan.dart |
| 有界预取池与票据/租约 | lib/domains/recorder/data/services/hls_prefetch_pool.dart |
| 保留窗口与清单渲染合同 | lib/domains/recorder/data/services/hls_retained_window.dart、hls_retained_manifest.dart |
| 生产 relay 接入与预取服务 | lib/domains/recorder/data/services/ffmpeg_hls_input_relay.dart、hls_relay_prefetch.dart |
| 双场景原生实验探针 | tool/probes/hls_scheduled_delivery_probe.dart、tool/probes/hls_rolling_delivery_probe_test.dart |
| 本轮审计文档 | docs/HLS_PREFETCH_PLAN_AUDIT_2026_09_09.md |
| 承接的上游审计 | docs/HLS_PREFETCH_FREEZE_AUDIT_2026_09_09.md |
结语
pure_live 的 HLS 预取原子准入为"双路 A/V 预取"树立了一个可复用的范式:把选择权与校验权彻底分离——HlsPrefetchPlan.fromMaster只负责识别"无需猜测的确定性组合",HlsPrefetchScheduler.selectAll只负责"全体先校验、再一次性提交",而运行期的一切不确定性(网络失败、清单漂移、密钥续签、来源切换)都交给既有的刷新、冻结与排空合同。这种设计让录制端在"多清晰度、多语言候选"的 HLS 世界里既不越权猜测,也绝不陷入"半套 A/V 流"的残缺状态。对任何需要稳定录制 HLS 直播的工程而言,这份从设计、实现到原生验证的完整链路都值得对照借鉴。
- 音视频
- 直播
- 移动开发
【免费下载链接】pure_live
纯粹直播:哔哩哔哩/虎牙/斗鱼/快手/抖音/网易cc/YY直播/Twitch直播/SOOP直播/M38自定义源应有尽有。
相关推荐
pure_live 录制链路 HLS 有界预取池:未读响应取消修复与读取租约所有权设计
pure_live 录制链路 HLS 有界预取池:未读响应取消修复与读取租约所有权设计 导读 本文基于 pure_live(纯粹直播)仓库中 HLS_PREFE
音视频直播移动开发pure_live HLS 预取调度器深度解析:独立清单刷新、有界分片预取与停止语义审计
pure_live HLS 预取调度器深度解析:独立清单刷新、有界分片预取与停止语义审计 本文基于 pure_live(纯粹直播)开源仓库的审计文档 docs/
音视频直播移动开发pure_live HLS 明确选流机制剖析:从多画质 master 解析到 niconico 双轨短录实战验证
pure_live HLS 明确选流机制剖析:从多画质 master 解析到 niconico 双轨短录实战验证 导读 本文以 pure_live(纯粹直播)仓
音视频直播移动开发
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考