Traitement des vidéos avec WebCodecs

Manipulation des composants du flux vidéo

Eugene Zemtsov
Eugene Zemtsov
François Beaufort
François Beaufort

Les technologies Web modernes offrent de nombreuses possibilités de travailler avec la vidéo. L'API Media Stream, l'API Media Recording, l'API Media Source et l'API WebRTC constituent un ensemble d'outils complet pour enregistrer, transférer et lire des flux vidéo. Bien qu'elles résolvent certaines tâches de haut niveau, ces API ne permettent pas aux programmeurs Web de travailler avec les composants individuels d'un flux vidéo, tels que les images et les segments de vidéo ou d'audio encodés non multiplexés. Pour obtenir un accès de bas niveau à ces composants de base, les développeurs ont utilisé WebAssembly pour intégrer des codecs vidéo et audio dans le navigateur. Toutefois, étant donné que les navigateurs modernes sont déjà livrés avec divers codecs (qui sont souvent accélérés par le matériel), les reconditionner en tant que WebAssembly semble être un gaspillage de ressources humaines et informatiques.

L'API WebCodecs élimine cette inefficacité en permettant aux programmeurs d'utiliser les composants multimédias déjà présents dans le navigateur. Plus spécifiquement :

  • Décodeurs vidéo et audio
  • Encodeurs vidéo et audio
  • Images vidéo brutes
  • Décodeurs d'images

L'API WebCodecs est utile pour les applications Web qui nécessitent un contrôle total sur la façon dont le contenu multimédia est traité, comme les éditeurs vidéo, les visioconférences, le streaming vidéo, etc.

Workflow de traitement des vidéos

Les images sont au cœur du traitement vidéo. Par conséquent, dans WebCodecs, la plupart des classes consomment ou produisent des images. Les encodeurs vidéo convertissent les images en blocs encodés. Les décodeurs vidéo font le contraire.

VideoFrame est également compatible avec d'autres API Web en tant que CanvasImageSource et dispose d'un constructeur qui accepte CanvasImageSource. Il peut donc être utilisé dans des fonctions telles que drawImage() et texImage2D(). Il peut également être construit à partir de canevas, de bitmaps, d'éléments vidéo et d'autres images vidéo.

L'API WebCodecs fonctionne bien en tandem avec les classes de l'API Insertable Streams, qui connectent WebCodecs aux pistes de flux multimédias.

  • MediaStreamTrackProcessor divise les pistes multimédias en trames individuelles.
  • MediaStreamTrackGenerator crée une piste multimédia à partir d'un flux d'images.

WebCodecs et Web Workers

Par conception, l'API WebCodecs effectue toutes les tâches lourdes de manière asynchrone et en dehors du thread principal. Toutefois, comme les rappels de frame et de chunk peuvent souvent être appelés plusieurs fois par seconde, ils peuvent encombrer le thread principal et rendre le site Web moins réactif. Il est donc préférable de déplacer la gestion des frames individuels et des segments encodés dans un nœud de calcul Web.

Pour vous aider, ReadableStream fournit un moyen pratique de transférer automatiquement tous les frames provenant d'un canal multimédia vers le worker. Par exemple, MediaStreamTrackProcessor peut être utilisé pour obtenir un ReadableStream pour un flux multimédia provenant de la webcam. Ensuite, le flux est transféré vers un nœud de calcul Web, où les frames sont lus un par un et mis en file d'attente dans un VideoEncoder.

Avec HTMLCanvasElement.transferControlToOffscreen, le rendu peut même être effectué en dehors du thread principal. Toutefois, si tous les outils de haut niveau se sont révélés peu pratiques, VideoFrame lui-même est transférable et peut être déplacé entre les travailleurs.

WebCodecs en action

Encodage

Chemin d'un canevas ou d'un ImageBitmap vers le réseau ou vers le stockage
Chemin d'accès d'un Canvas ou d'un ImageBitmap au réseau ou au stockage

Tout commence par un VideoFrame. Il existe trois façons de créer des images vidéo.

  • À partir d'une source d'image telle qu'un canevas, un bitmap d'image ou un élément vidéo.

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • Utiliser MediaStreamTrackProcessor pour extraire des frames d'un MediaStreamTrack

    const stream = await navigator.mediaDevices.getUserMedia({});
    const track = stream.getTracks()[0];
    
    const trackProcessor = new MediaStreamTrackProcessor(track);
    
    const reader = trackProcessor.readable.getReader();
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      const frameFromCamera = result.value;
    }
    
  • Créer un frame à partir de sa représentation binaire des pixels dans un BufferSource

    const pixelSize = 4;
    const init = {
      timestamp: 0,
      codedWidth: 320,
      codedHeight: 200,
      format: "RGBA",
    };
    const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
    for (let x = 0; x < init.codedWidth; x++) {
      for (let y = 0; y < init.codedHeight; y++) {
        const offset = (y * init.codedWidth + x) * pixelSize;
        data[offset] = 0x7f;      // Red
        data[offset + 1] = 0xff;  // Green
        data[offset + 2] = 0xd4;  // Blue
        data[offset + 3] = 0x0ff; // Alpha
      }
    }
    const frame = new VideoFrame(data, init);
    

