- 音视频
- 直播
- 移动开发
【免费下载链接】pure_live
纯粹直播:哔哩哔哩/虎牙/斗鱼/快手/抖音/网易cc/YY直播/Twitch直播/SOOP直播/M38自定义源应有尽有。
本文围绕 Pure Live 开源直播录制工程中 HLS 预取(prefetch)停止阶段的核心机制展开,详细讲解"已发布分片有限排空"(published drain)的设计契约、调度器与池的实现、20 秒停止上限的计算,以及原生录制端如何通过确定性 HTTP 复现与解码验证来证明"停止即补全"而非"裁掉音频伪造对齐"。读完本文,你将掌握 FFmpeg HLS 录制在 opt-in 预取模式下如何冻结清单、结算已准入依赖、提前结束下载阶段并保证音视频尾部同步。
一、背景:为什么"停止尾部"会成为问题
Pure Live 的录制模块通过本地 relay 把上游 HLS 输入改写后交付给 FFmpeg 原生解复用器。在启用并行预取(prefetch)之前,旧式按需暂存(on-demand staging)在停止时存在一个已知缺陷:冻结清单中包含了尚未完成下载的视频分片,而 relay 仍在一个 target duration 之后关闭全部上游下载,导致录制停止后视频提前结束、音频拖尾。
相关审计文档 HLS_PRODUCTION_PREFETCH_AUDIT_2026_09_09.md 记录了两个真实 native 场景的复现结果:两场景都连续交付视频序号 0–13,但停止后视频约在 28 秒结束,音频约 36/34 秒,tailDiscarded=true。也就是说,旧规则用"按需暂存的截止时间"去约束"并行预取的已发布依赖",两者并不匹配。
本文对应的提交批次(HLS_PUBLISHED_DRAIN_AUDIT_2026_09_09.md,源码提交56b139f39e2c394fb555a49cb179c19a50aaa859)只处理opt-in 预取停止生命周期,保留默认关闭、原有非预取停止规则与用户数据不变,也不归因于上游或擅自同步上游。
二、停止合同:冻结、结算、提前结束
本批审计把停止行为收敛为四条明确契约,全部可在 hls_prefetch_scheduler.dart 与 ffmpeg_hls_input_relay.dart 中找到对应实现。
2.1 立即冻结:不刷新、不准入、不重试
停止的第一动作是"冻结"(freeze):各路最后实际发布的清单与本地 URI 立即定格,停止后不再刷新清单、不准入新资源、不重试失败分片。对应实现是调度器的freeze():
- 取消各路刷新定时器与刷新取消令牌(
feed.timer?.cancel()、feed.refreshCancellation?.cancel()); - 为每条 feed 生成
finishedManifest:若从未发布过,则生成一个含#EXT-X-ENDLIST的空清单;若已发布,则在最后发布清单末尾追加#EXT-X-ENDLIST,但不会改写已包含 ENDLIST 的缓存; - 从"最后发布的代次"(
feed.published.first)中收集尚未交付(sequence >= feed.delivered - 1)的分片及其 MAP/KEY 依赖,写入feed.wanted作为冻结后的唯一待结算集合。
关键点在于"立即冻结各路最后实际发布的清单与本地 URI"——源码注释明确指出:快照的是实际提供的 URI 映射,而不是停止后后台刷新的更新元数据;停止之后也不会对从未发布的 feed 触发任何回调。
2.2 只结算池内已准入 ticket
freeze()之后,drainPublished()只等待"池内已准入 ticket"对应的readyFuture:
Future<bool> drainPublished({required Duration timeout}) { if (timeout <= Duration.zero || timeout > const Duration(seconds: 20)) { throw ArgumentError('Invalid HLS published download drain timeout'); } return _draining ??= _drainPublished(timeout); }_drainPublished遍历所有有发布记录的 feed,从最后发布代次中收集sequence > feed.delivered的依赖 key,然后Future.wait所有对应 ticket 的ready,整体以timeout封顶。两条重要规则:
- 缺少准入不新建下载:
_items[key]?.$2.ready ?? Future.value(false)—— 如果某个依赖因容量已满而从未获得 ticket,直接视为未完成(返回 false),绝不临时扩大池容量去"补下载"; - 幂等:
_draining ??=保证多次调用只结算一次。
2.3 完整体封口即提前结束下载阶段
排空的结束条件是"完整体封口"(ready全部为 true),而不是固定睡满一个延时。一旦所有已准入依赖封口,finish()立即进入下载阶段收尾:_stopFetching()会调用pool.evict退休未就绪条目、停止上游客户端、取消 fetch aborter 与连接。整个 download phase 结束后,已就绪的 MAP/KEY/媒体仍从就绪缓存交付,不重新下载。
这从源码结构看正是"待结束集合与实际完成事件均明确"的体现:drainPublished返回的是布尔值,表述缓存完整度,而不是 native 消费确认;返回 false 即记录尾部丢弃(_inputTailDiscarded = true),即使 native 之后没有再请求那个缺失分片,也不会在用户停止之后另发活动缺片警告。
2.4 预算:原响应总预算 + 独立 20 秒停止上限
下载阶段的时间预算由两部分叠加:
- 原响应总预算:每个 HLS 完整响应至多获得 4 个空闲间隔(
HlsResponseBudget.totalFor(idle) => idle * 4,见 hls_body_reader.dart),原有单请求空闲/总时限继续有效; - 独立 20 秒停止上限:
_prefetchDownloadGrace将总预算截断在 20 秒内(clamp(1, 20000)毫秒),drainPublished的参数校验同样拒绝超过 20 秒的 timeout。
超时发生后,finally分支调用stopFetching():只退休(retire)实际请求,pool/close 继续持有并等待迟到响应清理,绝不 abandon 一个仍存活的上游 Future。
对于 native 既有清单重载与封装排空时间,另行保留。默认target=2秒时,drainTimeout的计算为(2 * 2 + 2).clamp(3, 20)秒加上_prefetchDownloadGrace,即总上限 26 秒,而非旧式 6 秒。文档特别强调:这是"最大等待预算",不是所有停止操作的固定耗时——完整输入实际完成后即可提前结束。
// ffmpeg_hls_input_relay.dart Duration get drainTimeout => Duration(seconds: (2 * _targetSeconds + 2).clamp(3, 20)) + (_prefetch == null ? Duration.zero : _prefetchDownloadGrace); Duration get _prefetchDownloadGrace => Duration(milliseconds: HlsResponseBudget.totalFor(_bodyIdleTimeout).inMilliseconds.clamp(1, 20000));relay 的finish()仅在drainOnStop模式下生效:预取调度器存在时走drainPublished,否则沿用旧式_finishTimer = Timer(...)单 target duration 规则。
三、调度器排空与池所有权的底层实现
3.1 冻结后的依赖图
冻结期间wanted集合由_dependencies()展开(hls_prefetch_scheduler.dart):每个分片依次产出其 KEY 依赖、所属 initialization(MAP)依赖与自身媒体资源;segment.gap标记的分片不产出媒体下载。HlsPrefetchResource的三类资源(media/initialization/key)各自以完整 cache key 标识——key 由jsonEncode(['media', feedId, sequence, uri, range.identity])等构成,而非仅上游 URI,保证同 URI 不同范围的资源不会互相顶替。
3.2 停止时的所有权转移
stopFetching()与close()的分工是本批的关键设计:
drainPublished超时后stopFetching()把未就绪条目evict到调度器自身(_own(pool.evict(...))),evict会_retire条目并等待其disposed;- relay 的
_close()顺序为:置_closed→_stopFetching()→ 关闭本地 HTTP server → 等待全部 handler →await _prefetchDrain(等排空 Future 收敛)→await _prefetch?.close()(池关闭)→_connections.settled。close 语义是"先结束本地 writer、等处理器释放租约,再关闭池与连接所有者"。
pool 的close()会把所有 owned 条目逐一_retire,再await全部disposed;而_retire只标记与移除注册,_maybeDispose会等_loading结束、_readers == 0才真正释放 spool 与字节计数。因此"pool/close 继续持有并等待迟到响应清理"是一句可验证的实现事实。
3.3 停止时的池状态可观测
relay 暴露prefetchFeedCount、prefetchBodyCount、prefetchBytes三个 getter(ffmpeg_hls_input_relay.dart),分别映射到调度器的feedCount、池的ownedEntries与retainedBytes。审计中的"停止时池 17 条目、关闭后路/条目/字节全零"正是由这些观测点取得。调度器还提供describeDownload(key),返回admitted与 ticket 的诊断快照(ready/retired/disposed/failure),用于_diagnosePrefetchDownloads在两个阶段(stop-requested、downloads-ended)留证。
四、确定性复现:两个慢响应 HTTP 场景
旧缺陷的复现依赖真实平台、难以稳定重放,因此本批新增两个确定性 HTTP 复现,分别延迟响应头(headers)与响应体(body):
- 请求先进入 relay(请求已确认),随后用户停止;
- 超过 target duration 后,本地夹具才释放完整媒体;
- 以
published-tail-red命名的红测中234 PASS / 2 FAIL,两个 FAIL 均为"期望 200、实际 410",而不是夹具等待超时——说明在冻结清单中仍存在尚未完成的分片,native 请求时被拒绝(410 Gone)。
410 的来源可追溯到 hls_relay_prefetch.dart:_servePrefetchBody中acquire返回 null 时,若处于_finishing状态则置_inputTailDiscarded = true,并回_prefetchFailures[resource.key] ?? (_finishing ? 410 : 503)。红测证明旧实现确实在停止时把未完成的已发布分片直接判为不可得,从而裁掉视频尾部。
修复后的published-tail-fixed阶段两场景均通过:
| 场景 | TS 字节 | 视频完整序号 | 视频/音频包 | 停止耗时 ms |
|---|---|---|---|---|
| body 12 秒 | 4,244,100 | 0–17 | 1080 / 1688 | 8530 |
| headers 12 秒 | 4,244,100 | 0–17 | 1080 / 1688 | 8205 |
对比上一批(视频 0–13、约 28 秒、跨轨尾差 6–8 秒),本批补齐了最后已发布的视频分片(0–17,约 36 秒音视频),起点包时间差 21.033 ms、终点包时间差 1.633 ms,视频最大包步长 33.334 ms、音频 21.334 ms。停止耗时由约 2.1 秒增至 8.2–8.5 秒,但仍早于本场景 26 秒预算,且两场景code=0、inputDrained=true、forcedCancel=false,tailDiscarded/ 活动覆盖缺口 / 完整性错误均为 false。
在 tool/probes/hls_scheduled_delivery_probe.dart 中可以看到与这些数字直接对应的原生探针断言:
if (production) { expect(prefetch['entriesAtStop'] as int, lessThanOrEqualTo(32)); expect(terminal['inputTailDiscarded'], false); final tracks = inspection['tracks'] as List; final video = tracks.singleWhere((t) => (t as Map)['type'] == 'video') as Map; final audio = tracks.singleWhere((t) => (t as Map)['type'] == 'audio') as Map; expect(((video['firstPts'] as num) - (audio['firstPts'] as num)).abs(), lessThan(0.05)); expect(((video['lastPts'] as num) - (audio['lastPts'] as num)).abs(), lessThan(0.05)); }探针还断言inputCoverageIncomplete=false、receivedVideoSequenceGaps=false、completedUpstreamVideoRequests >= 10、prefetch['refreshBeforeFirstVideoComplete'] == true(首个慢视频完成前已继续刷新清单),以及关闭后entries == 0、bytes == 0。
五、验证矩阵:独立核对、全量解码与资源记录
5.1 不依赖 tailDiscarded 字段的独立核对
除调度器与 native 探针外,本批对hls-timeline.json做了独立核对:停止时两路冻结的最后一代均为 10–17,每个被提供的分片 ID 均找到 GET 200、完整 body 与完成发送记录,局部 HTTP 失败数为 0,结果保存为frozen-generation-delivery-check.json。这避免了两个陷阱:
- 只信
tailDiscarded字段而不核对实际 GET 结果; - 把 HTTP 写完直接当作 decoder 消费——后者由实际输出包与完整解码(full decode)另行支撑。
5.2 固定 CLI 全量解码与哈希一致性
两份 TS 用固定 CLI-v error -xerror -map 0:v:0 -map 0:a:0 -f null全量解码,均退出 0、错误文本为空;两份 SHA-256 一致:B0B497D8BAB72608335AC52AC89364C63B2FD0E5FE9CA861F98317C80A0C1BCB。仍保留 native 结束时的解复用 I/O 与封装线程诊断,不把"录制与解码通过"写成"日志零诊断"——这与审计一贯的措辞纪律一致。
5.3 资源记录
| 资源记录前缀 / 阶段 | 秒(含排队) | 峰值 CPU % | 峰值工作集 B | 结束活跃重型进程 |
|---|---|---|---|---|
| 20260909T132540329Z / published-tail-red | 131.388 | 9.24 | 5895565312 | 0 |
| 20260909T133234637Z / published-tail-fixed | 296.673 | 73.46 | 6814035968 | 0 |
| 20260909T133306277Z / decode | 7.489 | 0.06 | 4517515264 | 0 |
记录位于local-artifacts/build-records/;固定夹具、FFmpegKit DLL、ffprobe 和 CLI 本地哈希均由脚本验证,红测阶段 hook 的远端 ZIP SHA 校验通过、最终原生阶段 hook 未取得 SHA,二者分别记录不混成统一校验结果。所有执行句柄均取得终态,没有重启/终止其他任务。
六、接入范围与剩余项
本批实现贯穿三层调用链:
- RecorderController(recorder_controller.dart):每次直播 URL 尝试调用
ffmpeg.start(..., hlsPrefetch: true, ...),owned 输入自带 relay; - FFmpegService(ffmpeg_service.dart):
hlsPrefetch默认 false,透传给startForArguments(enablePrefetch: hlsPrefetch); - FFmpegManager(ffmpeg_manager.dart):构造 relay 时
prefetchEnabled: enablePrefetch && drainOnStop。
预取仅对录制drainOnStop模式可用;当前默认关闭,不改变所有用户默认行为,回滚可撤销本批源码提交,不涉及数据格式或配置迁移。尚未闭环的项包括:默认启用、完整 BYTERANGE/native 兼容、真实 TTing/LL-HLS 与全平台/UI/功能验收,以及稳定 3.2.0 发布;宏观保持20 PASS / 32 RUN / 10 NOT RUN,42 项未闭环。本批没有手机操作、安装、构建或发布。
七、小结
HLS 已发布分片有限排空把"停止"从一次武断的延时等待,重构为三段可验证的流水:
- 冻结:最后发布的清单、URI 与依赖代次立即定格,不刷新、不准入、不重试;
- 结算:只等待池内已准入 ticket 封口,缺准入即明确返回未完成,完整体封口立即结束下载阶段,而非固定睡满一个延时;
- 收尾:超时主动取消实际请求,pool/close 继续持有并等待迟到响应;返回值表述缓存完整度而非 native 消费确认,失败即记录尾部丢弃。
结合确定性 HTTP 复现、独立冻结代次核对与固定 CLI 全量解码,本批证明"补齐最后已发布的视频"可以做到与音频尾部对齐(终点包时间差 1.633 ms),而不是裁掉音频伪造对齐——这是 Pure Live HLS 录制走向默认启用预取前,停止生命周期中一次关键的契约收敛。
- 音视频
- 直播
- 移动开发
【免费下载链接】pure_live
纯粹直播:哔哩哔哩/虎牙/斗鱼/快手/抖音/网易cc/YY直播/Twitch直播/SOOP直播/M38自定义源应有尽有。
相关推荐
npm stop 命令详解:停止包运行的生命周期脚本机制
npm stop 命令详解:停止包运行的生命周期脚本机制 本文围绕 npm CLI 的 npm stop 命令展开,说明它如何通过 package.json 中
开发工具包管理器CLITelepresence compose rm 命令详解:清理已停止的服务容器与 Compose 项目生命周期管理
Telepresence compose rm 命令详解:清理已停止的服务容器与 Compose 项目生命周期管理 导读 telepresence compos
云原生开发工具微服务网络ClawX 有序退出生命周期解析:并发停止 ACP、Gateway 与 Computer Use 的实现与测试验证
ClawX 有序退出生命周期解析:并发停止 ACP、Gateway 与 Computer Use 的实现与测试验证 本文以 ClawX 仓库中的 harness
人工智能AI 应用桌面应用交互助手
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考