Filtres webcam expérimentaux avec FFmpeg.wasm et WebCodecs
Créez un aperçu webcam local qui exécute le filtre de niveaux de gris de FFmpeg dans votre navigateur. Vous verrez l’aperçu de la caméra et un canevas filtré, avec des commandes pour arrêter et relancer le traitement. Chaque petite image fait un aller-retour au format PNG via FFmpeg.wasm : attendez-vous donc à une expérience gourmande en CPU, avec une faible fréquence d’images.
Suivre une image à travers le filtre
Le chemin est le suivant : vidéo de la caméra → VideoFrame → PNG → système de fichiers en mémoire de FFmpeg →
PNG en niveaux de gris → canevas. Le filtre hue=s=0 de FFmpeg supprime la saturation.
L’application attend la fin de chaque tâche avant de demander une autre image. Elle ne met pas chaque
image de la caméra en file d’attente pour traitement. Chaque résultat met fin à son worker FFmpeg,
si bien que l’image suivante recharge le core. Appeler à répétition exec() sur le même core épinglé
peut provoquer une erreur de mémoire WebAssembly dans cette boucle. Utiliser une seule exécution par
worker évite cette défaillance, au prix d’un travail de démarrage supplémentaire.
WebAssembly nous permet de réutiliser localement les filtres de FFmpeg, mais ne rend pas ce pipeline aussi rapide que FFmpeg natif. La FAQ de ffmpeg.wasm met explicitement en garde contre cet écart de performances. L’encodage et le décodage PNG ainsi que les copies ajoutent encore du travail.
Ici, WebCodecs fournit un instantané VideoFrame
de la vidéo. Nous le fermons après avoir dessiné ses pixels. Nous n’utilisons ni VideoEncoder ni
VideoDecoder, et cet exemple ne revendique aucune accélération matérielle. La sortie est un aperçu sur
canevas : elle ne produit ni enregistrement ni MediaStream filtré pour un appel vidéo.
Prise en charge des navigateurs
Ce tutoriel a été testé dans Chromium 145.0.7632.6 sous Linux avec une caméra synthétique. Aucune
webcam physique ni aucun autre navigateur n’ont été testés. Le code vérifie VideoFrame, OffscreenCanvas et
requestVideoFrameCallback()
avant d’ouvrir la caméra.
Cette version prend des instantanés d’une vidéo en cours de lecture au lieu d’utiliser MediaStreamTrackProcessor ou
MediaStreamTrackGenerator. Ces API de flux insérables (insertable streams) présentent une
exposition incompatible entre fenêtre et worker selon les navigateurs.
Un rappel par image suffit pour cet aperçu séquentiel.
Exigences de sécurité
Ouvrez la page via localhost. L’accès à la caméra nécessite un
contexte sécurisé et l’autorisation de l’utilisateur.
HTTPS est requis lorsque la page est servie depuis une origine distante ordinaire. N’ouvrez pas
index.html directement.
Nous utilisons le @ffmpeg/core monothread, servi par le même serveur local que la page. Il ne nécessite ni
SharedArrayBuffer ni en-têtes d’isolation cross-origin. La version @ffmpeg/core-mt,
distincte, nécessite de la mémoire partagée, l’isolation cross-origin et un asset de worker
supplémentaire. Y passer sort du cadre de ce tutoriel. « Monothread » décrit le core FFmpeg : le
wrapper l’exécute tout de même dans un Web Worker.
Créer le projet local
Il vous faut un terminal compatible Bash, Node.js 22.12 ou plus récent, et Corepack disponible pour exécuter Yarn. La configuration testée utilisait Node.js 26.8.1, Yarn 4.12.0 et Vite 8.3.0. Vite gère les modules et les workers, et son guide d’installation manuelle explique le point d’entrée HTML.
Exécutez ceci depuis un répertoire parent où webcam-filter n’existe pas. La chaîne && s’arrête à la
première étape en échec, et mkdir refuse d’écraser un projet existant. Ne continuez que si tout le
bloc réussit. Les fichiers du core sont copiés depuis le paquet épinglé : le navigateur ne récupère
donc aucun moteur depuis un CDN tiers.
mkdir webcam-filter &&
cd webcam-filter &&
printf '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}\n' > package.json &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact @ffmpeg/ffmpeg@0.12.15 @ffmpeg/core@0.12.10 &&
corepack yarn add --dev --exact vite@8.3.0 &&
mkdir -p public/ffmpeg &&
cp node_modules/@ffmpeg/core/dist/esm/ffmpeg-core.{js,wasm} public/ffmpeg/
Dans webcam-filter, créez vite.config.ts. Exclure le wrapper FFmpeg du pré-bundling des dépendances
permet de garder l’URL de son worker de module rattachée au paquet.
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: { exclude: ['@ffmpeg/ffmpeg'] },
})
Créez index.html dans le même répertoire :
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>FFmpeg webcam experiment</title>
</head>
<body>
<h1>FFmpeg webcam experiment</h1>
<button id="start" type="button">Start camera</button>
<button id="stop" type="button" disabled>Stop</button>
<p id="status" role="status">Ready.</p>
<figure>
<figcaption>Camera</figcaption>
<video id="camera" aria-label="Camera preview" width="320" height="240" muted playsinline></video>
</figure>
<figure>
<figcaption>FFmpeg grayscale</figcaption>
<canvas id="output" aria-label="Grayscale preview" width="320" height="240"></canvas>
</figure>
<script type="module" src="/main.ts"></script>
</body>
</html>
Traiter une image à la fois
Créez main.ts. Chaque démarrage possède son propre wrapper FFmpeg et son propre flux de caméra. Un
clic sur Stop efface immédiatement la session en cours. Les promesses ultérieures doivent encore
appartenir à cette session avant de pouvoir mettre à jour l’aperçu. C’est important lorsque
l’autorisation, le chargement du moteur ou le décodage PNG se termine après Stop.
import { FFmpeg } from '@ffmpeg/ffmpeg'
const startButton = document.querySelector('#start')
const stopButton = document.querySelector('#stop')
const status = document.querySelector('#status')
const camera = document.querySelector('#camera')
const output = document.querySelector('#output')
if (
!(startButton instanceof HTMLButtonElement) ||
!(stopButton instanceof HTMLButtonElement) ||
!(status instanceof HTMLElement) ||
!(camera instanceof HTMLVideoElement) ||
!(output instanceof HTMLCanvasElement)
) {
throw new Error('Missing demo elements')
}
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Canvas 2D is unavailable')
interface Session {
ffmpeg: FFmpeg
canvas: OffscreenCanvas
stream?: MediaStream
callbackId?: number
}
let current: Session | undefined
const loadFFmpeg = async (session: Session): Promise<void> => {
if (session.ffmpeg.loaded) return
await session.ffmpeg.load({
coreURL: new URL('/ffmpeg/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/ffmpeg/ffmpeg-core.wasm', location.href).href,
})
}
const stop = (message = 'Stopped.'): void => {
const session = current
if (!session) return
current = undefined
if (session.callbackId !== undefined) {
camera.cancelVideoFrameCallback(session.callbackId)
}
session.ffmpeg.terminate()
for (const track of session.stream?.getTracks() ?? []) track.stop()
camera.pause()
camera.srcObject = null
outputContext.clearRect(0, 0, output.width, output.height)
status.textContent = message
startButton.disabled = false
stopButton.disabled = true
}
const schedule = (session: Session): void => {
if (current !== session) return
session.callbackId = camera.requestVideoFrameCallback((_now, metadata) => {
session.callbackId = undefined
void filterFrame(session, metadata.mediaTime)
})
}
const filterFrame = async (session: Session, mediaTime: number): Promise<void> => {
try {
if (current !== session) return
const context = session.canvas.getContext('2d')
if (!context) throw new Error('Canvas 2D is unavailable')
const frame = new VideoFrame(camera, { timestamp: Math.round(mediaTime * 1_000_000) })
try {
context.drawImage(frame, 0, 0, 320, 240)
} finally {
frame.close()
}
const blob = await session.canvas.convertToBlob({ type: 'image/png' })
const bytes = new Uint8Array(await blob.arrayBuffer())
if (current !== session) return
await loadFFmpeg(session)
if (current !== session) return
await session.ffmpeg.writeFile('in.png', bytes)
const code = await session.ffmpeg.exec([
'-y', '-i', 'in.png', '-vf', 'hue=s=0', '-frames:v', '1', '-update', '1', 'out.png',
])
if (code !== 0) throw new Error('FFmpeg failed')
const data = await session.ffmpeg.readFile('out.png')
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('FFmpeg produced no image')
}
const bitmap = await createImageBitmap(
new Blob([new Uint8Array(data)], { type: 'image/png' }),
)
try {
if (current !== session) return
outputContext.drawImage(bitmap, 0, 0)
status.textContent = 'Filtering…'
} finally {
bitmap.close()
}
// This per-frame experiment uses one exec() per worker.
session.ffmpeg.terminate()
schedule(session)
} catch {
if (current === session) stop('Filter failed. Start again to retry.')
}
}
startButton.onclick = async () => {
if (current) return
if (
!navigator.mediaDevices?.getUserMedia ||
typeof VideoFrame !== 'function' ||
typeof OffscreenCanvas !== 'function' ||
typeof createImageBitmap !== 'function' ||
typeof camera.requestVideoFrameCallback !== 'function' ||
typeof Worker !== 'function' ||
typeof WebAssembly !== 'object'
) {
status.textContent = 'Required browser APIs are unavailable. Use a supported browser on localhost.'
return
}
const session: Session = { ffmpeg: new FFmpeg(), canvas: new OffscreenCanvas(320, 240) }
current = session
startButton.disabled = true
stopButton.disabled = false
status.textContent = 'Loading FFmpeg…'
let failureMessage = 'Could not load FFmpeg. Check the local server and engine files, then retry.'
try {
await loadFFmpeg(session)
if (current !== session) return
status.textContent = 'Requesting camera…'
failureMessage = 'Could not open camera. Check camera permission and availability, then retry.'
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { width: { ideal: 320 }, height: { ideal: 240 }, frameRate: { ideal: 5 } },
})
// getUserMedia has no abort signal; release a stream granted after Stop.
if (current !== session) {
for (const track of stream.getTracks()) track.stop()
return
}
session.stream = stream
const track = stream.getVideoTracks()[0]
if (!track) throw new Error('No video track')
track.addEventListener('ended', () => {
if (current === session) stop('Camera ended. Start again to retry.')
}, { once: true })
camera.srcObject = stream
await camera.play()
if (current !== session) return
status.textContent = 'Waiting for a frame…'
schedule(session)
} catch {
if (current === session) stop(failureMessage)
}
}
stopButton.onclick = () => stop()
window.addEventListener('pagehide', () => stop())
Les dimensions et la fréquence d’images de la caméra sont des préférences, pas des garanties.
Dessiner dans le canevas de taille fixe met chaque entrée à l’échelle 320 × 240, même si la caméra
fournit une image plus grande ou un autre rapport d’aspect. L’option -update 1 écrit une seule image de
sortie, et -y permet de remplacer ce nom de fichier temporaire. Terminer le worker après chaque
image, ou lors d’un Stop ou d’un échec, libère son système de fichiers en mémoire. L’application
n’enregistre aucune image sur le disque.
VideoFrame.close() et
ImageBitmap.close() libèrent les ressources d’image que nous créons.
Lancer et arrêter l’aperçu
Depuis webcam-filter, démarrez le serveur au premier plan :
corepack yarn vite --host 127.0.0.1
Ouvrez l’URL locale affichée par Vite. Cliquez sur Start camera et autorisez l’accès à la caméra. Après Loading FFmpeg… et Requesting camera…, le statut devient Filtering… lorsque la première image en niveaux de gris apparaît. Placez un objet coloré dans le champ pour comparer les deux aperçus. Les images restent dans le navigateur : cette application ne les téléverse pas et ne les enregistre pas.
Cliquez sur Stop pour arrêter les pistes de caméra de cette application, terminer son worker FFmpeg et effacer les aperçus. Start camera redevient disponible, y compris si vous arrêtez pendant l’initialisation. Les navigateurs ne fournissent pas d’API pour fermer une demande d’autorisation de caméra en attente. Si vous l’accordez après l’arrêt, le flux tardif est arrêté sans mettre à jour la page. Quitter la page déclenche aussi le nettoyage. Une fois terminé, arrêtez le serveur dans le terminal avec Ctrl+C.
Si l’autorisation est refusée, l’erreur affichée vous invite à vérifier l’autorisation et la
disponibilité de la caméra. Autorisez l’accès dans les paramètres du site du navigateur et cliquez
de nouveau sur Start camera. Un fichier de moteur manquant produit une
erreur de chargement avant la demande d’accès à la caméra. Vérifiez alors que les deux fichiers
existent sous public/ffmpeg. Une erreur de traitement arrête la session et affiche
Filter failed. Start again to retry.. Si la caméra s’arrête, le
message est Camera ended. Start again to retry..
Déterminer si FFmpeg a sa place dans votre aperçu
Cette boucle ignore des images de la caméra tant que FFmpeg est occupé. Réduire la fréquence d’images demandée peut diminuer la charge, mais demander cinq images par seconde ne garantit pas cinq images filtrées par seconde. Mesurez l’aller-retour PNG complet sur votre matériel cible avant d’augmenter les dimensions ou d’ajouter des filtres. Un core multithread ne supprime ni les conversions d’image ni les copies de données.
Pour un aperçu de caméra en niveaux de gris, appliquer un filtre Canvas 2D ou un shader WebGL évite cet aller-retour par PNG et par le système de fichiers. Gardez cette version FFmpeg.wasm pour découvrir l’API basée sur les fichiers ou essayer un filtre FFmpeg sur de petits instantanés. L’enregistrement, l’audio et un flux filtré pour WebRTC nécessitent un autre chemin de sortie.
