// sous le capot

Zéro framework. Zéro build. Zéro bullshit.

BeamCam, c’est environ un millier de lignes de JavaScript brut posées sur les API standard du navigateur. Le navigateur embarque déjà une pile caméra, un codec vidéo, un transport temps réel chiffré et un contrôle de congestion. Tout le travail consiste à les brancher ensemble, puis à ne pas les gêner.

CoucheQuoiPourquoi
CapturegetUserMedia, getDisplayMediaCaméra, micro, écran. Natif, accéléré par le matériel.
TransportWebRTC via PeerJS 1.5.4Pair-à-pair, chiffré en DTLS-SRTP, contrôle de congestion intégré.
Signalisationpeerjs-server (Node) derrière CaddyÉchange le SDP et les candidats ICE sur une WebSocket. Ne voit jamais passer le média.
Traversée de NATcoturn sur :3478 UDP/TCPSTUN pour découvrir l’adresse publique, TURN comme relais de dernier recours.
EnregistrementMediaRecorderWebM/VP9 en mémoire, exporté sous forme de Blob.
InterfaceHTML + CSS, icônes Lucide, qrcode.jsTout est auto-hébergé. Pas de CDN, pas de polices chargées ailleurs.

Architecture

Il y a deux plans. Le plan de signalisation est minuscule : quelques kilo-octets de JSON pour que deux navigateurs se présentent l’un à l’autre. Le plan média transporte les mégabits, et il ne passe pas par notre serveur.

beamcam.live Caddy :443 fichiers statiques /peerjs → :9000 coturn :3478 STUN : IP publique TURN : relais Téléphone caméra getUserMedia → flux peer id : cam-xxxxxxxx répond aux appels Visionneuse peer id aléatoire appelle cam-xxxxxxxx lit le flux distant WSS: SDP + ICE WSS: SDP + ICE média : P2P, DTLS-SRTP vidéo + audio → · ← audio de retour

