第 7 章:客户端媒体管线——包层分流与帧层分流
scrcpy 的客户端不是一根
read → decode → render管道,而是一张两层有向图:压缩包可以直接录制,解码帧可以显示或输出到 V4L2。每层都有自己的 source/sink 接口。
一、拓扑由选项动态生成
总装配代码在 scrcpy.c。source 不知道具体 sink 类型,只顺序调用 ops 表。
二、C 语言里的 trait
sc_packet_sink 只有一个 ops 指针,操作包括 open/close/push/push_session/disable;sc_frame_sink 类似,但 push 的是 AVFrame。具体对象用 container_of 从接口成员反推宿主结构。
这种嵌入式接口有三个效果:
- 无堆分配的多态对象;
- 编译期仍能让 recorder 与 decoder 同时成为 packet sink;
- source 的错误回滚可以统一按已打开 sink 的逆序 close。
packet source 的 open/push/close 广播见 packet_source.c,frame 层对应实现见 frame_source.c。
三、Demuxer 并不是容器 demux
这里的 demuxer 不解析 MP4/MKV;它读取 scrcpy 自定义裸流:
- 读 4 字节 codec id;
- 找到 FFmpeg decoder,并创建
AVCodecContext; - 视频先读 session header,设置 width/height;
- 打开所有 packet sinks;
- 循环读 12 字节 header;
- session 包广播
push_session; - media 包构造
AVPacket后广播push; - EOF 时 close sinks 并报告状态。
主循环见 run_demuxer()。视频、音频各有独立 demuxer 线程,所以一个通道的阻塞不会占住另一个。
四、为什么 H.26x 需要 packet merger
Android MediaCodec 可能把 codec config(如 SPS/PPS)作为单独输出包。FFmpeg 解码或某些封装路径更希望 config 与随后的媒体包连续。sc_packet_merger 在看到 AV_NOPTS_VALUE 时缓存 config,下一正常包来时扩容并前置,见 packet_merger.c。
只对 H.264/H.265 做这一步;音频 config 仍需要独立保留,供 recorder 填充 extradata。
五、Decoder 同时是消费者和生产者
sc_decoder 的一侧实现 packet sink,另一侧拥有 frame source:
AVPacket → avcodec_send_packet
→ avcodec_receive_frame (可能 0..N 帧)
→ FrameSource.push(frame)每个输出 frame push 后立即 av_frame_unref,所以 frame sink 若要跨线程保存必须自行引用或复制。screen 和 V4L2 都通过 frame_buffer 获取自己的引用。
视频尺寸变化时,decoder 会把实际 frame 尺寸与当前 session 声明比较;不一致会警告用户降低分辨率。实现见 decoder.c。
六、单帧缓冲是主动丢旧策略
screen 收到 frame 时并不排队。它锁住 frame_buffer,把最新 frame 放进去;如果里面已有未消费帧,新帧覆盖旧引用,并且不再投递新的 SDL event,因为已有事件最终会消费最新内容,见 sc_screen_frame_sink_push()。
这是一种合并通知(coalescing):
- 解码线程不会等待 UI;
- SDL event 队列不会被每帧淹没;
- UI 卡顿恢复后展示最新画面,而非追赶历史;
- FPS counter 能记录被跳过帧。
对实时遥控而言,“旧但完整”通常比“新但跳帧”更糟,因为用户看到的状态会持续落后输入。
七、VideoRegulator:什么时候需要按 PTS 等待
默认低延迟显示可尽快渲染,但录制或某些流需要尊重 PTS。video_regulator 作为 frame sink 接收帧,在自己的线程中根据首帧系统时刻建立时钟基准,然后在目标 deadline 前等待,再把帧推给真正 sink。
它使用单帧缓冲而非无界队列;如果 producer 更快,新帧会替换未交付帧。等待还可被 stop 中断。实现入口见 video_regulator.c。
同一抽象既可放在 screen 前,也可放在 V4L2 前,两者拥有独立节奏,不互相拖慢。
八、session meta 如何穿过整张图
动态尺寸变化的传播路径是:
这避免为“控制面元数据”建立另一套旁路事件系统。session 与媒体沿相同拓扑传播,天然到达所有需要知道尺寸的 sink。
九、错误传播按层汇聚
- sink
open失败:source 逆序关闭之前成功打开的 sink; - sink
push失败:demuxer 结束并回调; - stream codec id 为 0:调用 sink 的
disable,允许 recorder 将 audio 标记为不存在; - EOF:video 通常表示设备断开;audio 是否致命取决于
--require-audio; - 解码器缺失:禁用相关 sink,而不是伪造数据。
scrcpy.c 的 video/audio demuxer 回调明确体现了不同策略,见 sc_video_demuxer_on_ended 与 sc_audio_demuxer_on_ended。
十、如何选择新扩展点
| 你要实现的功能 | 接入层 | 原因 |
|---|---|---|
| 网络转发原始 H.264 | packet sink | 避免解码/重编码 |
| 新文件容器 | packet sink | 保留编码包与时间戳 |
| 图像分析 | frame sink | 需要 YUV 像素 |
| 截图 | frame sink | 需要某一解码帧 |
| 新 UI | frame sink + input producer | 替换 SDL 边界 |
| 码流统计 | packet sink | 可观察 bitrate/keyframe/config |
关键判断是“功能需要压缩数据还是像素”。把扩展接错层,会引入不必要重编码或丢掉必须的 codec 信息。
十一、本章小结
客户端媒体架构的核心是按数据形态分成 packet 与 frame 两层,并用小型 C trait 组合 sink。低延迟通过有界/单槽缓冲和通知合并实现。下一章聚焦音频:为什么同样的“丢旧帧”策略不能用于声音,以及 scrcpy 如何用缓冲估计和重采样调速吸收时钟漂移。