Débloquez le traitement vidéo avancé dans le navigateur. Apprenez à accéder et manipuler les données brutes des plans VideoFrame avec l'API WebCodecs pour des effets et analyses personnalisés.
Accès aux plans VideoFrame de WebCodecs : Plongée au cœur de la manipulation des données vidéo brutes
Pendant des années, le traitement vidéo haute performance dans le navigateur web semblait un rêve lointain. Les développeurs étaient souvent confinés aux limitations de l'élément <video> et de l'API Canvas 2D qui, bien que puissants, introduisaient des goulots d'étranglement en termes de performance et un accès limité aux données vidéo brutes sous-jacentes. L'arrivée de l'API WebCodecs a fondamentalement changé ce paysage, offrant un accès de bas niveau aux codecs multimédias intégrés du navigateur. L'une de ses fonctionnalités les plus révolutionnaires est la capacité d'accéder directement et de manipuler les données brutes des images vidéo individuelles via l'objet VideoFrame.
Cet article est un guide complet pour les développeurs cherchant à aller au-delà de la simple lecture vidéo. Nous explorerons les subtilités de l'accès aux plans VideoFrame, démystifierons des concepts tels que les espaces colorimétriques et l'organisation en mémoire, et fournirons des exemples pratiques pour vous permettre de construire la prochaine génération d'applications vidéo intra-navigateur, des filtres en temps réel aux tâches de vision par ordinateur sophistiquées.
Prérequis
Pour tirer le meilleur parti de ce guide, vous devriez avoir une solide compréhension de :
- JavaScript moderne : Y compris la programmation asynchrone (
async/await, Promises). - Concepts vidéo de base : Une familiarité avec des termes comme images, résolution et codecs est utile.
- API du navigateur : Une expérience avec des API comme Canvas 2D ou WebGL sera bénéfique mais n'est pas strictement requise.
Comprendre les images vidéo, les espaces colorimétriques et les plans
Avant de nous plonger dans l'API, nous devons d'abord construire un modèle mental solide de ce à quoi ressemblent réellement les données d'une image vidéo. Une vidéo numérique est une séquence d'images fixes, ou trames. Chaque trame est une grille de pixels, et chaque pixel a une couleur. La manière dont cette couleur est stockée est définie par l'espace colorimétrique et le format de pixel.
RGBA : Le langage natif du Web
La plupart des développeurs web sont familiers avec le modèle de couleur RGBA. Chaque pixel est représenté par quatre composantes : Rouge, Vert, Bleu et Alpha (transparence). Les données sont généralement stockées de manière entrelacée en mémoire, ce qui signifie que les valeurs R, G, B et A pour un seul pixel sont stockées consécutivement :
[R1, G1, B1, A1, R2, G2, B2, A2, ...]
Dans ce modèle, l'image entière est stockée dans un seul bloc de mémoire continu. On peut considérer cela comme ayant un seul "plan" de données.
YUV : Le langage de la compression vidéo
Les codecs vidéo, cependant, travaillent rarement directement avec le RGBA. Ils préfèrent les espaces colorimétriques YUV (ou plus précisément, Y'CbCr). Ce modèle sépare les informations de l'image en :
- Y (Luma) : L'information de luminosité ou de niveaux de gris. L'œil humain est très sensible aux changements de luma.
- U (Cb) et V (Cr) : Les informations de chrominance ou de différence de couleur. L'œil humain est moins sensible aux détails de couleur qu'aux détails de luminosité.
Cette séparation est la clé d'une compression efficace. En réduisant la résolution des composantes U et V — une technique appelée sous-échantillonnage de la chrominance — nous pouvons réduire considérablement la taille du fichier avec une perte de qualité perceptible minimale. Cela conduit à des formats de pixels planaires, où les composantes Y, U et V sont stockées dans des blocs de mémoire séparés, ou "plans".
Un format courant est le I420 (un type de YUV 4:2:0), où pour chaque bloc de 2x2 pixels, il y a quatre échantillons Y mais un seul échantillon U et un seul V. Cela signifie que les plans U et V ont la moitié de la largeur et la moitié de la hauteur du plan Y.
Comprendre cette distinction est crucial car WebCodecs vous donne un accès direct à ces mêmes plans, exactement tels que le décodeur les fournit.
L'objet VideoFrame : Votre porte d'entrée vers les données pixel
La pièce maîtresse de ce puzzle est l'objet VideoFrame. Il représente une seule image de vidéo et contient non seulement les données des pixels mais aussi d'importantes métadonnées.
Propriétés clés de VideoFrame
format: Une chaîne de caractères indiquant le format des pixels (par ex., 'I420', 'NV12', 'RGBA').codedWidth/codedHeight: Les dimensions complètes de l'image telles que stockées en mémoire, y compris tout remplissage (padding) requis par le codec.displayWidth/displayHeight: Les dimensions qui devraient être utilisées pour afficher l'image.timestamp: L'horodatage de présentation de l'image en microsecondes.duration: La durée de l'image en microsecondes.
La méthode magique : copyTo()
La méthode principale pour accéder aux données de pixels brutes est videoFrame.copyTo(destination, options). Cette méthode asynchrone copie les données des plans de l'image dans un tampon que vous fournissez.
destination: UnArrayBufferou un tableau typé (commeUint8Array) assez grand pour contenir les données.options: Un objet qui spécifie quels plans copier et leur disposition en mémoire. S'il est omis, il copie tous les plans dans un seul tampon contigu.
La méthode renvoie une promesse (Promise) qui se résout avec un tableau d'objets PlaneLayout, un pour chaque plan de l'image. Chaque objet PlaneLayout contient deux informations cruciales :
offset: Le décalage en octets où les données de ce plan commencent dans le tampon de destination.stride: Le nombre d'octets entre le début d'une rangée de pixels et le début de la rangée suivante pour ce plan.
Un concept crucial : Stride vs. Width
C'est l'une des sources de confusion les plus courantes pour les développeurs novices en programmation graphique de bas niveau. Vous ne pouvez pas supposer que chaque rangée de données de pixels est étroitement empaquetée l'une après l'autre.
- Width (largeur) est le nombre de pixels dans une rangée de l'image.
- Stride (aussi appelé pitch ou pas de ligne) est le nombre d'octets en mémoire entre le début d'une rangée et le début de la suivante.
Souvent, le stride sera supérieur à width * bytes_per_pixel. C'est parce que la mémoire est souvent complétée (padding) pour s'aligner sur les limites matérielles (par ex., des limites de 32 ou 64 octets) pour un traitement plus rapide par le CPU ou le GPU. Vous devez toujours utiliser le stride pour calculer l'adresse mémoire d'un pixel dans une rangée spécifique.
Ignorer le stride entraînera des images inclinées ou déformées et un accès incorrect aux données.
Exemple pratique 1 : Accéder et afficher un plan en niveaux de gris
Commençons par un exemple simple mais puissant. La plupart des vidéos sur le web sont encodées dans un format YUV comme I420. Le plan 'Y' est effectivement une représentation complète de l'image en niveaux de gris. Nous pouvons extraire juste ce plan et le rendre sur un canevas.
async function displayGrayscale(videoFrame) {
// Nous supposons que videoFrame est dans un format YUV comme 'I420' ou 'NV12'.
if (!videoFrame.format.startsWith('I4')) {
console.error('Cet exemple nécessite un format planaire YUV 4:2:0.');
videoFrame.close();
return;
}
const yPlaneInfo = videoFrame.layout[0]; // Le plan Y est toujours le premier.
// Créer un tampon pour contenir uniquement les données du plan Y.
const yPlaneData = new Uint8Array(yPlaneInfo.stride * videoFrame.codedHeight);
// Copier le plan Y dans notre tampon.
await videoFrame.copyTo(yPlaneData, {
rect: { x: 0, y: 0, width: videoFrame.codedWidth, height: videoFrame.codedHeight },
layout: [yPlaneInfo]
});
// Maintenant, yPlaneData contient les pixels bruts en niveaux de gris.
// Nous devons le rendre. Nous allons créer un tampon RGBA pour le canevas.
const canvas = document.getElementById('my-canvas');
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(canvas.width, canvas.height);
// Itérer sur les pixels du canevas et les remplir à partir des données du plan Y.
for (let y = 0; y < videoFrame.displayHeight; y++) {
for (let x = 0; x < videoFrame.displayWidth; x++) {
// Important : Utiliser le stride pour trouver le bon index source !
const yIndex = y * yPlaneInfo.stride + x;
const luma = yPlaneData[yIndex];
// Calculer l'index de destination dans le tampon RGBA ImageData.
const rgbaIndex = (y * canvas.width + x) * 4;
imageData.data[rgbaIndex] = luma; // Rouge
imageData.data[rgbaIndex + 1] = luma; // Vert
imageData.data[rgbaIndex + 2] = luma; // Bleu
imageData.data[rgbaIndex + 3] = 255; // Alpha
}
}
ctx.putImageData(imageData, 0, 0);
// CRUCIAL : Toujours fermer le VideoFrame pour libérer sa mémoire.
videoFrame.close();
}
Cet exemple met en évidence plusieurs étapes clés : identifier la bonne disposition de plan, allouer un tampon de destination, utiliser copyTo pour extraire les données, et itérer correctement sur les données en utilisant le stride pour construire une nouvelle image.
Exemple pratique 2 : Manipulation sur place (Filtre Sépia)
Effectuons maintenant une manipulation directe des données. Un filtre sépia est un effet classique facile à mettre en œuvre. Pour cet exemple, il est plus simple de travailler avec une image RGBA, que vous pourriez obtenir d'un canevas ou d'un contexte WebGL.
async function applySepiaFilter(videoFrame) {
// Cet exemple suppose que l'image d'entrée est 'RGBA' ou 'BGRA'.
if (videoFrame.format !== 'RGBA' && videoFrame.format !== 'BGRA') {
console.error('L\'exemple de filtre sépia nécessite une image RGBA.');
videoFrame.close();
return null;
}
// Allouer un tampon pour contenir les données des pixels.
const frameDataSize = videoFrame.allocationSize();
const frameData = new Uint8Array(frameDataSize);
await videoFrame.copyTo(frameData);
const layout = videoFrame.layout[0]; // RGBA est un plan unique
// Maintenant, manipulons les données dans le tampon.
for (let y = 0; y < videoFrame.codedHeight; y++) {
for (let x = 0; x < videoFrame.codedWidth; x++) {
const pixelIndex = y * layout.stride + x * 4; // 4 octets par pixel (R,G,B,A)
const r = frameData[pixelIndex];
const g = frameData[pixelIndex + 1];
const b = frameData[pixelIndex + 2];
const tr = 0.393 * r + 0.769 * g + 0.189 * b;
const tg = 0.349 * r + 0.686 * g + 0.168 * b;
const tb = 0.272 * r + 0.534 * g + 0.131 * b;
frameData[pixelIndex] = Math.min(255, tr);
frameData[pixelIndex + 1] = Math.min(255, tg);
frameData[pixelIndex + 2] = Math.min(255, tb);
// L'alpha (frameData[pixelIndex + 3]) reste inchangé.
}
}
// Créer un *nouveau* VideoFrame avec les données modifiées.
const newFrame = new VideoFrame(frameData, {
format: videoFrame.format,
codedWidth: videoFrame.codedWidth,
codedHeight: videoFrame.codedHeight,
timestamp: videoFrame.timestamp,
duration: videoFrame.duration
});
// N'oubliez pas de fermer l'image originale !
videoFrame.close();
return newFrame;
}
Ceci démontre un cycle complet de lecture-modification-écriture : copier les données, les parcourir en utilisant le stride, appliquer une transformation mathématique à chaque pixel, et construire un nouveau VideoFrame avec les données résultantes. Cette nouvelle image peut ensuite être rendue sur un canevas, envoyée à un VideoEncoder, ou passée à une autre étape de traitement.
La performance compte : JavaScript vs. WebAssembly (WASM)
Itérer sur des millions de pixels pour chaque image (une image 1080p a plus de 2 millions de pixels, soit 8 millions de points de données en RGBA) en JavaScript peut être lent. Bien que les moteurs JS modernes soient incroyablement rapides, pour le traitement en temps réel de vidéos haute résolution (HD, 4K), cette approche peut facilement surcharger le thread principal, conduisant à une expérience utilisateur saccadée.
C'est là que WebAssembly (WASM) devient un outil essentiel. WASM vous permet d'exécuter du code écrit dans des langages comme C++, Rust ou Go à une vitesse quasi-native à l'intérieur du navigateur. Le flux de travail pour le traitement vidéo devient :
- En JavaScript : Utiliser
videoFrame.copyTo()pour obtenir les données de pixels brutes dans unArrayBuffer. - Passer à WASM : Passer une référence à ce tampon dans votre module WASM compilé. C'est une opération très rapide car elle n'implique pas la copie des données.
- En WASM (C++/Rust) : Exécuter vos algorithmes de traitement d'image hautement optimisés directement sur le tampon mémoire. C'est des ordres de grandeur plus rapide qu'une boucle JavaScript.
- Retour à JavaScript : Une fois que WASM a terminé, le contrôle revient à JavaScript. Vous pouvez alors utiliser le tampon modifié pour créer un nouveau
VideoFrame.
Pour toute application sérieuse de manipulation vidéo en temps réel — comme les arrière-plans virtuels, la détection d'objets ou les filtres complexes — tirer parti de WebAssembly n'est pas seulement une option ; c'est une nécessité.
Gérer différents formats de pixels (par ex., I420, NV12)
Bien que le RGBA soit simple, vous recevrez le plus souvent des images dans des formats YUV planaires d'un VideoDecoder. Voyons comment gérer un format entièrement planaire comme le I420.
Un VideoFrame au format I420 aura trois descripteurs de disposition dans son tableau layout :
layout[0]: Le plan Y (luma). Les dimensions sontcodedWidthxcodedHeight.layout[1]: Le plan U (chroma). Les dimensions sontcodedWidth/2xcodedHeight/2.layout[2]: Le plan V (chroma). Les dimensions sontcodedWidth/2xcodedHeight/2.
Voici comment vous copieriez les trois plans dans un seul tampon :
async function extractI420Planes(videoFrame) {
const totalSize = videoFrame.allocationSize({ format: 'I420' });
const allPlanesData = new Uint8Array(totalSize);
const layouts = await videoFrame.copyTo(allPlanesData);
// layouts est un tableau de 3 objets PlaneLayout
console.log('Layout du Plan Y :', layouts[0]); // { offset: 0, stride: ... }
console.log('Layout du Plan U :', layouts[1]); // { offset: ..., stride: ... }
console.log('Layout du Plan V :', layouts[2]); // { offset: ..., stride: ... }
// Vous pouvez maintenant accéder à chaque plan dans le tampon `allPlanesData`
// en utilisant son offset et son stride spécifiques.
const yPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[0].offset,
layouts[0].stride * videoFrame.codedHeight
);
// Notez que les dimensions de la chrominance sont divisées par deux !
const uPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[1].offset,
layouts[1].stride * (videoFrame.codedHeight / 2)
);
const vPlaneView = new Uint8Array(
allPlanesData.buffer,
layouts[2].offset,
layouts[2].stride * (videoFrame.codedHeight / 2)
);
console.log('Taille du plan Y accédé :', yPlaneView.byteLength);
console.log('Taille du plan U accédé :', uPlaneView.byteLength);
videoFrame.close();
}
Un autre format courant est le NV12, qui est semi-planaire. Il a deux plans : un pour Y, et un second plan où les valeurs U et V sont entrelacées (par ex., [U1, V1, U2, V2, ...]). L'API WebCodecs gère cela de manière transparente ; un VideoFrame au format NV12 aura simplement deux dispositions dans son tableau layout.
Défis et meilleures pratiques
Travailler à ce bas niveau est puissant, mais cela s'accompagne de responsabilités.
La gestion de la mémoire est primordiale
Un VideoFrame conserve une quantité importante de mémoire, qui est souvent gérée en dehors du tas du ramasse-miettes (garbage collector) de JavaScript. Si vous ne libérez pas explicitement cette mémoire, vous provoquerez une fuite de mémoire qui peut faire planter l'onglet du navigateur.
Appelez toujours, toujours videoFrame.close() lorsque vous avez terminé avec une image.
Nature asynchrone
Tout accès aux données est asynchrone. L'architecture de votre application doit gérer correctement le flux des Promesses et de async/await pour éviter les conditions de concurrence (race conditions) et assurer un pipeline de traitement fluide.
Compatibilité des navigateurs
WebCodecs est une API moderne. Bien que prise en charge par tous les principaux navigateurs, vérifiez toujours sa disponibilité et soyez conscient de tout détail d'implémentation ou limitation spécifique au fournisseur. Utilisez la détection de fonctionnalités avant d'essayer d'utiliser l'API.
Conclusion : Une nouvelle frontière pour la vidéo sur le Web
La capacité d'accéder directement et de manipuler les données brutes des plans d'un VideoFrame via l'API WebCodecs est un changement de paradigme pour les applications multimédias basées sur le web. Elle supprime la boîte noire de l'élément <video> et donne aux développeurs le contrôle granulaire autrefois réservé aux applications natives.
En comprenant les fondamentaux de l'organisation de la mémoire vidéo — plans, stride et formats de couleur — et en tirant parti de la puissance de WebAssembly pour les opérations critiques en termes de performance, vous pouvez maintenant construire des outils de traitement vidéo incroyablement sophistiqués directement dans le navigateur. De l'étalonnage des couleurs en temps réel et des effets visuels personnalisés à l'apprentissage automatique côté client et à l'analyse vidéo, les possibilités sont vastes. L'ère de la vidéo haute performance et de bas niveau sur le web a véritablement commencé.