Voltar ao blog
Architecture15 min read

Screen Mirror: Arquitetura de Casting de Baixa Latência

Este artigo aborda o design ponta a ponta do sistema de screen mirror do PlainApp: como o Android captura e codifica H.264/Opus via MediaCodec, como os quadros trafegam via WebSocket usando um protocolo binário personalizado, como o lado web decodifica via WebCodecs e renderiza via WebGL2 com zero cópias em CPU, como a detecção de perda, mudança de orientação e controle remoto por toque são tratados, e como o ciclo de vida do MediaProjection é mantido em sincronia.

Índice

Arquitetura de Alto Nível

O screen mirror do PlainApp é um sistema de casting de baixa latência ponta a ponta: o dispositivo Android captura o conteúdo da tela, codifica-o em vídeo H.264 e áudio Opus via hardware, e o envia via WebSocket usando um protocolo binário personalizado para o cliente web; o cliente web decodifica via API WebCodecs e renderiza diretamente em um Canvas via WebGL2, com zero cópias em CPU durante todo o processo. Uma sobreposição de toque transparente fecha o ciclo, transformando a entrada do ponteiro de volta em gestos no telefone.

Sem WebRTC, sem RTMP, sem servidor intermediário. Toda a pipeline é:

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

Por que não WebRTC?

O WebRTC foi projetado para comunicação em tempo real. Sua negociação ICE/STUN/TURN, controle de congestionamento e buffer de jitter são excessivos para casting em LAN. O caso de uso do PlainApp é:

  • Mesma LAN, latência < 5ms, sem necessidade de travessia NAT
  • Busca por latência extremamente baixa, sem buffer de jitter
  • Alta qualidade, taxa de bits pode ser alta (8 Mbps)
  • Controle de tela (injeção de toque), onde o DataChannel do WebRTC adiciona complexidade desnecessária

Um protocolo binário personalizado sobre WebSocket é mais leve e mais controlável para o cenário de LAN.

Mapa de componentes

Diagrama 1
Diagrama 1

CamadaAndroidWeb
Captura de telaMediaProjection + VirtualDisplay—
Codificação de vídeoCodificador de hardware MediaCodec H.264—
Codificação de áudioCodificador de hardware MediaCodec Opus—
TransporteEventos binários WebSocketReceptor WebSocket
Decodificação de vídeo—WebCodecs VideoDecoder
Decodificação de áudio—WebCodecs AudioDecoder → <audio>
Renderização—Textura WebGL2 renderização direta
ControleAccessibilityService injeção de gestosSobreposição de toque → mutação GraphQL

Vídeo e áudio sempre fluem dispositivo → navegador pela mesma conexão WebSocket; o controle flui na direção oposta via GraphQL (sendScreenMirrorControl), que também funciona como canal lateral para a configuração do codec (consulta screenMirrorVideoCodec) e solicitações de keyframe (mutação requestScreenMirrorKeyFrame).

Pipeline de Codificação de Vídeo (Android)

Ajuste de Parâmetros de Codificação

Os parâmetros de codificação foram ajustados especificamente para casting de baixa latência em LAN:

ParâmetroValorNotas
KEY_FRAME_RATE6060 fps para suavidade
KEY_I_FRAME_INTERVAL10Intervalo IDR de 10 s, reduz sobrecarga de keyframes
KEY_BIT_RATE_MODEVBR (implícito, sem modo explícito definido)Taxa de bits variável, adaptativa à cena
KEY_PRIORITY0Prioridade em tempo real
KEY_LATENCY1Modo de baixa latência

A taxa de bits é dividida em níveis por modo de qualidade — taxas mais altas (ex.: 24 Mbps) foram testadas e causaram quedas de quadros no codificador/decodificador e aumento da latência ponta a ponta sem ganho visível de qualidade para conteúdo de tela:

ModoTaxa de bitsResolução de captura
HD8 Mbps1080p lado curto
Smooth4 Mbps1080p lado curto
Low2 Mbps720p lado curto

Configuração de Baixa Latência do Codificador

