Índice
- Arquitetura de Alto Nível
- Pipeline de Codificação de Vídeo (Android)
- Design do Protocolo VideoPacket
- Pipeline de Decodificação de Vídeo (Web)
- Renderização WebGL2
- Detecção de Perda e Recuperação de Erros
- Tratamento de Mudança de Orientação
- Ciclo de Vida do MediaProjection do Sistema
- Controle Remoto: Injeção de Toque
- Pipeline de Áudio
- Otimizações de Performance
- Recapitulação de Padrões de Design
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
| Camada | Android | Web |
|---|---|---|
| Captura de tela | MediaProjection + VirtualDisplay | — |
| Codificação de vídeo | Codificador de hardware MediaCodec H.264 | — |
| Codificação de áudio | Codificador de hardware MediaCodec Opus | — |
| Transporte | Eventos binários WebSocket | Receptor WebSocket |
| Decodificação de vídeo | — | WebCodecs VideoDecoder |
| Decodificação de áudio | — | WebCodecs AudioDecoder → <audio> |
| Renderização | — | Textura WebGL2 renderização direta |
| Controle | AccessibilityService injeção de gestos | Sobreposiçã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âmetro | Valor | Notas |
|---|---|---|
KEY_FRAME_RATE | 60 | 60 fps para suavidade |
KEY_I_FRAME_INTERVAL | 10 | Intervalo IDR de 10 s, reduz sobrecarga de keyframes |
KEY_BIT_RATE_MODE | VBR (implícito, sem modo explícito definido) | Taxa de bits variável, adaptativa à cena |
KEY_PRIORITY | 0 | Prioridade em tempo real |
KEY_LATENCY | 1 | Modo 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:
| Modo | Taxa de bits | Resolução de captura |
|---|---|---|
| HD | 8 Mbps | 1080p lado curto |
| Smooth | 4 Mbps | 1080p lado curto |
| Low | 2 Mbps | 720p 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 -/
| Campo | Tamanho | Descrição |
|---|---|---|
MAGIC | 1 byte | Fixo 0x56 ('V'), para validação |
FLAGS | 1 byte | 0x01=keyframe, 0x02=config, 0x04=audio |
FRAME_ID | 4 bytes | Número de quadro monotonicamente crescente, uint32 big-endian |
TIMESTAMP | 8 bytes | PTS do codificador em microssegundos, big-endian |
DATA | variável | Unidade 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.
Notas de Design
- Análise unsigned de
FRAME_ID:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0— deve usar>>> 0para garantir unsigned, caso contrárioframeId > 2^31é interpretado como negativo, causando detecção falsa de perda. FRAME_IDnunca é reiniciado: Quando o codificador é reconstruído para mudança de orientação,frameIdcontinua incrementando (ele vive emScreenMirrorPipeline, não no codificador). Isso permite que o lado web detecte perda de quadros durante a rotação através de lacunas no frameId.TIMESTAMPusa 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 doArrayBufferoriginal 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 quadroshardwareAcceleration: 'prefer-hardware'— preferir decodificação por GPUavc: { 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:
| Estado | Comportamento |
|---|---|
NORMAL | Decodifica todos os quadros normalmente |
WAITING_FOR_IDR | Descarta 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:
- Na inicialização: pula keyframe GraphQL obsoleto, aguarda IDR real
- Na perda de pacote: descarta P-frames indecodificáveis, aguarda recuperação IDR
- No erro do decodificador: reinicia o decodificador, aguarda IDR
- 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.
rebuildEncoderAndResize():
- Cria um novo codificador nas novas dimensões (ex.: paisagem 1920x1080)
- Alterna
VirtualDisplay.surfacepara aSurfacede entrada do novo codificador - Para o codificador antigo
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():
- Reconfigura o decodificador com o novo SPS/PPS
- Decodifica o quadro IDR agrupado imediatamente
- Chama
video.requestIdr()para descartar quaisquer P-frames residuais do codificador antigo ainda em trânsito - 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)
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().
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
| Caminho | Método |
|---|---|
| VirtualDisplay → Surface do codificador | GPU direto, passagem de Surface |
| VideoDecoder → VideoFrame → textura WebGL | gl.texImage2D(VideoFrame), GPU direto |
| Recepção WebSocket → análise VideoPacket | Uint8Array.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ão | Onde | Por quê |
|---|---|---|
| Máquina de Estados | Flag waitingForIdr | Transições explícitas de estado de descarte/recuperação de P-frames |
| Guarda de Recursão | if (!running) return em stop() | Previne recursão onStop → stop → onDestroy → pipeline.stop → projection.stop → onStop |
| Pipeline Zero-Copy | VideoFrame → gl.texImage2D | Upload de textura GPU direto, sem cópia pela CPU |
| Varredura em Duas Passagens | avccToAnnexB | Pré-calcula tamanho, alocação única + cópia em lote, elimina boxing |
| Evento Agrupado | SPS/PPS + IDR em um evento | Mudança de configuração completa reconfiguração + decodificação do primeiro quadro em um evento |
| Separação de Callbacks | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Distinção clara entre renderização do primeiro quadro, falha de transporte e parada no lado do telefone |
| Rede de Segurança | requestIdr() + requestKeyFrame() | Descarta quadros residuais + solicita IDR limpo após mudança de configuração |
| Deduplicação de PTS | timestamp < lastRenderedPts | Descarta quadros fora de ordem |
| Lacuna de FrameId | frameId > lastFrameId + 1 | Detecção de perda de pacotes sem ACK |
| Contexto desynchronized | WebGL2 desynchronized: true | Ignora compositor, economiza 1 quadro de latência |
| Falha Rápida Explícita | sendScreenMirrorControl lança GraphQLError | Revela "acessibilidade desativada" em vez de descartar entrada silenciosamente |
Leitura Adicional
- WebCodecs API — Documentação MDN cobrindo as interfaces
VideoDecoder/AudioDecoder."