Ingénierie

Comment Ridealong diffuse un vrai Chrome vers ton téléphone en H.264

Un onglet Chrome, un processus ffmpeg, un websocket et un élément <video>. C'est la moitié streaming de Ridealong, lue directement dans le code, avec les parties ingrates qui lui ont permis de survivre aux vrais réseaux.

Claude Code qui pilote un navigateur, c'est formidable jusqu'au moment où il tombe sur une page de connexion, un CAPTCHA ou un bouton « Payer ». À ce moment-là tu veux voir exactement ce qu'il voit, et parfois tu veux reprendre la main. Sur le portable où tourne Chrome, un outil de capture d'écran couvre l'essentiel. Depuis un téléphone, à travers un tunnel, pendant que l'agent est en pleine tâche, une capture toutes les quelques secondes n'est pas une vue. C'est un diaporama.

Ridealong est un serveur MCP open source pour Claude Code qui pilote un vrai Google Chrome et le diffuse vers une page web que tu peux regarder, pointer, mettre en pause, approuver ou reprendre depuis ton téléphone. Ce billet parle de la moitié streaming : comment un onglet Chrome finit en H.264 dans un élément <video> sur un téléphone, et le travail ingrat qui l'a rendu fiable. Tout ce qui suit est lu dans le code ; les chiffres sont les vraies valeurs par défaut.

Ridealong sur un téléphone : le sélecteur Auto et Manuel, une carte d'approbation et l'image live de Chrome
La cible : un vrai Chrome, en direct sur un téléphone, avec une carte d'approbation par-dessus.

Pourquoi le screencast JPEG ne suffisait pas

Le protocole Chrome DevTools a Page.startScreencast. Donne-lui un format, une qualité et une taille maximale, et Chrome t'envoie un JPEG à chaque fois que l'onglet se redessine. La première vue live de Ridealong, c'était exactement ça. Chaque spectateur ouvre un websocket et annonce combien de pixels physiques il peut afficher (taille CSS fois devicePixelRatio) ; le serveur plafonne le screencast à cette taille, bornée entre 320 et 4096 px, en JPEG qualité 85. Un téléphone reçoit un flux à sa taille, un écran retina reçoit tout ce que Chrome sait rendre, et personne ne paie pour des pixels qu'il ne peut pas voir.

Ce chemin existe toujours, c'est le repli. Comme chemin principal, il a trois problèmes. Chaque image est une image complète : aucune compression inter-images, donc un défilement est une rafale de JPEG entiers, et sur un téléphone derrière un tunnel le socket s'engorge et l'image saccade exactement quand le mouvement compte. Chaque spectateur a son propre screencast, donc Chrome encode une fois par spectateur. Et les images voyagent en base64 dans des messages JSON texte, un tiers d'octets en plus pour rien.

Le H.264 règle le premier problème par construction : une P-frame sur une page qui n'a pas changé ne coûte presque rien, et un défilement, c'est surtout des vecteurs de mouvement. Un seul encodeur partagé par tous les spectateurs règle le deuxième. Des trames websocket binaires règlent le troisième.

Le pipeline

Pipeline vidéo de Ridealong Google Chrome émet des images JPEG de screencast vers ridealong, qui les écrit sur l'entrée standard de ffmpeg en MJPEG. ffmpeg encode en H.264 avec NVENC, VideoToolbox ou libx264 et sort du MP4 fragmenté en fragments de 100 ms. Un découpeur sépare le segment d'initialisation des segments média ; ils partent sur un websocket en trames binaires marquées 0x01 et 0x02 vers le téléphone, où Media Source Extensions les joue dans un élément video. En mode Manuel, la saisie du téléphone retourne vers Chrome et n'est jamais stockée. Google Chrome Page.startScreencast · JPEG q92 ridealong un screencast, encodeur partagé MJPEG ffmpeg nvenc · videotoolbox · libx264 fMP4 frag_keyframe 100 ms Mp4Splitter ftyp+moov · paires moof+mdat websocket 0x01 init · 0x02 segment ton téléphone MSE · avc1.42E01E · sequence Mode Manuel Input.dispatch* jamais stocké
Six étapes, un processus par Chrome, un octet de cadrage sur le fil.

