播放器接收数据,解码器输出画面,应用把视频呈现给用户。在这条链路中,究竟由谁决定:收到的流还能不能继续播放?
设想一场正在进行的直播。播放起初正常,但随后分发链路被攻陷,开始用另一条流替换预期的内容。数据仍可能持续到达,格式也完全正确:解码器正常输出画面,应用却在播放被替换的视频。这种替换未必触发网络错误,也未必触发解码错误。
因此,客户端需要额外的检查:收到的数据必须满足什么条件,条件不满足时又该怎么做。仅在开播时检查一次,无法回答后面会发生什么。本文讨论的是对选定片段进行周期检查,以及检查结果如何触发停止播放。
在我曾实现的一套方案中,源站(origin)会在流中加入用于检查的标记。播放器周期性地检查片段,未找到标记时停止直播播放。这样,客户端就能把收到的数据与应用的处置动作联系起来。
搜索一个固定标记,本身不能证明视频在密码学意义上的完整性。如果替换后的媒体数据保留了相同标记,仅检查标记是否存在,就无法区分它们。这里我更关心另一个工程问题:如何让检查结果真正触发必须执行的动作,以及优化会怎样改变这条链路。
我的实践是讨论的起点。接下来,为客户端链路设计建立一个有明确约束的周期检查模型。先假设被攻陷的分发链路无法修改播放器代码、预期检查条件、处置规则或客户端更新。最后再讨论这一前提被破坏时会发生什么。
1. 客户端究竟在检查什么
本例中的标记,是源站加入每个片段的辅助信息:在容器的指定区域放入一段约定的字节序列。播放器选取片段进行周期检查,并在其中搜索这段标记。后面的代码使用的是 SEQ。这是给片段添加标记,并没有为媒体数据生成密码学签名。
检查必须对应一个具体判断。"在选定区域找到了预期标记"是一种判断;"验证凭据与实际收到的媒体数据存在绑定"是另一种;"这份内容被允许用于当前直播"又是第三种。
关于密码学绑定,以及如何检查内容是否被允许用于当前直播,我会在后续一篇关于 C2PA 的独立文章中展开。这里先追踪所选检查的结果如何控制播放。
为了检查标记,源站和客户端需要约定标记的格式、放置位置和搜索范围。仅说"写入元数据"还不足以指导实现:必须知道客户端解析器实际会读取哪些数据。
例如,在 MPEG-TS 中,适配字段(adaptation field)包含标志位和可选字段。私有数据(private data)有自己的存在标志和长度,其前面还可能有其他字段。在 adaptation field 的全部剩余字节中搜索一个词,与按结构提取指定的 private data 区域,并不是同一种操作。相邻字节中出现相同序列,并不能证明找到了约定位置的标记。1
因此,要先确定源站把标记写入哪些数据包、哪个字段,再确定客户端对应的读取路径。无论检查一个词是否存在,还是使用更强的验证凭据,都需要这种约定:检查必须实际覆盖其结论所针对的数据。
2. 检查怎样接入流处理
在我对 ExoPlayer 的改造中,搜索逻辑被加入 MPEG-TS 处理过程。下面保留了启动搜索的条件和搜索成功的分支;将数据读入 adaptationData 的过程,以及跳过数据的分支,在此省略:
java
if (!this.psrField) {
int[] psrField = {83, 69, 81};
int psrSetted = findArray(adaptationData, psrField);
if (psrSetted != -1) {
this.psrField = true;
}
}
这组字节对应 ASCII 字符串 SEQ。搜索成功后,结果保存在 this.psrField 中。在这段逻辑里,只有布尔标记为 false 时才执行搜索;成功后就会跳过。要实现周期检查,外层逻辑还必须确定何时开始新的搜索范围,以及未找到标记时怎样处理。
这关系到检查状态的有效范围。设想同一条流中的两个片段:被检查的 A 含有标记,而下一个被选中检查的 B 没有。如果 A 的成功一直被当作 B 的播放许可,那么再次调用这段逻辑,并不意味着真正开始了新一轮检查。
以 ExoPlayer r2.19.1 为例,其中的 TS 解析器可能在不同 HLS 片段之间复用。进入新片段,本身并不保证创建新对象或重新初始化。2
为本文的模型约定一条规则:每轮检查都有明确的数据范围和新的结果;A 的成功不能自动延续到 B。用独立状态、显式重置还是其他方式实现,要由客户端的具体结构决定。
检查接入的位置也很重要。在所分析的 TsExtractor 中,一部分数据包会在处理 adaptation field 之前被跳过,例如找不到相应的 payload reader 时。放在这个提前返回位置之后的检查逻辑,就看不到这些包。因此,源站写入标记的位置必须落在检查代码实际可读取的范围内。3

