返回部落格
Architecture15 min read

螢幕鏡像:低延遲投放架構設計

本文完整介紹 PlainApp 螢幕鏡像方案的端到端設計:Android 端如何用 MediaCodec 硬體編碼 H.264/Opus,如何透過自訂二進位協定經 WebSocket 傳輸,Web 端如何用 WebCodecs + WebGL2 實現零複製 GPU 直渲,以及封包遺失偵測、螢幕旋轉、系統投放生命週期等工程難點的解決思路。

目錄

總體架構

PlainApp 螢幕鏡像是一個端到端的低延遲投放系統:Android 裝置擷取螢幕內容,硬體編碼為 H.264 影片和 Opus 音訊,透過 WebSocket 以自訂二進位協定推送到 Web 端;Web 端用 WebCodecs API 硬體解碼,透過 WebGL2 直接渲染到 Canvas,全程 GPU 零複製。

沒有 WebRTC,沒有 RTMP,沒有中間伺服器。整個管線是:

Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas

為什麼不用 WebRTC?

WebRTC 是為即時通訊設計的,它的 ICE/STUN/TURN 協商、壅塞控制、抖動緩衝對於區域網路投放來說太重了。PlainApp 的場景是:

  • 同一區域網路,延遲 < 5ms,無需 NAT 穿透
  • 追求極致低延遲,不要抖動緩衝
  • 需要高畫質,位元率可以很高(8Mbps)
  • 需要螢幕控制(觸控回傳),WebRTC 的 DataChannel 增加了不必要的複雜度

自訂二進位協定 + WebSocket 的方案在區域網路場景下更輕量、更可控。

元件對照

Diagram 1
1

Android 端Web 端
螢幕擷取MediaProjection + VirtualDisplay
影片編碼MediaCodec H.264 硬體編碼器
音訊編碼MediaCodec Opus 硬體編碼器
傳輸WebSocket 二進位事件WebSocket 接收
影片解碼WebCodecs VideoDecoder
音訊解碼WebCodecs AudioDecoder<audio>
渲染WebGL2 紋理直渲
控制AccessibilityService 觸控注入觸控重疊層 → GraphQL mutation

影片和音訊始終透過同一條 WebSocket 連線從裝置流向瀏覽器;控制則反向透過 GraphQLsendScreenMirrorControl)傳遞,這同時也作為編碼器設定(screenMirrorVideoCodec query)和關鍵幀請求(requestScreenMirrorKeyFrame mutation)的側通道。

影片編碼管線(Android)

編碼參數調校

針對區域網路低延遲投放場景專門調校的編碼參數:

參數說明
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_CHANGED 會送出 csd-0/csd-1(SPS/PPS),ScreenMirrorPipeline 將其合併為單一 Annex-B 設定 blob 並快取(cachedConfig)。後續的第一個 IDR 也會被快取(cachedKeyFrame),讓新連線的 Web 端可以透過 screenMirrorVideoCodec GraphQL query 拉取兩者,而無需等待下一個關鍵幀間隔。當設定發生變更(旋轉或切換畫質)時,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-frames,產生馬賽克畫面。

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,因此其線路格式可透過 JVM 單元測試驗證,無需任何 Android 依賴)和 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() 切出 payload——這是原始 WebSocket ArrayBuffer 的一個 view,並非複製。

