12k
All articles

Traitement Vidéo en Temps Réel avec l'API WebCodecs

Traitement vidéo WebCodecs avec MediaStreamTrackProcessor, TransformStream et VideoTrackGenerator, plus close des frames, backpressure, workers et support navigateur.

OpenReplay Team
OpenReplay Team
Traitement Vidéo en Temps Réel avec l'API WebCodecs

Un pipeline vidéo WebCodecs se compose de trois parties : un MediaStreamTrackProcessor qui convertit un MediaStreamTrack en ReadableStream<VideoFrame>, un TransformStream dans lequel vous manipulez chaque image, et un VideoTrackGenerator qui reconvertit les images traitées en MediaStreamTrack assignable à un élément <video>. VideoTrackGenerator est le nom retenu par la spécification actuelle ; les exemples Chromium utilisent encore l’ancien nom non standard MediaStreamTrackGenerator. Voici l’intégralité du pipeline :

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];

const processor = new MediaStreamTrackProcessor({ track });
const generator = new VideoTrackGenerator();

const grayscale = new TransformStream({
  async transform(frame, controller) {
    try {
      const canvas = new OffscreenCanvas(frame.displayWidth, frame.displayHeight);
      const ctx = canvas.getContext('2d');
      ctx.filter = 'grayscale(1)';
      ctx.drawImage(frame, 0, 0);
      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

processor.readable.pipeThrough(grayscale).pipeTo(generator.writable);
videoEl.srcObject = new MediaStream([generator.track]);

Cet article traite des aspects que tous les tutoriels existants passent sous silence : les modes de défaillance. La version idéale présentée ci-dessus fonctionne — jusqu’au moment où elle ne fonctionne plus : fuites d’images, transform en retard, encodeur entrant dans un état fermé, ou code supposant à tort que Safari ne peut pas l’exécuter. Chacun de ces cas représente une défaillance réelle en production, avec une cause précise et une solution spécifique — c’est ce que couvre la suite de cet article.

Points Clés

  • Un pipeline WebCodecs suit le schéma MediaStreamTrackProcessorTransformStreamVideoTrackGenerator ; utilisez le constructeur conforme à la spécification new MediaStreamTrackProcessor({ track }), et non la forme positionnelle dépréciée.
  • Oublier frame.close() épuise les ressources média finies dont dépend le pipeline ; une fois épuisées, l’émission d’images se bloque, produisant une vidéo qui saccade puis se fige pendant que le reste de la page reste réactif.
  • MediaStreamTrackProcessor ne propage pas la contre-pression en amont — lorsque votre transform prend du retard, le processor abandonne silencieusement les images les plus anciennes sans lever d’erreur.
  • Effectuez tout le travail sur les VideoFrame dans un seul worker : une image transférée entre workers se ferme automatiquement côté émetteur, et toute tentative d’y accéder à nouveau lève une exception.
  • La prise en charge de WebCodecs varie selon l’interface — les interfaces principales VideoEncoder/VideoFrame sont disponibles depuis Chrome 94+, Firefox 130+ et Safari 16.4+, tandis que MediaStreamTrackProcessor/VideoTrackGenerator arrivent plus tard (Safari 18+, non supporté dans Firefox).

Ce qu’est WebCodecs et Pourquoi le Pipeline a Cette Forme

WebCodecs donne à JavaScript un accès direct aux codecs média intégrés du navigateur — souvent accélérés matériellement — ainsi qu’aux images vidéo brutes. Auparavant, un MediaStream était opaque : on le connectait à un élément <video> et le navigateur gérait tout entre la capture et l’affichage. WebCodecs ouvre ce pipeline. L’interface VideoFrame expose les pixels bruts entre la capture et l’encodage, ce qui est précisément là où un filtre, un arrière-plan virtuel ou un encodeur personnalisé doit s’insérer.

Si le pipeline repose sur les Streams, c’est parce que les images décodées brutes sont volumineuses (plusieurs mégaoctets chacune) et arrivent rapidement (25 images par seconde ou plus), ce qui nécessite un contrôle de flux et un traitement incrémental plutôt que de tout mettre en mémoire tampon. L’API Streams du WHATWG a précisément été conçue pour ce type de traitement atomique par morceaux au travers de chaînes de pipes. MediaStreamTrackProcessor fait le pont entre une piste en direct et un stream ; le TransformStream est l’endroit où s’effectue votre traitement image par image ; VideoTrackGenerator fait le pont inverse vers une piste que le reste de la plateforme — <video>, RTCPeerConnection — comprend.

WebCodecs n’opère que sur des flux non conteneurisés. Si vous avez besoin de lire ou d’écrire du MP4/ISOBMFF, vous devez fournir votre propre logique de conteneurisation. L’audio dispose d’une surface parallèle (AudioData, AudioEncoder) que cet article ne couvre pas ; les patterns présentés ci-dessous sont spécifiques à la vidéo.

Un Pipeline Fonctionnel Caméra → Filtre → Affichage

Un pipeline de filtrage WebCodecs fonctionnel capture avec MediaStreamTrackProcessor, filtre à l’intérieur d’un TransformStream en utilisant Canvas2D directement sur le VideoFrame, et affiche via VideoTrackGenerator — la structure présentée dans le bloc de code d’introduction. L’optimisation clé est ctx.drawImage(frame, 0, 0)drawImage accepte directement un VideoFrame comme source, ce qui permet de dessiner des images sur un canvas sans les convertir manuellement en PNG ni créer un ImageBitmap intermédiaire.

Pour un filtre couleur Canvas2D, la chaîne ctx.filter est la solution la moins coûteuse. Pour tout traitement adressable au niveau du pixel — incrustation chroma, convolution personnalisée — utilisez getImageData/putImageData :

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      const w = frame.displayWidth, h = frame.displayHeight;
      const canvas = new OffscreenCanvas(w, h);
      const ctx = canvas.getContext('2d', { willReadFrequently: true });
      ctx.drawImage(frame, 0, 0);

      const imageData = ctx.getImageData(0, 0, w, h);
      const px = imageData.data;
      for (let i = 0; i < px.length; i += 4) {
        const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
        px[i] = px[i + 1] = px[i + 2] = lum;
      }
      ctx.putImageData(imageData, 0, 0);

      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

Deux propriétés sont transmises de l’image originale à la nouvelle : timestamp et duration. Le timestamp constitue l’identité de l’image tout au long du pipeline — il survit aux cycles d’encodage/décodage et sert à mesurer la latence en aval. L’omettre prive les consommateurs en aval de l’ordre des images.

Pour des traitements pixel par pixel intensifs à pleine résolution, la relecture via getImageData constitue le goulot d’étranglement ; WebGL ou WebGPU (via importExternalTexture) maintiennent l’image sur le GPU et évitent entièrement la relecture côté CPU. Utilisez Canvas2D pour les transformations de couleur et la composition simple ; optez pour un chemin GPU lorsque le coût par pixel domine votre budget d’image.

Le Cycle de Vie des VideoFrame : Pourquoi frame.close() est Obligatoire

Oublier frame.close() ne provoque pas seulement une fuite de mémoire ordinaire — cela épuise les ressources média finies dont dépend le pipeline, et une fois épuisées, le décodage ou l’émission d’images se bloque car aucune nouvelle image ne peut être allouée ou émise. Le symptôme caractéristique est une vidéo qui saccade progressivement puis se fige, tandis que le reste de la page reste réactif. VideoFrame.close() libère la ressource média sous-jacente que l’image détient, et la spécification WebCodecs est explicite sur le fait que ces ressources sont finies — les images adossées à des tampons matériels proviennent d’un pool limité, et une source ne peut pas émettre de nouvelle image lorsque ce pool est plein.

C’est pourquoi close() n’est pas un nettoyage facultatif que l’on peut déléguer au ramasse-miettes. Le ramasse-miettes ne connaît pas la ressource média sous-jacente selon son propre calendrier, et au moment où il s’exécute, le pool est déjà épuisé. Chaque VideoFrame lu depuis le processor, et chaque instance construite, doit être fermé exactement une fois lorsque vous en avez terminé.

Le cas de défaillance non évident est le chemin d’erreur. Si votre transform lève une exception après avoir lu une image mais avant de la fermer, cette image fuit — et un transform qui lève une exception sur une image le fait généralement sur la suivante, de sorte que la fuite s’accumule rapidement. La solution est try/finally :

async transform(frame, controller) {
  try {
    // ...traitement du filtre susceptible de lever une exception...
    controller.enqueue(newFrame);
  } finally {
    frame.close(); // s'exécute que le corps ait levé une exception ou non
  }
}

finally garantit que frame.close() s’exécute aussi bien sur le chemin de succès que sur le chemin d’erreur. C’est le pattern le plus important dans un pipeline WebCodecs.

Contre-Pression : Pourquoi les Transforms Lents Abandonnent Silencieusement des Images

MediaStreamTrackProcessor ne propage pas la contre-pression en amont. Lorsque votre TransformStream prend du retard, le processor abandonne silencieusement les images les plus anciennes plutôt que de ralentir la caméra, et vous ne verrez jamais d’erreur — seulement des images manquantes. La conséquence pratique : un transform qui s’exécute en 50ms par image sur une source à 30fps (budget de 33ms) ne lèvera pas d’erreur et ne mettra pas en file d’attente indéfiniment. Il fonctionnera silencieusement à environ 20fps, la différence étant abandonnée. Vous pouvez détecter cela en observant la file d’attente côté readable depuis l’intérieur du transform. TransformStreamDefaultController.desiredSize reflète l’état de contre-pression côté readable — lorsqu’il devient négatif, le côté readable dépasse sa limite haute et le consommateur est en retard :

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      if (controller.desiredSize !== null && controller.desiredSize < 0) {
        // Le consommateur est en retard. Abandonner cette image intentionnellement
        // plutôt que de prendre encore plus de retard.
        return;
      }
      // ...traitement du filtre...
      controller.enqueue(newFrame);
    } finally {
      frame.close();
    }
  },
});

