游戏录制管线与媒体编码
游戏录制管线是 momo-capture Android 采集服务中负责"把屏幕画面与设备音频变成压缩码流"的核心子系统:RecordSession 统管单次录制生命周期,ScreenRecorder 通过 VirtualDisplay → MediaCodec 输入 Surface 的零拷贝路径输出 H.264/H.265 视频样本,AudioEncoder 以可选方式提供音频流并在失败时干净降级为纯视频。
Purpose and Scope
本页覆盖游戏录制管线与媒体编码端到端机制:
RecordSession的完整生命周期(START_RECORD → RECORD_READY → 推流 → STOP_RECORD → RECORD_FINISHED)ScreenRecorder的编码器选择、格式配置、SPS/PPS 提取与排空(drain)线程AudioEncoder在会话中的接入点、降级策略与时间基准对齐StreamSink回调抽象与多线程并发写保护- 录制相关的运行参数(fps、bitrate、codecType 等)与失败模式
以下内容有意留给兄弟页面,本页只做交叉指引:
- 协议帧的二进制线格式与常驻 Unix Abstract Socket 会话管理(
CaptureServer/Main):参见采集协议与会话相关页面 - 单帧 JPEG/PNG 截图管线(
ScreenCapture):参见截图相关页面 - Windows C++ 端的推流接收、muxer 与 UI 展示:参见 Windows 宿主相关页面
- 构建脚本
scripts/build-android.js与scripts/run-android.js的部署细节:参见工程构建页面
Overview
momo-capture 以 app_process 在设备上以 Shell UID (2000) 运行,无需安装 APK。录制能力建立在 README 中描述的统一采集底座之上:
VirtualDisplay (镜像模式)→绑定 MediaCodec → 硬件编码器输入 Surface → 零拷贝推流 H.264
之所以选择"VirtualDisplay 镜像 + MediaCodec 输入 Surface"而不是 SurfaceControl 反射快照,是因为前者复用系统级显示合成路径:合成器把屏幕内容直接写入编码器 input surface,Java 层全程不触碰像素数据,CPU 开销接近于零,且 60fps 场景下稳定。截图管线与录制管线共享同一 VirtualDisplay 底座,仅在终点(ImageReader vs MediaCodec Surface)上分叉。
录制会话是一次性实体:每条来自 Windows 端的连接对应一个 RecordSession,它持有单条全双工 LocalSocket、一个 ScreenRecorder 与至多一个 AudioEncoder,结束时整体释放。
Architecture
要点说明:
RecordSession(会话总管):唯一知道完整流程的组件。它解析 START_RECORD 参数、按固定顺序初始化视频与音频、组装 RECORD_READY 元数据帧、启动两条 drain 推流、并在主线程阻塞等待 STOP_RECORD。ScreenRecorder(视频管线):内部自持MediaCodec、Surface、VirtualDisplay与独立的momo-video-drain排空线程;对外只暴露prepare / fetchConfig / startDraining / stop。AudioEncoder(音频管线):以prepare + fetchConfig + startDraining的对称接口接入,初始化失败时不抛出而是被会话层捕获并降级。StreamSink:一个函数式回调,把"帧从编码线程流向 Socket"这一动作从具体编码器中解耦出来,使视频与音频两条 drain 线程可以共用同一条发送路径。
核心组件与实现
RecordSession:录制会话生命周期总管
RecordSession 是包私有(package-private)的 final 类,由静态工厂式入口 handle(LocalSocket) 驱动。handle 把整个生命周期包进 try/finally,保证任何路径(正常、异常、Windows 断连)都执行 release() 与 Socket 关闭——这是"设备端资源绝不泄漏"的兜底设计:
1 static void handle(LocalSocket client) {
2 RecordSession session = new RecordSession(client);
3 try {
4 session.run();
5 } catch (Throwable t) {
6 System.err.println("RecordSession error: " + t.getMessage());
7 try {
8 session.sendError("RecordSession failed: " + t.getMessage());
9 } catch (Exception ignored) {
10 }
11 } finally {
12 session.release();
13 try {
14 client.close();
15 } catch (Exception ignored) {
16 }
17 }
18 }Source: RecordSession.java
注意 catch (Throwable t) 而非 Exception:设备端 Shell 环境下可能抛出 Error 类异常(如反射相关的 NoSuchMethodError),会话层选择"尽力回报 ERROR 帧后退出",避免进程静默死亡导致 Windows 端无限等待。
START_RECORD 参数解析与时间基准
run() 的第 1 步解析 Windows 端下发的录制参数。载荷采用"长度驱动"的容错解析:不足 8 字节时使用默认值,第 9 字节起才读取 codecType,新旧协议版本可以共存:
1 int fps = 60;
2 int bitrate = 16_000_000;
3 int codecType = 0; // 0 = H.264, 1 = H.265
4 if (startFrame.payload.length >= 8) {
5 ByteBuffer bb = ByteBuffer.wrap(startFrame.payload);
6 fps = bb.getInt();
7 bitrate = bb.getInt();
8 if (bb.remaining() >= 1) {
9 codecType = bb.get() & 0xff;
10 }
11 }Source: RecordSession.java
第 2–3 步按"视频必须成功、音频尽力而为"的顺序初始化:
1 screenRecorder = new ScreenRecorder(fps, bitrate, codecType);
2 screenRecorder.prepare();
3 byte[] videoConfig = screenRecorder.fetchConfig(3000);
4
5 byte[] audioConfig = null;
6 try {
7 audioEncoder = new AudioEncoder();
8 audioEncoder.prepare();
9 audioConfig = audioEncoder.fetchConfig(2000);
10 } catch (Throwable t) {
11 System.err.println("Audio initialization failed, proceeding with video only: " + t.getMessage());
12 if (audioEncoder != null) {
13 audioEncoder.stop();
14 audioEncoder = null;
15 }
16 }Source: RecordSession.java
时间基准 timeOriginUs = System.nanoTime() / 1000L 在两条管线启动之前采样一次(L91),随后同时传给 ScreenRecorder.startDraining 与 AudioEncoder.startDraining,保证音视频 PTS 都减去同一个原点,天然对齐——这是混合流muxing 侧免校正的关键设计。
RECORD_READY:一次性流描述帧
第 4 步把"Windows 端解码所需的全部格式信息"打包进单条 RECORD_READY 帧,避免接收端在流中途再协商:
1 readyOut.writeLong(timeOriginUs);
2 DisplayWrapper.DisplaySize displaySize = screenRecorder.getDisplaySize();
3 readyOut.writeInt(displaySize.width & ~1);
4 readyOut.writeInt(displaySize.height & ~1);
5 readyOut.writeInt(screenRecorder.getFps());
6 readyOut.writeByte(screenRecorder.getCodecType());
7 readyOut.writeInt(videoConfig.length);
8 readyOut.write(videoConfig);
9
10 readyOut.writeByte(hasAudio ? 1 : 0);
11 if (hasAudio) {
12 readyOut.writeInt(AudioEncoder.SAMPLE_RATE);
13 readyOut.writeInt(AudioEncoder.CHANNELS);
14 readyOut.writeInt(audioConfig.length);
15 readyOut.write(audioConfig);
16 } else {
17 readyOut.writeInt(0);
18 readyOut.writeInt(0);
19 readyOut.writeInt(0);
20 }Source: RecordSession.java
设计意图:
& ~1偶数对齐:H.264/H.265 编码器要求宏块对齐的宽高,最低位清零是最小代价的规整方式,同时与ScreenRecorder.prepare()内部的处理保持一致。- hasAudio 显式声明 + 零值占位:即使无音频也写入三个 0,保持固定骨架,Windows 端可用同一套解析逻辑处理两种情形。
- SPS/PPS(videoConfig)与 AudioSpecificConfig(audioConfig)随帧下发:接收端拿到配置即可初始化解码器,无需从首个样本前导字节中嗅探。
ScreenRecorder:VirtualDisplay 到 H.264/H.265 的零拷贝视频管线
编码器选择与格式配置
ScreenRecorder 构造函数对入参做防御性归一(非法 fps/bitrate 回落到默认值),并把 codecType 映射为 MIME 类型:
1 ScreenRecorder(int fps, int bitrate, int codecType) {
2 this.fps = (fps > 0) ? fps : 60;
3 this.bitrate = (bitrate > 0) ? bitrate : 16_000_000;
4 this.codecType = codecType;
5 this.mimeType = (codecType == 1) ? MediaFormat.MIMETYPE_VIDEO_HEVC : MediaFormat.MIMETYPE_VIDEO_AVC;
6 }Source: ScreenRecorder.java
prepare() 完成五步链路:查询主屏原生尺寸 → 硬件编码器能力选择(selectEncoder(mimeType),找不到即抛 IllegalStateException)→ 构建 MediaFormat → 创建并配置 MediaCodec(CONFIGURE_FLAG_ENCODE)+ input Surface → 建立镜像 VirtualDisplay:
1 MediaFormat format = MediaFormat.createVideoFormat(mimeType, width, height);
2 format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
3 format.setInteger(MediaFormat.KEY_BIT_RATE, bitrate);
4 format.setInteger(MediaFormat.KEY_FRAME_RATE, fps);
5 format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
6 if (fps > 0) {
7 format.setFloat("max-fps-to-encoder", fps);
8 }
9
10 codec = MediaCodec.createByCodecName(codecInfo.getName());
11 codec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
12 surface = codec.createInputSurface();
13 codec.start();
14
15 virtualDisplay = DisplayWrapper.createVirtualDisplay(
16 "momo-record", width, height, 0, surface);Source: ScreenRecorder.java
关键参数的"为什么":
COLOR_FormatSurface:声明输入不是 ByteBuffer 而是 Surface,这是零拷贝路径的开关。KEY_I_FRAME_INTERVAL = 1:每秒一个关键帧。录制游戏时随机寻求/丢包恢复都依赖 I 帧,1s 间隔在码率与可恢复性之间取工程平衡。max-fps-to-encoder:限制送入编码器的最大帧率,防止空闲界面下合成器高频送帧导致码流全是冗余帧。DisplayWrapper.createVirtualDisplay("momo-record", width, height, 0, surface):虚拟显示名momo-record便于在 dumpsys 中辨识;第 3 个参数 0 表示使用默认 DPI,镜像目标即编码器 surface。
SPS/PPS 预取:fetchConfig
fetchConfig(3000) 在推流前把编码器的配置块取出来,供 RECORD_READY 帧携带。它在一个带截止时间的循环里 dequeueOutputBuffer,只接受 BUFFER_FLAG_CODEC_CONFIG 帧:
1 byte[] fetchConfig(long timeoutMs) throws Exception {
2 MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
3 long deadline = System.currentTimeMillis() + timeoutMs;
4 while (System.currentTimeMillis() < deadline) {
5 int outIndex = codec.dequeueOutputBuffer(bufferInfo, 100000);
6 if (outIndex >= 0) {
7 ByteBuffer outBuffer = codec.getOutputBuffer(outIndex);
8 if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
9 byte[] spsPps = new byte[bufferInfo.size];
10 outBuffer.position(bufferInfo.offset);
11 outBuffer.get(spsPps, 0, bufferInfo.size);
12 codec.releaseOutputBuffer(outIndex, false);
13 return spsPps;
14 }
15 codec.releaseOutputBuffer(outIndex, false);
16 }
17 }
18 throw new TimeoutException("Failed to obtain video SPS/PPS within timeout");
19 }Source: ScreenRecorder.java
预取是必要的:若不先取走 CONFIG 帧,后续 drain 线程会在正式样本前意外遇到它。超时抛 TimeoutException(视频管线不可降级),错误沿 run() 冒泡到 handle() 的 ERROR 帧回报。
drain 线程:从编码器到 StreamSink
startDraining(sink, timeOriginUs) 启动名为 momo-video-drain 的独立线程执行 drainOutput()。这是管线的热路径:
1 if (bufferInfo.size > 0) {
2 byte[] sample = new byte[bufferInfo.size];
3 outBuffer.position(bufferInfo.offset);
4 outBuffer.get(sample, 0, bufferInfo.size);
5
6 long ptsUs = Math.max(0, bufferInfo.presentationTimeUs - timeOriginUs);
7 int flags = ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) ? 1 : 0;
8 if (sink != null) {
9 sink.sendFrame(CaptureProtocol.VIDEO_SAMPLE, 0, flags, ptsUs, sample);
10 }
11 }
12 codec.releaseOutputBuffer(outIndex, false);
13 if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
14 eosReceived = true;
15 break;
16 }Source: ScreenRecorder.java
实现细节与设计意图:
- 样本即拷贝一次:
MediaCodec输出 ByteBuffer 会被复用,必须先复制到byte[]才能跨线程/跨 Socket 使用;这是整个视频路径中唯一一次内存拷贝。 - PTS 归零化:
bufferInfo.presentationTimeUs - timeOriginUs且Math.max(0, ...)防御时钟回绕,使录制文件从 0µs 开始。 - 关键帧标志位透传:
flags=1仅在BUFFER_FLAG_KEY_FRAME时置位,Windows 端可据此做分片或即时解码刷新。 - EOS 三态处理:
eosRequested(请求停止)与eosReceived(编码器确认)分离;INFO_TRY_AGAIN_LATER分支在eosRequested后启动 1500ms 截止(L137-L140),防止编码器不吐 EOS 导致线程悬挂。
Core Flow:一次完整录制的时序
时序中的顺序不是任意的:
- 先视频后音频:视频 prepare 失败会让整个会话失败(录制屏幕是刚需),音频失败只降级——两种失败等级的差异化处理集中在
run()前半段。 - RECORD_READY 在推流前发送:Windows 端必须先拿到配置才能解释后续样本帧,"配置先行"消除了流内协商。
- STOP 之后的 drain 尾部:
stop()触发 EOS,drain 线程继续把编码器里滞留的最后几帧吐完(带 1500ms 超时兜底),再发送 RECORD_FINISHED。
停止路径与资源释放
run() 的第 5–7 步把"停止"拆成三层:
1 StreamSink sink = (type, reqId, flags, timestamp, payload) -> {
2 if (isRunning) {
3 sendFrame(type, reqId, flags, timestamp, payload);
4 }
5 };
6
7 screenRecorder.startDraining(sink, timeOriginUs);
8 if (hasAudio) {
9 audioEncoder.startDraining(sink, timeOriginUs);
10 }
11
12 while (isRunning) {
13 try {
14 CaptureProtocol.Frame frame = CaptureProtocol.readFrame(input);
15 if (frame.type == CaptureProtocol.STOP_RECORD) {
16 break;
17 }
18 } catch (Exception e) {
19 break;
20 }
21 }
22
23 isRunning = false;
24 if (screenRecorder != null) {
25 screenRecorder.stop();
26 }
27 if (audioEncoder != null) {
28 audioEncoder.stop();
29 }
30
31 try {
32 sendFrame(CaptureProtocol.RECORD_FINISHED, 0, 0, 0, new byte[0]);
33 } catch (Exception ignored) {
34 }Source: RecordSession.java
isRunning 是 volatile 门闩:主线程停止循环后将其置 false,drain 线程的 sink 回调据此停止发送,防止停止信号发出后还有旧帧与 RECORD_FINISHED 交错到达 Windows 端。
release()(由 handle() 的 finally 保证执行)对每个组件做"忽略异常"的幂等清理——即便 stop() 已经调用过一次,二次调用也不会导致清理中断。
并发模型与线程安全
并发要点:
- 多写者单锁:视频 drain、音频 drain 与主线程(元数据帧)都可能写同一 Socket;
sendFrame用synchronized (writeLock)串行化整帧写入,保证单条帧的原子性,接收端不会读到交错的半帧。 - 读写在同一线程分离:只有主线程读
input,只有持锁者写output,LocalSocket的全双工特性被完整利用。 - volatile 标志 + volatile 可见性:
isRunning、eosRequested、eosReceived均为 volatile,跨线程状态变化无需额外同步原语。 - 时间共享而非锁共享:音视频两条 drain 线程不互相感知,只通过共同的
timeOriginUs与共享 sink 输出达成隐式同步。
Usage Examples
接入一个录制会话(协议视角)
Windows 端要做的事:连接常驻 Socket → 发 START_RECORD(int fps, int bitrate, byte codecType)→ 读 RECORD_READY 解析配置 → 持续接收 VIDEO_SAMPLE/音频帧 → 发 STOP_RECORD → 等 RECORD_FINISHED。设备端对应实现即 run() 的七个步骤(见上文分步代码)。
自定义 sink 行为
StreamSink 是函数式接口,会话内部用它统一两条管线的输出。以下摘自 RecordSession 的 sink 组装,展示"运行标志短路"模式(任何一方停止后,后续帧被静默丢弃):
1 StreamSink sink = (type, reqId, flags, timestamp, payload) -> {
2 if (isRunning) {
3 sendFrame(type, reqId, flags, timestamp, payload);
4 }
5 };Source: RecordSession.java
配置选项
录制相关运行参数(均由 START_RECORD 载荷携带,无持久化配置文件):
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| fps | int | 60 | 目标帧率;非法值(≤0)回落 60,同时写入 max-fps-to-encoder 限流 |
| bitrate | int | 16_000_000 | 视频码率(bps);非法值(≤0)回落默认 |
| codecType | int | 0 | 0 = H.264 (MIMETYPE_VIDEO_AVC),1 = H.265 (MIMETYPE_VIDEO_HEVC) |
| KEY_I_FRAME_INTERVAL | int | 1 | ScreenRecorder.prepare() 内固定,每秒一个关键帧 |
| KEY_COLOR_FORMAT | int | COLOR_FormatSurface | 固定,Surface 零拷贝输入 |
| fetchConfig 视频超时 | long | 3000 ms | SPS/PPS 获取截止时间,超时抛 TimeoutException |
| fetchConfig 音频超时 | long | 2000 ms | 音频配置获取截止时间,超时触发降级 |
| drain 尾部超时 | long | 1500 ms | eosRequested 后等待 EOS 的兜底时长 |
构建产物 build/android/momo-capture.jar 约 10 KB(见 android/capture/README.md)。
API Reference
RecordSession.handle(LocalSocket client): void(static)
单连接会话的完整入口,含异常回报与 finally 清理。
参数: client 已建立的本地 Socket 连接。
行为: 成功路径走完 run() 七步;任意 Throwable 转为 ERROR 帧回报后释放。
RecordSession.run(): void(private)
会话主流程:解析参数 → 视频/音频初始化 → 发 RECORD_READY → 启动推流 → 等待 STOP → 排空收尾。
RecordSession.sendFrame(int type, int requestId, int flags, long timestamp, byte[] payload): void
线程安全的帧发送,整帧写入由 writeLock 保护。
参数: type 协议帧类型(如 VIDEO_SAMPLE、RECORD_READY、RECORD_FINISHED、ERROR);flags 关键帧等标志;timestamp 已归零化的 PTS(µs);payload 码流字节。
抛出: IOException(Socket 写失败;调用方多在 catch/finally 中吞掉)。
ScreenRecorder.prepare(): void
创建并启动视频编码管线(DisplayWrapper 尺寸 → 编码器选择 → MediaFormat → MediaCodec → VirtualDisplay)。
抛出: IllegalStateException(无可用编码器);TimeoutException 由 fetchConfig 抛出。
ScreenRecorder.fetchConfig(long timeoutMs): byte[]
阻塞获取 SPS/PPS 配置块。
返回: CODEC_CONFIG 帧的字节内容。
抛出: TimeoutException(超时未取到)。
ScreenRecorder.startDraining(StreamSink sink, long timeOriginUs): void
启动 momo-video-drain 线程,把后续样本经 sink 送出。
ScreenRecorder.stop(): void
请求 EOS 并等待 drain 线程排空尾部帧后释放编码器资源(内部含 1500ms 超时兜底,见 L137-L140 一带)。
AudioEncoder.prepare() / fetchConfig(long) / startDraining(StreamSink, long) / stop()
与视频侧对称的音频接口;额外暴露 SAMPLE_RATE 与 CHANNELS 常量供 RECORD_READY 帧写入(L108-L109)。AudioEncoder.java 的完整实现细节本页未逐行展开,见源文件。
Failure Modes, Edge Cases & Concurrency
| 场景 | 触发条件 | 处理路径 | 用户可见结果 |
|---|---|---|---|
| 无视频编码器 | selectEncoder 返回 null | IllegalStateException 冒泡至 handle() | ERROR 帧回报,会话结束(视频不可降级) |
| SPS/PPS 获取超时 | 3000ms 内无 CODEC_CONFIG 帧 | TimeoutException 冒泡 | ERROR 帧回报 |
| 音频初始化失败 | REMOTE_SUBMIX 不可用/权限不足 | 捕获 Throwable,audioEncoder.stop() 后置 null | RECORD_READY 的 hasAudio=0,纯视频继续 |
| Windows 断连 | readFrame 抛异常 | 停止循环 break → 正常收尾 | 设备端仍完整执行 stop/drain/释放 |
| 编码器不吐 EOS | eosRequested 后无 END_OF_STREAM | drain 线程 1500ms 超时退出 | 会话仍能结束,不悬挂 |
| 帧发送竞态 | 多线程同时写 Socket | writeLock 串行化整帧 | 帧不交错 |
| 停止后残留帧 | STOP 后编码器缓冲仍有帧 | isRunning=false 使 sink 短路丢弃 | RECORD_FINISHED 之前无迟到帧 |
| 尺寸为奇数 | 主屏宽/高为奇数 | & ~1 偶数化 | 编码器接受合法尺寸 |
| 时钟回绕 | presentationTimeUs 早于 origin | Math.max(0, ...) | PTS 不出现负值 |
Performance & Operational Notes
- 零拷贝采集:像素全程停留在 GPU/合成器与编码器之间,Java 层唯一拷贝是输出样本的
byte[]复制(ScreenRecorder.java L122-L124),这是 60fps 高分辨率下保持低 CPU 占用的关键。 max-fps-to-encoder限流:空闲界面时合成器仍可能高频送帧,限流避免冗余编码与码流膨胀。- 常驻会话 vs 冷启动:README 明确通过 Unix Abstract Socket 维持单一稳定连接,避免反复冷启动
app_process;但每个RecordSession仍是一次性的——重录需重新连接/重新发 START_RECORD。 - 停止路径的确定性:三层保障(STOP 帧、断连异常、finally release)确保设备端资源(MediaCodec、VirtualDisplay、AudioRecord)在任何情况下都被释放,避免 Shell 会话累积句柄。
- 独立调试:
node scripts/run-android.js --screenshot ...可脱离 Windows 主程序验证连通性(见 android/capture/README.md);录制相关源码级测试文件在本仓库 android/capture 目录下未见(无测试证据)。
Extension Points
- 新增编码格式:
ScreenRecorder构造函数中的codecType → mimeType映射是唯一分支点(L38),扩展只需扩枚举并在 START_RECORD 载荷中复用同一字节。 - 新增流类型:
StreamSink是统一出口,任何新管线(如麦克风采集)只要实现prepare/fetchConfig/startDraining/stop对称接口并复用timeOriginUs,即可接入。 - 更换镜像源:
DisplayWrapper.createVirtualDisplay(...)封装了显示创建,若需录制副屏可在此层扩展(详见 DisplayWrapper 相关页面)。 - 写入端替换:会话把"编码"与"传输"通过 sink 解耦,未来可把 sink 从 Socket 发送改为本地 muxer 写文件,而无需改动两条 drain 管线。
Related Links
- RecordSession.java — 会话生命周期总管
- ScreenRecorder.java — 视频编码管线
- AudioEncoder.java — 音频编码管线
- StreamSink.java — 推流回调抽象
- DisplayWrapper.java — 显示尺寸与 VirtualDisplay 创建
- CaptureProtocol.java — 帧类型与二进制线格式
- android/capture/README.md — 采集服务总览与构建说明