Table des matieres
- Architecture generale
- Pipeline d'encodage video (Android)
- Conception du protocole VideoPacket
- Pipeline de decodage video (Web)
- Rendu WebGL2
- Detection de perte et recuperation d'erreur
- Gestion du changement d'orientation
- Cycle de vie du MediaProjection systeme
- Controle a distance : injection tactile
- Pipeline audio
- Optimisations des performances
- Recapitulatif des patterns de conception
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
| Couche | Android | Web |
|---|---|---|
| Capture d'ecran | MediaProjection + VirtualDisplay | — |
| Encodage video | MediaCodec encodeur materiel H.264 | — |
| Encodage audio | MediaCodec encodeur materiel Opus | — |
| Transport | Evenements binaires WebSocket | Recepteur WebSocket |
| Decodage video | — | WebCodecs VideoDecoder |
| Decodage audio | — | WebCodecs AudioDecoder -> <audio> |
| Rendu | — | Texture WebGL2 en rendu direct |
| Controle | AccessibilityService injection de gestes | Superposition 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 :
| Parametre | Valeur | Notes |
|---|---|---|
KEY_FRAME_RATE | 60 | 60 ips pour la fluidite |
KEY_I_FRAME_INTERVAL | 10 | Intervalle IDR de 10 s, reduit le cout des keyframes |
KEY_BIT_RATE_MODE | VBR (implicite, aucun mode explicite defini) | Debit variable, adaptation a la scene |
KEY_PRIORITY | 0 | Priorite temps reel |
KEY_LATENCY | 1 | Mode 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 :
| Mode | Debit | Resolution de capture |
|---|---|---|
| HD | 8 Mbps | 1080p petit cote |
| Smooth | 4 Mbps | 1080p petit cote |
| Low | 2 Mbps | 720p 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 -/
| Champ | Taille | Description |
|---|---|---|
MAGIC | 1 octet | Fixe 0x56 ('V'), pour validation |
FLAGS | 1 octet | 0x01=keyframe, 0x02=config, 0x04=audio |
FRAME_ID | 4 octets | Numero de trame augmentant de maniere monotone, uint32 big-endian |
TIMESTAMP | 8 octets | PTS de l'encodeur en microsecondes, big-endian |
DATA | variable | Unite 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.
Notes de conception
- Analyse non signee de
FRAME_ID:((buf[2] << 24) | (buf[3] << 16) | (buf[4] << 8) | buf[5]) >>> 0-- doit utiliser>>> 0pour garantir le non-signe, sinonframeId > 2^31est analyse comme negatif, provoquant une fausse detection de perte. FRAME_IDne se reinitialise jamais : Lorsque l'encodeur est reconstruit pour un changement d'orientation,frameIdcontinue de s'incrementer (il vit dansScreenMirrorPipeline, pas dans l'encodeur). Cela permet au cote web de detecter la perte de trame pendant la rotation via les ecarts de frameId.TIMESTAMPutilise 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 leArrayBufferWebSocket 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 trameshardwareAcceleration: 'prefer-hardware'-- preferer le decodage GPUavc: { 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 :
| Etat | Comportement |
|---|---|
NORMAL | Decoder toutes les trames normalement |
WAITING_FOR_IDR | Supprimer 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 :
- Au demarrage : ignorer la keyframe GraphQL obselete, attendre le vrai IDR
- Sur perte de paquet : supprimer les P-trames non decodables, attendre la recuperation IDR
- Sur erreur du decodeur : reinitialiser le decodeur, attendre IDR
- 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.
rebuildEncoderAndResize() :
- Creer un nouvel encodeur aux nouvelles dimensions (ex. paysage 1920x1080)
- Basculer
VirtualDisplay.surfacevers laSurfaced'entree du nouvel encodeur - Arreter l'ancien encodeur
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() :
- Reconfigure le decodeur avec les nouveaux SPS/PPS
- Decode immediatement la trame IDR regroupee
- Appelle
video.requestIdr()pour supprimer les eventuelles P-trames residuelles de l'ancien encodeur encore en transit - 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)
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().
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
| Chemin | Methode |
|---|---|
| VirtualDisplay -> Surface encodeur | GPU direct, passage de Surface |
| VideoDecoder -> VideoFrame -> texture WebGL | gl.texImage2D(VideoFrame), GPU direct |
| Reception WebSocket -> analyse VideoPacket | Uint8Array.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
| Pattern | Emplacement | Raison |
|---|---|---|
| Machine d'etat | Drapeau waitingForIdr | Transitions d'etat explicites pour la suppression/recuperation des P-trames |
| Garde de recursion | if (!running) return dans stop() | Empeche la recursion onStop -> stop -> onDestroy -> pipeline.stop -> projection.stop -> onStop |
| Pipeline zero-copie | VideoFrame -> gl.texImage2D | Telechargement direct de texture GPU, pas de copie CPU |
| Scan en deux passes | avccToAnnexB | Pre-calcul de la taille, allocation unique + copie en bloc, elimine le boxage |
| Evenement regroupe | SPS/PPS + IDR dans un seul evenement | Le changement de configuration complete la reconfiguration + le decodage de la premiere trame en un seul evenement |
| Separation des callbacks | onFirstFrameRendered vs onDisconnected vs onScreenMirrorOff | Distinction claire entre le rendu de la premiere trame, l'echec du transport et l'arret cote telephone |
| Filet de securite | requestIdr() + requestKeyFrame() | Supprimer les trames residuelles + demander un IDR propre apres un changement de configuration |
| Deduplication PTS | timestamp < lastRenderedPts | Supprimer les trames hors ordre |
| Ecart FrameId | frameId > lastFrameId + 1 | Detection de perte de paquet sans ACK |
| Contexte desynchronized | WebGL2 desynchronized: true | Contourner le compositeur, economiser 1 trame de latence |
| Echec rapide explicite | sendScreenMirrorControl lance GraphQLError | Remonte "accessibilite desactivee" au lieu de supprimer silencieusement l'entree |
Pour aller plus loin
- WebCodecs API -- Documentation MDN couvrant les interfaces
VideoDecoder/AudioDecoder.