La source reste le screencast. Un screencast par Chrome, pas par spectateur, à la taille du viewport, JPEG qualité 92, qui alimente l'encodeur. L'alternative évidente sous Linux serait x11grab, mais le screencast suit déjà exactement l'onglet diffusé et son viewport, sans barre d'onglets ni autre fenêtre, et il ne coûte rien sur une page statique : Chrome n'émet aucune image, ffmpeg ne reçoit rien.

ffmpeg lit stdin comme du MJPEG. Les images sont écrites sur l'entrée standard de l'encodeur avec -f mjpeg -i pipe:0. Deux options font l'essentiel du travail : -use_wallclock_as_timestamps 1 et -fps_mode passthrough. Les horodatages sont l'heure réelle, passés tels quels, sans duplication à cadence constante, donc une longue période statique ne produit rien au lieu d'une rafale d'images identiques.

L'encodeur, c'est ce que la machine a. Au démarrage, Ridealong lance ffmpeg -encoders une fois et prend le premier de h264_nvenc, h264_videotoolbox, libx264. Sur le chemin NVIDIA, les réglages privilégient la qualité : preset p4, tune basse latence, VBR piloté par une cible de qualité constante de 19 avec AQ spatial et temporel, et mode zéro latence. Comparé en 1080p à p1 en CBR, ça fait environ 1,7 fois les octets sur une mire de pire cas et un texte d'interface visiblement plus net. VideoToolbox tourne en mode temps réel ; libx264 utilise ultrafast et zerolatency. Les trois produisent du Constrained Baseline, sans B-frames, avec une image clé toutes les deux secondes. Le débit cible suit la taille de l'image : 5 Mb/s en 1440x900, environ 8 Mb/s en 1920x1080, maxrate à 1,75 fois ça.

La sortie est du MP4 fragmenté. -movflags frag_keyframe+empty_moov+default_base_moof avec une durée de fragment de 100 ms. Un découpeur de boîtes incrémental lit la sortie standard de ffmpeg : il collecte ftyp et moov une seule fois comme segment d'initialisation, puis associe chaque moof à son mdat en un segment média. Tout le reste au niveau supérieur est ignoré.

Sur le fil, un octet de cadrage. Les segments partent en messages websocket binaires sur le même socket que le trafic de contrôle JSON (mode, onglets, cartes d'approbation, pointages). Le premier octet dit ce que c'est : 0x01 pour un segment d'initialisation, 0x02 pour un segment média. C'est tout le protocole.

Dans le navigateur, Media Source Extensions. La page crée un MediaSource (ou ManagedMediaSource sur Safari iOS) avec la chaîne de codec avc1.42E01E, Constrained Baseline 3.0, que tous les téléphones décodent en matériel. Le SourceBuffer tourne en mode sequence : un message 0x01 ouvre un MediaSource neuf, les messages 0x02 sont ajoutés dans l'ordre. Toutes les 250 ms, une boucle vérifie le retard de la lecture sur la fin du tampon. En Auto, elle tolère 0,9 s et pousse playbackRate à 1,1 passé 0,6 s. En Manuel, chaque tap compte, donc elle accélère passé 0,18 s et saute au direct passé 0,5 s. Le tampon est taillé derrière la tête de lecture pour qu'une longue session ne fasse pas gonfler la mémoire.

Deux détails donnent cette impression de continuité. Un retardataire a besoin d'un segment d'initialisation et d'une image clé ; la réponse simple et correcte est de redémarrer l'encodeur quand quelqu'un arrive, regroupé sur 150 ms, pour que tout le monde reçoive un init frais et une image IDR. Et comme Chrome n'émet une image que quand quelque chose change, une page immobile affamerait ffmpeg et bloquerait MSE. Donc au repos, le serveur renvoie la dernière image JPEG toutes les 500 ms quand rien n'est arrivé depuis 450 ms, à peu près 2 images par seconde. En VBR ce sont des P-frames identiques qui ne coûtent presque rien.

Les parties ingrates qui l'ont rendu fiable

Les déconnexions. Le proxy de Cloudflare coupe un websocket qui ne transporte aucun octet pendant environ 100 secondes ; mesuré à travers le tunnel, c'était un code de fermeture 1006 à 125 s. Une page de connexion statique ne produit aucune image de screencast, donc sans aide la vue live « se déconnectait » toutes les deux minutes d'attente. Le serveur envoie maintenant un ping au niveau du protocole toutes les 30 secondes, une marge de 3x. Le navigateur répond lui-même aux pings, la page ne les voit jamais, et ils ne transportent rien.

La reconnexion. Le lien tombe aussi pour des raisons ordinaires : le téléphone se verrouille, Safari passe en arrière-plan, le serveur MCP redémarre. La page garde la dernière image à l'écran et se reconnecte toute seule avec un délai qui part de 1 s et double jusqu'à 10 s, en réessayant tout de suite sur visibilitychange et online. Seul le code de fermeture 1008, un jeton invalide ou expiré, est définitif.

Un chien de garde avec repli JPEG. Quatre secondes après l'ouverture du socket sur le chemin vidéo, si l'élément <video> n'a rien décodé (pas d'init, ou un codec refusé par le navigateur), la page marque la vidéo comme échouée et se reconnecte sur le chemin JPEG au lieu d'afficher du noir. Si ffmpeg meurt côté serveur (pas d'encodeur, GPU occupé), les spectateurs sont prévenus et font pareil. Un bouton bascule entre les deux chemins à la main.

