ブログに戻る
Architecture15 min read

画面ミラーリング:低遅延キャスティングアーキテクチャ

本記事では、PlainApp の画面ミラーリングシステムのエンドツーエンド設計について解説します。Android が MediaCodec を介して H.264/Opus をハードウェアエンコードする仕組み、カスタムバイナリプロトコルで WebSocket 経由でフレームを転送する方法、Web 側が WebCodecs でデコードし WebGL2 で CPU コピーゼロでレンダリングする仕組み、ロス検出、画面回転処理、リモートタッチコントロール、そして 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 の方が軽量で制御しやすいです。

コンポーネントマップ

Diagram 1
1

レイヤAndroidWeb
画面キャプチャMediaProjection + VirtualDisplay
ビデオエンコードMediaCodec H.264 ハードウェアエンコーダ
オーディオエンコードMediaCodec Opus ハードウェアエンコーダ
トランスポートWebSocket バイナリイベントWebSocket レシーバ
ビデオデコードWebCodecs VideoDecoder
オーディオデコードWebCodecs AudioDecoder<audio>
レンダリングWebGL2 テクスチャ直接レンダリング
コントロールAccessibilityService ジェスチャーインジェクションタッチオーバーレイ → GraphQL mutation

ビデオとオーディオは常にデバイス → ブラウザの方向で同一の WebSocket 接続を流れます。コントロールは逆方向に GraphQLsendScreenMirrorControl)で流れ、これはコーデック設定(screenMirrorVideoCodec クエリ)とキーフレーム要求(requestScreenMirrorKeyFrame mutation)のサイドチャネルとしても機能します。

ビデオエンコードパイプライン(Android)

エンコードパラメータチューニング

エンコードパラメータは、低遅延 LAN 画面キャスティング向けに特別に調整されています。

パラメータ備考
KEY_FRAME_RATE6060fps、滑らかさを確保
KEY_I_FRAME_INTERVAL10IDR 間隔 10 秒、キーフレームオーバーヘッドを削減
KEY_BIT_RATE_MODEVBR(暗黙的、明示モード未設定)可変ビットレート、シーン適応型
KEY_PRIORITY0リアルタイム優先度
KEY_LATENCY1低遅延モード

ビットレートは品質モードによって段階的に設定されます。より高いビットレート(例:24 Mbps)もテストされましたが、エンコーダ/デコーダのフレームドロップやエンドツーエンドレイテンシの増加を引き起こし、画面コンテンツに対する画質向上は目に見えませんでした。

モードビットレートキャプチャ解像度
HD8 Mbps1080p ショートサイド
Smooth4 Mbps1080p ショートサイド
Low2 Mbps720p ショートサイド

エンコーダ低遅延設定

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=0KEY_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_CHANGEDcsd-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_CONFIGBUFFER_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 -/
フィールドサイズ説明
MAGIC1 byte固定値 0x56'V')、バリデーション用
FLAGS1 byte0x01=キーフレーム、0x02=設定、0x04=オーディオ
FRAME_ID4 bytes単調増加するフレーム番号、uint32 big-endian
TIMESTAMP8 bytesエンコーダ PTS(マイクロ秒)、big-endian
DATA可変長H.264 NAL ユニットまたは Opus データ

Android の VideoPacket.encode()commonMain にあり、Android 依存なしで JVM 単体テストでワイヤフォーマットを検証可能)と Web の parseVideoPacket() は、それぞれ独立してこのフォーマットを実装しています。共有のシリアライゼーションライブラリはなく、両側が仕様に従うだけです。

Diagram 2
2

設計上の注意点

  • 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() でペイロードをスライスします。元の WebSocket ArrayBuffer へのビューであり、コピーではありません。

ビデオデコードパイプライン(Web)

WebCodecs VideoDecoder