在我的实际工作中,发生内容替换时,这套机制曾帮助客户端在用户设备上及时停止直播播放。这是一个具体案例的结果;能否发现其他替换,取决于客户端实际检查的条件。
3. 从标记缺失到停止播放
继续 A → B 的场景。A 的检查已经成功。新一轮针对 B 的检查开始了,但目前读到的字节中还没有标记。
这里必须明确搜索在哪里结束。如果客户端检查的是选定片段中某些数据包里的约定字段,只有完整读取并检查这些区域后,才能认定标记缺失。在所需数据尚未接收完整时,结果仍未确定。
下载中断是另一种情况。输入可能在只读到部分数据时结束,库的读取方法会区分这种中断与允许的输入结束。但即使收到了下载完成的信号,也不能代替对检查范围的约定。4
| 同一场景中的状态 | 检查结果 |
|---|---|
| 在 A 中找到预期标记 | A 的受检范围满足条件 |
| B 仍在下载,暂未找到标记 | 结果尚未确定 |
| B 在所需范围接收完整前中断 | 观察不完整;另行处理数据接收故障 |
| B 的受检范围已完整检查,仍无标记 | 已确定缺少必需标记 |
我的系统使用过名为"未授权访问"的自定义错误。从工程角度看,它的内部含义必须比名称更准确:到达约定的检查边界后,仍未发现必需标记。仅凭这一结果,还不能确定异常原因。
检查确认条件不满足时,播放器应停止播放。结果只适用于接受检查的那条流。如果已经切换到另一条流,前一条流迟到的错误就不应再把新流停掉。
加载器抛出异常,并没有定义完整的处置过程。库的错误处理策略可能选择重试,也可能进入错误状态。例如,所分析的 ExoPlayer 策略允许对普通 IOException 重试;自定义异常需要明确的分类和处理规则。5 在本文的例子中,自动重试不能抹掉已确认的检查失败:继续播放必须遵守恢复规则,并重新检查。
还要区分发出停止请求与实际停止输出。数据可能已经进入播放队列。单凭一次错误回调,无法证明未经确认的画面在检查完成前一直被拦截。要提供这种保证,需要专门控制媒体数据何时可以进入播放过程。6
对本文的周期检查模式而言,目标是在发现约定条件被破坏后采取行动。如果要求任何未经检查的数据都不能显示,就必须在允许相应数据播放之前完成检查。这对应另一种检查覆盖约定。
停止播放也不是终点。客户端还需要恢复规则:谁允许再次尝试,重新启动哪项检查,哪些旧结果不再有效。ExoPlayer 允许在错误后通过 prepare 再次准备播放;但对于流检查机制,是否允许这样继续,要由应用决定。7

