Retour au blog
Architecture15 min read

Screen Mirror : Architecture de Cast à Faible Latence

Cet article couvre la conception de bout en bout du système de screen mirror de PlainApp : comment Android capture et encode en dur H.264/Opus via MediaCodec, comment les trames transitent par WebSocket avec un protocole binaire personnalisé, comment le côté web décode via WebCodecs et rend via WebGL2 sans copie CPU, comment la détection de perte, le changement d'orientation et le contrôle tactile à distance sont gérés, et comment le cycle de vie du MediaProjection système est maintenu synchronisé.

Table des matieres

Architecture generale

Le screen mirror de PlainApp est un systeme de cast de bout en bout a faible latence : le peripherique Android capture le contenu de l'ecran, l'encode en dur en video H.264 et audio Opus, et le pousse via WebSocket a l'aide d'un protocole binaire personnalise vers le client web ; le client web decode via l'API WebCodecs et restitue directement sur un Canvas via WebGL2, sans aucune copie CPU. Une superposition tactile transparente ferme la boucle, transformant les entrees du pointeur en gestes sur le telephone.

Pas de WebRTC, pas de RTMP, pas de serveur intermediaire. L'ensemble du pipeline est :

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

Pourquoi pas WebRTC ?

WebRTC est concu pour la communication en temps reel. Sa negociation ICE/STUN/TURN, son controle de congestion et son tampon de gigue sont excessifs pour le cast sur reseau local. Le cas d'utilisation de PlainApp est :

  • Meme LAN, latence < 5 ms, pas de traversee NAT necessaire
  • Recherche de latence extremement faible, pas de tampon de gigue
  • Haute qualite, le debit peut etre eleve (8 Mbps)
  • Controle de l'ecran (injection tactile), ou le DataChannel de WebRTC ajoute une complexite inutile

Un protocole binaire personnalise sur WebSocket est plus leger et plus maitrisable pour le scenario LAN.

Carte des composants

Diagram 1
1

CoucheAndroidWeb
Capture d'ecranMediaProjection + VirtualDisplay—
Encodage videoMediaCodec encodeur materiel H.264—
Encodage audioMediaCodec encodeur materiel Opus—
TransportEvenements binaires WebSocketRecepteur WebSocket
Decodage video—WebCodecs VideoDecoder
Decodage audio—WebCodecs AudioDecoder -> <audio>
Rendu—Texture WebGL2 en rendu direct
ControleAccessibilityService injection de gestesSuperposition tactile -> mutation GraphQL

La video et l'audio circulent toujours du peripherique vers le navigateur sur la meme connexion WebSocket ; le controle circule en sens inverse via GraphQL (sendScreenMirrorControl), qui sert egalement de canal lateral pour la configuration du codec (requete screenMirrorVideoCodec) et les demandes de keyframe (mutation requestScreenMirrorKeyFrame).

Pipeline d'encodage video (Android)

Reglage des parametres d'encodage

Les parametres d'encodage ont ete ajustes specifiquement pour le cast sur LAN a faible latence :

ParametreValeurNotes
KEY_FRAME_RATE6060 ips pour la fluidite
KEY_I_FRAME_INTERVAL10Intervalle IDR de 10 s, reduit le cout des keyframes
KEY_BIT_RATE_MODEVBR (implicite, aucun mode explicite defini)Debit variable, adaptation a la scene
KEY_PRIORITY0Priorite temps reel
KEY_LATENCY1Mode faible latence

Le debit est echelonne par mode de qualite -- des debits plus eleves (par ex. 24 Mbps) ont ete testes et ont provoque des chutes de trames de l'encodeur/decodeur et une augmentation de la latence de bout en bout sans gain de qualite visible pour le contenu d'ecran :

ModeDebitResolution de capture
HD8 Mbps1080p petit cote
Smooth4 Mbps1080p petit cote
Low2 Mbps720p petit cote

Configuration faible latence de l'encodeur

MediaCodecVideoEncoder configure l'encodeur une seule fois a la creation :

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 et KEY_LATENCY=1 sont les cles de la faible latence -- ils indiquent a l'encodeur de privilegier l'encodage en temps reel plutot que le taux de compression. L'entree est une Surface creee par MediaCodec.createInputSurface() et alimentee directement a VirtualDisplay -- pas de lecture SurfaceTexture, pas de conversion I420, aucun CPU ne touche aux pixels.