MediaCodecVideoEncoder configura o codificador uma vez no momento da criação:

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 são as chaves para baixa latência — eles dizem ao codificador para priorizar a codificação em tempo real em vez da taxa de compressão. A entrada é uma Surface criada por MediaCodec.createInputSurface() e alimentada diretamente para VirtualDisplay — sem leitura de SurfaceTexture, sem conversão I420, sem que a CPU toque nos pixels.

Resolução de Captura

ScreenMirrorCaptureSize.compute() deriva o tamanho real de captura a partir do tamanho físico da tela, do lado curto alvo do modo de qualidade (720/1080) e do maxWidth/maxHeight reportados pelo codificador e seu alinhamento de largura/altura (consultado uma vez via MediaCodecVideoEncoder.queryEncoderCaps()), de modo que o codificador nunca receba dimensões que não possa aceitar.

Solicitações de Keyframe

O cliente web pode solicitar um quadro IDR via mutação GraphQL requestScreenMirrorKeyFrame para se recuperar de perda de pacotes. O Android responde via 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 de Keyframe

Após o codificador iniciar, INFO_OUTPUT_FORMAT_CHANGED entrega csd-0/csd-1 (SPS/PPS), que ScreenMirrorPipeline une em um único blob de configuração Annex-B e armazena em cache (cachedConfig). O primeiro IDR que o segue também é armazenado em cache (cachedKeyFrame) para que um cliente web recém-conectado possa obter ambos via consulta GraphQL screenMirrorVideoCodec sem esperar pelo próximo intervalo de keyframe. Quando a configuração acaba de mudar (mudança de orientação ou qualidade), o Android não envia o novo IDR como um pacote de vídeo normal — ele agrupa SPS/PPS + IDR em um único evento WebSocket screen_mirror_video_codec, para que o cliente web complete a reconfiguração do decodificador e a decodificação do primeiro quadro em uma única etapa, em vez de competir um decodificador desatualizado contra um novo bitstream.

Alguns codificadores OEM (Qualcomm/Xiaomi) agrupam SPS+PPS+IDR em um único buffer de saída carregando tanto BUFFER_FLAG_CODEC_CONFIG quanto BUFFER_FLAG_SYNC_FRAME. O loop de drenagem apenas pula buffers que são puramente de configuração (isConfig && !isKey) — pular um buffer com flag de configuração que também carrega o sync frame descartaria silenciosamente o IDR e deixaria o decodificador apenas com P-frames, produzindo saída com mosaico.

Design do Protocolo VideoPacket

Tanto quadros de vídeo quanto de áudio são encapsulados no protocolo binário unificado VideoPacket para transporte via WebSocket.

Formato do Protocolo

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
CampoTamanhoDescrição
MAGIC1 byteFixo 0x56 ('V'), para validação
FLAGS1 byte0x01=keyframe, 0x02=config, 0x04=audio
FRAME_ID4 bytesNúmero de quadro monotonicamente crescente, uint32 big-endian
TIMESTAMP8 bytesPTS do codificador em microssegundos, big-endian
DATAvariávelUnidade NAL H.264 ou dados Opus

Tanto o VideoPacket.encode() do Android (em commonMain, portanto seu formato de transmissão é coberto por testes unitários JVM sem qualquer dependência Android) quanto o parseVideoPacket() da web implementam este formato independentemente — não há biblioteca de serialização compartilhada, apenas uma especificação que ambos os lados respeitam.

Diagrama 2
Diagrama 2

Notas de Design

  • Análise unsigned de FRAME_ID: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 — deve usar >>> 0 para garantir unsigned, caso contrário frameId > 2^31 é interpretado como negativo, causando detecção falsa de perda.
  • FRAME_ID nunca é reiniciado: Quando o codificador é reconstruído para mudança de orientação, frameId continua incrementando (ele vive em ScreenMirrorPipeline, não no codificador). Isso permite que o lado web detecte perda de quadros durante a rotação através de lacunas no frameId.
  • TIMESTAMP usa o PTS do codificador: Sem dependência do relógio do cliente web, evitando que a deriva de relógio cause dessincronização A/V.
  • Análise zero-copy: o parser web fatia o payload com Uint8Array.subarray() — uma visão do ArrayBuffer original do WebSocket, não uma cópia.