影片解碼管線(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 格式,SPS/PPS 內聯在每個 IDR 前
  • codec 字串本身並非寫死——extractAvc1CodecString() 直接從設定 blob 中的第一個 SPS NAL 讀取 profile/compat/level 位元組

綠屏問題與啟動時序

編碼器在 VirtualDisplay 渲染出真實螢幕內容之前就產生了第一個 IDR 幀——這是一個空白(全綠)的幀。如果 Web 端解碼這個幀,使用者會看到綠色閃爍,直到螢幕內容變化觸發新幀。

解決方案:啟動時,Web 端透過 screenMirrorVideoCodec GraphQL query 拉取快取的設定,但解碼其中捆綁的關鍵幀。而是呼叫 video.requestIdr()waitingForIdr 設為 true(丟棄所有 P-frames,直到 IDR 到達),然後呼叫 requestKeyFrame() 透過與封包遺失恢復相同的 mutation 請求一個全新的 IDR。等到新 IDR 到達時,VirtualDisplay 已經有真實的螢幕內容了。

video.requestIdr()      // 丟棄 P-frames,等待 IDR
await requestKeyFrame() // 請求新 IDR

onFirstFrameRendered 回呼綁定在 renderFrame() 而非 handleVideo() 上,確保 UI 只在實際渲染出真實幀後才更新——而不僅僅是收到幀。

WebGL2 渲染

零複製 GPU 直渲

解碼後的 VideoFrame 直接上傳為 WebGL2 紋理,全程不經過 CPU:

VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas

gl.texImage2D 接受 VideoFrame 作為像素來源,瀏覽器內部處理 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 時,緩衝區會在合成後被清空,產生黑屏截圖。

著色器本身刻意保持精簡——一個全螢幕三角形的頂點著色器和一行取樣紋理的片段著色器——因為每幀唯一需要做的工作就是「把這個紋理放到螢幕上」。

Canvas 自適應

Canvas 的 backing store 尺寸在 VideoFrame.displayWidth/Height 變更時同步更新。CSS 尺寸則透過 fitCanvasToWrapper() 按寬高比適配 wrapper 容器(必要時加上 letterbox 或 pillarbox)。Canvas 父元素上的 ResizeObserver 會在容器尺寸變化時重新執行適配,確保畫面不會變形。

封包遺失偵測與錯誤復原

FrameId 間隙偵測

每個影片幀攜帶單調遞增的 frameId。解碼器追蹤 lastFrameId,如果新幀的 frameId > lastFrameId + 1,表示中間有幀遺失:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // 封包遺失:丟棄後續 P-frames,請求新 IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

waitingForIdr 狀態機

waitingForIdr 是一個簡單的雙態狀態機:

Diagram 3
3

狀態行為
NORMAL正常解碼所有幀
WAITING_FOR_IDR丟棄所有 P-frames,僅解碼 IDR 幀;IDR 到達後重置為 NORMAL

觸發進入 WAITING_FOR_IDR 的情境:

  1. 啟動時:跳過陳舊的 GraphQL 關鍵幀,等待真實 IDR
  2. 封包遺失時:丟棄無法解碼的 P-frames,等待 IDR 恢復
  3. 解碼器錯誤時:重置解碼器,等待 IDR
  4. 設定變更時:螢幕旋轉或畫質切換後丟棄殘留的 P-frames

解碼器錯誤復原

VideoDecoder.onerror 觸發時,管線層(screen-mirror-pipeline.ts)會設定 decoderNeedsReset = true。在下一個 IDR 幀到達時,使用快取的 SPS/PPS 重新設定解碼器,無需再次往返 GraphQL:

if (decoderNeedsReset) {
    if (!packet.isKeyFrame || !cachedConfig) return
    video.configure(cachedConfig)
    decoderNeedsReset = false
}

背壓與時間戳記去重

如果 decoder.decodeQueueSize > 5,則丟棄傳入的 P-frames 而非排入佇列——閾值設為 5(而非 2)可以容忍硬體解碼器啟動延遲,而不會造成不必要的停頓。此外,渲染後會記錄 lastRenderedPts;時間戳記較舊的幀(亂序到達)會被丟棄,除非它是關鍵幀:

if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
    return
}

螢幕旋轉處理

編碼器重建

ScreenMirrorService 中的 OrientationEventListener 在每次感應器回呼時比較螢幕的 rotation 與快取的 isPortrait 標記;只有真正的直向/橫向翻轉才會呼叫 pipeline.onOrientationChanged() 並使觸控座標縮放所使用的 Accessibility 螢幕尺寸快取失效。

Diagram 4
4

rebuildEncoderAndResize()

  1. 建立新尺寸的新編碼器(例如橫向 1920×1080)
  2. VirtualDisplay.surface 切換到新編碼器的輸入 Surface
  3. 停止舊編碼器
  4. VirtualDisplay.resize() 到新尺寸

Surface 切換發生在 resize 之前——確保新編碼器先收到幀,且舊編碼器在停止後不會收到錯誤尺寸的幀。如果 virtualDisplay?.surface = ... 拋出例外,則中止重建並保持舊編碼器繼續執行,避免管線完全沒有編碼器。

設定變更通知

當新編碼器首次輸出 SPS/PPS 時,pendingConfigBroadcast 被設為 true。當新編碼器的第一個 IDR 到達時,它與該設定被捆綁成單一 screen_mirror_video_codec 事件,而非作為普通影片封包發送。

Web 端在 handleConfig() 中:

  1. 用新的 SPS/PPS 重新設定解碼器
  2. 立即解碼捆綁的 IDR 幀
  3. 呼叫 video.requestIdr() 丟棄仍在傳輸中的舊編碼器殘留 P-frames
  4. 呼叫 requestKeyFrame() 請求一個乾淨的新 IDR

