目錄
- 總體架構
- 影片編碼管線(Android)
- VideoPacket 協定設計
- 影片解碼管線(Web)
- WebGL2 渲染
- 封包遺失偵測與錯誤復原
- 螢幕旋轉處理
- 系統投放生命週期監聽
- 遠端控制:觸控注入
- 音訊管線
- 效能最佳化
- 設計模式回顧
總體架構
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 的方案在區域網路場景下更輕量、更可控。
元件對照
| 層 | Android 端 | Web 端 |
|---|---|---|
| 螢幕擷取 | MediaProjection + VirtualDisplay | — |
| 影片編碼 | MediaCodec H.264 硬體編碼器 | — |
| 音訊編碼 | MediaCodec Opus 硬體編碼器 | — |
| 傳輸 | WebSocket 二進位事件 | WebSocket 接收 |
| 影片解碼 | — | WebCodecs VideoDecoder |
| 音訊解碼 | — | WebCodecs AudioDecoder → <audio> |
| 渲染 | — | WebGL2 紋理直渲 |
| 控制 | AccessibilityService 觸控注入 | 觸控重疊層 → GraphQL mutation |
影片和音訊始終透過同一條 WebSocket 連線從裝置流向瀏覽器;控制則反向透過 GraphQL(sendScreenMirrorControl)傳遞,這同時也作為編碼器設定(screenMirrorVideoCodec query)和關鍵幀請求(requestScreenMirrorKeyFrame mutation)的側通道。
影片編碼管線(Android)
編碼參數調校
針對區域網路低延遲投放場景專門調校的編碼參數:
| 參數 | 值 | 說明 |
|---|---|---|
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 設定 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_CONFIG 和 BUFFER_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 -/
| 欄位 | 大小 | 說明 |
|---|---|---|
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 依賴)和 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()切出 payload——這是原始 WebSocketArrayBuffer的一個 view,並非複製。
影片解碼管線(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 格式,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 複製。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 的 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 是一個簡單的雙態狀態機:
| 狀態 | 行為 |
|---|---|
NORMAL | 正常解碼所有幀 |
WAITING_FOR_IDR | 丟棄所有 P-frames,僅解碼 IDR 幀;IDR 到達後重置為 NORMAL |
觸發進入 WAITING_FOR_IDR 的情境:
- 啟動時:跳過陳舊的 GraphQL 關鍵幀,等待真實 IDR
- 封包遺失時:丟棄無法解碼的 P-frames,等待 IDR 恢復
- 解碼器錯誤時:重置解碼器,等待 IDR
- 設定變更時:螢幕旋轉或畫質切換後丟棄殘留的 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 螢幕尺寸快取失效。
rebuildEncoderAndResize():
- 建立新尺寸的新編碼器(例如橫向 1920×1080)
- 將
VirtualDisplay.surface切換到新編碼器的輸入Surface - 停止舊編碼器
VirtualDisplay.resize()到新尺寸
Surface 切換發生在 resize 之前——確保新編碼器先收到幀,且舊編碼器在停止後不會收到錯誤尺寸的幀。如果 virtualDisplay?.surface = ... 拋出例外,則中止重建並保持舊編碼器繼續執行,避免管線完全沒有編碼器。
設定變更通知
當新編碼器首次輸出 SPS/PPS 時,pendingConfigBroadcast 被設為 true。當新編碼器的第一個 IDR 到達時,它與該設定被捆綁成單一 screen_mirror_video_codec 事件,而非作為普通影片封包發送。
Web 端在 handleConfig() 中:
- 用新的 SPS/PPS 重新設定解碼器
- 立即解碼捆綁的 IDR 幀
- 呼叫
video.requestIdr()丟棄仍在傳輸中的舊編碼器殘留 P-frames - 呼叫
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)
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() 之外注入任意觸控事件。
座標正規化(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 發送,帶有 action(TAP/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)
}
SWIPE 和 LONG_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 → 編碼器 Surface | GPU 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.texImage2D | GPU 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介面。