Pipeline de Decodificação de Vídeo (Web)

WebCodecs VideoDecoder

O cliente web usa o VideoDecoder da WebCodecs API para decodificação por hardware. Comparado a MediaSource Extensions ou WebRTC, o WebCodecs fornece controle refinado sobre o processo de decodificação — sem buffer de jitter, sem camada de contêiner, e os objetos VideoFrame decodificados podem ser diretamente enviados como texturas WebGL.

const decoder = new VideoDecoder({
    output: (frame) => this.renderFrame(frame),
    error: (e) => {
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.onError?.(e)
    },
})
decoder.configure({
    codec,                              // ex.: 'avc1.42c01e', lido do NAL SPS
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

Configurações principais:

  • optimizeForLatency: true — instrui o decodificador a priorizar baixa latência, sem buffer de quadros
  • hardwareAcceleration: 'prefer-hardware' — preferir decodificação por GPU
  • avc: { format: 'annexb' } — usar formato Annex-B com SPS/PPS inline antes de cada IDR
  • a string do codec em si não é hardcoded — extractAvc1CodecString() lê os bytes de profile/compat/level diretamente do primeiro NAL SPS no blob de configuração

Problema da Tela Verde e Sequência de Inicialização

O codificador produz seu primeiro quadro IDR antes que o VirtualDisplay tenha renderizado conteúdo real da tela — é um quadro em branco (verde). Se o lado web decodificar este quadro, o usuário vê um flash verde até que o conteúdo da tela mude e dispare um novo quadro.

Solução: Na inicialização, o lado web obtém a configuração em cache via consulta GraphQL screenMirrorVideoCodec, mas não decodifica o keyframe agrupado. Em vez disso, chama video.requestIdr() para definir waitingForIdr = true (descartando todos os P-frames até que um IDR chegue), então chama requestKeyFrame() para solicitar um novo IDR através da mesma mutação usada para recuperação de perda. Quando o novo IDR chega, o VirtualDisplay já tem conteúdo real de tela.

video.requestIdr()      // descarta P-frames, aguarda IDR
await requestKeyFrame() // solicita novo IDR via mutação GraphQL

O callback onFirstFrameRendered é vinculado a renderFrame() em vez de handleVideo(), garantindo que a UI só atualize após um quadro real ser renderizado — não meramente recebido.

Renderização WebGL2

Renderização Direta GPU Zero-Copy

Objetos VideoFrame decodificados são diretamente enviados como texturas WebGL2, nunca passando pela CPU:

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

gl.texImage2D aceita VideoFrame como fonte de pixels. O navegador gerencia a conversão YUV→RGB e o upload para GPU internamente — sem cópia ImageData pela CPU. MirrorGLRenderer recai para drawImage() do Canvas 2D se getContext('webgl2', ...) falhar, então navegadores mais antigos ainda obtêm uma imagem (com latência ligeiramente maior).

Contexto desynchronized

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // ignora o compositor, escreve diretamente na tela
    preserveDrawingBuffer: true, // preserva o buffer para screenshots
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true ignora o compositor do navegador, escrevendo diretamente na tela, economizando ~1 quadro de latência de exibição (~16ms @ 60fps).

preserveDrawingBuffer: true preserva o buffer de desenho para que screenshots via canvas.toDataURL() possam ler o conteúdo. Com o padrão false, o buffer é limpo após a composição, produzindo screenshots pretas.

O shader em si é deliberadamente mínimo — um vertex shader de triângulo em tela cheia e um fragment shader de uma linha que amostra a textura — porque o único trabalho necessário por quadro é "colocar esta textura na tela."

Ajuste Automático do Canvas

O tamanho do backing store do canvas é definido a partir de VideoFrame.displayWidth/Height sempre que muda. O tamanho CSS é então ajustado ao contêiner wrapper por fitCanvasToWrapper() preservando a proporção (letterboxing ou pillarboxing conforme necessário). Um ResizeObserver no elemento pai do canvas reexecuta este ajuste sempre que o contêiner redimensiona, para que o vídeo nunca estique.

Detecção de Perda e Recuperação de Erros

Detecção de Lacuna no FrameId

Cada quadro de vídeo carrega um frameId monotonicamente crescente. O decodificador rastreia lastFrameId; se o frameId de um novo quadro for > lastFrameId + 1, quadros foram perdidos:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Perda: descarta P-frames subsequentes, solicita novo IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

Máquina de Estados waitingForIdr

waitingForIdr é uma máquina de dois estados simples:

Diagrama 3
Diagrama 3

EstadoComportamento
NORMALDecodifica todos os quadros normalmente
WAITING_FOR_IDRDescarta todos os P-frames, apenas decodifica quadros IDR; volta para NORMAL quando o IDR chega

Cenários que disparam a transição para WAITING_FOR_IDR:

  1. Na inicialização: pula keyframe GraphQL obsoleto, aguarda IDR real
  2. Na perda de pacote: descarta P-frames indecodificáveis, aguarda recuperação IDR
  3. No erro do decodificador: reinicia o decodificador, aguarda IDR
  4. Na mudança de configuração: descarta P-frames residuais após mudança de orientação/qualidade

Recuperação de Erro do Decodificador

Quando VideoDecoder.onerror é disparado, decoderNeedsReset = true é definido na camada de pipeline (screen-mirror-pipeline.ts). No próximo quadro IDR, o decodificador é reconfigurado com o SPS/PPS em cache em vez de fazer um novo ciclo ao GraphQL:

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

Contrapressão e Deduplicação de Timestamp

Se decoder.decodeQueueSize > 5, os P-frames recebidos são descartados em vez de enfileirados — o limite de 5 (em vez de 2) tolera a latência de inicialização do decodificador de hardware sem causar engasgos desnecessários. Separadamente, após a renderização, lastRenderedPts é registrado; um quadro cujo timestamp é mais antigo (chegada fora de ordem) é descartado a menos que seja um keyframe:

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

Tratamento de Mudança de Orientação

Reconstrução do Codificador

Um OrientationEventListener em ScreenMirrorService compara a rotation da tela com o flag isPortrait em cache a cada callback do sensor; apenas uma mudança genuína de retrato/paisagem chama pipeline.onOrientationChanged() e invalida o cache de tamanho de tela do accessibility usado para escalonamento de coordenadas de toque.

Diagrama 4
Diagrama 4

rebuildEncoderAndResize():

  1. Cria um novo codificador nas novas dimensões (ex.: paisagem 1920x1080)
  2. Alterna VirtualDisplay.surface para a Surface de entrada do novo codificador
  3. Para o codificador antigo
  4. VirtualDisplay.resize() para as novas dimensões

A alternância de Surface acontece antes do redimensionamento — garantindo que o novo codificador receba quadros primeiro, e o codificador antigo seja parado antes de receber quadros com dimensões erradas. Se virtualDisplay?.surface = ... lançar uma exceção, a reconstrução é abortada e mantém o codificador antigo em execução, em vez de deixar a pipeline sem codificador algum.

Notificação de Mudança de Configuração

Quando o novo codificador produz SPS/PPS pela primeira vez, pendingConfigBroadcast é definido na pipeline. Quando o primeiro IDR do novo codificador chega, ele é agrupado com essa configuração em um único evento screen_mirror_video_codec em vez de ser enviado como um pacote de vídeo comum.

O cliente web então, em handleConfig():

  1. Reconfigura o decodificador com o novo SPS/PPS
  2. Decodifica o quadro IDR agrupado imediatamente
  3. Chama video.requestIdr() para descartar quaisquer P-frames residuais do codificador antigo ainda em trânsito
  4. Chama requestKeyFrame() para solicitar um IDR limpo e novo

Os passos 3-4 são uma rede de segurança — mesmo que o primeiro IDR do novo codificador tenha dimensões incorretas (durante a janela de redimensionamento assíncrono), o cliente web se recupera rapidamente para as dimensões corretas. handleConfig() também faz um curto-circuito se a configuração recebida for byte-idêntica à em cache, já que reconfigurar o decodificador com bytes inalterados é um no-op que ainda custa um IDR para se recuperar.

Ciclo de Vida do MediaProjection do Sistema

O Problema

Usuários podem fechar o cast de tela a nível de sistema (MediaProjection) através da barra de notificação do sistema Android, em vez de pela UI do aplicativo. Neste caso, ScreenMirrorService não sabe que o casting parou — running permanece true, o cliente web consulta screenMirrorState e obtém true, mas nenhum quadro de vídeo chega, e a página fica travada no carregamento.

MediaProjection.Callback

MediaProjection fornece um callback Callback.onStop() que é disparado quando o sistema interrompe o casting. ScreenMirrorPipeline.startEncoders() registra este callback e chama ScreenMirrorService.instance?.stop() em onStop():

projection.registerCallback(object : MediaProjection.Callback() {
    override fun onStop() {
        ScreenMirrorService.instance?.stop()
    }
}, null)

Diagrama 5
Diagrama 5

Responsabilidades de Service.stop()

stop() é o ponto de parada explícito, responsável por notificar o cliente web e parar o serviço:

fun stop() {
    if (!running) return  // prevenção de recursão
    running = false
    sendEvent(WebSocketEvent(EventType.SCREEN_MIRRORING, """{"running":false}"""))
    stopForeground(STOP_FOREGROUND_REMOVE)
    stopSelf()
}

O guard if (!running) return previne recursão: onStop() → stop() → stopSelf() → onDestroy() → pipeline.stop() → projection.stop() → onStop() → stop() (neste ponto running=false, retorna imediatamente).

Tratamento no Lado Web

Quando o cliente web recebe o evento {"running":false}, ele reseta para o estado ocioso e mostra o botão de iniciar:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true → conectar ao stream
}

Controle Remoto: Injeção de Toque

O espelhamento de tela é unidirecional por padrão (apenas vídeo/áudio); o controle remoto é opcional e requer que o usuário ative o Serviço de Acessibilidade do PlainApp uma vez, já que o Android não possui uma API pública para injetar eventos de toque arbitrários fora de AccessibilityService.dispatchGesture().

Diagrama 6
Diagrama 6

Normalização de Coordenadas (Web)

Uma sobreposição transparente fica sobre o <canvas> e captura eventos de ponteiro. normalizeCoords() converte clientX/clientY brutos em coordenadas [0,1] relativas à área de conteúdo de vídeo real — não ao bounding box da sobreposição — calculando o deslocamento de letterbox/pillarbox a partir da proporção do backing store do canvas versus a proporção do seu contêiner renderizado:

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
}