La contre-pression. Si le tampon stdin de ffmpeg dépasse 8 Mo, l'image suivante est sautée plutôt que mise en file ; celle d'après porte la même image. Un segment média ne peut pas être sauté sans casser le décodeur jusqu'à la prochaine image clé, donc un spectateur vidéo avec plus de 4 Mo en attente est coupé et se reconnecte pour un init frais. Sur le chemin JPEG, un spectateur au-dessus de 1 Mo perd juste une image.

Les jetons. Un lien live, c'est exp.hmac : une expiration unix et un HMAC-SHA256 par-dessus, comparé en temps constant. Le TTL est borné entre 30 s et 1 h, 15 min par défaut, parce qu'une connexion par passkey peut prendre plus de 5 min. Le jeton voyage dans le fragment d'URL, donc il n'atteint jamais le serveur ni les journaux d'accès Cloudflare ; la page l'efface de l'historique avec replaceState et le garde en sessionStorage pour qu'un rechargement réflexe ne te bloque pas dehors. Ouvrir un lien valide frappe un jeton d'appareil de 30 jours, signé avec une clé dérivée et stocké en localStorage, donc l'adresse nue fonctionne sur ce téléphone à partir de là. Les clients de contrôle utilisent une troisième clé dérivée, donc un lien spectateur ne peut jamais en tenir lieu. Changer le secret révoque tout.

Un Chrome, une vue live. Deux sessions Claude Code sur le même Chrome se disputeraient le port. À la place, la deuxième tombe sur EADDRINUSE et suit la première comme client de contrôle : elle reçoit les changements de mode, de pause et d'action, annonce ses propres actions et demande les approbations à travers le propriétaire. Quand le propriétaire disparaît, le suiveur prend le port et se promeut, donc les liens qu'il a distribués continuent de fonctionner.

Le mode Manuel ne capture rien. Quand tu prends le volant, les messages souris, clavier et pincement sont analysés seulement pour retirer le champ de type et passer le reste à Input.dispatchMouseEvent, Input.dispatchKeyEvent ou Input.synthesizePinchGesture. Rien n'est poussé dans un tableau, rien n'est journalisé, aucune propriété du serveur n'accumule de frappes, et les outils de perception de Claude refusent de s'exécuter tant que la vue est en mode input. Comme une page de connexion statique ne produit aucune image, le changement de mode est poussé explicitement à chaque spectateur plutôt que d'attendre la prochaine image.

La suite

Le débit adaptatif pour les liens mobiles faibles est le trou évident : aujourd'hui l'encodeur vise un débit par résolution et le spectateur se débrouille. L'enregistrement de session, la vidéo plus le journal en chapitres, est sur la feuille de route. Et oui, WebRTC gagnerait de la latence ; ça voudrait aussi dire une pile média dans Node et une histoire de STUN/TURN, là où du MP4 fragmenté sur un websocket que le spectateur avait déjà, c'était un processus et un port.

Essaie-le dans Claude Code

Une commande sur la machine que Claude pilote. Ensuite, demande à Claude d'ouvrir un site et de te donner la vue live.

claude mcp add ridealong -- npx -y -p ridealong-mcp ridealong
Le code sur GitHub Page d'accueil Ridealong