Quelle que soit leur origine, les cadres peuvent être encodés dans des objets EncodedVideoChunk avec un VideoEncoder.

Avant l'encodage, VideoEncoder doit recevoir deux objets JavaScript:

  • Initialisation du dictionnaire avec deux fonctions pour gérer les blocs encodés et les erreurs. Ces fonctions sont définies par le développeur et ne peuvent pas être modifiées une fois transmises au constructeur VideoEncoder.
  • Objet de configuration de l'encodeur, qui contient les paramètres du flux vidéo de sortie. Vous pourrez modifier ces paramètres ultérieurement en appelant configure().

La méthode configure() génère une erreur NotSupportedError si la configuration n'est pas compatible avec le navigateur. Nous vous recommandons d'appeler la méthode statique VideoEncoder.isConfigSupported() avec la configuration pour vérifier au préalable si la configuration est compatible et attendre sa promesse.

const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  width: 640,
  height: 480,
  bitrate: 2_000_000, // 2 Mbps
  framerate: 30,
};

const { supported } = await VideoEncoder.isConfigSupported(config);
if (supported) {
  const encoder = new VideoEncoder(init);
  encoder.configure(config);
} else {
  // Try another config.
}

Une fois l'encodeur configuré, il est prêt à accepter des images via la méthode encode(). configure() et encode() renvoient immédiatement une réponse, sans attendre la fin du travail réel. Il permet à plusieurs frames d'être mis en file d'attente pour l'encodage en même temps, tandis que encodeQueueSize indique le nombre de requêtes en attente dans la file d'attente pour que les encodages précédents soient terminés. Les erreurs sont signalées en générant immédiatement une exception, si les arguments ou l'ordre des appels de méthode ne respectent pas le contrat de l'API, ou en appelant le rappel error() en cas de problème rencontré lors de l'implémentation du codec. Si l'encodage aboutit, le rappel output() est appelé avec un nouveau bloc encodé en tant qu'argument. Un autre détail important est que les frames doivent être informés de leur inutilité en appelant close().

let frameCounter = 0;

const track = stream.getVideoTracks()[0];
const trackProcessor = new MediaStreamTrackProcessor(track);

const reader = trackProcessor.readable.getReader();
while (true) {
  const result = await reader.read();
  if (result.done) break;

  const frame = result.value;
  if (encoder.encodeQueueSize > 2) {
    // Too many frames in flight, encoder is overwhelmed
    // let's drop this frame.
    frame.close();
  } else {
    frameCounter++;
    const keyFrame = frameCounter % 150 == 0;
    encoder.encode(frame, { keyFrame });
    frame.close();
  }
}

Enfin, il est temps de terminer le code d'encodage en écrivant une fonction qui gère les segments de vidéo encodée à mesure qu'ils sortent de l'encodeur. En règle générale, cette fonction envoie des blocs de données sur le réseau ou les multiplexe dans un conteneur multimédia pour les stocker.

function handleChunk(chunk, metadata) {
  if (metadata.decoderConfig) {
    // Decoder needs to be configured (or reconfigured) with new parameters
    // when metadata has a new decoderConfig.
    // Usually it happens in the beginning or when the encoder has a new
    // codec specific binary configuration. (VideoDecoderConfig.description).
    fetch("/upload_extra_data", {
      method: "POST",
      headers: { "Content-Type": "application/octet-stream" },
      body: metadata.decoderConfig.description,
    });
  }

  // actual bytes of encoded data
  const chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body: chunkData,
  });
}

Si vous devez vous assurer qu'à un moment donné toutes les requêtes d'encodage en attente ont été traitées, vous pouvez appeler flush() et attendre sa promesse.

await encoder.flush();

Décodage

Chemin d&#39;accès du réseau ou du stockage à un canevas ou à un ImageBitmap.
Chemin d'accès du réseau ou du stockage à un Canvas ou à un ImageBitmap.

La configuration d'un VideoDecoder est semblable à celle du VideoEncoder: deux fonctions sont transmises lors de la création du décodeur, et les paramètres du codec sont transmis à configure().

L'ensemble des paramètres de codec varie d'un codec à l'autre. Par exemple, le codec H.264 peut nécessiter un blob binaire d'AVCC, sauf s'il est encodé au format Annexe B (encoderConfig.avc = { format: "annexb" }).

const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const { supported } = await VideoDecoder.isConfigSupported(config);
if (supported) {
  const decoder = new VideoDecoder(init);
  decoder.configure(config);
} else {
  // Try another config.
}

