Volver al blog
Architecture15 min read

Screen Mirror: Arquitectura de Proyeccion de Baja Latencia

Este articulo cubre el diseno integral del sistema de proyeccion de pantalla de PlainApp: como Android captura y codifica via hardware H.264/Opus con MediaCodec, como los fotogramas viajan por WebSocket usando un protocolo binario personalizado, como el lado web decodifica con WebCodecs y renderiza con WebGL2 sin copias por CPU, como se manejan la deteccion de perdidas, los cambios de orientacion y el control tactil remoto, y como se mantiene sincronizado el ciclo de vida de MediaProjection.

Table of Contents

High-Level Architecture

PlainApp screen mirror es un sistema de proyeccion de extremo a extremo con baja latencia: el dispositivo Android captura el contenido de la pantalla, lo codifica via hardware a video H.264 y audio Opus, y lo envia a traves de WebSocket usando un protocolo binario personalizado hacia el cliente web; el cliente web decodifica mediante la API WebCodecs y renderiza directamente en un Canvas via WebGL2, sin ninguna copia por CPU en todo el proceso. Una superposicion tactil transparente cierra el ciclo, convirtiendo la entrada del puntero en gestos en el telefono.

Sin WebRTC, sin RTMP, sin servidor intermedio. Todo el pipeline es:

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

Por que no WebRTC?

WebRTC esta disenado para comunicacion en tiempo real. Su negociacion ICE/STUN/TURN, control de congestion y buffer de jitter son excesivos para la proyeccion en LAN. El caso de uso de PlainApp es:

  • Misma LAN, latencia < 5ms, no se necesita traversal NAT
  • Busqueda de latencia extremadamente baja, sin buffer de jitter
  • Alta calidad, la tasa de bits puede ser alta (8 Mbps)
  • Control de pantalla (inyeccion tactil), donde el DataChannel de WebRTC anade complejidad innecesaria

Un protocolo binario personalizado sobre WebSocket es mas ligero y mas controlable para el escenario LAN.

Mapa de componentes

Diagram 1
1

CapaAndroidWeb
Captura de pantallaMediaProjection + VirtualDisplay--
Codificacion de videoMediaCodec codificador hardware H.264--
Codificacion de audioMediaCodec codificador hardware Opus--
TransporteEventos binarios WebSocketReceptor WebSocket
Decodificacion de video--WebCodecs VideoDecoder
Decodificacion de audio--WebCodecs AudioDecoder -> <audio>
Renderizado--WebGL2 textura directa
ControlAccessibilityService inyeccion de gestosSuperposicion tactil -> mutacion GraphQL

El video y el audio siempre fluyen dispositivo -> navegador sobre la misma conexion WebSocket; el control fluye en direccion opuesta a traves de GraphQL (sendScreenMirrorControl), que tambien funciona como canal secundario para la configuracion del codec (consulta screenMirrorVideoCodec) y las solicitudes de keyframe (mutacion requestScreenMirrorKeyFrame).

Video Encoding Pipeline (Android)

Ajuste de Parametros de Codificacion

Los parametros de codificacion se ajustaron especificamente para la proyeccion en LAN de baja latencia:

ParametroValorNotas
KEY_FRAME_RATE6060fps para suavidad
KEY_I_FRAME_INTERVAL10Intervalo IDR de 10s, reduce la sobrecarga de keyframes
KEY_BIT_RATE_MODEVBR (implicito, sin modo explicito)Tasa de bits variable, adaptativa a la escena
KEY_PRIORITY0Prioridad en tiempo real
KEY_LATENCY1Modo de baja latencia

La tasa de bits se clasifica por modo de calidad -- tasas mas altas (ej. 24 Mbps) se probaron pero causaron caidas de fotogramas en el codificador/decodificador y aumentaron la latencia extremo a extremo sin una ganancia visible de calidad para contenido de pantalla:

ModoTasa de bitsResolucion de captura
HD8 MbpsLado corto 1080p
Smooth4 MbpsLado corto 1080p
Low2 MbpsLado corto 720p

Configuracion de Baja Latencia del Codificador