Uma pressão de ponteiro inicia um GestureState que rastreia posição/tempo inicial; um toque mantido por 500ms com movimento < 10px escala para LONG_PRESS, movimento além desse limite torna-se um SWIPE, e uma liberação rápida é um TAP. Um indicador visual de toque (um ponto que cresce e desaparece) dá feedback ao operador sobre qual gesto foi reconhecido, antes mesmo do telefone responder.

GraphQL → AccessibilityService

Cada gesto reconhecido é enviado como uma mutação sendScreenMirrorControl(input) carregando uma action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) mais coordenadas normalizadas. O resolver chama dispatchScreenMirrorControl(), que multiplica as coordenadas normalizadas pelo tamanho real da tela (de PlainAccessibilityService.getScreenSize(), invalidado a cada mudança de orientação) e delega para 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 constroem o mesmo GestureDescription com uma duração de trajeto maior ou um caminho de linha em vez de um único ponto; SCROLL é implementado como um swipe sintético de (x, y) para (x, y + deltaY) limitado a ±500px. As quatro ações globais (BACK/HOME/RECENTS/LOCK_SCREEN) ignoram completamente o dispatch de gestos e chamam performGlobalAction() diretamente. Se o Serviço de Acessibilidade não estiver ativado, o resolver lança um GraphQLError em vez de descartar silenciosamente a entrada, para que a UI web possa solicitar ao usuário que o ative.

