목차
- 전체 아키텍처
- 비디오 인코딩 파이프라인 (Android)
- VideoPacket 프로토콜 설계
- 비디오 디코딩 파이프라인 (Web)
- WebGL2 렌더링
- 손실 감지 및 오류 복구
- 방향 전환 처리
- 시스템 MediaProjection 생명주기
- 원격 제어: 터치 주입
- 오디오 파이프라인
- 성능 최적화
- 디자인 패턴 요약
전체 아키텍처
PlainApp 화면 미러링은 엔드투엔드 저지연 미러링 시스템입니다: Android 기기가 화면 콘텐츠를 캡처하고 H.264 비디오와 Opus 오디오로 하드웨어 인코딩한 후, 커스텀 바이너리 프로토콜을 통해 WebSocket으로 웹 클라이언트에 전송합니다. 웹 클라이언트는 WebCodecs API로 디코딩하고 WebGL2를 통해 Canvas에 직접 렌더링하며, CPU 복사는 전혀 발생하지 않습니다. 투명한 터치 오버레이가 루프를 완성하여 포인터 입력을 다시 휴대폰의 제스처로 변환합니다.
WebRTC, RTMP, 중간 서버가 없습니다. 전체 파이프라인은 다음과 같습니다:
Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas
왜 WebRTC를 사용하지 않나요?
WebRTC는 실시간 통신을 위해 설계되었습니다. ICE/STUN/TURN 협상, 혼잡 제어, 지터 버퍼링은 LAN 화면 미러링에는 과도합니다. PlainApp의 사용 사례는 다음과 같습니다:
- 동일 LAN, 지연 5ms 미만, NAT 통과 불필요
- 극한의 저지연 추구, 지터 버퍼링 불필요
- 고품질, 비트레이트를 높게 설정 가능 (8Mbps)
- 화면 제어 (터치 주입), WebRTC의 DataChannel은 불필요한 복잡성을 추가함
LAN 시나리오에서는 커스텀 바이너리 프로토콜과 WebSocket 조합이 더 가볍고 제어하기 쉽습니다.
컴포넌트 구성
| 계층 | Android | Web |
|---|---|---|
| 화면 캡처 | MediaProjection + VirtualDisplay | — |
| 비디오 인코딩 | MediaCodec H.264 하드웨어 인코더 | — |
| 오디오 인코딩 | MediaCodec Opus 하드웨어 인코더 | — |
| 전송 | WebSocket 바이너리 이벤트 | WebSocket 수신 |
| 비디오 디코딩 | — | WebCodecs VideoDecoder |
| 오디오 디코딩 | — | WebCodecs AudioDecoder → <audio> |
| 렌더링 | — | WebGL2 텍스처 직접 렌더링 |
| 제어 | AccessibilityService 제스처 주입 | 터치 오버레이 → GraphQL mutation |
비디오와 오디오는 항상 기기에서 브라우저 방향으로 동일한 WebSocket 연결을 통해 흐르며, 제어는 반대 방향인 GraphQL (sendScreenMirrorControl)을 통해 흐릅니다. 이 GraphQL은 코덱 설정(screenMirrorVideoCodec 쿼리)과 키프레임 요청(requestScreenMirrorKeyFrame mutation)을 위한 사이드 채널로도 사용됩니다.
비디오 인코딩 파이프라인 (Android)
인코딩 파라미터 튜닝
인코딩 파라미터는 저지연 LAN 화면 미러링에 맞게 특별히 튜닝되었습니다:
| 파라미터 | 값 | 설명 |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps로 부드러움 보장 |
KEY_I_FRAME_INTERVAL | 10 | IDR 간격 10초, 키프레임 오버헤드 감소 |
KEY_BIT_RATE_MODE | VBR (암시적, 명시적 모드 설정 없음) | 가변 비트레이트, 장면 적응형 |
KEY_PRIORITY | 0 | 실시간 우선순위 |
KEY_LATENCY | 1 | 저지연 모드 |
비트레이트는 품질 모드에 따라 계층화됩니다. 더 높은 비트레이트(예: 24 Mbps)도 테스트되었으나, 인코더/디코더 프레임 드롭과 엔드투엔드 지연 증가를 유발했으며 화면 콘텐츠에 대한 가시적인 품질 향상은 없었습니다:
| 모드 | 비트레이트 | 캡처 해상도 |
|---|---|---|
| HD | 8 Mbps | 1080p 짧은 쪽 |
| Smooth | 4 Mbps | 1080p 짧은 쪽 |
| Low | 2 Mbps | 720p 짧은 쪽 |
인코더 저지연 설정
MediaCodecVideoEncoder는 생성 시점에 인코더를 한 번 설정합니다:
MediaFormat.createVideoFormat(MIME, width, height).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
setInteger(MediaFormat.KEY_BIT_RATE, bitrateBps)
setInteger(MediaFormat.KEY_FRAME_RATE, frameRate) // 60
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, iFrameIntervalSec) // 10
setLong(MediaFormat.KEY_REPEAT_PREVIOUS_FRAME_AFTER, 100_000L)
setInteger(MediaFormat.KEY_COLOR_RANGE, MediaFormat.COLOR_RANGE_LIMITED)
setInteger(MediaFormat.KEY_PRIORITY, 0)
setInteger(MediaFormat.KEY_LATENCY, 1)
}
KEY_PRIORITY=0과 KEY_LATENCY=1이 저지연의 핵심입니다. 이 값들은 인코더에게 압축률보다 실시간 인코딩을 우선시하도록 지시합니다. 입력은 MediaCodec.createInputSurface()로 생성된 Surface이며 VirtualDisplay에 직접 연결됩니다. SurfaceTexture 읽기, I420 변환, CPU의 픽셀 접근이 전혀 없습니다.
캡처 해상도
ScreenMirrorCaptureSize.compute()는 실제 화면 크기, 품질 모드의 짧은 쪽 목표(720/1080), 그리고 인코더가 보고한 maxWidth/maxHeight 및 너비/높이 정렬 조건(MediaCodecVideoEncoder.queryEncoderCaps()로 한 번 조회)을 기반으로 실제 캡처 크기를 산출합니다. 이를 통해 인코더가 수용할 수 없는 크기를 전달받지 않습니다.
키프레임 요청
웹 클라이언트는 GraphQL requestScreenMirrorKeyFrame mutation을 통해 패킷 손실을 복구하기 위해 IDR 프레임을 요청할 수 있습니다. Android는 MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME을 통해 응답합니다:
fun requestKeyFrame() {
val b = Bundle().apply { putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 1) }
codec?.setParameters(b)
}
SPS/PPS 및 키프레임 브로드캐스트
인코더가 시작된 후 INFO_OUTPUT_FORMAT_CHANGED가 csd-0/csd-1(SPS/PPS)을 전달하며, ScreenMirrorPipeline이 이를 하나의 Annex-B 설정 blob으로 결합하여 캐시합니다(cachedConfig). 그 뒤에 오는 첫 번째 IDR도 캐시됩니다(cachedKeyFrame). 이렇게 하면 새로 연결된 웹 클라이언트가 다음 키프레임 간격을 기다리지 않고 screenMirrorVideoCodec GraphQL 쿼리를 통해 둘 다 가져올 수 있습니다. 설정이 변경된 경우(방향 또는 품질 전환), Android는 새 IDR을 일반 비디오 패킷으로 보내지 않습니다. 대신 SPS/PPS + IDR을 하나의 screen_mirror_video_codec WebSocket 이벤트로 묶어서 보내므로, 웹 클라이언트가 디코더 재설정과 첫 프레임 디코딩을 한 번에 완료할 수 있습니다.
일부 OEM 인코더(Qualcomm/Xiaomi)는 SPS+PPS+IDR을 BUFFER_FLAG_CODEC_CONFIG와 BUFFER_FLAG_SYNC_FRAME을 모두 가진 단일 출력 버퍼로 묶어서 출력합니다. 드레인 루프는 순수 설정 버퍼(isConfig && !isKey)만 건너뜁니다. 설정 플래그가 있지만 동기화 프레임도 포함된 버퍼를 건너뛰면 IDR이 조용히 드롭되어 디코더에 P-프레임만 남고 모자이크 출력이 발생합니다.
VideoPacket 프로토콜 설계
비디오 프레임과 오디오 프레임은 모두 통합된 VideoPacket 바이너리 프로토콜로 래핑되어 WebSocket을 통해 전송됩니다.
프로토콜 형식
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| MAGIC | FLAGS | FRAME_ID (4 bytes, big-endian) | TIMESTAMP (8 bytes, BE) | DATA |
| 0x56 | | byte2 | byte3 | byte4 | byte5 | byte6 | byte7 | ... | byte13 | payload... |
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
\- 1B -/ \- 1B -/ \-------------------- 4B ----------------------/ \----------------------- 8B ------------------------/ \- var -/
| 필드 | 크기 | 설명 |
|---|---|---|
MAGIC | 1 byte | 고정 0x56 ('V'), 검증용 |
FLAGS | 1 byte | 0x01=키프레임, 0x02=설정, 0x04=오디오 |
FRAME_ID | 4 bytes | 단조 증가하는 프레임 번호, uint32 big-endian |
TIMESTAMP | 8 bytes | 인코더 PTS (마이크로초), big-endian |
DATA | 가변 | H.264 NAL 단위 또는 Opus 데이터 |
Android의 VideoPacket.encode()(commonMain에 위치하므로 JVM 단위 테스트에서 Android 의존성 없이 와이어 형식을 검증 가능)와 웹의 parseVideoPacket()은 각각 독립적으로 이 형식을 구현합니다. 공유 직렬화 라이브러리는 없으며, 양측이 준수하는 명세만 있습니다.
설계 참고 사항
FRAME_ID부호 없는 파싱:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0—>>> 0을 사용하여 부호 없음을 보장해야 합니다. 그렇지 않으면frameId > 2^31이 음수로 파싱되어 잘못된 손실 감지가 발생합니다.FRAME_ID는 절대 리셋되지 않음: 방향 전환으로 인코더가 재구축될 때도frameId는 계속 증가합니다(ScreenMirrorPipeline에 위치하며, 인코더에 있지 않음). 이를 통해 웹 측에서 frameId 간격을 통해 회전 중 프레임 손실을 감지할 수 있습니다.TIMESTAMP는 인코더 PTS 사용: 웹 클라이언트의 시계에 의존하지 않으므로, 클록 드리프트로 인한 A/V 동기화 불일치를 방지합니다.- 제로카피 파싱: 웹 파서는
Uint8Array.subarray()로 페이로드를 슬라이싱합니다. 원본 WebSocketArrayBuffer의 뷰(view)이지 복사본이 아닙니다.
비디오 디코딩 파이프라인 (Web)
WebCodecs VideoDecoder
웹 클라이언트는 WebCodecs API의 VideoDecoder를 사용하여 하드웨어 디코딩을 수행합니다. MediaSource Extensions 또는 WebRTC와 비교하여 WebCodecs는 디코딩 프로세스를 세밀하게 제어할 수 있습니다. 지터 버퍼가 없고, 컨테이너 계층이 없으며, 디코딩된 VideoFrame 객체를 WebGL 텍스처로 직접 업로드할 수 있습니다.
const decoder = new VideoDecoder({
output: (frame) => this.renderFrame(frame),
error: (e) => {
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.onError?.(e)
},
})
decoder.configure({
codec, // 예: 'avc1.42c01e', SPS NAL에서 읽음
avc: { format: 'annexb' },
optimizeForLatency: true,
hardwareAcceleration: 'prefer-hardware',
})
주요 설정:
optimizeForLatency: true— 디코더에게 저지연 우선, 프레임 버퍼링 없음 지시hardwareAcceleration: 'prefer-hardware'— GPU 디코딩 선호avc: { format: 'annexb' }— 각 IDR 앞에 인라인 SPS/PPS가 있는 Annex-B 형식 사용- 코덱 문자열은 하드코딩되지 않습니다.
extractAvc1CodecString()이 설정 blob의 첫 번째 SPS NAL에서 profile/compat/level 바이트를 직접 읽어옵니다.
녹색 화면 문제 및 시작 시퀀스
인코더는 VirtualDisplay가 실제 화면 콘텐츠를 렌더링하기 전에 첫 번째 IDR 프레임을 생성합니다. 이는 빈(녹색) 프레임입니다. 웹 측에서 이 프레임을 디코딩하면, 화면 콘텐츠가 변경되어 새 프레임이 트리거될 때까지 사용자에게 녹색 섬광이 표시됩니다.
해결 방법: 시작 시 웹 측은 screenMirrorVideoCodec GraphQL 쿼리를 통해 캐시된 설정을 가져오지만, 번들된 키프레임은 디코딩하지 않습니다. 대신 video.requestIdr()를 호출하여 waitingForIdr = true로 설정하고(모든 P-프레임을 드롭), requestKeyFrame()을 호출하여 손실 복구에 사용되는 동일한 mutation을 통해 새로운 IDR을 요청합니다. 새 IDR이 도착할 때쯤이면 VirtualDisplay에 실제 화면 콘텐츠가 있습니다.
video.requestIdr() // P-프레임 드롭, IDR 대기
await requestKeyFrame() // GraphQL mutation을 통해 새 IDR 요청
onFirstFrameRendered 콜백은 handleVideo()가 아닌 renderFrame()에 바인딩되어, UI가 단순히 수신했을 때가 아니라 실제 프레임이 렌더링된 후에만 업데이트되도록 보장합니다.
WebGL2 렌더링
제로카피 GPU 직접 렌더링
디코딩된 VideoFrame 객체는 WebGL2 텍스처로 직접 업로드되며, CPU를 전혀 거치지 않습니다:
VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas
gl.texImage2D는 VideoFrame을 픽셀 소스로 받아들입니다. 브라우저가 내부적으로 YUV→RGB 변환과 GPU 업로드를 처리하므로, ImageData CPU 복사가 없습니다. MirrorGLRenderer는 getContext('webgl2', ...)가 실패하면 Canvas 2D drawImage()로 폴백하므로, 구형 브라우저에서도 (약간 더 높은 지연으로) 화면을 볼 수 있습니다.
desynchronized 컨텍스트
const gl = canvas.getContext('webgl2', {
alpha: false,
desynchronized: true, // 컴포지터 우회, 화면에 직접 쓰기
preserveDrawingBuffer: true, // 스크린샷을 위한 버퍼 보존
powerPreference: 'high-performance',
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
})
desynchronized: true는 브라우저 컴포지터를 우회하고 화면에 직접 쓰므로, 약 1프레임의 디스플레이 지연(~16ms @ 60fps)을 절약합니다.
preserveDrawingBuffer: true는 드로잉 버퍼를 보존하여 canvas.toDataURL() 스크린샷이 콘텐츠를 읽을 수 있게 합니다. 기본값 false에서는 컴포지션 후 버퍼가 지워져 검은색 스크린샷이 생성됩니다.
셰이더 자체는 의도적으로 최소한으로 유지됩니다. 전체 화면 삼각형 버텍스 셰이더와 텍스처를 샘플링하는 한 줄의 프래그먼트 셰이더로 구성됩니다. 프레임 당 필요한 작업은 "이 텍스처를 화면에 배치"하는 것뿐이기 때문입니다.
Canvas 자동 맞춤
Canvas 백킹 스토어 크기는 VideoFrame.displayWidth/Height가 변경될 때마다 설정됩니다. CSS 크기는 fitCanvasToWrapper()를 통해 종횡비를 유지하면서 래퍼 컨테이너에 맞게 조정됩니다(필요시 레터박스 또는 필러박스 적용). Canvas의 부모 요소에 ResizeObserver가 연결되어 컨테이너 크기가 변경될 때마다 이 맞춤을 다시 실행하므로, 비디오가 늘어나지 않습니다.
손실 감지 및 오류 복구
FrameId 간격 감지
각 비디오 프레임은 단조 증가하는 frameId를 전달합니다. 디코더는 lastFrameId를 추적합니다. 새 프레임의 frameId > lastFrameId + 1이면 프레임이 손실된 것입니다:
if (!this.waitingForIdr && this.lastFrameId > 0
&& packet.frameId > this.lastFrameId + 1) {
if (!packet.isKeyFrame) {
// 손실: 이후 P-프레임 드롭, 새 IDR 요청
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.lastFrameId = packet.frameId
return
}
}
waitingForIdr 상태 머신
waitingForIdr은 간단한 2-상태 머신입니다:
| 상태 | 동작 |
|---|---|
NORMAL | 모든 프레임 정상 디코딩 |
WAITING_FOR_IDR | 모든 P-프레임 드롭, IDR 프레임만 디코딩; IDR 도착 시 NORMAL로 리셋 |
WAITING_FOR_IDR로 전환을 트리거하는 시나리오:
- 시작 시: 오래된 GraphQL 키프레임 건너뛰고 실제 IDR 대기
- 패킷 손실 시: 디코딩 불가능한 P-프레임 드롭, IDR 복구 대기
- 디코더 오류 시: 디코더 리셋 후 IDR 대기
- 설정 변경 시: 방향/품질 변경 후 잔여 P-프레임 드롭
디코더 오류 복구
VideoDecoder.onerror가 발생하면 파이프라인 계층(screen-mirror-pipeline.ts)에서 decoderNeedsReset = true가 설정됩니다. 다음 IDR 프레임에서 디코더가 GraphQL을 다시 거치지 않고 캐시된 SPS/PPS로 재설정됩니다:
if (decoderNeedsReset) {
if (!packet.isKeyFrame || !cachedConfig) return
video.configure(cachedConfig)
decoderNeedsReset = false
}
역압력 및 타임스탬프 중복 제거
decoder.decodeQueueSize > 5이면 들어오는 P-프레임이 큐에 추가되지 않고 드롭됩니다. 임계값을 5로 설정한 이유(2가 아닌)는 하드웨어 디코더 시작 지연 시간을 허용하면서 불필요한 스터터를 방지하기 위함입니다. 별도로, 렌더링 후 lastRenderedPts가 기록됩니다. 타임스탬프가 더 오래된 프레임(순서가 바뀌어 도착)은 키프레임이 아닌 경우 드롭됩니다:
if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
return
}
방향 전환 처리
인코더 재구축
ScreenMirrorService의 OrientationEventListener는 모든 센서 콜백에서 디스플레이의 rotation을 캐시된 isPortrait 플래그와 비교합니다. 실제 세로/가로 전환일 때만 pipeline.onOrientationChanged()를 호출하고, 터치 좌표 스케일링에 사용되는 접근성 화면 크기 캐시를 무효화합니다.
rebuildEncoderAndResize():
- 새 크기(예: 가로 1920x1080)로 새 인코더 생성
VirtualDisplay.surface를 새 인코더의 입력Surface로 전환- 이전 인코더 중지
VirtualDisplay.resize()를 새 크기로 조정
Surface 전환은 resize보다 먼저 수행됩니다. 이렇게 하면 새 인코더가 먼저 프레임을 수신하고, 이전 인코더는 잘못된 크기의 프레임을 받기 전에 중지됩니다. virtualDisplay?.surface = ...가 실패하면 재구축이 중단되고 이전 인코더가 계속 실행되므로, 파이프라인에 인코더가 없는 상태가 되는 것을 방지합니다.
설정 변경 알림
새 인코더가 처음으로 SPS/PPS를 출력하면 파이프라인에 pendingConfigBroadcast가 설정됩니다. 새 인코더의 첫 번째 IDR이 도착하면, 일반 비디오 패킷으로 전송되지 않고 해당 설정과 함께 하나의 screen_mirror_video_codec 이벤트로 번들링됩니다.
그러면 웹 클라이언트는 handleConfig()에서:
- 새 SPS/PPS로 디코더 재설정
- 번들된 IDR 프레임을 즉시 디코딩
video.requestIdr()를 호출하여 아직 전송 중인 이전 인코더의 잔여 P-프레임 드롭requestKeyFrame()을 호출하여 깨끗하고 새로운 IDR 요청
3-4단계는 안전망입니다. 비동기 resize 윈도우 중 새 인코더의 첫 번째 IDR 크기가 잘못되더라도 웹 클라이언트는 올바른 크기로 빠르게 복구됩니다. handleConfig()는 또한 들어오는 설정이 캐시된 설정과 바이트 단위로 동일하면 조기에 종료됩니다. 변경되지 않은 바이트로 디코더를 재설정하는 것은 무의미한 동작이면서도 복구를 위해 IDR을 소모하기 때문입니다.
시스템 MediaProjection 생명주기
문제
사용자는 앱 UI를 통하지 않고 Android 시스템 알림 표시줄을 통해 시스템 수준 화면 미러링(MediaProjection)을 종료할 수 있습니다. 이 경우 ScreenMirrorService는 미러링이 중단되었음을 알지 못합니다. running은 여전히 true이고, 웹 클라이언트가 screenMirrorState를 조회하면 true를 받지만 비디오 프레임이 도착하지 않아 페이지가 로딩 상태에 갇히게 됩니다.
MediaProjection.Callback
MediaProjection은 시스템이 미러링을 중단할 때 호출되는 Callback.onStop() 콜백을 제공합니다. ScreenMirrorPipeline.startEncoders()가 이 콜백을 등록하고 onStop()에서 ScreenMirrorService.instance?.stop()을 호출합니다:
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
ScreenMirrorService.instance?.stop()
}
}, null)
Service.stop()의 책임
stop()은 명시적인 중단 지점으로, 웹 클라이언트에 알리고 서비스를 중단할 책임이 있습니다:
fun stop() {
if (!running) return // 재귀 방지
running = false
sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
if (!running) return 가드는 재귀를 방지합니다: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (이 시점에서 running=false이므로 즉시 반환).
웹 측 처리
웹 클라이언트가 {"running":false} 이벤트를 수신하면 idle 상태로 리셋되고 시작 버튼을 표시합니다:
const onScreenMirroring = (data: any) => {
if (data?.running === false) {
cleanupFn()
fullReset()
return
}
// running=true → 스트림 연결
}
원격 제어: 터치 주입
화면 미러링은 기본적으로 단방향(비디오/오디오 전용)입니다. 원격 제어는 옵트인이며, 사용자가 PlainApp의 접근성 서비스를 한 번 활성화해야 합니다. Android는 AccessibilityService.dispatchGesture() 외부에서 임의의 터치 이벤트를 주입할 수 있는 공개 API가 없기 때문입니다.
좌표 정규화 (Web)
<canvas> 위에 투명 오버레이가 위치하여 포인터 이벤트를 캡처합니다. normalizeCoords()는 원시 clientX/clientY를 Canvas의 백킹 스토어 종횡비와 렌더링된 컨테이너 종횡비를 비교하여 레터박스/필러박스 오프셋을 계산한 후, 오버레이의 바운딩 박스가 아닌 실제 비디오 콘텐츠 영역을 기준으로 한 [0,1] 좌표로 변환합니다:
if (videoAspect > containerAspect) {
// 레터박스: 위/아래
renderW = containerW
renderH = containerW / videoAspect
offsetY = (containerH - renderH) / 2
} else {
// 필러박스: 왼쪽/오른쪽
renderH = containerH
renderW = containerH * videoAspect
offsetX = (containerW - renderW) / 2
}
포인터를 누르면 시작 위치/시간을 추적하는 GestureState가 시작됩니다. 500ms 동안 10px 미만의 움직임이면 LONG_PRESS로, 그 임계값을 넘는 움직임은 SWIPE로, 빠른 릴리스는 TAP으로 간주됩니다. 시각적 터치 표시기(커졌다가 사라지는 점)가 휴대폰이 응답하기 전에 운영자에게 인식된 제스처를 알려줍니다.
GraphQL → AccessibilityService
인식된 모든 제스처는 action(TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY)과 정규화된 좌표를 포함하는 하나의 sendScreenMirrorControl(input) mutation으로 전송됩니다. 리졸버는 dispatchScreenMirrorControl()을 호출하며, 정규화된 좌표에 실제 화면 크기(PlainAccessibilityService.getScreenSize()에서 가져오며, 방향 변경 시마다 무효화됨)를 곱하여 PlainAccessibilityService.dispatchControl()에 위임합니다:
private fun dispatchTap(x: Float, y: Float) {
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, 50)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
SWIPE와 LONG_PRESS는 더 긴 스트로크 지속 시간이나 단일 점 대신 선 경로를 사용하여 동일한 GestureDescription을 구성합니다. SCROLL은 (x, y)에서 (x, y + deltaY)로의 합성 스와이프로 구현되며, ±500px로 제한됩니다. 네 가지 글로벌 액션(BACK/HOME/RECENTS/LOCK_SCREEN)은 제스처 디스패치를 건너뛰고 직접 performGlobalAction()을 호출합니다. 접근성 서비스가 활성화되지 않은 경우, 리졸버는 입력을 조용히 드롭하는 대신 GraphQLError를 발생시켜 웹 UI가 사용자에게 활성화를 요청할 수 있도록 합니다.
오디오 파이프라인
Android Opus 인코딩
MediaCodecAudioEncoder는 동일한 MediaProjection으로 구축된 AudioPlaybackCaptureConfiguration을 사용하여 AudioRecord를 통해 시스템 오디오를 캡처하고, 원시 PCM을 MediaCodec Opus 인코더에 공급합니다. 이를 위해서는 Android 10+와 RECORD_AUDIO 권한이 필요합니다. 구형 기기나 권한이 없는 경우 start()가 경고를 로그로 남기고 오디오를 건너뜁니다(비디오는 계속 작동). 인코딩된 Opus 패킷은 동일한 VideoPacket 프로토콜(FLAG_AUDIO 설정)로 래핑되며, 비디오 패킷과 동일한 SCREEN_MIRROR_AUDIO WebSocket 채널을 공유합니다.
Web Opus 디코딩
ScreenMirrorAudioPipeline은 WebCodecs AudioDecoder를 사용하여 Opus 데이터를 디코딩하고, 출력된 AudioData를 <audio> 요소로 라우팅합니다. 오디오 프레임의 timestamp는 A/V 동기화에 사용됩니다. 비디오 프레임과 동일한 시간 기준(인코더 PTS, 마이크로초)을 공유하므로, 두 스트림 간에 별도의 클록 협상이 필요하지 않습니다.
성능 최적화
제로카피 경로
| 경로 | 방식 |
|---|---|
| VirtualDisplay → 인코더 Surface | GPU 직접, Surface 통과 |
| VideoDecoder → VideoFrame → WebGL 텍스처 | gl.texImage2D(VideoFrame), GPU 직접 |
| WebSocket 수신 → VideoPacket 파싱 | Uint8Array.subarray()는 뷰, 복사 없음 |
avccToAnnexB 최적화
일부 Android 인코더는 AVCC 형식(4바이트 길이 접두사)을 출력하며, WebCodecs 디코딩을 위해 Annex-B 형식(00 00 00 01 시작 코드)으로 변환이 필요합니다.
초기 구현은 ArrayList<Byte>를 사용하여 바이트 단위로 박싱(boxing)했습니다. 50KB IDR 프레임에 대해 50,000번의 java.lang.Byte 박싱 연산이 발생하여 막대한 GC 부하가 생겼습니다. 최적화는 2-패스 스캔 + copyInto(JVM에서 System.arraycopy 내장 함수로 매핑)를 사용합니다:
// 첫 번째 패스: 출력 크기 계산
var outSize = 0
// 두 번째 패스: 벌크 복사
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)
P-프레임 드롭 전략
디코더는 초기화 중에 느릴 수 있습니다. P-프레임 큐가 너무 길면 지연이 누적됩니다. 디코드 큐 크기 임계값은 하드웨어 디코더 초기화 중 과도한 프레임 손실을 방지하기 위해 > 5(> 2가 아님)로 설정됩니다.
IDR 요청 중복 제거
waitingForIdr 가드는 손실 이벤트당 하나의 IDR 요청만 발생하도록 보장하여, IDR이 도착할 때까지 기다리는 동안 중복 요청을 방지합니다.
디자인 패턴 요약
| 패턴 | 위치 | 이유 |
|---|---|---|
| 상태 머신 | waitingForIdr 플래그 | 명시적인 P-프레임 드롭/복구 상태 전환 |
| 재귀 방지 가드 | stop()의 if (!running) return | onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop 재귀 방지 |
| 제로카피 파이프라인 | VideoFrame → gl.texImage2D | GPU 직접 텍스처 업로드, CPU 복사 없음 |
| 2-패스 스캔 | avccToAnnexB | 크기 사전 계산, 단일 할당 + 벌크 복사, 박싱 제거 |
| 번들 이벤트 | SPS/PPS + IDR을 하나의 이벤트로 | 설정 변경 시 재설정 + 첫 프레임 디코딩을 한 번에 완료 |
| 콜백 분리 | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | 첫 프레임 렌더링, 전송 실패, 휴대폰 측 중단을 명확히 구분 |
| 안전망 | requestIdr() + requestKeyFrame() | 잔여 프레임 드롭 + 설정 변경 후 깨끗한 IDR 요청 |
| PTS 중복 제거 | timestamp < lastRenderedPts | 순서가 바뀐 프레임 드롭 |
| FrameId 간격 | frameId > lastFrameId + 1 | ACK 없는 패킷 손실 감지 |
| desynchronized 컨텍스트 | WebGL2 desynchronized: true | 컴포지터 우회, 1프레임 지연 절약 |
| 명시적 조기 실패 | sendScreenMirrorControl이 GraphQLError 발생 | "접근성 비활성화" 상태를 조용히 드롭하지 않고 표시 |
추가 자료
- WebCodecs API —
VideoDecoder/AudioDecoder인터페이스를 다루는 MDN 문서.