MediaCodecVideoEncoder configura el codificador una vez al crearse:

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 y KEY_LATENCY=1 son las claves para la baja latencia -- le indican al codificador que priorice la codificacion en tiempo real sobre la relacion de compresion. La entrada es un Surface creado por MediaCodec.createInputSurface() y alimentado directamente a VirtualDisplay -- sin lectura de SurfaceTexture, sin conversion I420, la CPU no toca los pixeles.

Resolucion de Captura

ScreenMirrorCaptureSize.compute() deriva el tamano real de captura a partir del tamano fisico de la pantalla, el objetivo de lado corto del modo de calidad (720/1080), y el maxWidth/maxHeight reportados por el codificador junto con la alineacion de ancho/alto (consultados una vez via MediaCodecVideoEncoder.queryEncoderCaps()), de modo que el codificador nunca recibe dimensiones que no pueda aceptar.

Solicitudes de Keyframe

El cliente web puede solicitar un fotograma IDR a traves de la mutacion GraphQL requestScreenMirrorKeyFrame para recuperarse de la perdida de paquetes. 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 y Transmision de Keyframes

Despues de que el codificador se inicia, INFO_OUTPUT_FORMAT_CHANGED entrega csd-0/csd-1 (SPS/PPS), que ScreenMirrorPipeline une en un unico blob de configuracion Annex-B y almacena en cache (cachedConfig). El primer IDR que sigue tambien se almacena en cache (cachedKeyFrame) para que un cliente web recien conectado pueda obtener ambos a traves de la consulta GraphQL screenMirrorVideoCodec sin esperar al siguiente intervalo de keyframe. Cuando la configuracion acaba de cambiar (orientacion o cambio de calidad), Android no envia el nuevo IDR como un paquete de video normal -- agrupa SPS/PPS + IDR en un unico evento WebSocket screen_mirror_video_codec, de modo que el cliente web completa la reconfiguracion del decodificador y la decodificacion del primer fotograma en un solo paso, en lugar de competir con un decodificador desactualizado contra un nuevo flujo de bits.

Algunos codificadores OEM (Qualcomm/Xiaomi) agrupan SPS+PPS+IDR en un unico buffer de salida que lleva tanto BUFFER_FLAG_CODEC_CONFIG como BUFFER_FLAG_SYNC_FRAME. El bucle de drenado solo omite los buffers que son puramente de configuracion (isConfig && !isKey) -- saltarse un buffer marcado como configuracion que tambien lleva el fotograma de sincronizacion descartaria silenciosamente el IDR y dejaria al decodificador solo con fotogramas P, produciendo una imagen con mosaico.

VideoPacket Protocol Design

Tanto los fotogramas de video como los de audio se encapsulan en el protocolo binario unificado VideoPacket para el transporte por WebSocket.

Formato del 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 -/
CampoTamanoDescripcion
MAGIC1 byteFijo 0x56 ('V'), para validacion
FLAGS1 byte0x01=keyframe, 0x02=config, 0x04=audio
FRAME_ID4 bytesNumero de fotograma monotonamente creciente, uint32 big-endian
TIMESTAMP8 bytesPTS del codificador en microsegundos, big-endian
DATAvariableUnidad NAL H.264 o datos Opus

Tanto VideoPacket.encode() de Android (en commonMain, por lo que su formato de red esta cubierto por pruebas unitarias JVM sin dependencia de Android) como parseVideoPacket() del lado web implementan este formato de forma independiente -- no hay una biblioteca de serializacion compartida, solo una especificacion que ambas partes respetan.

Diagram 2
2

Notas de Diseno

  • Analisis sin signo de FRAME_ID: ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 -- debe usarse >>> 0 para garantizar que sea sin signo; de lo contrario, frameId > 2^31 se analiza como negativo, causando deteccion falsa de perdida.
  • FRAME_ID nunca se reinicia: Cuando el codificador se reconstruye por un cambio de orientacion, frameId sigue incrementandose (vive en ScreenMirrorPipeline, no en el codificador). Esto permite que el lado web detecte la perdida de fotogramas durante la rotacion a traves de los huecos en frameId.
  • TIMESTAMP usa el PTS del codificador: Sin dependencia del reloj del cliente web, evitando que la deriva del reloj cause desincronizacion de A/V.
  • Analisis de copia cero: el analizador web divide el payload con Uint8Array.subarray() -- una vista del ArrayBuffer original de WebSocket, no una copia.

