Videos im Browser mit KI untertiteln
Erstellen Sie Videountertitel im Browser mit Transformers.js und Whisper. Extrahieren Sie Audio, nutzen Sie WebGPU oder WASM und erzeugen Sie WebVTT, ohne Medien hochzuladen.
Transformers.js führt OpenAIs Spracherkennungsmodell Whisper direkt im Browser aus. So lässt sich die Tonspur eines Videos ohne Upload, ohne API-Key und ohne Server in Untertitel transkribieren.
Die meisten Untertitel-Tutorials verlangen zunächst, dass Sie das Video des Nutzers an eine Transkriptions-API senden und für jede Minute bezahlen. Ist das Video privat oder möchten Sie schlicht keine weitere Rechnung und kein zusätzliches Backend pflegen, kommt dieser Ansatz für Sie nicht infrage.
Diese Anleitung baut die gesamte Pipeline auf dem Client auf. Sie dekodieren das Audio, führen Whisper in einem Web Worker mit Fallback von WebGPU auf WASM aus, formatieren die Ausgabe als gültiges WebVTT und hängen das Ergebnis an ein <video>-Element an.
Das Wichtigste in Kürze
- Whisper in transformers.js erwartet Mono-Audio mit 16 kHz als
Float32Array. Die Tonspur eines Videos muss daher vor der Transkription dekodiert und resampelt werden. - Dass
navigator.gpuvorhanden ist, bedeutet nicht, dass WebGPU funktioniert. Verwenden Siedevice: "webgpu"nur, wennnavigator.gpu.requestAdapter()einen Adapter zurückgibt, und greifen Sie andernfalls auf WASM zurück. - Führen Sie die Transkriptions-Pipeline in einem Web Worker aus. Andernfalls blockieren das Laden des Modells und die Inferenz den Main Thread für die gesamte Dauer des Jobs.
- Eine WebVTT-Datei beginnt mit
WEBVTT, trennt Cues durch Leerzeilen, verwendet Zeitstempel im Formathh:mm:ss.tttund erfordert, dass&und<im Cue-Text escaped werden. - Whisper im Browser eignet sich gut für Clips von wenigen Minuten Länge. Für stundenlange Aufnahmen oder große Medienmengen sollten Sie einen gehosteten Transkriptionsdienst verwenden.
Was Sie bauen und warum alles auf dem Gerät bleibt
Die fertige Untertitelungs-App im Browser besteht aus vier Teilen: einem Dateieingabefeld, einem <video>-Element, einem Worker, der Whisper ausführt, und einer generierten .vtt-Datei, die als Untertitelspur angehängt wird. Die Mediendatei wird von der Festplatte des Nutzers in den Arbeitsspeicher gelesen und verlässt den Tab nie. Der einzige Netzwerkverkehr ist der einmalige Download des Modells vom Hugging Face Hub sowie – sofern Sie diese nicht selbst hosten – der ONNX-Runtime-WASM-Dateien, die transformers.js standardmäßig von einem CDN lädt. Weder Audio noch Video wird irgendwohin gesendet.
Dieser Artikel behandelt weder WebCodecs noch eingebrannte Untertitel oder den MP4-Export. Die Untertitel bleiben eine separate Textspur, die der Browser über dem Video rendert. Arbeiten auf Frame-Ebene gehören in eine Echtzeit-Videoverarbeitungs-Pipeline auf Basis von WebCodecs – das ist eine andere Aufgabe.
Wie extrahiert man die Tonspur aus einem Video?
Whisper in transformers.js erwartet Mono-Audio mit einer Abtastrate von 16 kHz als Float32Array. Die Pipeline akzeptiert Roh-Audio als Typed Array und geht davon aus, dass die Abtastrate bereits stimmt. Eine Prüfung findet nicht statt – Audio mit einer anderen Rate liefert daher falsche Transkripte, ohne dass ein Fehler auftritt.
decodeAudioData() dekodiert das Audio der Datei. Ob das bei Videodateien funktioniert, hängt davon ab, welche Container und Codecs der Browser unterstützt. Eine MP4-Datei, die in einem Browser problemlos abgespielt wird, lässt sich in einem anderen unter Umständen nicht dekodieren. Anschließend rendern Sie den dekodierten Puffer über einen OfflineAudioContext, der mit einem Kanal und 16.000 Hz erstellt wurde. Web Audio mischt die Quelle auf das einkanalige Ziel herunter und resampelt sie auf die Rate des Kontexts – ein einziger Render-Durchgang erledigt also beide Konvertierungen.
async function extractAudio(file) {
const arrayBuffer = await file.arrayBuffer();
const decodeCtx = new AudioContext();
const decoded = await decodeCtx.decodeAudioData(arrayBuffer);
await decodeCtx.close();
const targetRate = 16000;
const offline = new OfflineAudioContext(
1,
Math.ceil(decoded.duration * targetRate),
targetRate
);
const source = offline.createBufferSource();
source.buffer = decoded;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return { audio: rendered.getChannelData(0), duration: decoded.duration };
}
Behalten Sie duration. Der Formatter benötigt den Wert später.
Transformers.js-Whisper in einem Web Worker ausführen
Übergeben Sie beim Aufruf der ASR-Pipeline von transformers.js return_timestamps: true. Die Ausgabe enthält dann ein chunks-Array, dessen Einträge jeweils einen text-String und ein Paar timestamp: [start, end] in Sekunden enthalten. Führen Sie die Pipeline in einem Web Worker aus, damit die Seite reaktionsfähig bleibt: Andernfalls blockieren das Laden des Modells und die Inferenz den Main Thread für die gesamte Dauer des Jobs.
Installieren Sie das Paket mit npm install @huggingface/transformers. Der Worker erstellt die Pipeline einmalig und verwendet sie anschließend für jede weitere Datei wieder:
// worker.js
import { pipeline } from "@huggingface/transformers";
let transcriberPromise;
async function pickDevice() {
if (!("gpu" in navigator)) return "wasm";
try {
const adapter = await navigator.gpu.requestAdapter();
return adapter ? "webgpu" : "wasm";
} catch {
return "wasm";
}
}
function getTranscriber() {
transcriberPromise ??= pickDevice().then((device) =>
pipeline("automatic-speech-recognition", "onnx-community/whisper-tiny.en", { device })
);
return transcriberPromise;
}
self.onmessage = async ({ data }) => {
try {
const transcriber = await getTranscriber();
const output = await transcriber(data.audio, {
return_timestamps: true,
chunk_length_s: 30,
stride_length_s: 5,
});
self.postMessage({ type: "done", chunks: output.chunks });
} catch (err) {
self.postMessage({ type: "error", message: String(err) });
}
};
Senden Sie die Samples im Main Thread an den Worker, indem Sie den Puffer übertragen (Transfer), statt ihn zu kopieren. Die Form new URL(..., import.meta.url) ist das Worker-Muster, das Vite erkennt, und auch webpack 5 kommt damit zurecht:
const worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
function transcribe(audio) {
return new Promise((resolve, reject) => {
worker.onmessage = ({ data }) =>
data.type === "done" ? resolve(data.chunks) : reject(new Error(data.message));
worker.postMessage({ audio }, [audio.buffer]);
});
}
Whisper arbeitet mit 30-Sekunden-Fenstern. Lassen Sie chunk_length_s weg, verarbeitet transformers.js nur die ersten 30 Sekunden des Audios und gibt eine Warnung aus – der Rest des Videos erhält dann keine Untertitel. Mit chunk_length_s: 30 und stride_length_s: 5 zerlegt die Pipeline längeres Audio in überlappende 30-Sekunden-Abschnitte und fügt die Ergebnisse zu einem einheitlichen Satz von Chunks mit Zeitstempeln zusammen.
WebGPU vs. WASM: Das Device vor dem Laden wählen
Transformers.js sollte Whisper nur dann auf WebGPU ausführen, wenn navigator.gpu.requestAdapter() einen Adapter zurückgibt, andernfalls auf WASM. Für den Wechsel auf die GPU genügt eine einzige Pipeline-Option, device: "webgpu", die beim Laden des Modells gesetzt wird. Das Vorhandensein von navigator.gpu garantiert allerdings nicht, dass WebGPU funktioniert. Rufen Sie requestAdapter() auf – das Ergebnis kann null sein – und wählen Sie WebGPU nur, wenn ein Adapter zurückgegeben wird. navigator.gpu ist auch in Workern verfügbar, sodass pickDevice() von oben dort ausgeführt werden kann.
| Bedingung | Device | Ergebnis für den Nutzer |
|---|---|---|
Kein navigator.gpu | "wasm" | Langsamere Transkription, nur CPU |
navigator.gpu vorhanden, requestAdapter() gibt null zurück | "wasm" | Langsamere Transkription, nur CPU |
| Adapter zurückgegeben | "webgpu" | GPU-beschleunigte Inferenz |
Ein null-Adapter kommt auf realen Rechnern häufig vor. Der WebGPU-Troubleshooting-Guide von Chrome nennt die üblichen Ursachen: Der Nutzer hat die Grafikbeschleunigung in den Einstellungen deaktiviert, die GPU steht auf der Blocklist von Chrome, WebGPU wird auf der jeweiligen Plattform noch nicht unterstützt oder Chrome findet überhaupt keine GPU. Wenn Sie den Adapter vor dem Erstellen der Pipeline selbst prüfen, wie es pickDevice() tut, decken Sie all diese Fälle ab.
Laut dem Guide von Hugging Face liegt die weltweite WebGPU-Unterstützung Stand März 2026 bei rund 85 %. Die WebGPU-Seite zum Implementierungsstatus führt WebGPU als standardmäßig aktiviert in Chrome ab Version 113 unter Windows, macOS und ChromeOS sowie in Chrome ab Version 121 auf den meisten Android-Geräten. Unter Linux aktiviert Chrome es nur für bestimmte GPUs. Firefox hat es unter Windows seit Version 141 und auf Macs mit Apple Silicon seit Version 147 standardmäßig aktiviert. Safari 26 unterstützt es unter macOS, iOS, iPadOS und visionOS. In Firefox für Android ist es weiterhin standardmäßig deaktiviert: Dort sind Firefox Beta oder Nightly sowie die Einstellung gfx.webgpu.ignore-blocklist in about:config erforderlich.
Wie konvertiert man Whisper-Chunks in WebVTT?
Jeder Whisper-Chunk wird zu einem WebVTT-Cue: Start- und Endzeit kommen in die Timing-Zeile, der Text in die Zeile darunter. Die Regeln finden Sie in der WebVTT-Referenz von MDN. Die Datei beginnt mit WEBVTT, eine Leerzeile trennt jeden Cue vom nächsten, und zwei durch --> verbundene Zeitstempel legen fest, wann ein Cue angezeigt wird.
Der folgende Formatter schreibt immer die lange Form hh:mm:ss.ttt. Stunden erhalten zwei oder mehr Stellen, Minuten und Sekunden überschreiten nie 59, und der Millisekundenanteil hat immer drei Stellen. Außerdem muss jeder Cue später enden, als er beginnt. Zwei Zeichen dürfen im Cue-Text nicht unverändert vorkommen: & und <. Der Code ersetzt zuerst & durch & und anschließend < durch <. MDN empfiehlt zudem, > als > zu schreiben, deshalb erledigt der Code auch das.
const pad = (n, width = 2) => String(n).padStart(width, "0");
function toVttTime(seconds) {
const total = Math.round(seconds * 1000);
const h = Math.floor(total / 3_600_000);
const m = Math.floor((total % 3_600_000) / 60_000);
const s = Math.floor((total % 60_000) / 1000);
const ms = total % 1000;
return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(ms, 3)}`;
}
function escapeCueText(text) {
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function chunksToVtt(chunks, duration) {
const cues = [];
for (const { text, timestamp } of chunks) {
const [start, end] = timestamp;
const stop = end ?? duration;
const body = escapeCueText(text.replace(/\s+/g, " ").trim());
if (!body || stop <= start) continue;
cues.push(`${toVttTime(start)} --> ${toVttTime(stop)}\n${body}`);
}
return `WEBVTT\n\n${cues.join("\n\n")}\n`;
}
Einige Details sind hier wichtig:
toVttTimerundet auf ganze Millisekunden, bevor der Wert in Stunden, Minuten und Sekunden aufgeteilt wird. Dadurch kann niemals.1000ausgegeben werden.&wird zuerst escaped, weil die anderen Ersetzungen Ampersands einfügen – würde man es zuletzt escapen, würden diese doppelt escaped.- Das Zusammenfassen von Whitespace entfernt alle Zeilenumbrüche aus dem Text des Modells. Eine Leerzeile innerhalb des Payloads würde den Cue vorzeitig beenden.
- Der Fallback
?? durationist eine Vorsichtsmaßnahme für den Fall, dass ein letzter Chunk ohne Endzeit zurückkommt.
Die Ausgabe des Formatters sieht etwa so aus (Beispiel):
WEBVTT
00:00:00.000 --> 00:00:04.320
Welcome back. Today we are wiring up captions.
00:00:04.320 --> 00:00:09.100
The model runs in a worker & nothing is uploaded.
Sobald Sie eine VTT-Datei haben, können Sie die Untertitel auch in andere Sprachen übersetzen.
Untertitel per Blob-URL anhängen
Um die Untertitel anzuzeigen, verpacken Sie den VTT-String in einen Blob vom Typ text/vtt, erstellen dafür eine Object-URL und verwenden diese URL als src eines <track>-Elements. Versehen Sie den Track mit kind="captions", srclang und default. Der Blob-Konstruktor kodiert JavaScript-Strings als UTF-8 – genau die Kodierung, die WebVTT vorschreibt.
function attachCaptions(video, vtt) {
const url = URL.createObjectURL(new Blob([vtt], { type: "text/vtt" }));
const track = document.createElement("track");
track.kind = "captions";
track.srclang = "en";
track.label = "English (auto-generated)";
track.src = url;
track.default = true;
video.append(track);
track.track.mode = "showing";
return () => URL.revokeObjectURL(url);
}
srclang ist "en", weil whisper-tiny.en ein rein englischsprachiges Modell ist. Da der Track erst hinzugefügt wird, nachdem das Video bereits geladen ist, sorgt das explizite Setzen von mode auf "showing" dafür, dass er sofort angezeigt wird. Rufen Sie die zurückgegebene Funktion auf, wenn Sie das Video ersetzen oder entfernen, damit die Object-URL freigegeben wird. In React gehört derselbe Track in das gerenderte <video>-Element, wie in Video in React einbetten und Einen Videoplayer mit React bauen beschrieben.
Wo liegen die Grenzen von Whisper im Browser?
Whisper im Browser funktioniert gut bei Clips von wenigen Minuten Länge und schlecht bei stundenlangen Aufnahmen.
- Downloadgröße. Beim ersten Durchlauf werden Modellgewichte heruntergeladen, die je nach Variante zwischen einigen zehn und mehreren hundert Megabyte groß sind. Die Modellseite von
whisper-tiny.enverlinkt die zugehörigen Dateien. Größere Whisper-Varianten auf dem Hugging Face Hub sind genauer, aber auch größer – prüfen Sie daher vor einem Wechsel die jeweilige Model Card. - Geschwindigkeit. Lange Dateien werden langsam verarbeitet, insbesondere über den WASM-Pfad. Einige Minuten Audio sind unproblematisch. Eine Stunde Audio blockiert den Tab für lange Zeit.
- Arbeitsspeicher. Das vollständige dekodierte Audio wird als Typed Array im Arbeitsspeicher gehalten.
- Große oder lange Medien. Für stundenlange Aufnahmen oder Batch-Jobs ist ein gehosteter Transkriptionsdienst die bessere Wahl.
Fazit
Sie können ein kurzes Video vollständig im Browser untertiteln: Dekodieren und resampeln Sie die Tonspur, führen Sie Whisper in einem Worker auf WebGPU aus (oder auf WASM, falls kein Adapter verfügbar ist), formatieren Sie die Chunks als WebVTT und hängen Sie die Datei als Blob-basierten Track an. Beginnen Sie mit whisper-tiny.en und einem zweiminütigen Clip und gleichen Sie die Cue-Timings mit dem Audio ab. Wechseln Sie erst dann zu einem größeren Hub-Modell, wenn die Genauigkeit nicht ausreicht und Ihre Nutzer den größeren Download in Kauf nehmen können.
FAQs
Lädt transformers.js das Whisper-Modell bei jedem Seitenaufruf herunter?
Nein. Beim ersten Durchlauf lädt transformers.js die Modelldateien herunter und speichert sie im Browser-Cache, sodass spätere Ladevorgänge aus dem Cache statt aus dem Netzwerk lesen. Gesteuert wird das über die Einstellung env.useBrowserCache. In transformers.js v4 meldet ModelRegistry.is_pipeline_cached, ob die Dateien einer Pipeline bereits im Cache liegen, und ModelRegistry.clear_pipeline_cache entfernt sie.
Wie zeige ich den Download-Fortschritt des Modells an, während Whisper in einem Worker lädt?
Übergeben Sie in den Pipeline-Optionen neben device eine progress_callback-Funktion. Transformers.js ruft sie mit Statusupdates auf, während die einzelnen Modelldateien heruntergeladen werden. Leiten Sie jedes Update innerhalb des Workers per postMessage an den Main Thread weiter und rendern Sie dort die Fortschrittsanzeige. Transformers.js v4 ergänzt ein progress_total-Event, das den Gesamtfortschritt des Ladevorgangs meldet, sodass Sie die Updates pro Datei nicht selbst aufsummieren müssen.
Kann ich Untertitel für Videos erzeugen, die nicht auf Englisch sind?
Ja, allerdings benötigen Sie dafür einen mehrsprachigen Whisper-Checkpoint statt eines rein englischsprachigen Modells mit der Endung .en. Übergeben Sie language und task beim Aufruf des Transcribers, zum Beispiel language: 'french' mit task: 'transcribe'. Setzen Sie task auf 'translate', erzeugt Whisper aus Sprache in einer anderen Sprache englischen Text. Setzen Sie srclang des Tracks auf die Sprache des Untertiteltexts, nicht auf die Sprache des Audios.
Kann die Untertitelungs-App laufen, ohne den Hugging Face Hub oder ein CDN zu kontaktieren?
Ja, wenn Sie sämtliche Dateien selbst hosten. Setzen Sie env.allowRemoteModels auf false und lassen Sie env.localModelPath auf einen Ordner auf Ihrem Server verweisen, der die Modelldateien enthält. Standardmäßig werden auch die ONNX-Runtime-WASM-Binaries von einem CDN geladen; setzen Sie daher env.backends.onnx.wasm.wasmPaths ebenfalls auf Ihre eigenen Kopien. Für einen vollständigen Offline-Betrieb fügen Sie einen Service Worker hinzu, damit die Seite selbst auch ohne Verbindung lädt.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k