Pipeline de Áudio

Codificação Opus no Android

MediaCodecAudioEncoder usa AudioPlaybackCaptureConfiguration (construído a partir do mesmo MediaProjection) para capturar áudio do sistema via AudioRecord, alimentando PCM bruto em um codificador Opus MediaCodec. Isso requer Android 10+ e a permissão RECORD_AUDIO — em dispositivos mais antigos ou sem a permissão, start() registra um aviso e ignora o áudio (o vídeo continua funcionando). Pacotes Opus codificados são encapsulados no mesmo protocolo VideoPacket (com FLAG_AUDIO definido) e compartilham o mesmo canal WebSocket SCREEN_MIRROR_AUDIO dos pacotes de vídeo.

Decodificação Opus na Web

ScreenMirrorAudioPipeline usa WebCodecs AudioDecoder para decodificar dados Opus, produzindo AudioData roteado para um elemento <audio>. O timestamp do quadro de áudio é usado para sincronização A/V — compartilhando a mesma base de tempo (PTS do codificador, em microssegundos) que os quadros de vídeo, portanto nenhuma negociação de relógio separada é necessária entre os dois fluxos.

Otimizações de Performance

Caminhos Zero-Copy

CaminhoMétodo
VirtualDisplay → Surface do codificadorGPU direto, passagem de Surface
VideoDecoder → VideoFrame → textura WebGLgl.texImage2D(VideoFrame), GPU direto
Recepção WebSocket → análise VideoPacketUint8Array.subarray() é uma visão, sem cópia