Video Decoding Pipeline (Web)

WebCodecs VideoDecoder

El cliente web usa VideoDecoder de la API WebCodecs para la decodificacion por hardware. En comparacion con MediaSource Extensions o WebRTC, WebCodecs proporciona un control detallado sobre el proceso de decodificacion -- sin buffer de jitter, sin capa de contenedor, y los objetos VideoFrame decodificados se pueden subir directamente 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,                              // ej. 'avc1.42c01e', leido del NAL SPS
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

Configuraciones clave:

  • optimizeForLatency: true -- le indica al decodificador que priorice la baja latencia, sin buffer de fotogramas
  • hardwareAcceleration: 'prefer-hardware' -- prefiere decodificacion por GPU
  • avc: { format: 'annexb' } -- usa formato Annex-B con SPS/PPS en linea antes de cada IDR
  • la cadena del codec no esta hardcodeada -- extractAvc1CodecString() lee los bytes de perfil/compatibilidad/nivel directamente del primer NAL SPS en el blob de configuracion

Problema de Pantalla Verde y Secuencia de Inicio

El codificador produce su primer fotograma IDR antes de que VirtualDisplay haya renderizado contenido real de pantalla -- es un fotograma vacio (verde). Si el lado web decodifica este fotograma, el usuario ve un destello verde hasta que el contenido de la pantalla cambie y active un nuevo fotograma.

Solucion: Al iniciar, el lado web obtiene la configuracion en cache a traves de la consulta GraphQL screenMirrorVideoCodec pero no decodifica el keyframe incluido. En su lugar, llama a video.requestIdr() para establecer waitingForIdr = true (descartando todos los fotogramas P hasta que llegue un IDR), y luego llama a requestKeyFrame() para solicitar un nuevo IDR a traves de la misma mutacion utilizada para la recuperacion de perdidas. Cuando el nuevo IDR llega, VirtualDisplay ya tiene contenido real de pantalla.

video.requestIdr()      // descarta fotogramas P, espera IDR
await requestKeyFrame() // solicita nuevo IDR via mutacion GraphQL

El callback onFirstFrameRendered esta vinculado a renderFrame() en lugar de a handleVideo(), asegurando que la UI solo se actualice despues de que se renderice un fotograma real -- no meramente recibido.

WebGL2 Rendering

Renderizado Directo GPU de Copia Cero

Los objetos VideoFrame decodificados se suben directamente como texturas WebGL2, sin pasar nunca por la CPU:

VideoDecoder -> VideoFrame -> gl.texImage2D(VideoFrame) -> Canvas

gl.texImage2D acepta VideoFrame como fuente de pixeles. El navegador maneja la conversion YUV->RGB y la subida a GPU internamente -- sin copia CPU de ImageData. MirrorGLRenderer recurre a drawImage() de Canvas 2D si getContext('webgl2', ...) falla, por lo que los navegadores mas antiguos aun obtienen una imagen (con latencia ligeramente mayor).

Contexto desynchronized

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // omite el compositor, escribe directamente en pantalla
    preserveDrawingBuffer: true, // conserva el buffer para capturas de pantalla
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true omite el compositor del navegador, escribiendo directamente en la pantalla, ahorrando ~1 fotograma de latencia de visualizacion (~16ms a 60fps).

preserveDrawingBuffer: true conserva el buffer de dibujo para que las capturas de pantalla con canvas.toDataURL() puedan leer el contenido. Con el valor predeterminado false, el buffer se limpia despues de la composicion, produciendo capturas de pantalla negras.

El shader en si es deliberadamente minimo -- un vertex shader de triangulo de pantalla completa y un fragment shader de una linea que muestrea la textura -- porque el unico trabajo necesario por fotograma es "poner esta textura en la pantalla".

Ajuste Automatico del Canvas

El tamano del backing store del canvas se establece a partir de VideoFrame.displayWidth/Height cada vez que cambia. El tamano CSS se ajusta entonces al contenedor wrapper mediante fitCanvasToWrapper() mientras se preserva la relacion de aspecto (letterboxing o pillarboxing segun sea necesario). Un ResizeObserver en el elemento padre del canvas re-ejecuta este ajuste cada vez que el contenedor cambia de tamano, por lo que el video nunca se estira.

