Table of Contents
- Arquitectura de Alto Nivel
- Pipeline de Codificacion de Video (Android)
- Diseno del Protocolo VideoPacket
- Pipeline de Decodificacion de Video (Web)
- Renderizado con WebGL2
- Deteccion de Perdidas y Recuperacion de Errores
- Manejo de Cambios de Orientacion
- Ciclo de Vida de MediaProjection del Sistema
- Control Remoto: Inyeccion Tactil
- Pipeline de Audio
- Optimizaciones de Rendimiento
- Resumen de Patrones de Diseno
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
| Capa | Android | Web |
|---|---|---|
| Captura de pantalla | MediaProjection + VirtualDisplay | -- |
| Codificacion de video | MediaCodec codificador hardware H.264 | -- |
| Codificacion de audio | MediaCodec codificador hardware Opus | -- |
| Transporte | Eventos binarios WebSocket | Receptor WebSocket |
| Decodificacion de video | -- | WebCodecs VideoDecoder |
| Decodificacion de audio | -- | WebCodecs AudioDecoder -> <audio> |
| Renderizado | -- | WebGL2 textura directa |
| Control | AccessibilityService inyeccion de gestos | Superposicion 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:
| Parametro | Valor | Notas |
|---|---|---|
KEY_FRAME_RATE | 60 | 60fps para suavidad |
KEY_I_FRAME_INTERVAL | 10 | Intervalo IDR de 10s, reduce la sobrecarga de keyframes |
KEY_BIT_RATE_MODE | VBR (implicito, sin modo explicito) | Tasa de bits variable, adaptativa a la escena |
KEY_PRIORITY | 0 | Prioridad en tiempo real |
KEY_LATENCY | 1 | Modo 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:
| Modo | Tasa de bits | Resolucion de captura |
|---|---|---|
| HD | 8 Mbps | Lado corto 1080p |
| Smooth | 4 Mbps | Lado corto 1080p |
| Low | 2 Mbps | Lado 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 -/
| Campo | Tamano | Descripcion |
|---|---|---|
MAGIC | 1 byte | Fijo 0x56 ('V'), para validacion |
FLAGS | 1 byte | 0x01=keyframe, 0x02=config, 0x04=audio |
FRAME_ID | 4 bytes | Numero de fotograma monotonamente creciente, uint32 big-endian |
TIMESTAMP | 8 bytes | PTS del codificador en microsegundos, big-endian |
DATA | variable | Unidad 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.
Notas de Diseno
- Analisis sin signo de
FRAME_ID:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0-- debe usarse>>> 0para garantizar que sea sin signo; de lo contrario,frameId > 2^31se analiza como negativo, causando deteccion falsa de perdida. FRAME_IDnunca se reinicia: Cuando el codificador se reconstruye por un cambio de orientacion,frameIdsigue incrementandose (vive enScreenMirrorPipeline, 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.TIMESTAMPusa 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 delArrayBufferoriginal 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 fotogramashardwareAcceleration: 'prefer-hardware'-- prefiere decodificacion por GPUavc: { 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:
| Estado | Comportamiento |
|---|---|
NORMAL | Decodifica todos los fotogramas normalmente |
WAITING_FOR_IDR | Descarta 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:
- Al inicio: omitir keyframe antiguo de GraphQL, esperar IDR real
- En perdida de paquetes: descartar fotogramas P no decodificables, esperar recuperacion IDR
- En error del decodificador: reiniciar decodificador, esperar IDR
- 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.
rebuildEncoderAndResize():
- Crear un nuevo codificador en las nuevas dimensiones (ej. landscape 1920x1080)
- Cambiar
VirtualDisplay.surfacealSurfacede entrada del nuevo codificador - Detener el codificador antiguo
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():
- Reconfigura el decodificador con los nuevos SPS/PPS
- Decodifica el fotograma IDR agrupado inmediatamente
- Llama a
video.requestIdr()para descartar cualquier fotograma P residual del codificador antiguo aun en transito - 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)
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().
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
| Ruta | Metodo |
|---|---|
| VirtualDisplay -> Surface del codificador | GPU directo, paso a traves de Surface |
| VideoDecoder -> VideoFrame -> textura WebGL | gl.texImage2D(VideoFrame), GPU directo |
| Recepcion WebSocket -> analisis VideoPacket | Uint8Array.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
| Patron | Donde | Por que |
|---|---|---|
| Maquina de Estados | Flag waitingForIdr | Transiciones de estado explicitas para descarte/recuperacion de fotogramas P |
| Guardia de Recursion | if (!running) return en stop() | Previene la recursion onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop |
| Pipeline de Copia Cero | VideoFrame -> gl.texImage2D | Subida directa de textura a GPU, sin copia por CPU |
| Escaneo de Dos Pasadas | avccToAnnexB | Precalcular tamano, asignacion unica + copia por lotes, elimina el boxing |
| Evento Agrupado | SPS/PPS + IDR en un evento | El cambio de configuracion completa la reconfiguracion + decodificacion del primer fotograma en un evento |
| Separacion de Callbacks | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Distincion clara entre renderizado del primer fotograma, fallo de transporte y detencion del telefono |
| Red de Seguridad | requestIdr() + requestKeyFrame() | Descartar fotogramas residuales + solicitar IDR limpio despues de un cambio de configuracion |
| Deduplicacion de PTS | timestamp < lastRenderedPts | Descartar fotogramas fuera de orden |
| Hueco de FrameId | frameId > lastFrameId + 1 | Deteccion de perdida de paquetes sin ACK |
| Contexto desynchronized | WebGL2 desynchronized: true | Omitir compositor, ahorrar 1 fotograma de latencia |
| Fallo Rapido Explicito | sendScreenMirrorControl lanza GraphQLError | Expone "accesibilidad deshabilitada" en lugar de descartar silenciosamente la entrada |
Further Reading
- WebCodecs API -- Documentacion de MDN que cubre las interfaces
VideoDecoder/AudioDecoder.