4. 优化改变了什么
检查有成本:读取、搜索、复制、内存分配,以及结果传递。在条件和覆盖范围不变的前提下降低成本,是一项任务;改变检查频率或成功结果的有效期,是另一项任务。
前一种优化可以从搜索方式和缓冲区使用入手。具体能加速多少,需要在目标设备上测量。仅凭一小段代码,不能认定开销可以忽略。
后一种优化则改变了实际检查到的流范围。两次周期检查之间,异常可能暂时不被发现。除了检查间隔,搜索何时结束、结果何时到达控制逻辑,以及输出何时停止,都会影响响应时间。
仅凭检查间隔,无法给出发现任意内容替换的通用时间上限。包含其他媒体数据、但仍保留 SEQ 的片段,仍然能通过"是否存在 SEQ"这项检查。更频繁地搜索这个词,并不会使它与视频数据建立密码学绑定。
因此,检查依据的证明能力与检查覆盖范围,需要分别评估。周期检查应测量的是发现约定条件不满足所需的时间,以及后续响应时间。严格的播放前准入,则要确认每份必须检查的媒体数据都确实在显示前通过了必需的检查。
结果的有效期也属于这个选择。重新连接、切换频道或启动新流后,旧结果应当如何处理,需要明确规定。不能让"以前找到过标记"悄悄变成对后续全部播放的许可。
5. 谁能够改变这个决定
到这里,我们一直假设客户端未被篡改。现在看看,哪些权限能够让这套机制失效。
如果被攻陷的基础设施能够向用户下发修改后的 Web 播放器,它就能改变检查本身。如果播放器代码受到保护,但同一基础设施仍能修改预期值或错误处理逻辑,处置动作也可能被取消。客户端更新,以及绕过控制点、直接把数据送去播放的路径,也需要关注。
这里需要追踪的是具体权限:谁能够修改代码、检查条件、周期、处置动作和恢复规则。如果同一访问权限可以改写所有这些部分,那么即使它们部署在不同服务器上,也不能据此认为它们彼此独立。
流中的标记,本身不能保护执行检查的代码。即使使用了更强的验证凭据,应用一旦不再依据检查结果采取行动,也无法保证必须拒绝的内容会被拒绝。

要在自己的系统中检查这套机制,我会按五步进行:
- 明确检查的具体条件,以及它所适用的数据范围。
- 追踪新一轮检查如何开始,旧的成功结果何时失效。
- 区分完整检查后未找到标记,与数据尚未接收完整。
- 沿着失败结果追踪到实际动作,并检查重新启动的规则。
- 确定谁能修改这条路径中的任一环节,或者绕过它。
在我们的场景中,A 的成功只属于 A。检查 B 时,要开始新的观察;确认标记缺失后,结果必须触发停止播放,而恢复播放需要满足自己的条件。把这项检查移植到其他客户端时,需要保留的正是这条完整链路。
谢谢你读到这里。你的看法对我很重要:在你的系统中,流检查的结果是怎样控制播放的?
参考资料
- ITU-T H.222.0 (07/95),历史版本:§§2.4.3.4--2.4.3.5,Table 2-6,adaptation field 的语法。
- ExoPlayer r2.19.1,commit
c31197048c10a9f021637ca1b7ba828ac0fd962d:HlsMediaChunk,传递 extractor 的条件;load第 424--430 行,prepareExtraction第 549--575 行;BundledHlsMediaChunkExtractor.isReusable。 - 同一固定版本:TsExtractor.read,提前返回及 adaptation field 处理。
- 同一版本:ExtractorInput,读取约定;HlsMediaChunk.feedDataToExtractor。应用自行定义自定义检查的边界。
- 同一版本:DefaultLoadErrorHandlingPolicy.getRetryDelayMsFor;Loader,错误后的动作;HlsSampleStreamWrapper.onLoadError。
- 同一版本:ExoPlayerImplInternal.doSomeWork;HlsSampleStreamWrapper.readData。这些代码本身没有建立基于标记搜索结果的播放前拦截机制。
- 同一版本:ExoPlayerImpl.prepare。另参阅现代播放器事件文档:Android Developers --- Player events, Playback errors,查阅日期:2026-10-19。