Loss Detection & Error Recovery

Deteccion de Huecos en FrameId

Cada fotograma de video lleva un frameId monotonamente creciente. El decodificador rastrea lastFrameId; si el frameId de un nuevo fotograma es > lastFrameId + 1, se perdieron fotogramas:

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Perdida: descartar fotogramas P subsiguientes, solicitar nuevo IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

Maquina de Estados waitingForIdr

waitingForIdr es una maquina de estados simple de dos estados:

Diagram 3
3

EstadoComportamiento
NORMALDecodifica todos los fotogramas normalmente
WAITING_FOR_IDRDescarta todos los fotogramas P, solo decodifica fotogramas IDR; se reinicia a NORMAL cuando llega un IDR

Escenarios que activan la transicion a WAITING_FOR_IDR:

  1. Al inicio: omitir keyframe antiguo de GraphQL, esperar IDR real
  2. En perdida de paquetes: descartar fotogramas P no decodificables, esperar recuperacion IDR
  3. En error del decodificador: reiniciar decodificador, esperar IDR
  4. En cambio de configuracion: descartar fotogramas P residuales despues de cambio de orientacion/calidad

Recuperacion de Errores del Decodificador

Cuando se dispara VideoDecoder.onerror, se establece decoderNeedsReset = true en la capa del pipeline (screen-mirror-pipeline.ts). En el siguiente fotograma IDR, el decodificador se reconfigura con los SPS/PPS en cache en lugar de hacer un viaje de ida y vuelta a GraphQL:

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

Contrapresion y Deduplicacion de Timestamps

Si decoder.decodeQueueSize > 5, los fotogramas P entrantes se descartan en lugar de encolarse -- el umbral de 5 (en lugar de 2) tolera la latencia de inicio del decodificador hardware sin causar tartamudeo innecesario. Por separado, despues de renderizar, se registra lastRenderedPts; un fotograma cuyo timestamp es mas antiguo (llegada desordenada) se descarta a menos que sea un keyframe:

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

Orientation Change Handling

Reconstruccion del Codificador

Un OrientationEventListener en ScreenMirrorService compara la rotation de la pantalla con el flag isPortrait en cache en cada callback del sensor; solo un cambio genuino de portrait/landscape llama a pipeline.onOrientationChanged() e invalida la cache de tamano de pantalla de accesibilidad utilizada para escalar las coordenadas tactiles.

Diagram 4
4

rebuildEncoderAndResize():

  1. Crear un nuevo codificador en las nuevas dimensiones (ej. landscape 1920x1080)
  2. Cambiar VirtualDisplay.surface al Surface de entrada del nuevo codificador
  3. Detener el codificador antiguo
  4. VirtualDisplay.resize() a las nuevas dimensiones

El cambio de Surface ocurre antes del redimensionamiento -- asegurando que el nuevo codificador reciba fotogramas primero, y el codificador antiguo se detenga antes de poder recibir fotogramas de dimensiones incorrectas. Si virtualDisplay?.surface = ... lanza una excepcion, la reconstruccion se aborta y mantiene el codificador antiguo en funcionamiento en lugar de dejar el pipeline sin ningun codificador.

Notificacion de Cambio de Configuracion

Cuando el nuevo codificador emite SPS/PPS por primera vez, se establece pendingConfigBroadcast en el pipeline. Cuando llega el primer IDR del nuevo codificador, se agrupa con esa configuracion en un unico evento screen_mirror_video_codec en lugar de enviarse como un paquete de video ordinario.

El cliente web entonces, en handleConfig():

  1. Reconfigura el decodificador con los nuevos SPS/PPS
  2. Decodifica el fotograma IDR agrupado inmediatamente
  3. Llama a video.requestIdr() para descartar cualquier fotograma P residual del codificador antiguo aun en transito
  4. Llama a requestKeyFrame() para solicitar un IDR limpio y nuevo