Web クライアントは WebCodecs APIVideoDecoder を使用してハードウェアデコードを行います。MediaSource ExtensionsWebRTC と比較して、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.texImage2DVideoFrame をピクセルソースとして受け入れます。ブラウザが内部で YUV→RGB 変換と GPU アップロードを処理するため、ImageData による CPU コピーは発生しません。MirrorGLRenderergetContext('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 状態のステートマシンです。

Diagram 3
3

状態動作
NORMAL全フレームを通常通りデコード
WAITING_FOR_IDR全 P フレームを破棄、IDR フレームのみデコード。IDR 到着時に NORMAL に復帰

WAITING_FOR_IDR への遷移を引き起こすシナリオ:

  1. 起動時:古い GraphQL キーフレームをスキップ、実際の IDR を待機
  2. パケットロス時:デコード不能な P フレームを破棄、IDR リカバリを待機
  3. デコーダエラー時:デコーダをリセット、IDR を待機
  4. 設定変更時:画面回転/品質変更後の残留 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() を呼び出し、タッチ座標スケーリングに使用するアクセシビリティの画面サイズキャッシュを無効化します。

Diagram 4
4

rebuildEncoderAndResize() の処理:

  1. 新しいサイズ(例:ランドスケープ 1920×1080)で新しいエンコーダを作成
  2. VirtualDisplay.surface を新しいエンコーダの入力 Surface に切り替え
  3. 古いエンコーダを停止
  4. VirtualDisplay.resize() を新しいサイズで実行

Surface の切り替えは resize の前に行われます。これにより、新しいエンコーダが先にフレームを受信し、古いエンコーダが誤ったサイズのフレームを受信する前に停止されます。virtualDisplay?.surface = ... がスローされた場合、再構築は中断され、古いエンコーダを動作させ続けるため、パイプラインがエンコーダなしの状態になることはありません。

設定変更通知

新しいエンコーダが最初に SPS/PPS を出力すると、pendingConfigBroadcast がパイプラインに設定されます。新しいエンコーダからの最初の IDR が到着すると、通常のビデオパケットとして送信されるのではなく、その設定とともに単一の screen_mirror_video_codec イベントにバンドルされます。

Web クライアントは handleConfig() 内で以下の処理を行います。

  1. 新しい SPS/PPS でデコーダを再設定
  2. バンドルされた IDR フレームを即座にデコード
  3. video.requestIdr() を呼び出し、まだ転送中の古いエンコーダからの残留 P フレームを破棄
  4. requestKeyFrame() を呼び出し、クリーンな新しい IDR を要求

手順 3-4 はセーフティネットです。非同期の resize ウィンドウ中に新しいエンコーダの最初の IDR のサイズが誤っていた場合でも、Web クライアントは正しいサイズに迅速に復旧できます。handleConfig() は、受信した設定がキャッシュされたものとバイト単位で同一の場合も早期リターンします。変更のないバイトでデコーダを再設定しても効果はなく、復旧に IDR を消費するだけだからです。

MediaProjection ライフサイクル管理

問題点

ユーザーはアプリの UI を通さずに、Android システムの通知バーからシステムレベルの画面キャスト(MediaProjection)を終了する場合があります。この場合、ScreenMirrorService はキャストが停止したことを認識できません。runningtrue のまま、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)

Diagram 5
5

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 がないためです。

Diagram 6
6

座標正規化(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

認識された各ジェスチャーは、actionTAP/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)
}

SWIPELONG_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 デコード

ScreenMirrorAudioPipelineWebCodecs AudioDecoder を使用して Opus データをデコードし、出力された AudioData<audio> 要素にルーティングします。オーディオフレームの timestamp は A/V 同期に使用されます。ビデオフレームと同じ時間ベース(エンコーダ PTS、マイクロ秒)を共有するため、2 つのストリーム間で別途クロックネゴシエーションは必要ありません。

パフォーマンス最適化

ゼロコピーパス

パス方式
VirtualDisplay → エンコーダ SurfaceGPU 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) returnonStop → stop → onDestroy → pipeline.stop → projection.stop → onStop の再帰を防止
ゼロコピーパイプラインVideoFrame → gl.texImage2DGPU 直接テクスチャアップロード、CPU コピーなし
2 パススキャンavccToAnnexBサイズを事前計算、1 回の割り当て + 一括コピー、ボクシングを排除
バンドルイベントSPS/PPS + IDR を 1 イベントに設定変更時に再設定 + 初回フレームデコードを 1 イベントで完了
コールバック分離onFirstFrameRenderedonDisconnectedonScreenMirrorOff初回フレームレンダリング、トランスポート障害、電話側停止を明確に区別
セーフティネットrequestIdr() + requestKeyFrame()設定変更後に残留フレームを破棄 + クリーンな IDR を要求
PTS 重複排除timestamp < lastRenderedPts順序不同で到着した古いフレームを破棄
FrameId ギャップframeId > lastFrameId + 1ACK 不要のパケットロス検出
desynchronized コンテキストWebGL2 desynchronized: trueコンポジタをバイパス、1 フレーム分のレイテンシを節約
明示的な早期失敗sendScreenMirrorControlGraphQLError をスロー入力を静かに破棄するのではなく「アクセシビリティが無効」を通知

関連情報

  • WebCodecs API — MDN ドキュメント、VideoDecoder/AudioDecoder インターフェースの解説。