Repository Wiki
ChanIok/SpinningMomo

游戏录制管线与媒体编码

游戏录制管线是 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

Loading diagram...

要点说明:

  • 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 关闭——这是"设备端资源绝不泄漏"的兜底设计:

java
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,新旧协议版本可以共存:

java
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 步按"视频必须成功、音频尽力而为"的顺序初始化:

java
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 帧,避免接收端在流中途再协商:

java
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 类型:

java
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:

java
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 帧:

java
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()。这是管线的热路径:

java
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:一次完整录制的时序

Loading diagram...

时序中的顺序不是任意的:

  1. 先视频后音频:视频 prepare 失败会让整个会话失败(录制屏幕是刚需),音频失败只降级——两种失败等级的差异化处理集中在 run() 前半段。
  2. RECORD_READY 在推流前发送:Windows 端必须先拿到配置才能解释后续样本帧,"配置先行"消除了流内协商。
  3. STOP 之后的 drain 尾部:stop() 触发 EOS,drain 线程继续把编码器里滞留的最后几帧吐完(带 1500ms 超时兜底),再发送 RECORD_FINISHED。

停止路径与资源释放

run() 的第 5–7 步把"停止"拆成三层:

java
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() 已经调用过一次,二次调用也不会导致清理中断。

并发模型与线程安全

Loading diagram...

并发要点:

  • 多写者单锁:视频 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 组装,展示"运行标志短路"模式(任何一方停止后,后续帧被静默丢弃):

java
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 载荷携带,无持久化配置文件):

选项类型默认值说明
fpsint60目标帧率;非法值(≤0)回落 60,同时写入 max-fps-to-encoder 限流
bitrateint16_000_000视频码率(bps);非法值(≤0)回落默认
codecTypeint00 = H.264 (MIMETYPE_VIDEO_AVC),1 = H.265 (MIMETYPE_VIDEO_HEVC)
KEY_I_FRAME_INTERVALint1ScreenRecorder.prepare() 内固定,每秒一个关键帧
KEY_COLOR_FORMATintCOLOR_FormatSurface固定,Surface 零拷贝输入
fetchConfig 视频超时long3000 msSPS/PPS 获取截止时间,超时抛 TimeoutException
fetchConfig 音频超时long2000 ms音频配置获取截止时间,超时触发降级
drain 尾部超时long1500 mseosRequested 后等待 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 返回 nullIllegalStateException 冒泡至 handle()ERROR 帧回报,会话结束(视频不可降级)
SPS/PPS 获取超时3000ms 内无 CODEC_CONFIG 帧TimeoutException 冒泡ERROR 帧回报
音频初始化失败REMOTE_SUBMIX 不可用/权限不足捕获 Throwable,audioEncoder.stop() 后置 nullRECORD_READY 的 hasAudio=0,纯视频继续
Windows 断连readFrame 抛异常停止循环 break → 正常收尾设备端仍完整执行 stop/drain/释放
编码器不吐 EOSeosRequested 后无 END_OF_STREAMdrain 线程 1500ms 超时退出会话仍能结束,不悬挂
帧发送竞态多线程同时写 SocketwriteLock 串行化整帧帧不交错
停止后残留帧STOP 后编码器缓冲仍有帧isRunning=false 使 sink 短路丢弃RECORD_FINISHED 之前无迟到帧
尺寸为奇数主屏宽/高为奇数& ~1 偶数化编码器接受合法尺寸
时钟回绕presentationTimeUs 早于 originMath.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 管线。

Sources

(3 files)
android/capture
android/capture/src/com/spinningmomo/capture