Lorsque vous détectez une contre-pression, deux leviers s’offrent à vous. Abandonner intentionnellement — ignorer l’image courante, comme ci-dessus, de sorte qu’une cadence délibérée remplace la perte aléatoire silencieuse. Ou réduire l’entrée : demander une résolution ou une fréquence d’images inférieure via getUserMedia avec MediaTrackConstraints, ou appeler track.applyConstraints() pour réduire à l’exécution. Diminuer la résolution réduit directement le travail par pixel et constitue généralement la solution la plus efficace pour un filtre limité par le CPU.

Workers : Pourquoi Effectuer Tout le Travail sur les VideoFrame dans un Seul Worker ?

Effectuez tout le travail sur les VideoFrame dans un seul worker. Lorsqu’un VideoFrame est transféré entre workers via postMessage, la référence côté émetteur est fermée automatiquement, et toute tentative de la lire ou de la fermer à nouveau lève une exception — une situation de compétition silencieuse quasiment impossible à déboguer entre les files de messages des workers. Les images à l’intérieur de streams transférés sont sérialisées, ce qui les clone et nécessite une fermeture explicite des deux côtés. Mélanger les deux approches produit la défaillance de fermeture prématurée :

controller.enqueue(frame);
frame.close(); // Trop tôt — enqueue est asynchrone ; l'image est peut-être encore en transit

