Table of Contents
- Architettura di Alto Livello
- Pipeline di Codifica Video (Android)
- Progettazione del Protocollo VideoPacket
- Pipeline di Decodifica Video (Web)
- Rendering WebGL2
- Rilevamento Perdita e Recupero Errori
- Gestione del Cambio di Orientamento
- Ciclo di Vita del MediaProjection di Sistema
- Controllo Remoto: Iniezione Tattile
- Pipeline Audio
- Ottimizzazioni delle Prestazioni
- Riepilogo dei Pattern di Progettazione
Architettura di Alto Livello
Lo screen mirror di PlainApp è un sistema di casting end-to-end a bassa latenza: il dispositivo Android cattura il contenuto dello schermo, lo codifica via hardware in video H.264 e audio Opus, e lo invia tramite WebSocket usando un protocollo binario personalizzato al client web; il client web decodifica tramite l'API WebCodecs e renderizza direttamente su una Canvas tramite WebGL2, senza copie CPU in tutto il percorso. Un overlay tattile trasparente chiude il ciclo, trasformando l'input del puntatore in gesture sul telefono.
Niente WebRTC, niente RTMP, niente server intermedio. L'intera pipeline è:
Android VirtualDisplay → MediaCodec H.264 Encoder → WebSocket →
WebCodecs VideoDecoder → WebGL2 Texture → Canvas
Perché non WebRTC?
WebRTC è progettato per la comunicazione in tempo reale. La sua negoziazione ICE/STUN/TURN, il controllo della congestione e il buffer di jitter sono eccessivi per il casting su LAN. Il caso d'uso di PlainApp è:
- Stessa LAN, latenza < 5ms, nessun attraversamento NAT necessario
- Ricerca di latenza ultra-bassa, nessun buffer di jitter
- Alta qualità, il bitrate può essere elevato (8 Mbps)
- Controllo dello schermo (iniezione tattile), dove il DataChannel di WebRTC aggiungerebbe complessità inutile
Un protocollo binario personalizzato su WebSocket è più leggero e più controllabile per lo scenario LAN.
Mappa dei componenti
| Livello | Android | Web |
|---|---|---|
| Acquisizione schermo | MediaProjection + VirtualDisplay | — |
| Codifica video | MediaCodec codec hardware H.264 | — |
| Codifica audio | MediaCodec codec hardware Opus | — |
| Trasporto | Eventi WebSocket binari | Ricevitore WebSocket |
| Decodifica video | — | WebCodecs VideoDecoder |
| Decodifica audio | — | WebCodecs AudioDecoder → <audio> |
| Rendering | — | WebGL2 texture direct-render |
| Controllo | AccessibilityService iniezione gesture | Overlay tattile → mutazione GraphQL |
Video e audio fluiscono sempre dispositivo → browser sulla stessa connessione WebSocket; il controllo fluisce nella direzione opposta tramite GraphQL (sendScreenMirrorControl), che funge anche da canale laterale per la configurazione del codec (query screenMirrorVideoCodec) e le richieste di keyframe (mutazione requestScreenMirrorKeyFrame).
Pipeline di Codifica Video (Android)
Ottimizzazione dei Parametri di Codifica
I parametri di codifica sono stati ottimizzati specificamente per il casting su LAN a bassa latenza:
| Parametro | Valore | Note |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps per fluidità |
KEY_I_FRAME_INTERVAL | 10 | Intervallo IDR 10s, riduce l'overhead dei keyframe |
KEY_BIT_RATE_MODE | VBR (implicito, nessuna modalità esplicita impostata) | Bitrate variabile, adattivo alla scena |
KEY_PRIORITY | 0 | Priorità in tempo reale |
KEY_LATENCY | 1 | Modalità a bassa latenza |
Il bitrate è suddiviso in livelli in base alla modalità di qualità — bitrate più elevati (es. 24 Mbps) sono stati testati e hanno causato cadute di frame nel codificatore/decodificatore e un aumento della latenza end-to-end senza un miglioramento visibile della qualità per i contenuti dello schermo:
| Modalità | Bitrate | Risoluzione di acquisizione |
|---|---|---|
| HD | 8 Mbps | 1080p lato corto |
| Smooth | 4 Mbps | 1080p lato corto |
| Low | 2 Mbps | 720p lato corto |
Configurazione a Bassa Latenza del Codificatore
MediaCodecVideoEncoder configura il codificatore una volta al momento della creazione:
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 e KEY_LATENCY=1 sono le chiavi per la bassa latenza — dicono al codificatore di dare priorità alla codifica in tempo reale rispetto al rapporto di compressione. L'input è un Surface creato da MediaCodec.createInputSurface() e alimentato direttamente a VirtualDisplay — nessun SurfaceTexture readback, nessuna conversione I420, la CPU non tocca i pixel.
Risoluzione di Acquisizione
ScreenMirrorCaptureSize.compute() deriva la dimensione effettiva di acquisizione dalle dimensioni fisiche dello schermo, dal target del lato corto della modalità di qualità (720/1080), e dal maxWidth/maxHeight e allineamento larghezza/altezza riportati dal codificatore (interrogati una volta tramite MediaCodecVideoEncoder.queryEncoderCaps()), così il codificatore non riceve mai dimensioni che non può accettare.
Richieste di Keyframe
Il client web può richiedere un frame IDR tramite la mutazione GraphQL requestScreenMirrorKeyFrame per recuperare dalla perdita di pacchetti. Android risponde tramite 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 e Broadcast di Keyframe
Dopo l'avvio del codificatore, INFO_OUTPUT_FORMAT_CHANGED fornisce csd-0/csd-1 (SPS/PPS), che ScreenMirrorPipeline unisce in un unico blob di configurazione Annex-B e memorizza nella cache (cachedConfig). Anche il primo IDR successivo viene memorizzato nella cache (cachedKeyFrame) in modo che un client web appena connesso possa recuperarli entrambi tramite la query GraphQL screenMirrorVideoCodec senza attendere il prossimo intervallo di keyframe. Quando la configurazione cambia (cambio di orientamento o qualità), Android non invia il nuovo IDR come un normale pacchetto video — raggruppa SPS/PPS + IDR in un unico evento WebSocket screen_mirror_video_codec, così il client web completa la riconfigurazione del decodificatore e la decodifica del primo frame in un colpo solo, invece di far correre un decodificatore obsoleto contro un nuovo bitstream.
Alcuni codificatori OEM (Qualcomm/Xiaomi) raggruppano SPS+PPS+IDR in un singolo buffer di output che porta sia BUFFER_FLAG_CODEC_CONFIG che BUFFER_FLAG_SYNC_FRAME. Il ciclo di drenaggio salta solo i buffer che sono puramente di configurazione (isConfig && !isKey) — saltare un buffer marcato come config che trasporta anche il sync frame eliminerebbe silenziosamente l'IDR e lascerebbe il decodificatore con soli P-frame, producendo un'immagine a mosaico.
Progettazione del Protocollo VideoPacket
Sia i frame video che audio sono incapsulati nel protocollo binario unificato VideoPacket per il trasporto WebSocket.
Formato del Protocollo
+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
| Campo | Dimensione | Descrizione |
|---|---|---|
MAGIC | 1 byte | Fisso 0x56 ('V'), per validazione |
FLAGS | 1 byte | 0x01=keyframe, 0x02=config, 0x04=audio |
FRAME_ID | 4 byte | Numero di frame monotonico crescente, uint32 big-endian |
TIMESTAMP | 8 byte | PTS del codificatore in microsecondi, big-endian |
DATA | variabile | Unità NAL H.264 o dati Opus |
Sia VideoPacket.encode() di Android (in commonMain, quindi il suo formato wire è coperto da test unitari JVM senza alcuna dipendenza Android) che parseVideoPacket() del web implementano questo formato indipendentemente — non esiste una libreria di serializzazione condivisa, solo una specifica che entrambi i lati rispettano.
Note di Progettazione
- Parsing unsigned di
FRAME_ID:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0— deve usare>>> 0per garantire unsigned, altrimentiframeId > 2^31viene interpretato come negativo, causando falsi rilevamenti di perdita. FRAME_IDnon viene mai resettato: Quando il codificatore viene ricostruito per un cambio di orientamento,frameIdcontinua a incrementare (risiede inScreenMirrorPipeline, non nel codificatore). Questo permette al lato web di rilevare la perdita di frame durante la rotazione tramite gap in frameId.TIMESTAMPusa il PTS del codificatore: Nessuna dipendenza dall'orologio del client web, evitando che la deriva dell'orologio causi desincronizzazione A/V.- Parsing zero-copy: il parser web suddivide il payload con
Uint8Array.subarray()— una vista nell'ArrayBufferWebSocket originale, non una copia.
Pipeline di Decodifica Video (Web)
WebCodecs VideoDecoder
Il client web utilizza VideoDecoder dell'API WebCodecs per la decodifica hardware. Rispetto a MediaSource Extensions o WebRTC, WebCodecs fornisce un controllo granulare sul processo di decodifica — nessun buffer di jitter, nessun livello contenitore, e gli oggetti VideoFrame decodificati possono essere caricati direttamente come texture WebGL.
const decoder = new VideoDecoder({
output: (frame) => this.renderFrame(frame),
error: (e) => {
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.onError?.(e)
},
})
decoder.configure({
codec, // es. 'avc1.42c01e', letto dal NAL SPS
avc: { format: 'annexb' },
optimizeForLatency: true,
hardwareAcceleration: 'prefer-hardware',
})
Configurazioni chiave:
optimizeForLatency: true— dice al decodificatore di dare priorità alla bassa latenza, nessun buffer di framehardwareAcceleration: 'prefer-hardware'— preferisce la decodifica GPUavc: { format: 'annexb' }— usa il formato Annex-B con SPS/PPS inline prima di ogni IDR- la stringa del codec non è hardcodata —
extractAvc1CodecString()legge i byte profile/compat/level direttamente dal primo NAL SPS nel blob di configurazione
Problema dello Schermo Verde e Sequenza di Avvio
Il codificatore produce il suo primo frame IDR prima che VirtualDisplay abbia renderizzato il contenuto reale dello schermo — è un frame vuoto (verde). Se il lato web decodifica questo frame, l'utente vede un flash verde fino a quando il contenuto dello schermo cambia e attiva un nuovo frame.
Soluzione: All'avvio, il lato web recupera la configurazione cache tramite la query GraphQL screenMirrorVideoCodec ma non decodifica il keyframe incluso. Invece, chiama video.requestIdr() per impostare waitingForIdr = true (scartando tutti i P-frame finché non arriva un IDR), poi chiama requestKeyFrame() per richiedere un IDR fresco tramite la stessa mutazione usata per il recupero dalla perdita. Quando il nuovo IDR arriva, VirtualDisplay ha già contenuto reale dello schermo.
video.requestIdr() // scarta P-frame, attendi IDR
await requestKeyFrame() // richiedi IDR fresco tramite mutazione GraphQL
Il callback onFirstFrameRendered è legato a renderFrame() piuttosto che a handleVideo(), assicurando che l'UI si aggiorni solo dopo che un frame reale è stato renderizzato — non semplicemente ricevuto.
Rendering WebGL2
Render Diretto GPU Zero-Copy
Gli oggetti VideoFrame decodificati vengono caricati direttamente come texture WebGL2, senza mai passare attraverso la CPU:
VideoDecoder → VideoFrame → gl.texImage2D(VideoFrame) → Canvas
gl.texImage2D accetta VideoFrame come sorgente di pixel. Il browser gestisce internamente la conversione YUV→RGB e il caricamento GPU — nessuna copia CPU di ImageData. MirrorGLRenderer ricade su Canvas 2D drawImage() se getContext('webgl2', ...) fallisce, quindi i browser più vecchi ricevono comunque un'immagine (con latenza leggermente superiore).
Contesto desynchronized
const gl = canvas.getContext('webgl2', {
alpha: false,
desynchronized: true, // bypassa il compositor, scrive direttamente sullo schermo
preserveDrawingBuffer: true, // preserva il buffer per screenshot
powerPreference: 'high-performance',
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
})
desynchronized: true bypassa il compositor del browser, scrivendo direttamente sullo schermo, risparmiando circa 1 frame di latenza di visualizzazione (~16ms @ 60fps).
preserveDrawingBuffer: true preserva il buffer di disegno così gli screenshot con canvas.toDataURL() possono leggere il contenuto. Con il valore predefinito false, il buffer viene cancellato dopo il compositing, producendo screenshot neri.
Lo shader stesso è volutamente minimale — un vertex shader fullscreen-triangle e un fragment shader di una riga che campiona la texture — perché l'unico lavoro necessario per frame è "mettere questa texture sullo schermo."
Adattamento Automatico della Canvas
La dimensione del backing store della canvas è impostata da VideoFrame.displayWidth/Height ogni volta che cambia. La dimensione CSS viene quindi adattata al contenitore wrapper da fitCanvasToWrapper() preservando l'aspect ratio (letterboxing o pillarboxing secondo necessità). Un ResizeObserver sull'elemento genitore della canvas ripete questo adattamento ogni volta che il contenitore si ridimensiona, così il video non si allunga mai.
Rilevamento Perdita e Recupero Errori
Rilevamento Gap di FrameId
Ogni frame video trasporta un frameId monotonico crescente. Il decodificatore tiene traccia di lastFrameId; se il frameId di un nuovo frame è > lastFrameId + 1, alcuni frame sono stati persi:
if (!this.waitingForIdr && this.lastFrameId > 0
&& packet.frameId > this.lastFrameId + 1) {
if (!packet.isKeyFrame) {
// Perdita: scarta i successivi P-frame, richiedi nuovo IDR
this.waitingForIdr = true
this.onRequestKeyFrame?.()
this.lastFrameId = packet.frameId
return
}
}
Macchina a Stati waitingForIdr
waitingForIdr è una semplice macchina a due stati:
| Stato | Comportamento |
|---|---|
NORMAL | Decodifica tutti i frame normalmente |
WAITING_FOR_IDR | Scarta tutti i P-frame, decodifica solo i frame IDR; torna a NORMAL quando arriva un IDR |
Scenari che attivano la transizione a WAITING_FOR_IDR:
- All'avvio: salta il keyframe obsoleto di GraphQL, attendi un IDR reale
- In caso di perdita pacchetti: scarta i P-frame non decodificabili, attendi il recupero IDR
- In caso di errore del decodificatore: resetta il decodificatore, attendi IDR
- In caso di cambio configurazione: scarta i P-frame residui dopo un cambio di orientamento/qualità
Recupero da Errore del Decodificatore
Quando VideoDecoder.onerror si attiva, decoderNeedsReset = true viene impostato nel livello pipeline (screen-mirror-pipeline.ts). Al prossimo frame IDR, il decodificatore viene riconfigurato con gli SPS/PPS memorizzati nella cache invece di fare un nuovo round-trip a GraphQL:
if (decoderNeedsReset) {
if (!packet.isKeyFrame || !cachedConfig) return
video.configure(cachedConfig)
decoderNeedsReset = false
}
Contropressione e Deduplicazione dei Timestamp
Se decoder.decodeQueueSize > 5, i P-frame in arrivo vengono scartati invece di essere accodati — la soglia di 5 (invece di 2) tollera la latenza di avvio del decodificatore hardware senza causare inutili arresti. Separatamente, dopo il rendering, viene registrato lastRenderedPts; un frame con timestamp più vecchio (arrivo fuori ordine) viene scartato a meno che non sia un keyframe:
if (packet.timestamp < this.lastRenderedPts && !packet.isKeyFrame) {
return
}
Gestione del Cambio di Orientamento
Ricostruzione del Codificatore
Un OrientationEventListener in ScreenMirrorService confronta la rotation del display con il flag isPortrait memorizzato nella cache a ogni callback del sensore; solo un reale capovolgimento portrait/landscape chiama pipeline.onOrientationChanged() e invalida la cache delle dimensioni dello schermo dell'accessibilità usata per il ridimensionamento delle coordinate tattili.
rebuildEncoderAndResize():
- Crea un nuovo codificatore con le nuove dimensioni (es. landscape 1920×1080)
- Cambia
VirtualDisplay.surfacecon ilSurfacedi input del nuovo codificatore - Ferma il vecchio codificatore
VirtualDisplay.resize()alle nuove dimensioni
Il cambio di Surface avviene prima del resize — assicurando che il nuovo codificatore riceva prima i frame, e il vecchio codificatore venga fermato prima di poter ricevere frame con dimensioni errate. Se virtualDisplay?.surface = ... lancia un'eccezione, la ricostruzione viene interrotta e mantiene il vecchio codificatore in esecuzione piuttosto che lasciare la pipeline senza alcun codificatore.
Notifica di Cambio Configurazione
Quando il nuovo codificatore emette per la prima volta SPS/PPS, pendingConfigBroadcast viene impostato sulla pipeline. Quando arriva il primo IDR dal nuovo codificatore, viene raggruppato con quella configurazione in un unico evento screen_mirror_video_codec invece di essere inviato come un normale pacchetto video.
Il client web quindi, in handleConfig():
- Riconfigura il decodificatore con i nuovi SPS/PPS
- Decodifica immediatamente il frame IDR raggruppato
- Chiama
video.requestIdr()per scartare eventuali P-frame residui del vecchio codificatore ancora in transito - Chiama
requestKeyFrame()per richiedere un IDR fresco e pulito
I passi 3-4 sono una rete di sicurezza — anche se il primo IDR del nuovo codificatore ha dimensioni errate (durante la finestra di resize asincrono), il client web si ripristina rapidamente alle dimensioni corrette. handleConfig() fa anche un short-circuit se la configurazione in arrivo è identica byte per byte a quella memorizzata nella cache, poiché riconfigurare il decodificatore con byte invariati è un no-op che costa comunque un IDR per il recupero.
Ciclo di Vita del MediaProjection di Sistema
Il Problema
Gli utenti possono chiudere il cast dello schermo a livello di sistema (MediaProjection) tramite la barra di notifica di Android, invece che attraverso l'UI dell'app. In questo caso, ScreenMirrorService non sa che il casting è stato interrotto — running rimane true, il client web interroga screenMirrorState e riceve true, ma nessun frame video arriva, e la pagina rimane bloccata sul caricamento.
MediaProjection.Callback
MediaProjection fornisce un callback Callback.onStop() che si attiva quando il sistema ferma il casting. ScreenMirrorPipeline.startEncoders() registra questo callback e chiama ScreenMirrorService.instance?.stop() in onStop():
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
ScreenMirrorService.instance?.stop()
}
}, null)
Responsabilità di Service.stop()
stop() è il punto di arresto esplicito, responsabile di notificare il client web e fermare il servizio:
fun stop() {
if (!running) return // previene ricorsione
running = false
sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
Il controllo if (!running) return previene la ricorsione: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (a questo punto running=false, ritorna immediatamente).
Gestione sul Lato Web
Quando il client web riceve l'evento {"running":false}, si resetta allo stato idle e mostra il pulsante di avvio:
const onScreenMirroring = (data: any) => {
if (data?.running === false) {
cleanupFn()
fullReset()
return
}
// running=true → connettiti allo stream
}
Controllo Remoto: Iniezione Tattile
Lo screen mirroring è unidirezionale per impostazione predefinita (solo video/audio); il controllo remoto è opzionale e richiede che l'utente abiliti il Servizio di Accessibilità di PlainApp una volta, poiché Android non ha un'API pubblica per iniettare eventi tattili arbitrari al di fuori di AccessibilityService.dispatchGesture().
Normalizzazione delle Coordinate (Web)
Un overlay trasparente si trova sopra la <canvas> e cattura gli eventi del puntatore. normalizeCoords() converte clientX/clientY grezzi in coordinate [0,1] relative all'area di contenuto video effettiva — non al bounding box dell'overlay — calcolando l'offset di letterbox/pillarbox dall'aspect ratio del backing store della canvas rispetto all'aspect ratio del suo contenitore renderizzato:
if (videoAspect > containerAspect) {
// Letterboxed top/bottom
renderW = containerW
renderH = containerW / videoAspect
offsetY = (containerH - renderH) / 2
} else {
// Pillarboxed left/right
renderH = containerH
renderW = containerH * videoAspect
offsetX = (containerW - renderW) / 2
}
Una pressione del puntatore avvia un GestureState che tiene traccia della posizione/tempo di inizio; una pressione di 500ms con movimento < 10px scala a LONG_PRESS, il movimento oltre quella soglia diventa uno SWIPE, e un rilascio rapido è un TAP. Un indicatore tattile visivo (un punto che cresce/sfuma) fornisce feedback all'operatore su quale gesto è stato riconosciuto, prima ancora che il telefono risponda.
GraphQL → AccessibilityService
Ogni gesto riconosciuto viene inviato come una mutazione sendScreenMirrorControl(input) che trasporta un action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) più le coordinate normalizzate. Il resolver chiama dispatchScreenMirrorControl(), che moltiplica le coordinate normalizzate per la dimensione reale dello schermo (da PlainAccessibilityService.getScreenSize(), invalidata a ogni cambio di orientamento) e delega a 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 e LONG_PRESS costruiscono lo stesso GestureDescription con una durata del tratto più lunga o un percorso di linea invece di un singolo punto; SCROLL è implementato come uno swipe sintetico da (x, y) a (x, y + deltaY) bloccato a ±500px. Le quattro azioni globali (BACK/HOME/RECENTS/LOCK_SCREEN) saltano completamente l'invio del gesto e chiamano direttamente performGlobalAction(). Se il Servizio di Accessibilità non è abilitato, il resolver lancia un GraphQLError invece di ignorare silenziosamente l'input, così l'UI web può chiedere all'utente di abilitarlo.
Pipeline Audio
Codifica Opus su Android
MediaCodecAudioEncoder utilizza AudioPlaybackCaptureConfiguration (costruito dallo stesso MediaProjection) per catturare l'audio di sistema tramite AudioRecord, alimentando il PCM grezzo in un codificatore Opus MediaCodec. Questo richiede Android 10+ e il permesso RECORD_AUDIO — su dispositivi più vecchi o senza il permesso, start() registra un avviso e salta del tutto l'audio (il video continua a funzionare). I pacchetti Opus codificati sono incapsulati nello stesso protocollo VideoPacket (con FLAG_AUDIO impostato) e condividono il canale WebSocket SCREEN_MIRROR_AUDIO dei pacchetti video.
Decodifica Opus sul Web
ScreenMirrorAudioPipeline utilizza WebCodecs AudioDecoder per decodificare i dati Opus, producendo AudioData instradato a un elemento <audio>. Il timestamp dei frame audio viene utilizzato per la sincronizzazione A/V — condividendo la stessa base temporale (PTS del codificatore, in microsecondi) dei frame video, quindi non è necessaria una negoziazione separata dell'orologio tra i due flussi.
Ottimizzazioni delle Prestazioni
Percorsi Zero-Copy
| Percorso | Metodo |
|---|---|
| VirtualDisplay → Surface del codificatore | GPU direct, Surface passthrough |
| VideoDecoder → VideoFrame → texture WebGL | gl.texImage2D(VideoFrame), GPU direct |
| Ricezione WebSocket → parsing VideoPacket | Uint8Array.subarray() è una vista, nessuna copia |
Ottimizzazione avccToAnnexB
Alcuni codificatori Android emettono in formato AVCC (prefisso di 4 byte di lunghezza), che richiede la conversione al formato Annex-B (codice di inizio 00 00 00 01) per la decodifica WebCodecs.
L'implementazione iniziale usava ArrayList<Byte> con boxing per byte — un frame IDR di 50KB produceva 50.000 operazioni di boxing java.lang.Byte, creando un'enorme pressione sul GC. L'ottimizzazione utilizza scansione a due passate + copyInto (che mappa all'intrinseco System.arraycopy su JVM):
// Prima passata: calcola la dimensione dell'output
var outSize = 0
// Seconda passata: copia bulk
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)
Strategia di Scarto dei P-Frame
Il decodificatore potrebbe essere lento durante l'inizializzazione. Se la coda dei P-frame è troppo lunga, la latenza si accumula. La soglia della dimensione della coda di decodifica è impostata a > 5 (invece di > 2) per evitare un'eccessiva perdita di frame durante l'inizializzazione del decodificatore hardware.
Deduplicazione delle Richieste IDR
Il controllo waitingForIdr garantisce una sola richiesta IDR per evento di perdita, prevenendo richieste duplicate mentre si attende l'arrivo di un IDR.
Riepilogo dei Pattern di Progettazione
| Pattern | Dove | Perché |
|---|---|---|
| Macchina a Stati | flag waitingForIdr | Transizioni di stato esplicite per scarto/recupero P-frame |
| Guardia di Ricorsione | if (!running) return in stop() | Previene la ricorsione onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop |
| Pipeline Zero-Copy | VideoFrame → gl.texImage2D | Caricamento texture GPU direct, nessuna copia CPU |
| Scansione a Due Passate | avccToAnnexB | Pre-calcolo dimensione, allocazione singola + copia bulk, elimina il boxing |
| Evento Raggruppato | SPS/PPS + IDR in un unico evento | Il cambio configurazione completa riconfigurazione + decodifica primo frame in un evento |
| Separazione dei Callback | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Chiara distinzione tra rendering primo frame, fallimento trasporto e arresto lato telefono |
| Rete di Sicurezza | requestIdr() + requestKeyFrame() | Scarta frame residui + richiedi IDR pulito dopo cambio configurazione |
| Deduplicazione PTS | timestamp < lastRenderedPts | Scarta frame fuori ordine |
| Gap FrameId | frameId > lastFrameId + 1 | Rilevamento perdita pacchetti senza ACK |
| Contesto desynchronized | WebGL2 desynchronized: true | Bypassa il compositor, risparmia 1 frame di latenza |
| Fallimento Veloce Esplicito | sendScreenMirrorControl lancia GraphQLError | Segnala "accessibilità disabilitata" invece di ignorare silenziosamente l'input |
Letture Consigliate
- WebCodecs API — Documentazione MDN che copre le interfacce
VideoDecoder/AudioDecoder.