Resolution de capture

ScreenMirrorCaptureSize.compute() derive la taille de capture reelle a partir de la taille physique de l'ecran, de la cible du petit cote du mode de qualite (720/1080), et du maxWidth/maxHeight et de l'alignement largeur/hauteur rapportes par l'encodeur (interroges une fois via MediaCodecVideoEncoder.queryEncoderCaps()), de sorte que l'encodeur ne recoive jamais de dimensions qu'il ne peut pas accepter.

Demandes de Keyframe

Le client web peut demander une trame IDR via la mutation GraphQL requestScreenMirrorKeyFrame pour recuperer les paquets perdus. Android repond 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 et Diffusion de Keyframe

Apres le demarrage de l'encodeur, INFO_OUTPUT_FORMAT_CHANGED fournit csd-0/csd-1 (SPS/PPS), que ScreenMirrorPipeline assemble en un seul blob de configuration Annex-B et met en cache (cachedConfig). Le premier IDR qui suit est egalement mis en cache (cachedKeyFrame) afin qu'un client web fraichement connecte puisse recuperer les deux via la requete GraphQL screenMirrorVideoCodec sans attendre le prochain intervalle de keyframe. Lorsque la configuration vient de changer (orientation ou changement de qualite), Android n'envoie pas le nouvel IDR comme un paquet video normal -- il regroupe SPS/PPS + IDR en un seul evenement WebSocket screen_mirror_video_codec, de sorte que le client web effectue la reconfiguration du decodeur et le decodage de la premiere trame en une seule operation au lieu de confronter un decodeur obselete a un nouveau flux binaire.

Certains encodeurs OEM (Qualcomm/Xiaomi) regroupent SPS+PPS+IDR dans un seul tampon de sortie portant a la fois BUFFER_FLAG_CODEC_CONFIG et BUFFER_FLAG_SYNC_FRAME. La boucle de vidage ne saute que les tampons qui sont pure configuration (isConfig && !isKey) -- sauter un tampon marque config qui transporte egalement la trame de synchronisation supprimerait silencieusement l'IDR et laisserait le decodeur avec seulement des P-trames, produisant un affichage mosaique.

Conception du protocole VideoPacket

Les trames video et audio sont toutes deux encapsulees dans le protocole binaire unifie VideoPacket pour le transport WebSocket.

Format du protocole

+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+--------+
| 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 -/
ChampTailleDescription
MAGIC1 octetFixe 0x56 ('V'), pour validation
FLAGS1 octet0x01=keyframe, 0x02=config, 0x04=audio
FRAME_ID4 octetsNumero de trame augmentant de maniere monotone, uint32 big-endian
TIMESTAMP8 octetsPTS de l'encodeur en microsecondes, big-endian
DATAvariableUnite NAL H.264 ou donnees Opus

Le VideoPacket.encode() d'Android (dans commonMain, donc son format filaire est couvert par des tests unitaires JVM sans aucune dependance Android) et le parseVideoPacket() du web implementent ce format independamment -- il n'y a pas de bibliotheque de serialisation partagee, juste une spec que les deux cotes respectent.

Diagram 2
2

Notes de conception

  • Analyse non signee de FRAME_ID : ((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0 -- doit utiliser >>> 0 pour garantir le non-signe, sinon frameId > 2^31 est analyse comme negatif, provoquant une fausse detection de perte.
  • FRAME_ID ne se reinitialise jamais : Lorsque l'encodeur est reconstruit pour un changement d'orientation, frameId continue de s'incrementer (il vit dans ScreenMirrorPipeline, pas dans l'encodeur). Cela permet au cote web de detecter la perte de trame pendant la rotation via les ecarts de frameId.
  • TIMESTAMP utilise le PTS de l'encodeur : Aucune dependance a l'horloge du client web, evitant la derive d'horloge provoquant une desynchronisation A/V.
  • Analyse zero-copie : l'analyseur web decoupe la charge utile avec Uint8Array.subarray() -- une vue dans le ArrayBuffer WebSocket original, pas une copie.

Pipeline de decodage video (Web)

WebCodecs VideoDecoder

Le client web utilise le VideoDecoder de l'API WebCodecs pour le decodage materiel. Compare a MediaSource Extensions ou WebRTC, WebCodecs offre un controle fin du processus de decodage -- pas de tampon de gigue, pas de couche conteneur, et les objets VideoFrame decodes peuvent etre directement telecharges comme textures 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', lu depuis le NAL SPS
    avc: { format: 'annexb' },
    optimizeForLatency: true,
    hardwareAcceleration: 'prefer-hardware',
})