Parce que controller.enqueue() est asynchrone par rapport au worker consommateur, fermer la référence de l’émetteur trop tôt provoque des échecs de sérialisation, tandis que ne jamais la fermer entraîne la fuite puis le gel décrits précédemment. Maintenir toute la chaîne MediaStreamTrackProcessorTransformStreamVideoTrackGenerator dans un seul worker évite entièrement le problème de propriété. (Pour l’envoi de chunks encodés hors de l’appareil — WebTransport, data channels — consultez la série de benchmarks de pipelines de webrtcHacks ; c’est un sujet à part entière.)

Lorsque vous devez passer une image à un worker — pour alimenter le pipeline, et non pour le diviser — transférez-la explicitement et cessez d’y accéder côté émetteur :

// Thread principal
worker.postMessage({ frame }, { transfer: [frame] });
// `frame` est désormais neutralisé ici. Ne pas le lire ni le fermer sur le thread principal.

Après un transfert, le worker destinataire est propriétaire de l’image et responsable de sa fermeture. Le thread émetteur doit considérer sa référence comme inexistante.

Encodage pour la Transmission ou l’Enregistrement

Un VideoEncoder compresse des objets VideoFrame bruts en objets EncodedVideoChunk, transmis via un callback de sortie pour l’enregistrement ou la transmission. Configurez-le avec une chaîne de codec, les dimensions, le débit binaire et la fréquence d’images :

const chunks = [];
const encoder = new VideoEncoder({
  output: (chunk, metadata) => {
    // chunk.type vaut 'key' ou 'delta' ; chunk possède timestamp, duration, byteLength
    chunks.push(chunk);
  },
  error: (e) => console.error('encoder error', e),
});

encoder.configure({
  codec: 'vp8',          // ou par exemple 'avc1.42001f' pour H.264 baseline
  width: 640,
  height: 480,
  bitrate: 1_000_000,
  framerate: 30,
});

Le callback output vous fournit un EncodedVideoChunk ainsi que des métadonnées optionnelles ; le chunk porte son type ('key' ou 'delta'), son timestamp, sa duration et les octets encodés. Pour les chaînes de codec, consultez le registre de codecs WebCodecs et le guide des codecs MDN plutôt que de deviner les chaînes de profil AVC.