Otimização avccToAnnexB

Alguns codificadores Android produzem formato AVCC (prefixo de 4 bytes de comprimento), que precisa ser convertido para o formato Annex-B (código de início 00 00 00 01) para decodificação WebCodecs.

A implementação inicial usava ArrayList<Byte> com boxing por byte — um quadro IDR de 50KB produzia 50.000 operações de boxing java.lang.Byte, criando pressão massiva no GC. A otimização usa varredura em duas passagens + copyInto (que mapeia para o intrinsic System.arraycopy na JVM):

// Primeira passagem: calcular tamanho de saída
var outSize = 0
// Segunda passagem: cópia em lote
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

Estratégia de Descarte de P-Frames

O decodificador pode estar lento durante a inicialização. Se a fila de P-frames for muito longa, a latência se acumula. O limite de tamanho da fila de decodificação é definido como > 5 (em vez de > 2) para evitar perda excessiva de quadros durante a inicialização do decodificador de hardware.

Deduplicação de Solicitação IDR

O guard waitingForIdr garante que apenas uma solicitação IDR seja feita por evento de perda, prevenindo solicitações duplicadas enquanto se aguarda a chegada de um IDR.

Recapitulação de Padrões de Design

PadrãoOndePor quê
Máquina de EstadosFlag waitingForIdrTransições explícitas de estado de descarte/recuperação de P-frames
Guarda de Recursãoif (!running) return em stop()Previne recursão onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop
Pipeline Zero-CopyVideoFrame → gl.texImage2DUpload de textura GPU direto, sem cópia pela CPU
Varredura em Duas PassagensavccToAnnexBPré-calcula tamanho, alocação única + cópia em lote, elimina boxing
Evento AgrupadoSPS/PPS + IDR em um eventoMudança de configuração completa reconfiguração + decodificação do primeiro quadro em um evento
Separação de CallbacksonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffDistinção clara entre renderização do primeiro quadro, falha de transporte e parada no lado do telefone
Rede de SegurançarequestIdr() + requestKeyFrame()Descarta quadros residuais + solicita IDR limpo após mudança de configuração
Deduplicação de PTStimestamp < lastRenderedPtsDescarta quadros fora de ordem
Lacuna de FrameIdframeId > lastFrameId + 1Detecção de perda de pacotes sem ACK
Contexto desynchronizedWebGL2 desynchronized: trueIgnora compositor, economiza 1 quadro de latência
Falha Rápida ExplícitasendScreenMirrorControl lança GraphQLErrorRevela "acessibilidade desativada" em vez de descartar entrada silenciosamente

Leitura Adicional

  • WebCodecs API — Documentação MDN cobrindo as interfaces VideoDecoder/AudioDecoder."