Video-Thumbnails im Browser mit ffmpeg.wasm extrahieren
Wählen Sie ein lokales Video, geben Sie Zeitpunkte in Sekunden ein und zeigen Sie PNG-Vorschauen an, ohne das Video hochzuladen. Diese Anleitung erstellt eine kleine Browser-App mit ffmpeg.wasm und Vite, einschließlich Dateiauswahl, passender Wasm-Assets, Fehlermeldungen und Bereinigung zwischen Durchläufen.
Warum Thumbnails im Browser extrahieren?
Mit einer lokalen Vorschau lässt sich ein geeignetes Einzelbild auswählen, bevor man entscheidet, ob man ein Video hochladen möchte. In diesem Beispiel lädt der Browser den Anwendungscode und den FFmpeg-Core von Ihrem lokalen Server herunter; das ausgewählte Video und die erzeugten Bilder bleiben im Browserspeicher. Das beschreibt das Verhalten dieser App und ist keine Datenschutzgarantie für jede Anwendung, die ffmpeg.wasm nutzt.
ffmpeg.wasm kennenlernen
Der ffmpeg.wasm-Wrapper führt FFmpeg in einem eigenen Web Worker aus. Sie kopieren eine Datei in dessen virtuelles Dateisystem, führen einen vertrauten FFmpeg-Befehl aus und lesen das Ergebnis wieder aus. Auch der hier verwendete Singlethread-Core läuft außerhalb des Hauptthreads des Browsers.
Beginnen Sie mit einem kurzen, unverschlüsselten H.264-MP4-Video. Dieses Beispiel begrenzt Eingaben auf 25 MiB und Anfragen auf sechs Zeitpunkte. Das sind Demo-Grenzen, keine Garantie, dass jedes Gerät eine solche Datei verarbeiten kann. Die getestete Umgebung besteht aus Linux, Node.js 24.15.0, Yarn 4.12.0 und Chromium 145.0.7632.6. Node erstellt die App und stellt sie bereit; der Browser übernimmt die Medienverarbeitung. Andere Browser und Mobilgeräte müssen separat getestet werden.
Installieren und initialisieren
Wenn Node.js 24.15.0 und Corepack verfügbar sind, fügen Sie Folgendes in einem Arbeitsverzeichnis in
eine Bash-kompatible Shell ein. Es erstellt ein neues Projekt namens wasm-thumbnails
und verweigert das Überschreiben eines vorhandenen Verzeichnisses. Die Subshell lässt das aktuelle
Verzeichnis und die Optionen Ihrer Shell unverändert. Die lokale Lockdatei und die
TypeScript-Konfiguration halten das Projekt von der Konfiguration eines übergeordneten Workspaces
getrennt.
(
set -eu
mkdir wasm-thumbnails
cd wasm-thumbnails
cat > package.json <<'JSON'
{
"name": "wasm-thumbnails",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"dependencies": {
"@ffmpeg/core": "0.12.10",
"@ffmpeg/ffmpeg": "0.12.15",
"@ffmpeg/util": "0.12.2",
"vite": "8.3.1"
}
}
JSON
printf '{"compilerOptions":{"target":"ES2022"}}\n' > tsconfig.json
touch yarn.lock
YARN_NODE_LINKER=node-modules corepack yarn install
)
Bewahren Sie die erzeugte Datei yarn.lock für eine reproduzierbare Auflösung der
Abhängigkeiten auf. Speichern Sie die folgenden drei Dateien in wasm-thumbnails.
Zuerst kopiert copy-core.ts den ESM-Core aus dem installierten Paket, damit die
JavaScript- und Wasm-Dateien zusammenpassen. Die offizielle
Ladeanleitung schreibt ESM-Assets für Vite vor; Assets desselben
Ursprungs lassen sich direkt ohne Blob-URL-Wrapper laden.
import { copyFile, mkdir } from 'node:fs/promises'
await mkdir('public/core', { recursive: true })
for (const name of ['ffmpeg-core.js', 'ffmpeg-core.wasm']) {
await copyFile(`node_modules/@ffmpeg/core/dist/esm/${name}`, `public/core/${name}`)
}
Speichern Sie als Nächstes index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Local video thumbnails</title>
<link rel="icon" href="data:," />
<style>
body { font-family: system-ui, sans-serif; max-width: 48rem; margin: 2rem auto; padding: 1rem; }
label { display: block; margin-block: 1rem; }
input { max-width: 100%; }
figure { margin-inline: 0; }
img { max-width: 100%; height: auto; }
</style>
</head>
<body>
<h1>Local video thumbnails</h1>
<form id="picker">
<fieldset id="controls">
<legend>Choose a video and timestamps</legend>
<label>Video <input id="video" type="file" accept="video/*" required /></label>
<label>Seconds, separated by commas
<input id="times" type="text" value="0.5, 1.5" required />
</label>
<button type="submit">Extract thumbnails</button>
</fieldset>
</form>
<p id="status" role="status">Choose a video to begin.</p>
<section id="results" aria-label="Thumbnails"></section>
<script type="module" src="/main.ts"></script>
</body>
</html>
Browseranforderungen erfüllen
Stellen Sie die App wie unten gezeigt über localhost bereit, statt index.html
als Datei zu öffnen. Dieser Singlethread-Core @ffmpeg/core funktioniert ohne
SharedArrayBuffer oder COOP/COEP-Header. Der Wrapper benötigt weiterhin Modul-Worker und
WebAssembly. Der Core-Download umfasst vor der HTTP-Komprimierung etwa 31 MiB, daher kann die erste
Extraktion deutlich länger dauern als spätere.
Der Wechsel zu @ffmpeg/core-mt ist eine separate Integration:
Das Ladebeispiel mit mehreren Threads benötigt ein zusätzliches
Worker-Asset sowie die Sicherheitsbedingungen für SharedArrayBuffer, einschließlich
ursprungsübergreifender Isolation. Fügen Sie diese Anforderungen diesem Singlethread-Beispiel nicht
standardmäßig hinzu. HTTPS im Produktivbetrieb, Content Security Policy und Hosting unter einem
Unterpfad benötigen eine separate Konfiguration; diese Anleitung stellt die App im Stammverzeichnis
des Ursprungs bereit.
Ein einzelnes Thumbnail extrahieren
Speichern Sie dieses vollständige Programm als main.ts.
extractThumbnail() wählt den ersten Videostream aus, springt zum angeforderten Zeitpunkt
und schreibt ein PNG. Jeder Aufruf verwendet eindeutige virtuelle Dateinamen, prüft den Exit-Status
und die Ausgabebytes und entfernt anschließend seine Dateien, auch bei einem Fehler.
import { FFmpeg } from '@ffmpeg/ffmpeg'
import { fetchFile } from '@ffmpeg/util'
const ffmpeg = new FFmpeg()
let extracting = false
async function extractThumbnail(videoFile: File, seconds: number): Promise<Blob> {
if (!Number.isFinite(seconds) || seconds < 0) throw new Error('Invalid timestamp')
if (extracting) throw new Error('A thumbnail operation is already running')
extracting = true
const id = crypto.randomUUID()
const input = `input-${id}.mp4`
const output = `thumb-${id}.png`
try {
if (!ffmpeg.loaded) {
await ffmpeg.load({
coreURL: new URL('/core/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/core/ffmpeg-core.wasm', location.href).href,
})
}
await ffmpeg.writeFile(input, await fetchFile(videoFile))
const code = await ffmpeg.exec([
'-xerror', '-ss', String(seconds), '-i', input,
'-map', '0:v:0', '-frames:v', '1', '-vf', 'scale=320:-1', output,
])
if (code !== 0) throw new Error('FFmpeg could not extract this frame')
// A seek past the end can succeed without producing a usable image.
const data = await ffmpeg.readFile(output)
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('No thumbnail was produced')
}
return new Blob([new Uint8Array(data)], { type: 'image/png' })
} finally {
// A failed command may never have created one or both files.
await Promise.allSettled([ffmpeg.deleteFile(input), ffmpeg.deleteFile(output)])
extracting = false
}
}
function parseTimes(value: string): number[] {
const parts = value.split(',').map((part) => part.trim())
if (parts.length > 6 || parts.some((part) => !/^\d+(\.\d+)?$/.test(part))) {
throw new Error('Enter one to six nonnegative timestamps in seconds.')
}
const times = parts.map(Number)
if (times.some((time) => !Number.isFinite(time))) throw new Error('Invalid timestamp')
return times
}
const form = document.getElementById('picker')
const controls = document.getElementById('controls')
const video = document.getElementById('video')
const times = document.getElementById('times')
const status = document.getElementById('status')
const results = document.getElementById('results')
if (!(form instanceof HTMLFormElement) || !(controls instanceof HTMLFieldSetElement) ||
!(video instanceof HTMLInputElement) || !(times instanceof HTMLInputElement) ||
!status || !results) {
throw new Error('Missing page controls')
}
let running = false
const previewUrls: string[] = []
function clearPreviews(container: HTMLElement): void {
container.replaceChildren()
for (const url of previewUrls) URL.revokeObjectURL(url)
previewUrls.length = 0
}
form.addEventListener('change', () => {
if (running) return
clearPreviews(results)
status.textContent = 'Ready to extract.'
})
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (running) return
const file = video.files?.[0]
if (!file) return
running = true
controls.disabled = true
clearPreviews(results)
status.textContent = 'Loading FFmpeg and extracting…'
const deadline = setTimeout(() => ffmpeg.terminate(), 60_000)
try {
if (file.size === 0 || file.size > 25 * 1024 * 1024) {
throw new Error('Choose a nonempty video of at most 25 MiB.')
}
const marks = parseTimes(times.value)
const figures = []
for (const mark of marks) {
const blob = await extractThumbnail(file, mark)
const url = URL.createObjectURL(blob)
previewUrls.push(url)
const image = new Image()
image.alt = `Video frame at ${mark} seconds`
image.src = url
await image.decode()
const caption = document.createElement('figcaption')
caption.textContent = `${mark} s`
const figure = document.createElement('figure')
figure.append(image, caption)
figures.push(figure)
}
results.replaceChildren(...figures)
status.textContent = `${marks.length} thumbnail(s) ready.`
} catch {
clearPreviews(results)
ffmpeg.terminate()
status.textContent = 'Could not extract thumbnails. Use a small valid video and times within its duration, then retry.'
} finally {
clearTimeout(deadline)
controls.disabled = false
running = false
}
})
Die Option -ss springt vor dem Decodieren
zur Position. Das standardmäßig präzise Positionieren von FFmpeg verwirft beim Transkodieren
Einzelbilder vor der angeforderten Position. Videoeinzelbilder liegen zu diskreten Zeitpunkten vor:
Ein Zeitpunkt zwischen Einzelbildern erzeugt kein interpoliertes Einzelbild. Die Ausgabe ist
320 Pixel breit. Dies ist ein Werkzeug zum Extrahieren von Vorschauen, keine Integritätsprüfung der
gesamten Datei; ein erfolgreich erzeugtes frühes Thumbnail belegt nicht, dass spätere Pakete eines
Videos intakt sind.
Mehrere Thumbnails nacheinander extrahieren
Die Schleife des Formulars wartet für jeden Zeitpunkt auf das Ergebnis derselben FFmpeg-Instanz. Sie kopiert die Eingabe für jedes Einzelbild erneut und bevorzugt damit eine kleine, eigenständige Extraktionsfunktion gegenüber einer aufwendigeren Batch-API. Die Ergebnisse erscheinen erst dann gemeinsam, wenn jedes angeforderte Bild decodiert wurde. Erneutes Absenden leert die vorherige Sammlung; bei einem fehlgeschlagenen Batch bleiben keine Teilvorschauen zurück.
Führen Sie Folgendes aus dem Verzeichnis aus, das wasm-thumbnails enthält. Das Kopieren
des Cores überschreibt nur die beiden Projekt-Assets, und Vite ersetzt den Build des Projekts unter
dist. Jeder Schritt wird nur ausgeführt, wenn der vorherige erfolgreich war:
(
cd wasm-thumbnails &&
node copy-core.ts &&
corepack yarn vite build &&
corepack yarn vite preview --host 127.0.0.1 --port 4173 --strictPort
)
Öffnen Sie http://127.0.0.1:4173, wählen Sie ein Video mit mehr als 2 Sekunden Länge, belassen
Sie die Zeitpunkte bei 0.5, 1.5 und drücken Sie
Extract thumbnails. Es sollten zwei Bilder erscheinen,
darunter die angeforderten Zeitpunkte und darüber
2 thumbnail(s) ready..
Verwenden Sie einen einzelnen Wert wie 0.5 für ein Thumbnail. Stoppen Sie
den Server mit Ctrl+C; wenn Port 4173 belegt ist, wählen Sie im Befehl und in der URL einen anderen
Port. Der Vorschauserver von Vite dient zur Prüfung eines lokalen Builds.
Die Benutzeroberfläche mit einem Web Worker reaktionsfähig halten
Der Wrapper verwaltet den Worker bereits selbst. Während der Verarbeitung deaktiviert das Formular seine Dateiauswahl, die Zeitpunkteingabe und die Schaltfläche zum Absenden und ignoriert mehrfaches Absenden. Es bietet weder eine Abbruchfunktion noch lässt es eine neue Auswahl einen aktiven Batch überholen. Das Zeitlimit von 60 Sekunden beendet den Worker, wenn das Laden oder die Extraktion hängen bleibt; beim nächsten Absenden wird eine neue Instanz des Cores geladen.
Tipps zur Leistung
Der Core wird beim ersten Absenden geladen und nach erfolgreichen Batches wiederverwendet. Temporäre virtuelle Dateien werden nach jeder Extraktion gelöscht. Die Vorschau-URLs bleiben gültig, solange ihre Bilder angezeigt werden, und werden widerrufen, wenn sich die Eingaben ändern oder ein weiterer Durchlauf startet. Beim Neuladen oder Schließen der Seite wird die Sitzung verworfen; die App speichert keine Thumbnails auf der Festplatte.
Das Löschen virtueller Dateien garantiert nicht, dass die Wasm-Laufzeit den gesamten zugewiesenen Speicher sofort an das Betriebssystem zurückgibt. Ein Worker kann seinen Speicher zur Wiederverwendung behalten. Kleinere Ausgabebilder beseitigen auch nicht den Aufwand für das Decodieren einer hochauflösenden Eingabe. Testen Sie repräsentative Dateien auf den Geräten, die Sie unterstützen möchten, bevor Sie die Demo-Grenzen erhöhen.
Verarbeitung im Browser oder auf dem Server
Die Extraktion im Browser ist für eine Vorschau vor dem Upload nützlich, doch Downloadzeit, Speicherbedarf und Verarbeitungsgeschwindigkeit hängen vom Gerät der nutzenden Person ab. Serverseitige Extraktion erfordert das Senden des Videos sowie die Planung von Verarbeitungskapazität und Aufbewahrung. Sie kann sich für Abläufe eignen, die dauerhafte Ergebnisse oder gleichbleibende Verarbeitungsressourcen benötigen. Keiner der beiden Ansätze gewährleistet für sich genommen universelle Codec-Unterstützung oder unbegrenzte Kapazität.
Häufige Probleme beheben
- Keine Vorschauen: Prüfen Sie, ob das Video nicht leer ist, innerhalb der Größenbegrenzung liegt
und einen decodierbaren Videostream enthält. Geben Sie Sekunden als Dezimalzahl wie
1.25ein, nicht00:00:01.25. Ein Zeitpunkt am oder nach dem Ende liefert möglicherweise kein Einzelbild; versuchen Sie es mit einem früheren Zeitpunkt erneut. - Laden des Cores schlägt fehl: Vergewissern Sie sich, dass sowohl
/core/ffmpeg-core.jsals auch/core/ffmpeg-core.wasmaus dem Build bereitgestellt werden. Wiederholen Sie nach einem Wechsel der Core-Version den Befehl zum Kopieren und Erstellen. Der JavaScript-Wrapper und der Core haben separate Versionsnummern; ihre Versionszeichenfolgen müssen nicht übereinstimmen. - Extraktion schlägt fehl oder erreicht das Zeitlimit: Versuchen Sie es mit einem kürzeren Video mit geringerer Auflösung. Die Fehlerbehandlung verwirft Vorschauen und beendet den Worker, damit das nächste Absenden einen sauberen Neustart ermöglicht.
- Fehler mit
SharedArrayBuffer: Prüfen Sie, ob Sie versehentlich den Multithread-Core eingesetzt haben. Zusätzliche Isolationsheader beheben keine fehlenden Assets in dieser Singlethread-Konfiguration.
Über lokale Vorschauen hinausgehen
Wenn die Zielgeräte Ihre Videos nicht verarbeiten können, erwägen Sie einen Upload-basierten Ablauf mit ausdrücklicher Zustimmung der nutzenden Person. Die Dokumentation zu Video-Thumbnails beschreibt die serverseitige Option von Transloadit. Halten Sie die lokale Vorschau auch für sich genommen nützlich: Wenn die Extraktion fehlschlägt, sollte man eine andere Datei oder einen anderen Zeitpunkt wählen können, ohne die übrige Arbeit zu verlieren.