Demandez une image clé avec encoder.encode(frame, { keyFrame: true }) (notez le F majuscule) lorsque vous avez besoin d’une image intra, par exemple au démarrage du flux, après une recherche ou à un point de reprise — encoder chaque image comme image clé annule entièrement la compression inter-images et augmentera significativement votre débit binaire. La syntaxe de cette option est documentée dans le guide Using the WebCodecs API de MDN.

Récupération après un Encodeur Fermé

Lorsque le callback d’erreur de VideoEncoder se déclenche et que l’encodeur passe à l’état 'closed', il ne peut plus être réutilisé. VideoEncoder.reset() existe pour les cas non terminaux, mais la récupération après un encodeur fermé implique de construire une nouvelle instance et d’appeler à nouveau configure() avec les mêmes paramètres. Vérifiez l’état avant chaque encode() et reconstruisez en cas de fermeture :

function encodeFrame(frame, keyFrame = false) {
  if (encoder.state === 'closed') {
    encoder = makeEncoder();   // construire + configurer un nouveau VideoEncoder
  }
  if (encoder.state === 'configured') {
    encoder.encode(frame, { keyFrame });
  }
}

Protéger encode() avec une vérification d’état et un chemin de reconstruction est ce qui permet à une session longue durée de survivre à une erreur de codec transitoire.

Compatibilité Navigateur en 2026

La prise en charge de WebCodecs varie selon l’interface, et la traiter comme un numéro de version unique est l’erreur que commettent tous les tutoriels obsolètes. Les interfaces principales VideoEncoder/VideoFrame sont largement disponibles ; les composants Insertable Streams — MediaStreamTrackProcessor et VideoTrackGenerator — suivent un calendrier différent, plus lent.

InterfaceChrome / EdgeFirefoxSafari
VideoEncoder / VideoFrame (WebCodecs principal)94+130+16.4+
MediaStreamTrackProcessor94+Non supporté18+
VideoTrackGeneratorNon supportéNon supporté18+
MediaStreamTrackGenerator (non standard)94+Non supportéNon supporté

Vérifié à partir des données de compatibilité navigateur MDN pour VideoEncoder et MediaStreamTrackProcessor. La mise en garde générale « Safari ne supporte pas WebCodecs » présente dans la plupart des tutoriels est à la fois dépassée et imprécise : Safari prend en charge le cœur de WebCodecs depuis la version 16.4, avec une prise en charge étendue des codecs (dont HEVC) dans Safari 17.4. Ce qui manque à Safari et Firefox, c’est la couche de capture/sortie Insertable Streams — ainsi, le pipeline caméra → filtre → affichage présenté ci-dessus fonctionne aujourd’hui dans Chromium, et dans Safari 18+ lorsqu’il est implémenté dans un worker dédié, mais sous Firefox vous pouvez encoder et décoder des images tout en les sourçant autrement.

La conclusion pratique : détectez les fonctionnalités par interface, et non par navigateur. Vérifiez window.MediaStreamTrackProcessor et window.VideoEncoder séparément, et prévoyez un fallback Canvas/requestVideoFrameCallback pour la couche de capture là où les composants Insertable Streams sont absents.

Liste de Vérification pour le Débogage

Les trois modes de défaillance d’un pipeline WebCodecs — images abandonnées, mémoire incontrôlée et pics de latence — ont chacun un symptôme distinct et une étape de diagnostic directe.

SymptômeCause probableÉtape de diagnostic
Saccades progressives puis gel, reste de la page réactifframe.close() manquant sur un chemin → ressources média finies épuiséesVérifier que chaque VideoFrame lu ou construit est fermé exactement une fois ; confirmer la couverture try/finally
Images manquantes, aucune erreur dans la consoleTransform lent ; le processor abandonne silencieusement les images les plus anciennesJournaliser controller.desiredSize dans le transform ; s’il tend vers le négatif, le consommateur est en retard
La latence augmente avec le tempsFiltre par image lent consommant le budget d’imageMesurer la durée par étape ; comparer au budget de votre fréquence d’images (33ms à 30fps)
L’encodeur cesse de produire des chunksVideoEncoder est passé à l’état 'closed' après une erreurVérifier encoder.state avant chaque encode() ; reconstruire en cas de 'closed'

La signature saccade-puis-gel mérite d’être reconnue au premier coup d’œil. Les replays de session de fonctionnalités basées sur WebCodecs font régulièrement apparaître ce pattern : une vidéo fluide qui se lit normalement, puis commence à perdre des images visiblement, puis se fige entièrement tandis que le reste de l’interface reste interactif. C’est la signature visible de l’épuisement des ressources média finies causé par des images non fermées — le replay montre clairement le symptôme, mais la cause est invisible sans savoir qu’il faut chercher une image non fermée quelque part dans le code du pipeline.