Une fois le décodeur initialisé, vous pouvez commencer à le nourrir avec des objets EncodedVideoChunk. Pour créer un bloc, vous avez besoin des éléments suivants:

  • BufferSource de données vidéo encodées
  • Code temporel de début du segment en microsecondes (code temporel du premier frame encodé du segment)
  • le type de bloc, l'un des suivants :
    • key si le segment peut être décodé indépendamment des segments précédents
    • delta si le segment ne peut être décodé qu'après le décodage d'un ou de plusieurs segments précédents

De plus, tous les segments émis par l'encodeur sont prêts pour le décodeur tels quels. Tout ce qui a été dit ci-dessus sur le signalement des erreurs et la nature asynchrone des méthodes de l'encodeur est également vrai pour les décodeurs.

const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    type: responses[i].key ? "key" : "delta",
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

Il est maintenant temps de montrer comment un frame fraîchement décodé peut être affiché sur la page. Il est préférable de s'assurer que le rappel de sortie du décodeur (handleFrame()) renvoie rapidement. Dans l'exemple ci-dessous, il n'ajoute qu'un seul frame à la file d'attente des frames prêts à être affichés. Le rendu se produit séparément et comprend deux étapes:

  1. Attend que le moment soit opportun pour afficher le frame.
  2. Dessin du cadre sur le canevas.

Une fois qu'un frame n'est plus nécessaire, appelez close() pour libérer la mémoire sous-jacente avant que le garbage collector ne s'en occupe. Cela réduira la quantité moyenne de mémoire utilisée par l'application Web.

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let pendingFrames = [];
let underflow = true;
let baseTime = 0;

function handleFrame(frame) {
  pendingFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function calculateTimeUntilNextFrame(timestamp) {
  if (baseTime == 0) baseTime = performance.now();
  let mediaTime = performance.now() - baseTime;
  return Math.max(0, timestamp / 1000 - mediaTime);
}

async function renderFrame() {
  underflow = pendingFrames.length == 0;
  if (underflow) return;

  const frame = pendingFrames.shift();

  // Based on the frame's timestamp calculate how much of real time waiting
  // is needed before showing the next frame.
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise((r) => {
    setTimeout(r, timeUntilNextFrame);
  });
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Immediately schedule rendering of the next frame
  setTimeout(renderFrame, 0);
}

Conseils pour les développeurs

Utilisez le panneau multimédia dans les outils pour les développeurs Chrome pour afficher les journaux multimédias et déboguer WebCodecs.

Capture d&#39;écran du panneau multimédia pour le débogage de WebCodecs
Panneau "Media" dans les outils pour les développeurs Chrome pour déboguer WebCodecs.

Démo

La démonstration ci-dessous montre comment les frames d'animation d'un canevas sont:

  • capturé à 25 FPS dans un ReadableStream par MediaStreamTrackProcessor
  • transféré à un worker Web ;
  • encodé au format vidéo H.264 ;
  • décodé à nouveau en séquence d'images vidéo ;
  • et affiché sur le deuxième canevas à l'aide de transferControlToOffscreen()

Autres démonstrations

Découvrez également nos autres démonstrations:

Utiliser l'API WebCodecs

Détection de fonctionnalités

Pour vérifier la compatibilité avec WebCodecs:

if ('VideoEncoder' in window) {
  // WebCodecs API is supported.
}

N'oubliez pas que l'API WebCodecs n'est disponible que dans les contextes sécurisés. Par conséquent, la détection échouera si self.isSecureContext est faux.

Commentaires

L'équipe Chrome souhaite connaître votre expérience avec l'API WebCodecs.

Parlez-nous de la conception de l'API

L'API ne fonctionne-t-elle pas comme prévu ? Ou manque-t-il des méthodes ou des propriétés dont vous avez besoin pour implémenter votre idée ? Vous avez une question ou un commentaire sur le modèle de sécurité ? Signalez un problème de spécification dans le dépôt GitHub correspondant ou ajoutez vos commentaires à un problème existant.

Signaler un problème d'implémentation

Avez-vous trouvé un bug dans l'implémentation de Chrome ? Ou l'implémentation est-elle différente de la spécification ? Signalez un bug sur new.crbug.com. Veillez à inclure autant de détails que possible, des instructions simples pour reproduire le problème et saisissez Blink>Media>WebCodecs dans le champ Composants. Glitch est idéal pour partager des reproductions rapidement et facilement.

Afficher la compatibilité avec l'API

Comptez-vous utiliser l'API WebCodecs ? Votre soutien public aide l'équipe Chrome à hiérarchiser les fonctionnalités et montre aux autres fournisseurs de navigateurs à quel point il est essentiel de les prendre en charge.

Envoyez un e-mail à media-dev@chromium.org ou un tweet à @ChromiumDev en utilisant le hashtag #WebCodecs et indiquez-nous où et comment vous l'utilisez.

Image héros par Denise Jans sur Unsplash.