Configurations cles :

  • optimizeForLatency: true -- indique au decodeur de privilegier la faible latence, pas de mise en tampon des trames
  • hardwareAcceleration: 'prefer-hardware' -- preferer le decodage GPU
  • avc: { format: 'annexb' } -- utiliser le format Annex-B avec SPS/PPS en ligne avant chaque IDR
  • la chaine de codec elle-meme n'est pas codee en dur -- extractAvc1CodecString() lit les octets profile/compat/niveau directement du premier NAL SPS dans le blob de configuration

Probleme d'ecran vert et sequence de demarrage

L'encodeur produit sa premiere trame IDR avant que VirtualDisplay n'ait rendu le contenu reel de l'ecran -- c'est une trame vide (verte). Si le cote web decode cette trame, l'utilisateur voit un flash vert jusqu'a ce que le contenu de l'ecran change et declenche une nouvelle trame.

Solution : Au demarrage, le cote web recupere la configuration mise en cache via la requete GraphQL screenMirrorVideoCodec mais ne decode pas la keyframe regroupee. Au lieu de cela, il appelle video.requestIdr() pour definir waitingForIdr = true (supprimant toutes les P-trames jusqu'a l'arrivee d'un IDR), puis appelle requestKeyFrame() pour demander un nouvel IDR via la meme mutation utilisee pour la recuperation de perte. Au moment ou le nouvel IDR arrive, VirtualDisplay a un contenu d'ecran reel.

video.requestIdr()      // supprimer les P-trames, attendre IDR
await requestKeyFrame() // demander un nouvel IDR via la mutation GraphQL

Le callback onFirstFrameRendered est lie a renderFrame() plutot qu'a handleVideo(), garantissant que l'interface utilisateur ne se met a jour qu'apres le rendu d'une trame reelle -- pas simplement apres sa reception.

Rendu WebGL2

Rendu direct GPU zero-copie

Les objets VideoFrame decodes sont directement telecharges comme textures WebGL2, sans jamais passer par le CPU :

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

gl.texImage2D accepte VideoFrame comme source de pixels. Le navigateur gere la conversion YUV->RGB et le telechargement GPU en interne -- pas de copie CPU ImageData. MirrorGLRenderer tombe sur drawImage() du Canvas 2D si getContext('webgl2', ...) echoue, donc les navigateurs plus anciens obtiennent toujours une image (avec une latence legerement plus elevee).

Contexte desynchronized

const gl = canvas.getContext('webgl2', {
    alpha: false,
    desynchronized: true,        // contourner le compositeur, ecrire directement sur l'ecran
    preserveDrawingBuffer: true, // conserver le buffer pour les captures d'ecran
    powerPreference: 'high-performance',
    antialias: false,
    depth: false,
    stencil: false,
    premultipliedAlpha: false,
})

desynchronized: true contourne le compositeur du navigateur, ecrivant directement sur l'ecran, economisant environ 1 trame de latence d'affichage (~16 ms @ 60 ips).

preserveDrawingBuffer: true conserve le buffer de dessin afin que les captures d'ecran canvas.toDataURL() puissent lire le contenu. Avec la valeur par defaut false, le buffer est efface apres la composition, produisant des captures d'ecran noires.

Le shader lui-meme est deliberement minimal -- un vertex shader de triangle plein ecran et un fragment shader d'une ligne qui echantillonne la texture -- car le seul travail necessaire par trame est "mettre cette texture sur l'ecran."

Ajustement automatique du Canvas

La taille du backing store du canvas est definie a partir de VideoFrame.displayWidth/Height chaque fois qu'elle change. La taille CSS est ensuite ajustee au conteneur wrapper par fitCanvasToWrapper() tout en preservant le rapport d'aspect (letterboxing ou pillarboxing selon les besoins). Un ResizeObserver sur l'element parent du canvas reexecute cet ajustement chaque fois que le conteneur est redimensionne, de sorte que la video ne s'etire jamais.

Detection de perte et recuperation d'erreur

Detection d'ecart FrameId

Chaque trame video porte un frameId augmentant de maniere monotone. Le decodeur suit lastFrameId ; si le frameId d'une nouvelle trame est frameId > lastFrameId + 1, des trames ont ete perdues :

if (!this.waitingForIdr && this.lastFrameId > 0
    && packet.frameId > this.lastFrameId + 1) {
    if (!packet.isKeyFrame) {
        // Perte : supprimer les P-trames suivantes, demander un nouvel IDR
        this.waitingForIdr = true
        this.onRequestKeyFrame?.()
        this.lastFrameId = packet.frameId
        return
    }
}

Machine d'etat waitingForIdr

waitingForIdr est une machine a deux etats simple :

Diagram 3
3

EtatComportement
NORMALDecoder toutes les trames normalement
WAITING_FOR_IDRSupprimer toutes les P-trames, decoder uniquement les trames IDR ; repasser a NORMAL quand l'IDR arrive

Scenarios qui declenchent la transition vers WAITING_FOR_IDR :

  1. Au demarrage : ignorer la keyframe GraphQL obselete, attendre le vrai IDR
  2. Sur perte de paquet : supprimer les P-trames non decodables, attendre la recuperation IDR
  3. Sur erreur du decodeur : reinitialiser le decodeur, attendre IDR
  4. Sur changement de configuration : supprimer les P-trames residuelles apres changement d'orientation/qualite

Recuperation d'erreur du decodeur

Lorsque VideoDecoder.onerror se declenche, decoderNeedsReset = true est defini dans la couche pipeline (screen-mirror-pipeline.ts). A la prochaine trame IDR, le decodeur est reconfigure avec les SPS/PPS mis en cache au lieu de refaire un aller-retour vers GraphQL :

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

Contre-pression et deduplication des timestamps

Si decoder.decodeQueueSize > 5, les P-trames entrantes sont supprimees plutot que mises en file d'attente -- le seuil de 5 (plutot que 2) tolere la latence de demarrage du decodeur materiel sans provoquer de saccades inutiles. Separement, apres le rendu, lastRenderedPts est enregistre ; une trame dont le timestamp est plus ancien (arrivee hors ordre) est supprimee sauf s'il s'agit d'une keyframe :

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

Gestion du changement d'orientation

Reconstruction de l'encodeur

Un OrientationEventListener dans ScreenMirrorService compare la rotation de l'affichage avec le drapeau isPortrait mis en cache a chaque callback du capteur ; seul un veritable basculement portrait/paysage appelle pipeline.onOrientationChanged() et invalide le cache de taille d'ecran d'accessibilite utilise pour le mise a l'echelle des coordonnees tactiles.

Diagram 4
4

rebuildEncoderAndResize() :

  1. Creer un nouvel encodeur aux nouvelles dimensions (ex. paysage 1920x1080)
  2. Basculer VirtualDisplay.surface vers la Surface d'entree du nouvel encodeur
  3. Arreter l'ancien encodeur
  4. VirtualDisplay.resize() aux nouvelles dimensions

Le basculement de surface se fait avant le redimensionnement -- garantissant que le nouvel encodeur recoit les trames en premier, et que l'ancien encodeur est arrete avant de pouvoir recevoir des trames aux mauvaises dimensions. Si virtualDisplay?.surface = ... echoue, la reconstruction est abandonnee et l'ancien encodeur reste actif plutot que de laisser le pipeline sans encodeur du tout.

Notification de changement de configuration

Lorsque le nouvel encodeur produit ses premiers SPS/PPS, pendingConfigBroadcast est defini sur le pipeline. Lorsque le premier IDR du nouvel encodeur arrive, il est regroupe avec cette configuration dans un seul evenement screen_mirror_video_codec au lieu d'etre envoye comme un paquet video ordinaire.

Le client web ensuite, dans handleConfig() :

  1. Reconfigure le decodeur avec les nouveaux SPS/PPS
  2. Decode immediatement la trame IDR regroupee
  3. Appelle video.requestIdr() pour supprimer les eventuelles P-trames residuelles de l'ancien encodeur encore en transit
  4. Appelle requestKeyFrame() pour demander un IDR propre et frais

Les etapes 3-4 sont un filet de securite -- meme si le premier IDR du nouvel encodeur a des dimensions incorrectes (pendant la fenetre de redimensionnement asynchrone), le client web recupere rapidement les bonnes dimensions. handleConfig() court-circuite egalement si la configuration entrante est octet-identique a celle en cache, car reconfigurer le decodeur avec des octets inchanges est une operation sans effet qui coute quand meme un IDR pour recuperer.

Cycle de vie du MediaProjection systeme

Le probleme

Les utilisateurs peuvent fermer le cast d'ecran au niveau systeme (MediaProjection) via la barre de notification Android, plutot que via l'interface de l'application. Dans ce cas, ScreenMirrorService ne sait pas que le cast est arrete -- running reste true, le client web interroge screenMirrorState et obtient true, mais aucune trame video n'arrive, et la page reste bloquee sur le chargement.

MediaProjection.Callback

MediaProjection fournit un callback Callback.onStop() qui se declenche lorsque le systeme arrete le cast. ScreenMirrorPipeline.startEncoders() enregistre ce callback et appelle ScreenMirrorService.instance?.stop() dans onStop() :

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

Diagram 5
5

Responsabilites de Service.stop()

stop() est le point d'arret explicite, responsable de notifier le client web et d'arreter le service :

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

La garde if (!running) return empeche la recursion : onStop() -> stop() -> stopSelf() -> onDestroy() -> pipeline.stop() -> projection.stop() -> onStop() -> stop() (a ce stade running=false, retourne immediatement).

Gestion cote Web

Lorsque le client web recoit l'evenement {"running":false}, il se reinitialise a l'etat inactif et affiche le bouton de demarrage :

const onScreenMirroring = (data: any) => {
    if (data?.running === false) {
        cleanupFn()
        fullReset()
        return
    }
    // running=true -> se connecter au flux
}

Controle a distance : injection tactile

Le screen mirroring est unidirectionnel par defaut (video/audio uniquement) ; le controle a distance est optionnel et necessite que l'utilisateur active une fois le service d'accessibilite de PlainApp, car Android n'a pas d'API publique pour injecter des evenements tactiles arbitraires en dehors de AccessibilityService.dispatchGesture().

Diagram 6
6

Normalisation des coordonnees (Web)

Une superposition transparente se trouve au-dessus du <canvas> et capture les evenements du pointeur. normalizeCoords() convertit des coordonnees clientX/clientY brutes en coordonnees [0,1] relatives a la zone de contenu video reelle -- pas a la boite englobante de la superposition -- en calculant le decalage letterbox/pillarbox a partir du rapport d'aspect du backing store du canvas par rapport au rapport d'aspect de son conteneur rendu :

if (videoAspect > containerAspect) {
    // Letterbox haut/bas
    renderW = containerW
    renderH = containerW / videoAspect
    offsetY = (containerH - renderH) / 2
} else {
    // Pillarbox gauche/droite
    renderH = containerH
    renderW = containerH * videoAspect
    offsetX = (containerW - renderW) / 2
}

Une pression du pointeur demarre un GestureState qui suit la position/heure de depart ; un maintien de 500 ms avec un mouvement < 10 px evolue en LONG_PRESS, un mouvement au-dela de ce seuil devient un SWIPE, et un relachement rapide est un TAP. Un indicateur visuel de toucher (un point qui grandit puis s'estompe) donne a l'operateur un retour sur le geste reconnu, avant meme que le telephone ne reponde.

GraphQL -> AccessibilityService

Chaque geste reconnu est envoye comme une mutation sendScreenMirrorControl(input) portant une action (TAP/LONG_PRESS/SWIPE/SCROLL/BACK/HOME/RECENTS/LOCK_SCREEN/KEY) plus les coordonnees normalisees. Le resolver appelle dispatchScreenMirrorControl(), qui multiplie les coordonnees normalisees par la taille reelle de l'ecran (depuis PlainAccessibilityService.getScreenSize(), invalide a chaque changement d'orientation) et delegue 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 et LONG_PRESS construisent le meme GestureDescription avec une duree de trajet plus longue ou un chemin de ligne au lieu d'un point unique ; SCROLL est implemente comme un balayage synthetique de (x, y) a (x, y + deltaY) limite a +/-500 px. Les quatre actions globales (BACK/HOME/RECENTS/LOCK_SCREEN) contournent entierement l'envoi de geste et appellent directement performGlobalAction(). Si le service d'accessibilite n'est pas active, le resolver lance une GraphQLError plutot que de supprimer silencieusement l'entree, afin que l'interface web puisse inviter l'utilisateur a l'activer.

Pipeline audio

Encodage Opus Android

MediaCodecAudioEncoder utilise AudioPlaybackCaptureConfiguration (construit a partir du meme MediaProjection) pour capturer l'audio systeme via AudioRecord, alimentant le PCM brut dans un encodeur Opus MediaCodec. Cela necessite Android 10+ et l'autorisation RECORD_AUDIO -- sur les appareils plus anciens ou sans l'autorisation, start() enregistre un avertissement et ignore completement l'audio (la video continue de fonctionner). Les paquets Opus encodes sont encapsules dans le meme protocole VideoPacket (avec le drapeau FLAG_AUDIO defini) et partagent le canal WebSocket SCREEN_MIRROR_AUDIO des paquets video.

Decodage Opus Web

ScreenMirrorAudioPipeline utilise WebCodecs AudioDecoder pour decoder les donnees Opus, produisant des AudioData achemines vers un element <audio>. Le timestamp de la trame audio est utilise pour la synchronisation A/V -- partageant la meme base de temps (PTS de l'encodeur, en microsecondes) que les trames video, donc aucune negociation d'horloge separee n'est necessaire entre les deux flux.

Optimisations des performances

Chemins zero-copie

CheminMethode
VirtualDisplay -> Surface encodeurGPU direct, passage de Surface
VideoDecoder -> VideoFrame -> texture WebGLgl.texImage2D(VideoFrame), GPU direct
Reception WebSocket -> analyse VideoPacketUint8Array.subarray() est une vue, pas de copie

Optimisation avccToAnnexB

Certains encodeurs Android produisent le format AVCC (prefixe de longueur de 4 octets), qui necessite une conversion au format Annex-B (code de debut 00 00 00 01) pour le decodage WebCodecs.

L'implementation initiale utilisait ArrayList<Byte> avec un boxage par octet -- une trame IDR de 50 Ko produisait 50 000 operations de boxage java.lang.Byte, creant une pression GC massive. L'optimisation utilise un scan en deux passes + copyInto (qui correspond a l'intrinseque System.arraycopy sur JVM) :

// Premiere passe : calculer la taille de sortie
var outSize = 0
// Deuxieme passe : copie en bloc
val out = ByteArray(outSize)
avcc.copyInto(out, writeOff + 4, off + 4, off + 4 + len)

Strategie de suppression des P-trames

Le decodeur peut etre lent pendant l'initialisation. Si la file d'attente des P-trames est trop longue, la latence s'accumule. Le seuil de taille de la file de decodage est fixe a > 5 (plutot que > 2) pour eviter une perte de trames excessive pendant l'initialisation du decodeur materiel.

Deduplication des demandes IDR

La garde waitingForIdr garantit qu'une seule demande IDR est faite par evenement de perte, empechant les demandes en double pendant l'attente d'un IDR.

Recapitulatif des patterns de conception

PatternEmplacementRaison
Machine d'etatDrapeau waitingForIdrTransitions d'etat explicites pour la suppression/recuperation des P-trames
Garde de recursionif (!running) return dans stop()Empeche la recursion onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop
Pipeline zero-copieVideoFrame -> gl.texImage2DTelechargement direct de texture GPU, pas de copie CPU
Scan en deux passesavccToAnnexBPre-calcul de la taille, allocation unique + copie en bloc, elimine le boxage
Evenement regroupeSPS/PPS + IDR dans un seul evenementLe changement de configuration complete la reconfiguration + le decodage de la premiere trame en un seul evenement
Separation des callbacksonFirstFrameRendered vs onDisconnected vs onScreenMirrorOffDistinction claire entre le rendu de la premiere trame, l'echec du transport et l'arret cote telephone
Filet de securiterequestIdr() + requestKeyFrame()Supprimer les trames residuelles + demander un IDR propre apres un changement de configuration
Deduplication PTStimestamp < lastRenderedPtsSupprimer les trames hors ordre
Ecart FrameIdframeId > lastFrameId + 1Detection de perte de paquet sans ACK
Contexte desynchronizedWebGL2 desynchronized: trueContourner le compositeur, economiser 1 trame de latence
Echec rapide explicitesendScreenMirrorControl lance GraphQLErrorRemonte "accessibilite desactivee" au lieu de supprimer silencieusement l'entree

Pour aller plus loin

  • WebCodecs API -- Documentation MDN couvrant les interfaces VideoDecoder/AudioDecoder.