Pour mesurer la latence de bout en bout réelle — de la capture caméra à l’affichage — encodez le timestamp de l’image sous forme de superposition de pixels avant le pipeline et décodez-le depuis la sortie rendue via requestVideoFrameCallback. À titre de référence, le benchmark de pipeline webrtcHacks (mars 2023) a rapporté ces coûts par image :

ÉtapeDurée
Suppression d’arrière-plan22ms
Ajout de superposition1ms
Encodage8ms
Décodage1ms
Affichage38ms

Vos chiffres varieront selon le matériel et la complexité du filtre. Le résultat notable est que l’affichage seul représente environ 38ms — le terme dominant — ce qui signifie qu’un filtre qui s’inscrit confortablement dans le budget d’une cadence à 30fps peut tout de même sembler lent si vous ne tenez pas compte de la queue d’affichage. Mesurez l’intégralité du chemin, pas seulement votre transform.

Conclusion

La structure du pipeline WebCodecs — MediaStreamTrackProcessorTransformStreamVideoTrackGenerator — est suffisamment compacte pour tenir dans un seul bloc de code, mais l’écart entre une démo et une fonctionnalité livrable réside entièrement dans les modes de défaillance : fermer chaque image, détecter la contre-pression silencieuse, maintenir toute la chaîne dans un seul worker, récupérer après un encodeur fermé, et détecter les fonctionnalités par interface plutôt que par navigateur. Partez de l’exemple try/finally en tête de cet article, ajoutez la vérification desiredSize et la protection d’état de l’encodeur, et vous obtenez un pipeline qui résiste aux cas que les tutoriels du chemin idéal n’atteignent jamais.

FAQ

Quand utiliser Canvas2D plutôt que WebGL ou WebGPU pour un filtre WebCodecs ?

Utilisez Canvas2D pour les transformations de couleur et la composition simple, où les chaînes ctx.filter ou des boucles getImageData modestes s'inscrivent dans le budget d'image. Optez pour WebGL ou WebGPU lorsque le coût par pixel domine, car ils maintiennent l'image sur le GPU via importExternalTexture et évitent la relecture CPU que force getImageData. À pleine résolution, cette relecture est généralement le goulot d'étranglement, donc un chemin GPU est la solution pour les traitements pixel par pixel intensifs comme l'incrustation chroma.

Pourquoi mes VideoFrames se ferment-ils de manière inattendue lorsque je les passe entre workers ?

Transférer un VideoFrame entre workers via postMessage ferme automatiquement la référence côté émetteur, de sorte que toute tentative de la lire ou de la fermer à nouveau côté émetteur lève une exception. Cela diffère des images dans des streams transférés, qui sont sérialisées et clonées et nécessitent une fermeture explicite des deux côtés. Pour éviter la situation de compétition, maintenez l'intégralité du pipeline dans un seul worker, ou après un transfert, considérez la référence de l'émetteur comme inexistante et laissez le worker destinataire posséder et fermer l'image.

Le pipeline caméra-vers-filtre-vers-affichage fonctionne-t-il dans Firefox ?

Pas entièrement. Firefox 130 et versions ultérieures supportent les interfaces principales VideoEncoder et VideoFrame, mais ne supportent pas la couche de capture et de sortie Insertable Streams, ce qui signifie que MediaStreamTrackProcessor et VideoTrackGenerator sont indisponibles. Vous pouvez encoder et décoder des images dans Firefox, mais vous devez sourcer les images autrement, par exemple via un Canvas avec requestVideoFrameCallback. Détectez les fonctionnalités par interface en vérifiant window.MediaStreamTrackProcessor et window.VideoEncoder séparément plutôt qu'en testant le navigateur.

Quelle est la différence entre VideoEncoder.reset() et la reconstruction de l'encodeur ?

VideoEncoder.reset() gère les cas non terminaux, en effaçant le travail en attente sur un encodeur encore utilisable. Il ne peut pas récupérer un encodeur qui est passé à l'état closed après le déclenchement d'une erreur, car un encodeur fermé ne peut être ni reconfiguré ni réutilisé. La récupération après un état closed implique de construire une nouvelle instance de VideoEncoder et d'appeler à nouveau configure() avec les mêmes paramètres. Vérifiez encoder.state avant chaque encode() et reconstruisez lorsqu'il indique closed.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.