Experimentelle Webcam-Filter mit FFmpeg.wasm und WebCodecs
Dieser DevTip zeigt einen Webcam-Filter mithilfe von FFmpeg.wasm, WebCodecs-Objekten vom Typ
VideoFrame und Insertable Streams von MediaStreamTrack. Er konvertiert jeden
Frame über PNG-Dateien und ist damit ein Experiment für kleine Frames und niedrige Frameraten, keine
Garantie für Performance in Echtzeit. Für eine produktive Graustufen-Vorschau ist Canvas oder WebGL
in der Regel der besser geeignete Verarbeitungsweg.
Einführung in die Echtzeit-Videoverarbeitung in Browsern
Bis vor Kurzem erforderte komplexe Videobearbeitung serverseitige Infrastruktur. Heute können Browser Frames lokal mit leistungsfähigen APIs decodieren, transformieren und neu codieren. Die clientseitige Verarbeitung senkt die Latenz, hält Rohmaterial privat, weil es auf dem Gerät der Nutzerinnen und Nutzer verarbeitet wird, und reduziert Back-End-Kosten.
Überblick über FFmpeg und WebAssembly
FFmpeg ist ein umfassendes, praxiserprobtes Multimedia-Toolkit, das nahezu jedes jemals geschaffene Medienformat decodieren, codieren, muxen, demuxen, streamen, filtern und abspielen kann. WebAssembly (Wasm) ist ein binäres Befehlsformat, mit dem Code aus Sprachen wie C/C++ nahezu mit nativer Geschwindigkeit im Browser läuft. Indem Sie FFmpeg nach Wasm kompilieren – Projekte wie ffmpeg.wasm übernehmen diese komplexe Aufgabe –, erschließen Sie die leistungsfähigen Kommandozeilen-Funktionen von FFmpeg direkt in JavaScript.
Wie lässt sich FFmpeg direkt in Browsern verwenden?
- Laden Sie den FFmpeg-Wasm-Build in Ihre Webanwendung.
- Schreiben Sie mit der bereitgestellten API Eingabedateien (etwa Videoframes oder vorhandene Videos) in das virtuelle In-Memory-Dateisystem von FFmpeg.
- Führen Sie FFmpeg-Befehle mit
ffmpeg.exec()und den vertrauten CLI-Argumenten aus (z. B.-vf 'hue=s=0'für Graustufen). - Lesen Sie die verarbeiteten Ausgabedateien wieder aus dem virtuellen Dateisystem.
Welche Vorteile bietet WebAssembly für die Videoverarbeitung?
WebAssembly bietet nahezu native Ausführungsgeschwindigkeit für rechenintensive Aufgaben wie Video-Encoding und Filterung, liefert im Vergleich zu JavaScript-Implementierungen eine konsistentere Performance über verschiedene Browser hinweg und ermöglicht den Einsatz ausgereifter, funktionsreicher C/C++-Bibliotheken wie FFmpeg, ohne dass für die Verarbeitung Server-Roundtrips nötig sind.
FFmpeg im Browser mithilfe von WebAssembly einrichten
Installieren Sie zunächst die erforderlichen Pakete:
npm install @ffmpeg/ffmpeg@0.12.15 @ffmpeg/util@0.12.2
Verwenden Sie einen Browser-Bundler mit Unterstützung für Modul-Worker. Laden Sie den Single-Threaded-Core explizit:
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { toBlobURL } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
async function loadFFmpeg() {
if (ffmpeg.loaded) return
const baseURL = 'https://unpkg.com/@ffmpeg/core@0.12.10/dist/esm'
const coreURL = await toBlobURL(`${baseURL}/ffmpeg-core.js`, 'text/javascript')
try {
const wasmURL = await toBlobURL(`${baseURL}/ffmpeg-core.wasm`, 'application/wasm')
try {
await ffmpeg.load({ coreURL, wasmURL })
} finally {
URL.revokeObjectURL(wasmURL)
}
} finally {
URL.revokeObjectURL(coreURL)
}
}
Einführung in die WebCodecs API
Die WebCodecs API bietet Low-Level-Zugriff auf die im Browser
integrierten Medien-Codecs (Encoder und Decoder). Statt ein MediaStream als
undurchsichtiges Objekt zu behandeln, das in ein Element vom Typ <video>
geleitet wird, können Sie mit WebCodecs mit einzelnen Audio-Chunks
(AudioData) und Videoframes (VideoFrame) arbeiten. Sie
können komprimierte Streams decodieren, auf rohe Frame-Daten (Pixel) zugreifen und sie verändern
sowie Frames wieder in ein komprimiertes Format codieren oder anzeigen.
Wie verbessert die WebCodecs API die Echtzeit-Videoverarbeitung?
- Direkter Zugriff auf Frames: Sie ermöglicht die direkte Manipulation roher Videoframe-Daten und beseitigt ineffiziente Umwege wie das Zeichnen von Frames auf ein Canvas, den Export als Bild (PNG/JPEG), die Verarbeitung und das anschließende Zurückzeichnen.
- Effizientes Encoding/Decoding: Sie nutzt die Medien-Pipeline des Browsers, die häufig hardwarebeschleunigt ist, für effizientes Encoding und Decoding.
- Stream-Integration: Sie lässt sich nahtlos in die Streams API integrieren und ermöglicht so effiziente Verarbeitungs-Pipelines mit integriertem Back-Pressure-Management.
Browser-Unterstützung
Die Unterstützung für WebCodecs allein reicht für dieses Beispiel nicht aus. Es nutzt außerdem die
im Fensterkontext verfügbaren APIs MediaStreamTrackProcessor und
MediaStreamTrackGenerator, die in
Chromes Leitfaden zu Insertable Streams beschrieben werden. Andere
Browser und der sich weiterentwickelnde Standard stellen abweichende APIs oder Worker-Kontexte
bereit. Das Beispiel prüft vor der Kameraanfrage die Konstruktoren, die es tatsächlich verwendet;
schließen Sie nicht von der Unterstützung für VideoEncoder oder
VideoDecoder auf Kompatibilität.
Der hier verwendete Single-Threaded-Core @ffmpeg/core benötigt Unterstützung für
WebAssembly und Worker, aber kein SharedArrayBuffer.
Sicherheitsanforderungen
Verwenden Sie HTTPS oder localhost für den Kamerazugriff und WebCodecs. Wenn Sie zu
@ffmpeg/core-mt wechseln, stellen Sie dessen zusätzliches Worker-Asset bereit und
aktivieren Sie Cross-Origin-Isolation für SharedArrayBuffer:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Diese Header stellen sicher, dass Ihre Seite ausreichend isoliert ist, was aus Sicherheitsgründen
beim Einsatz leistungsfähiger Funktionen wie SharedArrayBuffer erforderlich ist. Die
meisten Hosting-Plattformen und Frameworks bieten Möglichkeiten, diese Header zu konfigurieren
(z. B. über nginx.conf, netlify.toml, Vercels
vercel.json oder Ihre Hosting-Konfiguration). Das Single-Threaded-Beispiel
benötigt diese Isolations-Header nicht.
Einen experimentellen Filter mit FFmpeg und WebCodecs umsetzen
Nachfolgend finden Sie ein konzeptionelles Beispiel, das zeigt, wie Sie mithilfe von FFmpeg.wasm und WebCodecs einen Graustufenfilter auf einen Webcam-Stream anwenden. Hinweis: Bei diesem konkreten Ansatz wird jeder Frame in ein PNG konvertiert, mit FFmpeg verarbeitet und anschließend zurückkonvertiert. Dieser Umweg funktioniert zwar, ist aber für Filterung in Echtzeit mit hoher Auflösung oder hoher FPS-Zahl nicht performant. Effizientere Verfahren würden direkte Pixelmanipulation mithilfe der Canvas API oder WebGL nutzen oder, sofern für direkten Speicherzugriff verfügbar, fortgeschrittenere Techniken von FFmpeg.wasm.
async function startFilteredStream(videoEl) {
if (
typeof MediaStreamTrackProcessor !== 'function' ||
typeof MediaStreamTrackGenerator !== 'function' ||
typeof VideoFrame !== 'function' ||
typeof OffscreenCanvas !== 'function'
) {
throw new Error('This browser does not support the required webcam processing APIs')
}
await loadFFmpeg()
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 320 }, height: { ideal: 240 }, frameRate: { ideal: 5 } },
})
const inputTrack = stream.getVideoTracks()[0]
const abortController = new AbortController()
let generator
let finished
const stop = () => {
abortController.abort()
for (const track of stream.getTracks()) track.stop()
generator?.stop()
ffmpeg.terminate()
videoEl.srcObject = null
}
try {
const processor = new MediaStreamTrackProcessor({ track: inputTrack })
generator = new MediaStreamTrackGenerator({ kind: 'video' })
const transformer = new TransformStream({
async transform(frame, controller) {
let bitmap
let newFrame
try {
// Draw frame to an OffscreenCanvas for conversion
const { displayWidth: w, displayHeight: h } = frame
const canvas = new OffscreenCanvas(w, h)
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Canvas 2D is unavailable')
ctx.drawImage(frame, 0, 0, w, h)
// Convert canvas to PNG Blob, then ArrayBuffer
const blob = await canvas.convertToBlob({ type: 'image/png' })
const arrayBuffer = await blob.arrayBuffer()
// Write input PNG to FFmpeg's virtual filesystem
const inputFilename = 'in.png'
const outputFilename = 'out.png'
await ffmpeg.writeFile(inputFilename, new Uint8Array(arrayBuffer))
// Execute FFmpeg command (grayscale filter)
// Note: This is the performance bottleneck
const code = await ffmpeg.exec([
'-i',
inputFilename,
'-vf',
'hue=s=0',
'-frames:v',
'1',
outputFilename,
])
if (code !== 0) throw new Error(`FFmpeg failed with exit code ${code}`)
// Read the processed PNG file
const outputData = await ffmpeg.readFile(outputFilename)
if (!(outputData instanceof Uint8Array) || outputData.length === 0) {
throw new Error('FFmpeg produced no image')
}
// Create an ImageBitmap from the output PNG data
const outputBlob = new Blob([outputData], { type: 'image/png' })
bitmap = await createImageBitmap(outputBlob)
// Create a new VideoFrame with the processed bitmap
newFrame = new VideoFrame(bitmap, {
timestamp: frame.timestamp,
...(frame.duration === null ? {} : { duration: frame.duration }),
})
// Enqueue the new frame into the output stream
controller.enqueue(newFrame)
newFrame = undefined // The output stream owns the enqueued frame.
} finally {
newFrame?.close()
bitmap?.close()
frame.close()
await Promise.allSettled([ffmpeg.deleteFile('in.png'), ffmpeg.deleteFile('out.png')])
}
},
})
// Pipe the processor through the transformer to the generator
finished = processor.readable
.pipeThrough(transformer)
.pipeTo(generator.writable, { signal: abortController.signal })
.catch((error) => {
if (!abortController.signal.aborted) console.error('Filter pipeline failed:', error)
})
.finally(stop)
// Display the output stream in the video element
videoEl.srcObject = new MediaStream([generator])
videoEl.muted = true
await videoEl.play()
return async () => {
stop()
await finished
}
} catch (error) {
stop()
await finished
throw error
}
}
// Provide start/stop buttons and a <video muted playsinline> element.
const videoElement = document.querySelector('video')
const startButton = document.getElementById('start')
const stopButton = document.getElementById('stop')
let stopFiltering
stopButton.disabled = true
startButton.onclick = async () => {
startButton.disabled = true
try {
stopFiltering = await startFilteredStream(videoElement)
stopButton.disabled = false
} catch (error) {
console.error('Could not start the filter:', error)
startButton.disabled = false
}
}
stopButton.onclick = async () => {
stopButton.disabled = true
await stopFiltering?.()
stopFiltering = undefined
startButton.disabled = false
}
window.addEventListener('pagehide', () => stopFiltering?.(), { once: true })
Performance-Überlegungen und Optimierungstipps
- Worker-Grenzen berücksichtigen: FFmpeg.wasm führt seinen Core bereits in einem Worker aus. Die PNG-Konvertierungen, die Frame-Verarbeitung und die Datenübertragungen verursachen dennoch Kosten; erstellen Sie ein Profil der gesamten Pipeline.
- Ressourcenverwaltung: Rufen Sie stets
frame.close()für Objekte vom TypVideoFrameauf, sobald Sie diese nicht mehr benötigen, um zugrunde liegenden Speicher und GPU-Ressourcen zeitnah freizugeben. Andernfalls kann es zu Speicherlecks und Performance-Einbußen kommen. - Datenkopien minimieren: Der Umweg über die PNG-Konvertierung im Beispiel ist ineffizient.
Verwenden Sie für einfachere Filter (Helligkeit, Kontrast, Sättigung, grundlegende
Farbtransformationen) die Canvas 2D API oder WebGL direkt auf den Daten von
VideoFrame, um deutlich bessere Performance zu erzielen.ctx.drawImage(frame, ...)ist effizient. - Framerate/Auflösung steuern: Wenn die Verarbeitung zu langsam ist, erwägen Sie, eine
niedrigere Auflösung oder Framerate von
getUserMediaanzufordern oder gezielt Frames imTransformStreamzu verwerfen. - Zustand der Pipeline überwachen: Behandeln Sie das von
pipeTozurückgegebene Promise und stoppen Sie bei einem Fehler die Kamera-Tracks. Messen Sie die Verarbeitungszeit pro Frame; Stream-Locks messen nicht den Verarbeitungsdurchsatz. - Hardwarebeschleunigung: WebCodecs nutzt für Encoding/Decoding häufig Hardwarebeschleunigung. Achten Sie darauf, dass Ihre Verarbeitungslogik nicht zum Flaschenhals wird, der diese Vorteile zunichtemacht.
Fazit und mögliche Anwendungsfälle
Die Kombination aus WebAssembly (über FFmpeg.wasm) und der WebCodecs API eröffnet spannende Möglichkeiten für clientseitige Videobearbeitung direkt im Browser. Die Filterung in Echtzeit mit komplexen FFmpeg-Operationen Frame für Frame stößt mit den aktuellen Methoden zwar an Performance-Grenzen, doch die zugrunde liegenden Technologien sind leistungsfähig. Mögliche Anwendungen sind:
- Einfache Filter (Helligkeit, Kontrast) oder Overlays in Videochat-Anwendungen anwenden.
- Virtuelle Hintergründe umsetzen (wofür sich spezialisierte ML-Modelle jedoch oft besser eignen).
- Leichtgewichtige, clientseitige Videobearbeitungs-Tools für das Trimmen oder einfache Effekte erstellen.
- Datenschutzfreundliches Weichzeichnen oder Maskieren von Videoinhalten vor dem Upload.
Was sind praktische Anwendungsfälle für Videofilter in Echtzeit in Webanwendungen? Sie reichen von einer besseren Nutzererfahrung in Kommunikations-Apps über neuartige Kreativwerkzeuge bis hin zu mehr Datenschutz, und das alles bei geringerer Serverlast.
Die clientseitige Verarbeitung macht zwar rasch Fortschritte, doch komplexes, hochwertiges Video-Encoding und umfangreiche Videobearbeitung profitieren oft weiterhin von robusten serverseitigen Lösungen. Wenn Sie zuverlässige, skalierbare Videoverarbeitung im Produktivbetrieb benötigen, ziehen Sie Dienste wie die Video-Encoding-Plattform von Transloadit in Betracht, die von vielseitigen Robots wie 🤖 /video/encode angetrieben wird.
Experimentieren Sie mit WebCodecs und FFmpeg.wasm und entdecken Sie, was Sie direkt im Browser bauen können!
