Skip to content

第 7 章:客户端媒体管线——包层分流与帧层分流

scrcpy 的客户端不是一根 read → decode → render 管道,而是一张两层有向图:压缩包可以直接录制,解码帧可以显示或输出到 V4L2。每层都有自己的 source/sink 接口。

一、拓扑由选项动态生成

video socket │ ▼ Demuxer (PacketSource) ├──────────────→ Recorder.video (PacketSink) └→ Decoder.video (PacketSink / FrameSource) ├────────→ VideoRegulator → Screen (FrameSink) └────────→ V4L2Regulator → V4L2Sink (FrameSink) audio socket │ ▼ Demuxer (PacketSource) ├──────────────→ Recorder.audio (PacketSink) └→ Decoder.audio → AudioRegulator → AudioPlayer

总装配代码在 scrcpy.c。source 不知道具体 sink 类型,只顺序调用 ops 表。

二、C 语言里的 trait

sc_packet_sink 只有一个 ops 指针,操作包括 open/close/push/push_session/disablesc_frame_sink 类似,但 push 的是 AVFrame。具体对象用 container_of 从接口成员反推宿主结构。

这种嵌入式接口有三个效果:

  1. 无堆分配的多态对象;
  2. 编译期仍能让 recorder 与 decoder 同时成为 packet sink;
  3. source 的错误回滚可以统一按已打开 sink 的逆序 close。

packet source 的 open/push/close 广播见 packet_source.c,frame 层对应实现见 frame_source.c

三、Demuxer 并不是容器 demux

这里的 demuxer 不解析 MP4/MKV;它读取 scrcpy 自定义裸流:

  1. 读 4 字节 codec id;
  2. 找到 FFmpeg decoder,并创建 AVCodecContext
  3. 视频先读 session header,设置 width/height;
  4. 打开所有 packet sinks;
  5. 循环读 12 字节 header;
  6. session 包广播 push_session
  7. media 包构造 AVPacket 后广播 push
  8. 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:

text
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 如何穿过整张图

动态尺寸变化的传播路径是:

Streamer.writeSessionMeta → Demuxer.parse_session → PacketSource.push_session → Decoder 更新 session → FrameSource.push_session → Screen / regulator 更新当前尺寸与 client_resized 语义

这避免为“控制面元数据”建立另一套旁路事件系统。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_endedsc_audio_demuxer_on_ended

十、如何选择新扩展点

你要实现的功能接入层原因
网络转发原始 H.264packet sink避免解码/重编码
新文件容器packet sink保留编码包与时间戳
图像分析frame sink需要 YUV 像素
截图frame sink需要某一解码帧
新 UIframe sink + input producer替换 SDL 边界
码流统计packet sink可观察 bitrate/keyframe/config

关键判断是“功能需要压缩数据还是像素”。把扩展接错层,会引入不必要重编码或丢掉必须的 codec 信息。

十一、本章小结

客户端媒体架构的核心是按数据形态分成 packet 与 frame 两层,并用小型 C trait 组合 sink。低延迟通过有界/单槽缓冲和通知合并实现。下一章聚焦音频:为什么同样的“丢旧帧”策略不能用于声音,以及 scrcpy 如何用缓冲估计和重采样调速吸收时钟漂移。

基于 scrcpy 4.1 源码的独立学习笔记