Experimentelle Webcamfilter mit FFmpeg.wasm und WebCodecs
Erstellen Sie eine lokale Webcamvorschau, die den Graustufenfilter von FFmpeg im Browser ausführt. Sie sehen die Kameravorschau und ein gefiltertes Canvas sowie Steuerelemente zum Stoppen und Neustarten der Verarbeitung. Jedes kleine Bild durchläuft FFmpeg.wasm als PNG hin und zurück. Rechnen Sie daher mit einem CPU-intensiven Experiment mit niedriger Bildrate.
Verfolgen Sie ein Bild durch den Filter
Der Ablauf: Kameravideo → VideoFrame → PNG → FFmpeg-Dateisystem im Arbeitsspeicher → Graustufen-PNG →
Canvas. Der Filter hue=s=0 von FFmpeg entfernt die Sättigung.
Die App wartet, bis jeder Job abgeschlossen ist, bevor sie ein weiteres Bild anfordert. Sie stellt
nicht jedes Kamerabild zur Verarbeitung in eine Queue. Jedes Ergebnis beendet seinen FFmpeg-Worker,
sodass das nächste Bild den Core neu lädt. Wiederholte Aufrufe von
exec() mit demselben Core in der festgelegten Version können in dieser Schleife
zu einem WebAssembly-Speicherfehler führen. Eine Ausführung pro Worker vermeidet diesen Fehler,
erfordert aber zusätzliche Startvorgänge.
Mit WebAssembly können wir die Filter von FFmpeg lokal wiederverwenden. Diese Pipeline wird dadurch aber nicht so schnell wie natives FFmpeg. Die FAQ von ffmpeg.wasm weist ausdrücklich auf diesen Leistungsunterschied hin. Das Codieren und Decodieren der PNGs sowie die Kopiervorgänge verursachen zusätzlichen Aufwand.
Hier liefert WebCodecs eine Momentaufnahme des Videos als
VideoFrame. Wir schließen sie, nachdem ihre Pixel
gezeichnet wurden. Wir verwenden weder VideoEncoder noch
VideoDecoder und behaupten für dieses Beispiel keine Hardwarebeschleunigung.
Die Ausgabe ist eine Canvasvorschau. Sie erzeugt weder eine Aufzeichnung noch einen gefilterten
MediaStream für einen Videoanruf.
Browserunterstützung
Diese Anleitung wurde in Chromium 145.0.7632.6 unter Linux mit einer synthetischen Kamera getestet.
Eine physische Webcam und andere Browser wurden nicht getestet. Der Code prüft
VideoFrame, OffscreenCanvas und
requestVideoFrameCallback(), bevor er die Kamera öffnet.
Diese Version erstellt Momentaufnahmen eines laufenden Videos, statt
MediaStreamTrackProcessor oder MediaStreamTrackGenerator zu verwenden.
Diese APIs für einfügbare Streams sind
in verschiedenen Browsern nicht einheitlich in Window und Worker verfügbar.
Für diese sequenzielle Vorschau reicht ein Callback pro Bild.
Sicherheitsanforderungen
Öffnen Sie die Seite über localhost. Der Kamerazugriff erfordert einen
sicheren Kontext und die Erlaubnis der nutzenden Person.
Bei der Auslieferung von einem regulären entfernten Ursprung ist HTTPS erforderlich.
Öffnen Sie index.html nicht direkt.
Wir verwenden @ffmpeg/core mit einem einzelnen Thread, ausgeliefert vom selben
lokalen Server wie die Seite. Dafür sind weder SharedArrayBuffer noch Header zur
Cross-Origin-Isolation erforderlich. Der separate
Build @ffmpeg/core-mt
benötigt gemeinsam genutzten Speicher, Cross-Origin-Isolation und eine zusätzliche Worker-Datei.
Der Wechsel zu diesem Build ist nicht Teil dieser Anleitung. Der einzelne Thread bezieht sich auf
den FFmpeg-Core: Der Wrapper führt ihn weiterhin in einem Web Worker aus.
Erstellen Sie das lokale Projekt
Sie benötigen ein Bash-kompatibles Terminal, Node.js 22.12 oder neuer sowie Corepack, um Yarn auszuführen. Die getestete Umgebung verwendete Node.js 26.8.1, Yarn 4.12.0 und Vite 8.3.0. Vite übernimmt die Handhabung von Modulen und Workern. Die Anleitung zur manuellen Installation erläutert den HTML-Einstiegspunkt.
Führen Sie dies in einem übergeordneten Verzeichnis aus, in dem
webcam-filter noch nicht existiert. Die Verkettung mit
&& stoppt bei einem fehlgeschlagenen Schritt, und
mkdir verweigert das Überschreiben eines bestehenden Projekts.
Fahren Sie nur fort, wenn der gesamte Block erfolgreich ausgeführt wurde. Die Core-Dateien werden
aus dem Paket mit festgelegter Version kopiert, sodass der Browser keine Engine von einem
Drittanbieter-CDN abruft.
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/
Erstellen Sie in webcam-filter die Datei vite.config.ts.
Wenn der FFmpeg-Wrapper vom Vorab-Bündeln der Abhängigkeiten ausgeschlossen wird, bleibt die URL
seines Modul-Workers dem Paket zugeordnet.
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: { exclude: ['@ffmpeg/ffmpeg'] },
})
Erstellen Sie index.html im selben Verzeichnis:
<!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>
Verarbeiten Sie jeweils ein Bild
Erstellen Sie main.ts. Jeder Start besitzt einen eigenen FFmpeg-Wrapper und
Kamerastream. Stop löscht die aktuelle Sitzung sofort. Später aufgelöste Promises müssen noch zu
dieser Sitzung gehören, bevor sie die Vorschau aktualisieren dürfen. Das ist wichtig, wenn die
Berechtigungserteilung, das Laden der Engine oder das Decodieren eines PNGs erst nach Stop endet.
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())
Die Kameraabmessungen und die Bildrate sind Wunschwerte, keine Garantien. Beim Zeichnen auf das
Canvas mit fester Größe wird jede Eingabe auf 320 × 240 skaliert, auch wenn die Kamera größere
Bilder oder ein anderes Seitenverhältnis liefert. Die Option -update 1
schreibt ein einzelnes Ausgabebild, und -y erlaubt das Ersetzen der Datei
unter diesem temporären Namen. Wenn der Worker nach jedem Bild, bei Stop oder bei einem Fehler
beendet wird, wird sein Dateisystem im Arbeitsspeicher freigegeben. Die App speichert keine Bilder
auf der Festplatte.
VideoFrame.close() und
ImageBitmap.close() geben die von uns erstellten Bildressourcen frei.
Starten und stoppen Sie die Vorschau
Starten Sie den Server aus webcam-filter im Vordergrund:
corepack yarn vite --host 127.0.0.1
Öffnen Sie die lokale URL, die Vite ausgibt. Klicken Sie auf Start camera und erlauben Sie den Kamerazugriff. Nach Loading FFmpeg… und Requesting camera… wechselt der Status zu Filtering…, sobald das erste Graustufenbild erscheint. Halten Sie etwas Farbiges ins Bild, um die beiden Vorschauen zu vergleichen. Die Bilder bleiben im Browser. Diese App lädt sie weder hoch noch zeichnet sie sie auf.
Klicken Sie auf Stop, um die Kameraspuren dieser App zu stoppen, ihren FFmpeg-Worker zu beenden und die Vorschauen zu leeren. Start camera ist danach wieder verfügbar, auch wenn Sie während der Initialisierung stoppen. Browser bieten keine API, um eine ausstehende Anfrage zur Kameraberechtigung zu schließen. Wenn Sie die Erlaubnis nach dem Stoppen erteilen, wird der verspätete Stream gestoppt, ohne die Seite zu aktualisieren. Auch beim Verlassen der Seite werden die Ressourcen freigegeben. Beenden Sie anschließend den Server im Terminal mit Ctrl+C.
Wenn die Erlaubnis verweigert wird, fordert die sichtbare Fehlermeldung Sie auf, die
Kameraberechtigung und die Verfügbarkeit der Kamera zu prüfen. Erlauben Sie den Zugriff in den
Website-Einstellungen des Browsers und klicken Sie erneut auf
Start camera.
Eine fehlende Engine-Datei verursacht einen Ladefehler, bevor die Kamera angefordert wird.
Prüfen Sie, ob beide Dateien unter public/ffmpeg vorhanden sind.
Ein Verarbeitungsfehler stoppt die Sitzung und zeigt
Filter failed. Start again to retry. an. Wenn die Kamera endet,
lautet die Meldung Camera ended. Start again to retry..
Entscheiden Sie, ob FFmpeg in Ihre Vorschau gehört
Diese Schleife überspringt Kamerabilder, während FFmpeg beschäftigt ist. Eine niedrigere angeforderte Bildrate kann den Aufwand reduzieren. Fünf angeforderte Bilder pro Sekunde versprechen aber keine fünf gefilterten Bilder pro Sekunde. Messen Sie den gesamten PNG-Durchlauf auf Ihrer Zielhardware, bevor Sie die Abmessungen erhöhen oder weitere Filter hinzufügen. Ein Core mit mehreren Threads beseitigt die Bildkonvertierungen und Datenkopien nicht.
Für eine Kameravorschau in Graustufen vermeidet ein Canvas-2D-Filter oder ein WebGL-Shader diesen Umweg über PNG und Dateisystem. Nutzen Sie diese FFmpeg.wasm-Version, um die dateibasierte API kennenzulernen oder einen FFmpeg-Filter an kleinen Momentaufnahmen auszuprobieren. Aufzeichnung, Audio und ein gefilterter Stream für WebRTC erfordern einen anderen Ausgabeweg.