第 3-4 步是安全網——即使新編碼器的第一個 IDR 尺寸不正確(在非同步 resize 的視窗期間),Web 端也能快速恢復到正確尺寸。handleConfig() 也會在傳入的設定與快取設定位元組完全相同時短路返回,因為用相同位元組重新設定解碼器是無意義的操作,卻仍需要一個 IDR 來恢復。

系統投放生命週期監聽

問題

使用者可能透過 Android 系統通知列關閉系統層級的螢幕投放(MediaProjection),而不是透過 App 的 UI。此時 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)

Diagram 5
5

Service.stop() 的職責

stop() 是明確的停止點,負責通知 Web 端並停止 service:

fun stop() {
    if (!running) return  // 防止遞迴
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

if (!running) return 的 guard 防止遞迴:onStop()stop()stopSelf()onDestroy()pipeline.stop()projection.stop()onStop()stop()(此時 running=false,直接返回)。

Web 端處理

Web 端收到 {"running":false} 事件後,重置為 idle 狀態,顯示啟動按鈕:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → 連線投放串流
}

遠端控制:觸控注入

螢幕鏡像預設是單向的(僅影片/音訊);遠端控制是選擇加入的功能,需要使用者先啟用 PlainApp 的 Accessibility Service,因為 Android 沒有公開 API 可以在 AccessibilityService.dispatchGesture() 之外注入任意觸控事件。

Diagram 6
6

座標正規化(Web)

一個透明重疊層位於 <canvas> 上方,負責擷取指標事件。normalizeCoords() 將原始的 clientX/clientY 轉換為相對於實際影片內容區域[0,1] 座標——而非重疊層的邊界框——透過計算 Canvas 的 backing store 寬高比與其渲染容器的寬高比之間的 letterbox/pillarbox 偏移量:

if (videoAspect > containerAspect) {
    // Letterbox 上下留黑
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarbox 左右留黑
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

指標按下時會建立一個 GestureState 來追蹤起始位置和時間;按住 500ms 且移動少於 10px 會升級為 LONG_PRESS,超過此閾值的移動變為 SWIPE,快速放開則為 TAP。一個視覺化的觸控指示器(逐漸放大再淡出的圓點)會在手機實際回應之前,告訴操作者識別出了什麼手勢。

GraphQL → AccessibilityService

每個識別出的手勢都以 sendScreenMirrorControl(input) mutation 發送,帶有 actionTAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY)以及正規化後的座標。解析器呼叫 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 內。四個全域動作(BACK/HOME/RECENTS/LOCK_SCREEN)則完全跳過手勢分派,直接呼叫 performGlobalAction()。如果 Accessibility Service 未啟用,解析器會拋出 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,單位微秒),因此兩個串流之間無需單獨的時鐘協商。

效能最佳化

零複製路徑

路徑方式
VirtualDisplay → 編碼器 SurfaceGPU direct,Surface 直接傳遞
VideoDecoder → VideoFrame → WebGL 紋理gl.texImage2D(VideoFrame),GPU direct
WebSocket 接收 → VideoPacket 解析Uint8Array.subarray() 是 view,不複製

avccToAnnexB 最佳化

部分 Android 編碼器輸出 AVCC 格式(4 位元組長度前綴),需要轉換為 Annex-B 格式(00 00 00 01 起始碼)供 WebCodecs 解碼。

早期的實作使用 ArrayList<Byte> 逐位元組裝箱,一個 50KB 的 IDR 幀會產生 50,000 次 java.lang.Byte 裝箱操作,造成巨大的 GC 壓力。最佳化後使用兩遍掃描 + copyInto(JVM 上對應到 System.arraycopy intrinsic):

// 第一遍:計算輸出大小
var outSize = 0
// 第二遍:批次複製
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

P-Frame 丟棄策略

解碼器在初始化階段可能較慢,如果 P-frame 佇列過長會導致延遲累積。解碼佇列大小閾值設為 > 5(而非 > 2),避免在硬體解碼器初始化期間過度丟幀。

IDR 請求防重複

waitingForIdr guard 確保每次封包遺失事件只請求一次 IDR,避免在等待 IDR 到達期間重複發送請求。

設計模式回顧

模式位置原因
狀態機waitingForIdr 標記明確的 P-frame 丟棄/恢復狀態轉換
遞迴防護stop() 中的 if (!running) return防止 onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop 遞迴
零複製管線VideoFrame → gl.texImage2DGPU direct 紋理上傳,無 CPU 複製
兩遍掃描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 — MDN 文件,涵蓋 VideoDecoder/AudioDecoder 介面。