Los pasos 3-4 son una red de seguridad -- incluso si el primer IDR del nuevo codificador tiene dimensiones incorrectas (durante la ventana de redimensionamiento asincrono), el cliente web se recupera rapidamente a las dimensiones correctas. handleConfig() tambien cortocircuita si la configuracion entrante es identica byte a byte a la almacenada en cache, ya que reconfigurar el decodificador con bytes sin cambios es una operacion nula que aun cuesta un IDR para recuperarse.

System MediaProjection Lifecycle

El Problema

Los usuarios pueden cerrar la proyeccion de pantalla a nivel de sistema (MediaProjection) a traves de la barra de notificacion del sistema Android, en lugar de hacerlo a traves de la UI de la aplicacion. En este caso, ScreenMirrorService no sabe que la proyeccion se ha detenido -- running permanece true, el cliente web consulta screenMirrorState y obtiene true, pero no llegan fotogramas de video, y la pagina se queda atascada en la carga.

MediaProjection.Callback

MediaProjection proporciona un callback Callback.onStop() que se dispara cuando el sistema detiene la proyeccion. ScreenMirrorPipeline.startEncoders() registra este callback y llama a ScreenMirrorService.instance?.stop() en onStop():

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

Diagram 5
5

Responsabilidades de Service.stop()

stop() es el punto de detencion explicito, responsable de notificar al cliente web y detener el servicio:

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

El guardia if (!running) return previene la recursion: onStop() -> stop() -> stopSelf() -> onDestroy() -> pipeline.stop() -> projection.stop() -> onStop() -> stop() (en este punto running=false, retorna inmediatamente).

Manejo en el Lado Web

Cuando el cliente web recibe el evento {"running":false}, se reinicia al estado de inactividad y muestra el boton de inicio:

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true -> conectar al flujo
}

Remote Control: Touch Injection

La proyeccion de pantalla es unidireccional por defecto (solo video/audio); el control remoto es opcional y requiere que el usuario habilite el Servicio de Accesibilidad de PlainApp una vez, ya que Android no tiene una API publica para inyectar eventos tactiles arbitrarios fuera de AccessibilityService.dispatchGesture().

Diagram 6
6

Normalizacion de Coordenadas (Web)

Una superposicion transparente se encuentra sobre el <canvas> y captura los eventos del puntero. normalizeCoords() convierte un clientX/clientY sin procesar en coordenadas [0,1] relativas al area de contenido de video real -- no al cuadro delimitador de la superposicion -- calculando el desplazamiento de letterbox/pillarbox a partir de la relacion de aspecto del backing store del canvas en comparacion con la relacion de aspecto de su contenedor renderizado:

if (videoAspect > containerAspect) {
    // Letterboxed arriba/abajo
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarboxed izquierda/derecha
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Una pulsacion del puntero inicia un GestureState que rastrea la posicion/tiempo de inicio; una retencion de 500ms con menos de 10px de movimiento escala a LONG_PRESS, el movimiento mas alla de ese umbral se convierte en SWIPE, y una liberacion rapida es un TAP. Un indicador tactil visual (un punto que crece y se desvanece) le da al operador retroalimentacion sobre que gesto se reconocio, antes incluso de que el telefono responda.

GraphQL -> AccessibilityService

Cada gesto reconocido se envia como una mutacion sendScreenMirrorControl(input) que lleva una action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) mas las coordenadas normalizadas. El resolver llama a dispatchScreenMirrorControl(), que multiplica las coordenadas normalizadas por el tamano real de la pantalla (de PlainAccessibilityService.getScreenSize(), invalidado en cada cambio de orientacion) y 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 y LONG_PRESS construyen el mismo GestureDescription con una duracion de trazo mas larga o una trayectoria de linea en lugar de un solo punto; SCROLL se implementa como un deslizamiento sintetico desde (x, y) hasta (x, y + deltaY) limitado a +/-500px. Las cuatro acciones globales (BACK/HOME/RECENTS/LOCK_SCREEN) omiten completamente el envio de gestos y llaman a performGlobalAction() directamente. Si el Servicio de Accesibilidad no esta habilitado, el resolver lanza un GraphQLError en lugar de descartar silenciosamente la entrada, para que la UI web pueda solicitar al usuario que lo habilite.

Audio Pipeline

Codificacion Opus en Android

