目次
- 全体アーキテクチャ
- ビデオエンコードパイプライン(Android)
- VideoPacket プロトコル設計
- ビデオデコードパイプライン(Web)
- WebGL2 レンダリング
- ロス検出とエラーリカバリ
- 画面回転処理
- MediaProjection ライフサイクル管理
- リモートコントロール:タッチインジェクション
- オーディオパイプライン
- パフォーマンス最適化
- デザインパターンまとめ
全体アーキテクチャ
PlainApp の画面ミラーリングはエンドツーエンドの低遅延キャスティングシステムです。Android デバイスが画面をキャプチャし、H.264 ビデオと Opus オーディオにハードウェアエンコードし、カスタムバイナリプロトコルで WebSocket 経由で Web クライアントにプッシュします。Web クライアントは 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)で流れ、これはコーデック設定(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() で一度だけ取得)から実際のキャプチャサイズを導出します。これにより、エンコーダが受け入れられないサイズが渡されることはありません。
キーフレーム要求
Web クライアントは 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 設定ブロブに結合してキャッシュします(cachedConfig)。続く最初の IDR もキャッシュされ(cachedKeyFrame)、新しく接続した Web クライアントは、次のキーフレーム間隔を待たずに、screenMirrorVideoCodec GraphQL クエリで両方を取得できます。設定が変更された場合(画面回転や品質切り替え)、Android は新しい IDR を通常のビデオパケットとして送信するのではなく、SPS/PPS + IDR を一つの screen_mirror_video_codec WebSocket イベントにまとめて送信するため、Web クライアントはデコーダの再設定と最初のフレームデコードを一度に行えます。
一部の 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 にあり、Android 依存なしで JVM 単体テストでワイヤフォーマットを検証可能)と Web の parseVideoPacket() は、それぞれ独立してこのフォーマットを実装しています。共有のシリアライゼーションライブラリはなく、両側が仕様に従うだけです。
設計上の注意点
FRAME_IDの符号なしパース:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0—>>> 0を使用して符号なしにする必要があります。そうしないとframeId > 2^31が負の値としてパースされ、誤ったロス検出を引き起こします。FRAME_IDはリセットされない:画面回転のためにエンコーダが再構築されても、frameIdは増加し続けます(ScreenMirrorPipelineに保持され、エンコーダには保持されません)。これにより、Web 側は frameId のギャップから回転中のフレームロスを検出できます。TIMESTAMPはエンコーダ PTS を使用:Web クライアントの時計に依存しないため、クロックドリフトによる A/V 同期ズレを回避できます。- ゼロコピーパース:Web パーサーは
Uint8Array.subarray()でペイロードをスライスします。元の WebSocketArrayBufferへのビューであり、コピーではありません。
ビデオデコードパイプライン(Web)
WebCodecs VideoDecoder
Web クライアントは 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' }— Annex-B 形式を使用、各 IDR の前に SPS/PPS をインライン配置- コーデック文字列はハードコードされていません。
extractAvc1CodecString()が設定ブロブ内の最初の SPS NAL から profile/compat/level バイトを直接読み取ります
グリーンスクリーン問題と起動シーケンス
エンコーダは、VirtualDisplay が実際の画面コンテンツをレンダリングする前に最初の IDR フレームを生成します。これは空白(緑色)のフレームです。Web 側がこのフレームをデコードすると、画面コンテンツが変更されて新しいフレームが生成されるまで、ユーザーに緑色のフラッシュが見えます。
解決策:起動時、Web 側は screenMirrorVideoCodec GraphQL クエリでキャッシュされた設定を取得しますが、バンドルされたキーフレームはデコードしません。代わりに、video.requestIdr() を呼び出して waitingForIdr = true を設定し(IDR が到着するまで全 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 では、コンポジット後にバッファがクリアされ、黒いスクリーンショットになります。
シェーダ自体は意図的に最小限に抑えられています。フルスクリーン三角形の頂点シェーダと、テクスチャをサンプリングする 1 行のフラグメントシェーダのみです。フレームごとに必要な処理は「このテクスチャを画面に表示する」だけだからです。
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() の処理:
- 新しいサイズ(例:ランドスケープ 1920×1080)で新しいエンコーダを作成
VirtualDisplay.surfaceを新しいエンコーダの入力Surfaceに切り替え- 古いエンコーダを停止
VirtualDisplay.resize()を新しいサイズで実行
Surface の切り替えは resize の前に行われます。これにより、新しいエンコーダが先にフレームを受信し、古いエンコーダが誤ったサイズのフレームを受信する前に停止されます。virtualDisplay?.surface = ... がスローされた場合、再構築は中断され、古いエンコーダを動作させ続けるため、パイプラインがエンコーダなしの状態になることはありません。
設定変更通知
新しいエンコーダが最初に SPS/PPS を出力すると、pendingConfigBroadcast がパイプラインに設定されます。新しいエンコーダからの最初の IDR が到着すると、通常のビデオパケットとして送信されるのではなく、その設定とともに単一の screen_mirror_video_codec イベントにバンドルされます。
Web クライアントは handleConfig() 内で以下の処理を行います。
- 新しい SPS/PPS でデコーダを再設定
- バンドルされた IDR フレームを即座にデコード
video.requestIdr()を呼び出し、まだ転送中の古いエンコーダからの残留 P フレームを破棄requestKeyFrame()を呼び出し、クリーンな新しい IDR を要求
手順 3-4 はセーフティネットです。非同期の resize ウィンドウ中に新しいエンコーダの最初の IDR のサイズが誤っていた場合でも、Web クライアントは正しいサイズに迅速に復旧できます。handleConfig() は、受信した設定がキャッシュされたものとバイト単位で同一の場合も早期リターンします。変更のないバイトでデコーダを再設定しても効果はなく、復旧に IDR を消費するだけだからです。
MediaProjection ライフサイクル管理
問題点
ユーザーはアプリの UI を通さずに、Android システムの通知バーからシステムレベルの画面キャスト(MediaProjection)を終了する場合があります。この場合、ScreenMirrorService はキャストが停止したことを認識できません。running は true のまま、Web クライアントは 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() は明示的な停止ポイントであり、Web クライアントへの通知とサービスの停止を担当します。
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 のため即座に return)。
Web 側の処理
Web クライアントが {"running":false} イベントを受信すると、アイドル状態にリセットし、開始ボタンを表示します。
const onScreenMirroring = (data: any) => {
if (data?.running === false) {
cleanupFn()
fullReset()
return
}
// running=true → ストリームに接続
}
リモートコントロール:タッチインジェクション
画面ミラーリングはデフォルトで片方向(ビデオ/オーディオのみ)です。リモートコントロールはオプトイン方式で、ユーザーが PlainApp のアクセシビリティサービスを一度有効にする必要があります。Android には AccessibilityService.dispatchGesture() 以外に任意のタッチイベントを注入する公開 API がないためです。
座標正規化(Web)
<canvas> の上に透明なオーバーレイが配置され、ポインタイベントをキャプチャします。normalizeCoords() は、生の clientX/clientY を、オーバーレイのバウンディングボックスではなく、実際のビデオコンテンツ領域を基準とした [0,1] 座標に変換します。Canvas のバッキングストアのアスペクト比とレンダリングされたコンテナのアスペクト比から、レターボックス/ピラーボックスのオフセットを計算します。
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 にクランプされます。4 つのグローバルアクション(BACK/HOME/RECENTS/LOCK_SCREEN)はジェスチャーディスパッチを完全にスキップし、performGlobalAction() を直接呼び出します。アクセシビリティサービスが有効でない場合、リゾルバーは入力を静かに破棄するのではなく、GraphQLError をスローするため、Web UI はユーザーに有効化を促すことができます。
オーディオパイプライン
Android 側 Opus エンコード
MediaCodecAudioEncoder は、AudioPlaybackCaptureConfiguration(同一の MediaProjection から構築)を使用して 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、マイクロ秒)を共有するため、2 つのストリーム間で別途クロックネゴシエーションは必要ありません。
パフォーマンス最適化
ゼロコピーパス
| パス | 方式 |
|---|---|
| VirtualDisplay → エンコーダ Surface | GPU direct、Surface パススルー |
| VideoDecoder → VideoFrame → WebGL テクスチャ | gl.texImage2D(VideoFrame)、GPU direct |
| WebSocket 受信 → VideoPacket パース | Uint8Array.subarray() はビュー、コピーなし |
avccToAnnexB 最適化
一部の Android エンコーダは AVCC 形式(4 バイト長さプレフィックス)を出力するため、WebCodecs でデコードするには Annex-B 形式(00 00 00 01 スタートコード)に変換する必要があります。
初期の実装では ArrayList<Byte> を使用してバイト単位でボクシングしていました。50KB の IDR フレームで 50,000 回の java.lang.Byte ボクシング操作が発生し、大きな GC プレッシャーを生み出していました。最適化後は、2 パススキャン + copyInto(JVM 上で System.arraycopy intrinsic にマッピング)を使用します。
// 第一パス:出力サイズを計算
var outSize = 0
// 第二パス:一括コピー
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)
P フレーム破棄戦略
デコーダは初期化中に低速になる可能性があります。P フレームキューが長くなりすぎると、レイテンシが蓄積されます。デコードキューサイズのしきい値は > 5(> 2 ではなく)に設定されており、ハードウェアデコーダの初期化中に過度なフレームロスが発生するのを防ぎます。
IDR 要求の重複排除
waitingForIdr ガードにより、ロスイベントごとに 1 回の IDR 要求のみが行われ、IDR の到着を待機中に重複した要求が送信されるのを防ぎます。
デザインパターンまとめ
| パターン | 場所 | 理由 |
|---|---|---|
| ステートマシン | waitingForIdr フラグ | 明示的な P フレーム破棄/リカバリの状態遷移 |
| 再帰ガード | stop() 内の if (!running) return | onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop の再帰を防止 |
| ゼロコピーパイプライン | VideoFrame → gl.texImage2D | GPU 直接テクスチャアップロード、CPU コピーなし |
| 2 パススキャン | avccToAnnexB | サイズを事前計算、1 回の割り当て + 一括コピー、ボクシングを排除 |
| バンドルイベント | SPS/PPS + IDR を 1 イベントに | 設定変更時に再設定 + 初回フレームデコードを 1 イベントで完了 |
| コールバック分離 | onFirstFrameRendered と onDisconnected と onScreenMirrorOff | 初回フレームレンダリング、トランスポート障害、電話側停止を明確に区別 |
| セーフティネット | requestIdr() + requestKeyFrame() | 設定変更後に残留フレームを破棄 + クリーンな IDR を要求 |
| PTS 重複排除 | timestamp < lastRenderedPts | 順序不同で到着した古いフレームを破棄 |
| FrameId ギャップ | frameId > lastFrameId + 1 | ACK 不要のパケットロス検出 |
| desynchronized コンテキスト | WebGL2 desynchronized: true | コンポジタをバイパス、1 フレーム分のレイテンシを節約 |
| 明示的な早期失敗 | sendScreenMirrorControl が GraphQLError をスロー | 入力を静かに破棄するのではなく「アクセシビリティが無効」を通知 |
関連情報
- WebCodecs API — MDN ドキュメント、
VideoDecoder/AudioDecoderインターフェースの解説。