Le site lui-même est statique : index.html (l’app), about/ (la page d’accueil), tech.html, un script et deux feuilles de style. Caddy sert les fichiers et relaie /peerjs/* (HTTP + WebSocket) vers le serveur PeerJS sur 127.0.0.1:9000. Le serveur PeerJS tourne avec allow_discovery: false : personne ne peut donc lister les caméras en ligne.

La poignée de main, étape par étape

  1. Le téléphone caméra génère un peer id avec crypto.getRandomValues (8 octets aléatoires, rendus sous la forme cam- + 12 caractères en base 36), l’enregistre auprès du serveur de signalisation et l’affiche en texte et sous forme de QR code pointant vers https://beamcam.live/#cam-….
  2. La visionneuse démarre avec un id anonyme. Un hash #cam-… dans l’URL (issu du QR code) est mis en file d’attente et composé dès que le pair est ouvert : pas de course contre un minuteur.
  3. La visionneuse appelle la caméra avec un flux factice : un canvas noir à 1 fps et une piste AudioContext silencieuse. C’est voulu. L’offre SDP contient alors une m-line audio et une m-line vidéo, si bien que la réponse peut transporter du vrai média dans les deux sens, sans renégociation.
  4. La caméra décroche automatiquement avec son vrai MediaStream (720p idéalement, fps choisi, micro actif).
  5. Puis vient ICE : candidats host (LAN), server-reflexive (STUN), relay (TURN). La meilleure paire fonctionnelle l’emporte. La caméra interroge iceConnectionState pour afficher « Connecté » dans l’interface.
  6. À partir de là, le serveur est hors circuit jusqu’à ce que quelqu’un raccroche.
// visionneuse : on propose audio + vidéo alors qu'on n'a encore rien à envoyer
const canvasStream = canvas.captureStream(1);
const silence = new AudioContext().createMediaStreamDestination();
const dummy = new MediaStream([
  ...canvasStream.getVideoTracks(), ...silence.stream.getAudioTracks()
]);
currentCall = peer.call(targetId, dummy);

Traversée de NAT : même Wi-Fi, mesh, 4G

Sur le même réseau (y compris les systèmes Wi-Fi mesh, qui ne forment qu’un seul LAN derrière plusieurs points d’accès), les candidats host se connectent en direct. Votre vidéo circule sur votre réseau local et n’atteint jamais Internet. Seule la poignée de main initiale y passe.

D’un réseau à l’autre, chaque appareil demande son adresse publique au serveur STUN et les deux percent leurs NAT respectifs. Quand ça échoue (NAT symétrique, pare-feu d’entreprise), TURN relaie les paquets chiffrés. Les deux pairs utilisent iceTransportPolicy: "all" ; forcer "relay" a été essayé, et cela cassait des connexions qui auraient fonctionné en direct.

Pourquoi pas entièrement hors ligne sur un LAN ? Deux navigateurs ne peuvent pas se trouver sans point de rendez-vous, et les pages web n’ont accès à aucune API mDNS/Bonjour. Un serveur de signalisation local ferait l’affaire : voir l’auto-hébergement.

Talkie-walkie

Grâce au flux factice, la visionneuse dispose déjà d’un émetteur audio négocié qui transporte du silence. Répondre tient en un appel :

const sender = pc.getSenders().find(s => s.track?.kind === "audio");
const mic = (await getUserMedia({ audio: true })).getAudioTracks()[0];
await sender.replaceTrack(mic);   // une seule fois ; sans renégociation
mic.enabled = pressed;              // à chaque appui / relâchement

Le micro est demandé au premier appui, puis conservé. Ensuite, appuyer et relâcher ne font que basculer track.enabled : aucun délai, et pas de demande d’autorisation au milieu d’une phrase. Côté caméra, le flux entrant est lu par un élément <audio> masqué. L’annulation d’écho acoustique du navigateur empêche le haut-parleur du téléphone de repasser dans son propre micro. Le bouton utilise la capture de pointeur (souris, tactile et stylet), et la barre d’espace fonctionne aussi au clavier.

Appels en tête-à-tête

Même signalisation et même appairage, mais les deux côtés émettent. L’hôte enregistre un id call-… ; la personne qui rejoint passe par un sas (« avec vidéo » / « audio seul »). Ce tap est aussi le geste utilisateur qu’iOS exige avant de jouer du son.

  • Les deux m-lines sont toujours négociées. Caméra coupée, une piste canvas noire à 1 fps sert le temps de l’offre/réponse, puis l’émetteur reçoit replaceTrack(null) : rien n’est encodé, rien n’est envoyé.
  • Caméra coupée veut dire coupée. La piste est arrêtée, le matériel libéré et le voyant de la caméra s’éteint. La rallumer, c’est getUserMedia() + replaceTrack(), sans renégociation.
  • Un data channel transporte l’état ({cam, mic}, et un bye au raccrochage). L’autre côté affiche un avatar ou un badge « Muet » au lieu de deviner à partir d’une image noire. L’état ICE est surveillé lui aussi, car PeerJS ne déclenche pas toujours close à l’autre bout.
  • Audio full duplex avec l’annulation d’écho, la réduction de bruit et le contrôle automatique de gain du navigateur. Un casque reste préférable à un téléphone tenu à bout de bras en haut-parleur.
  • Strictement en tête-à-tête. Un second appelant est refusé tant que la ligne est occupée. À la fin de l’appel, l’hôte garde son code et attend l’appel suivant.
  • Le débit adaptatif et le wake lock tournent des deux côtés.

Caméra ⇄ partage d’écran

En mode caméra comme en appel, le bouton caméra ouvre un menu : Caméra, Partager l’écran, Couper la vidéo. Chaque choix correspond à un replaceTrack() sur l’émetteur vidéo existant : l’appel ne coupe jamais.

  • Les pistes d’écran viennent de getDisplayMedia() (1080p idéalement, 15 fps) et reçoivent contentHint = "detail" : sous contrainte, l’encodeur sacrifie des images, pas la résolution, et le texte reste lisible.
  • Le débit adaptatif n’applique jamais scaleResolutionDownBy à un écran.
  • Le récepteur est prévenu ({screen: true} sur le data channel) et passe en object-fit: contain : une démo s’affiche en entier, jamais rognée. La visionneuse caméra utilise toujours contain.
  • Le bouton « Arrêter le partage » du navigateur déclenche ended sur la piste : BeamCam revient à ce qui était actif avant (la caméra, ou rien).
  • Les navigateurs mobiles n’implémentent pas getDisplayMedia() ; l’option y apparaît désactivée.

Débit adaptatif : mesuré, pas deviné

Le contrôle de congestion propre à WebRTC (GCC) réagit déjà en quelques secondes. Il est bon pour réagir et mauvais pour s’engager : sur un lien instable, il monte, se brûle les ailes et redescend, encore et encore. Le mode Auto de BeamCam se place au-dessus et choisit un plafond que le lien a tenu dans la durée, calculé localement sur le téléphone caméra :

  • Toutes les 2 s, lecture de getStats() : availableOutgoingBitrate sur la paire de candidats nominée, fractionLost et RTT depuis remote-inbound-rtp. Sur Firefox, qui ne fournit pas d’estimation de bande passante, c’est le débit d’envoi réel tiré de outbound-rtp.bytesSent qui sert.
  • Fenêtre glissante de 90 échantillons = 3 minutes.
  • Les 10 premières secondes sont ignorées : l’estimateur démarre bas puis monte en charge, et ces échantillons tireraient le budget vers le bas pendant trois minutes.
  • Budget = 20e centile de la bande passante mesurée. Une bonne seconde ne suffit pas à le relever : le lien doit tenir ce débit la plupart du temps.
  • Des pertes supérieures à 2 % / 5 % sur les 30 dernières secondes réduisent le budget de 15 % / 30 %.
  • Déplacement sur une échelle de niveaux, appliqués via RTCRtpSender.setParameters() :
NiveaumaxBitratescaleResolutionDownBy≈ Sortie depuis du 720p
0300 kbps2360p
1600 kbps2360p
21 Mbps1.5480p
31.5 Mbps1720p (niveau de départ)
42.5 Mbps1720p
54 Mbps1720p, détail élevé

Bande d’hystérésis : l’estimation de bande passante a tendance à coller à ce qui est déjà envoyé, donc la seule règle « budget sous le plafond » ferait baisser la qualité cran par cran, indéfiniment. À la place, on ne descend que lorsque le budget tenu passe sous 90 % du plafond actuel, et on va directement au niveau le plus élevé qui tient dans 85 % de ce budget. On ne monte que d’un niveau à la fois, et seulement quand le budget dépasse le niveau suivant de 25 % avec des pertes inférieures à 2 %. Les mauvaises nouvelles sont prises en compte immédiatement ; les bonnes doivent durer avant de compter. Choisissez un débit fixe dans le menu pour désactiver tout cela.

Ce que ce mécanisme ne touche pas : la profondeur de couleur. Les codecs vidéo temps réel (VP8/VP9/H.264) encodent en 8 bits 4:2:0. Il n’existe pas de réglage « moins de couleurs ». Le levier réel le plus proche est le filtre noir et blanc, qui vide les plans de chrominance.

Filtres d’image : appliqués à ce qui est envoyé

Luminosité, contraste et noir et blanc modifient le flux que reçoit la visionneuse, pas seulement l’aperçu local. Dès qu’un curseur quitte sa position neutre, la piste caméra passe par un petit pipeline :

piste caméra → <video> masquée → <canvas> → canvas.captureStream() → RTCRtpSender.replaceTrack()
  • Canvas 2D ctx.filter (grayscale() contrast() brightness()) là où c’est pris en charge : Chrome, Edge, Firefox. Le rendu passe par le GPU.
  • Fragment shader WebGL en repli pour les navigateurs sans ctx.filter : mélange de luma, puis contraste autour du gris moyen, puis gain.
  • Dessin une fois par image caméra avec requestVideoFrameCallback, avec repli sur requestAnimationFrame.
  • De retour aux valeurs neutres, le pipeline est démonté et la piste caméra brute est remise en place : il ne coûte rien quand on ne s’en sert pas.

Le noir et blanc économise aussi de la bande passante : des plans de chrominance uniformes se compressent quasiment à rien, donc à débit égal l’encodeur consacre tout au détail de luminance. Pratique pour une chambre de bébé plongée dans le noir à 500 kbps.

Enregistreur local

La caméra (getUserMedia, 1080p idéalement) ou l’écran (getDisplayMedia), avec l’audio, alimente un MediaRecorder. VP9 est utilisé s’il est disponible, sinon le codec par défaut du navigateur. Les fragments sont collectés en mémoire toutes les 100 ms, puis assemblés en un Blob pour la lecture et le téléchargement. Aucun réseau en jeu. Gardez-le en tête pour les longs enregistrements : tout reste en RAM jusqu’au téléchargement.

Modèle de confidentialité

  • Le média est chiffré de bout en bout par WebRTC (DTLS-SRTP, non désactivable). Un relais TURN ne fait que transmettre du contenu chiffré.
  • Aucun secret à longue durée de vie dans le navigateur. Les identifiants TURN sont générés à chaque session par le serveur de signalisation (/peerjs/turn) : un nom d’utilisateur portant un horodatage d’expiration et un HMAC de celui-ci, valable 24 h. Le secret coturn reste sur le serveur.
  • Ni comptes, ni base de données. Les peer ids sont éphémères et disparaissent à la fermeture de l’onglet.
  • Aucune requête vers des tiers. PeerJS, Lucide et qrcode.js sont servis depuis beamcam.live. Ni CDN, ni mesure d’audience, ni polices externes, ni pixels de suivi.
  • Logs client désactivés en production. Les logs de débogage ne s’activent que sur localhost.
  • Le code d’appairage est un jeton au porteur. Il est assez aléatoire pour être impossible à deviner en pratique, et la découverte étant désactivée, les codes ne peuvent pas être listés. Mais quiconque détient le code peut regarder : traitez-le comme une clé.

Plan du code

CheminRôle
src/app.htmlBalisage de l’app (fragment, enveloppé dans dist/index.html par le build)
src/script.jsTout le reste : vues, diffusion, visionneuse, appels, talkie-walkie, filtres, débit adaptatif, enregistreur, routage
src/style.cssStyles de l’app
src/pages/Page d’accueil et cette page, plus src/site.css
bin/build.shAssemble dist/ : enveloppe HTML, manifest, copies. C’est tout le build.
server/Serveur PeerJS (server.js), scripts d’installation systemd et coturn
config-samples/Exemples de reverse proxy Caddy et nginx

L’app tient en une seule page avec quatre vues basculées par une classe active. Les hashes d’URL servent de points d’entrée : #camera, #viewer, #record, #videocall, #audiocall, ou #cam-… / #call-… pour se connecter directement.

Auto-hébergement

Trois briques, toutes volontairement ennuyeuses. Tout ce qui dépend du déploiement tient dans un seul fichier .env : nom, domaine, apps affichées, secret TURN.

# réglages
cp .env.example .env              # APP_NAME, APP_DOMAIN, APP_LIVE / APP_VIDEOCALL / APP_AUDIOCALL / APP_EDITOR

# 1. serveur de signalisation + STUN/TURN
cd server
./install.sh                      # dépendances npm
sudo ./install-coturn.sh          # coturn ; écrit TURN_SECRET dans ../.env
sudo ./install-service.sh         # unité systemd pour server.js

# 2. fichiers statiques : .env -> dist/config.js (valeurs publiques uniquement)
bash bin/build.sh

# 3. proxy (Caddy s'occupe du TLS)
your.domain {
    root * /path/to/dist
    file_server
    reverse_proxy /peerjs/* 127.0.0.1:9000
}

Le build ne copie dans la page qu’une liste blanche de valeurs publiques : le secret TURN n’atteint jamais dist/. Faites-le tourner sur une machine chez vous et vous obtenez une installation 100 % LAN : signalisation, média et TURN ne quittent jamais votre réseau.

Limites connues

  • Premier plan uniquement sur mobile. iOS et Android suspendent la capture caméra dans les onglets en arrière-plan. L’API Screen Wake Lock garde l’écran allumé pendant la diffusion et est réacquise quand l’onglet redevient visible.
  • Pas de caméra virtuelle. Une page web ne peut pas déclarer de périphérique caméra système. Utilisez la caméra virtuelle d’OBS.
  • Service Worker désactivé. Une version précédente interceptait la WebSocket de signalisation. Il est désenregistré au chargement. Pas d’installation hors ligne pour l’instant.
  • Tête-à-tête par conception. Une visionneuse par caméra, deux personnes par appel. Pas d’appels de groupe : il faudrait un SFU, donc un serveur média. C’est un autre produit.

Code source

Sous licence MIT. Lisez-le, forkez-le, envoyez un patch. Les patchs courts et clairs sont mergés.

beamcam/beamcamgithub.com/beamcam/beamcam · MIT · © 2026 L3DLP
→