MediaCodecAudioEncoder usa AudioPlaybackCaptureConfiguration (construido a partir del mismo MediaProjection) para capturar el audio del sistema a traves de AudioRecord, alimentando PCM sin procesar a un codificador Opus de MediaCodec. Esto requiere Android 10+ y el permiso RECORD_AUDIO -- en dispositivos mas antiguos o sin el permiso, start() registra una advertencia y omite el audio por completo (el video sigue funcionando). Los paquetes Opus codificados se encapsulan en el mismo protocolo VideoPacket (con FLAG_AUDIO establecido) y comparten el canal WebSocket SCREEN_MIRROR_AUDIO de los paquetes de video.

Decodificacion Opus en Web

ScreenMirrorAudioPipeline usa WebCodecs AudioDecoder para decodificar datos Opus, generando AudioData que se enruta a un elemento <audio>. El timestamp del fotograma de audio se utiliza para la sincronizacion A/V -- compartiendo la misma base de tiempo (PTS del codificador, en microsegundos) que los fotogramas de video, por lo que no se necesita negociacion de reloj separada entre los dos flujos.

Performance Optimizations

Rutas de Copia Cero

RutaMetodo
VirtualDisplay -> Surface del codificadorGPU directo, paso a traves de Surface
VideoDecoder -> VideoFrame -> textura WebGLgl.texImage2D(VideoFrame), GPU directo
Recepcion WebSocket -> analisis VideoPacketUint8Array.subarray() es una vista, sin copia

Optimizacion avccToAnnexB

Algunos codificadores Android generan formato AVCC (prefijo de longitud de 4 bytes), que necesita conversion al formato Annex-B (codigo de inicio 00 00 00 01) para la decodificacion con WebCodecs.

La implementacion temprana usaba ArrayList<Byte> con boxing por byte -- un fotograma IDR de 50KB producia 50,000 operaciones de boxing java.lang.Byte, creando una presion masiva en el GC. La optimizacion utiliza escaneo de dos pasadas + copyInto (que se asigna al intrinsic System.arraycopy en JVM):

// Primera pasada: calcular el tamano de salida
var outSize = 0
// Segunda pasada: copia por lotes
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

Estrategia de Descarte de Fotogramas P

El decodificador puede ser lento durante la inicializacion. Si la cola de fotogramas P es demasiado larga, la latencia se acumula. El umbral de tamano de la cola de decodificacion se establece en > 5 (en lugar de > 2) para evitar la perdida excesiva de fotogramas durante la inicializacion del decodificador hardware.

Deduplicacion de Solicitudes IDR

El guardia waitingForIdr asegura que solo se realice una solicitud IDR por evento de perdida, evitando solicitudes duplicadas mientras se espera que llegue un IDR.

Design Patterns Recap

PatronDondePor que
Maquina de EstadosFlag waitingForIdrTransiciones de estado explicitas para descarte/recuperacion de fotogramas P
Guardia de Recursionif (!running) return en stop()Previene la recursion onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop
Pipeline de Copia CeroVideoFrame -> gl.texImage2DSubida directa de textura a GPU, sin copia por CPU
Escaneo de Dos PasadasavccToAnnexBPrecalcular tamano, asignacion unica + copia por lotes, elimina el boxing
Evento AgrupadoSPS/PPS + IDR en un eventoEl cambio de configuracion completa la reconfiguracion + decodificacion del primer fotograma en un evento
Separacion de CallbacksonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffDistincion clara entre renderizado del primer fotograma, fallo de transporte y detencion del telefono
Red de SeguridadrequestIdr() + requestKeyFrame()Descartar fotogramas residuales + solicitar IDR limpio despues de un cambio de configuracion
Deduplicacion de PTStimestamp < lastRenderedPtsDescartar fotogramas fuera de orden
Hueco de FrameIdframeId > lastFrameId + 1Deteccion de perdida de paquetes sin ACK
Contexto desynchronizedWebGL2 desynchronized: trueOmitir compositor, ahorrar 1 fotograma de latencia
Fallo Rapido ExplicitosendScreenMirrorControl lanza GraphQLErrorExpone "accesibilidad deshabilitada" en lugar de descartar silenciosamente la entrada

Further Reading

  • WebCodecs API -- Documentacion de MDN que cubre las interfaces VideoDecoder/AudioDecoder.