Ajouter des sous-titres à une vidéo grâce à l'IA, directement dans le navigateur
Générez des sous-titres vidéo dans le navigateur avec Transformers.js et Whisper. Extrayez l’audio, utilisez WebGPU ou WASM et créez des fichiers WebVTT sans envoyer les médias.
Transformers.js exécute le modèle de reconnaissance vocale Whisper d’OpenAI directement dans le navigateur. La piste audio d’une vidéo peut ainsi être transcrite en sous-titres sans téléversement, sans clé d’API et sans serveur.
La plupart des tutoriels sur le sous-titrage commencent par vous demander d’envoyer la vidéo de l’utilisateur à une API de transcription facturée à la minute. Si la vidéo est privée, ou si vous ne voulez tout simplement pas d’une facture supplémentaire ni d’un backend à maintenir, cette approche ne vous convient pas.
Ce guide construit l’ensemble du pipeline côté client. Vous décodez l’audio, exécutez Whisper dans un Web Worker avec un repli de WebGPU vers WASM, formatez la sortie en WebVTT valide et associez le résultat à un élément <video>.
Points clés à retenir
- Dans transformers.js, Whisper attend un signal audio mono échantillonné à 16 kHz sous forme de
Float32Array. La piste audio d’une vidéo doit donc être décodée et rééchantillonnée avant la transcription. - La présence de
navigator.gpune garantit pas que WebGPU fonctionnera. Utilisezdevice: "webgpu"uniquement lorsquenavigator.gpu.requestAdapter()renvoie un adaptateur, et repliez-vous sur WASM dans le cas contraire. - Exécutez le pipeline de transcription dans un Web Worker. Sinon, le chargement du modèle et l’inférence bloqueraient le thread principal pendant toute la durée du traitement.
- Un fichier WebVTT commence par
WEBVTT, sépare les cues par des lignes vides, utilise des horodatages au formathh:mm:ss.tttet exige l’échappement de&et<dans le texte des cues. - Whisper dans le navigateur convient aux clips de quelques minutes. Pour des enregistrements d’une heure ou des volumes importants de médias, utilisez un service de transcription hébergé.
Ce que vous allez construire et pourquoi tout reste sur l’appareil
L’application de sous-titrage finale, exécutée dans le navigateur, comporte quatre éléments : un champ de sélection de fichier, un élément <video>, un worker qui exécute Whisper et un fichier .vtt généré, associé à la vidéo comme piste de sous-titres. Le média est lu depuis le disque de l’utilisateur, chargé en mémoire, et ne quitte jamais l’onglet. Le seul trafic réseau correspond au téléchargement initial (unique) du modèle depuis le Hugging Face Hub et, sauf si vous les hébergez vous-même, des fichiers WASM d’ONNX Runtime, que transformers.js charge par défaut depuis un CDN. Aucun contenu audio ou vidéo n’est envoyé où que ce soit.
Cet article ne traite pas de WebCodecs, des sous-titres incrustés ni de l’export MP4. Les sous-titres restent une piste de texte distincte que le navigateur affiche par-dessus la vidéo. Le traitement image par image relève d’un pipeline de traitement vidéo en temps réel basé sur WebCodecs, ce qui est un tout autre sujet.
Comment extraire la piste audio d’une vidéo ?
Dans transformers.js, Whisper prend en entrée un signal audio mono échantillonné à 16 kHz sous forme de Float32Array. Le pipeline accepte l’audio brut sous forme de tableau typé et suppose que la fréquence d’échantillonnage est déjà la bonne. Il ne la vérifie pas : un audio à une autre fréquence produit des transcriptions erronées, sans la moindre erreur.
decodeAudioData() décode l’audio du fichier. Pour les fichiers vidéo, le bon fonctionnement dépend des conteneurs et des codecs pris en charge par le navigateur : un MP4 parfaitement lu dans un navigateur peut échouer au décodage dans un autre. Vous effectuez ensuite le rendu du buffer décodé via un OfflineAudioContext créé avec un seul canal à 16 000 Hz. Web Audio mixe la source en mono pour l’adapter à la destination monocanal et la rééchantillonne à la fréquence du contexte : un seul rendu réalise donc les deux conversions.
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 };
}
Conservez duration : le formateur s’en sert plus loin.
Exécuter Whisper avec Transformers.js dans un Web Worker
Passez return_timestamps: true lors de l’appel au pipeline ASR de transformers.js. La sortie contient alors un tableau chunks, dont chaque entrée comporte une chaîne text et une paire timestamp: [start, end] exprimée en secondes. Exécutez le pipeline dans un Web Worker afin que la page reste réactive : à défaut, le chargement du modèle et l’inférence bloqueraient le thread principal pendant toute la durée du traitement.
Installez le paquet avec npm install @huggingface/transformers. Le worker crée le pipeline une seule fois, puis le réutilise pour tous les fichiers suivants :
// 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) });
}
};
Sur le thread principal, envoyez les échantillons au worker en transférant le buffer plutôt qu’en le copiant. La forme new URL(..., import.meta.url) correspond au modèle de worker reconnu par Vite, et webpack 5 la prend également en charge :
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 travaille sur des fenêtres de 30 secondes. Si vous omettez chunk_length_s, transformers.js ne conserve que les 30 premières secondes de l’audio et affiche un avertissement : le reste de la vidéo n’aura alors aucun sous-titre. Avec chunk_length_s: 30 et stride_length_s: 5, le pipeline découpe les audios plus longs en segments de 30 secondes qui se chevauchent, puis fusionne les résultats en un seul ensemble de chunks horodatés.
WebGPU ou WASM : choisir le device avant le chargement
Transformers.js ne doit exécuter Whisper sur WebGPU que lorsque navigator.gpu.requestAdapter() renvoie un adaptateur, et sur WASM dans le cas contraire. Le passage au GPU ne nécessite qu’une seule option du pipeline, device: "webgpu", définie au chargement du modèle. Toutefois, la présence de navigator.gpu ne garantit pas que WebGPU fonctionne. Appelez requestAdapter(), qui peut renvoyer null, et ne choisissez WebGPU que si un adaptateur est renvoyé. navigator.gpu est également exposé dans les workers : la fonction pickDevice() ci-dessus peut donc s’y exécuter.
| Condition | Device | Résultat pour l’utilisateur |
|---|---|---|
Pas de navigator.gpu | "wasm" | Transcription plus lente, CPU uniquement |
navigator.gpu existe, requestAdapter() renvoie null | "wasm" | Transcription plus lente, CPU uniquement |
| Adaptateur renvoyé | "webgpu" | Inférence accélérée par le GPU |
Un adaptateur null est fréquent sur des machines réelles. Le guide de dépannage WebGPU de Chrome en énumère les causes habituelles : l’utilisateur a désactivé l’accélération graphique dans les paramètres, le GPU figure sur la liste de blocage de Chrome, WebGPU n’est pas encore pris en charge sur la plateforme, ou Chrome ne détecte aucun GPU. Vérifier vous-même l’adaptateur avant de créer le pipeline, comme le fait pickDevice(), couvre tous ces cas.
Selon le guide de Hugging Face, la prise en charge mondiale de WebGPU atteint environ 85 % en mars 2026. La page d’état d’implémentation de WebGPU indique qu’il est activé par défaut à partir de Chrome 113 sous Windows, macOS et ChromeOS, et à partir de Chrome 121 sur la plupart des appareils Android. Sous Linux, Chrome ne l’active que pour certains GPU. Firefox l’active par défaut sous Windows depuis la version 141 et sur les Mac Apple Silicon depuis la version 147. Safari 26 le prend en charge sous macOS, iOS, iPadOS et visionOS. Firefox pour Android le laisse encore désactivé par défaut : il faut utiliser Firefox Beta ou Nightly et activer le paramètre gfx.webgpu.ignore-blocklist dans about:config.
Comment convertir les chunks Whisper en WebVTT ?
Chaque chunk Whisper devient une cue WebVTT : ses temps de début et de fin figurent sur la ligne de timing, et son texte sur la ligne suivante. La référence WebVTT de MDN en précise les règles. Le fichier commence par WEBVTT, une ligne vide sépare chaque cue de la suivante, et deux horodatages reliés par --> déterminent le moment d’affichage de chaque cue.
Le formateur ci-dessous utilise toujours la forme longue hh:mm:ss.ttt. Les heures comportent au moins deux chiffres, les minutes et les secondes ne dépassent jamais 59, et la partie millisecondes compte toujours trois chiffres. Chaque cue doit en outre se terminer après son début. Deux caractères ne peuvent pas figurer tels quels dans le texte d’une cue : & et <. Le code remplace d’abord & par &, puis < par <. MDN recommande également d’écrire > sous la forme >, ce que fait aussi le code.
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`;
}
Quelques détails ont leur importance :
toVttTimearrondit à la milliseconde entière avant de décomposer la valeur en heures, minutes et secondes, ce qui l’empêche de produire.1000.&est échappé en premier, car les autres remplacements ajoutent des esperluettes : l’échapper en dernier provoquerait un double échappement.- La réduction des espaces supprime tout retour à la ligne dans le texte produit par le modèle. Une ligne vide au sein du contenu met fin prématurément à la cue.
- Le repli
?? durationest une précaution au cas où un dernier chunk serait renvoyé sans temps de fin.
Voici un exemple de sortie du formateur (à titre d’illustration) :
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.
Une fois le fichier VTT obtenu, vous pouvez également traduire les sous-titres dans d’autres langues.
Associer les sous-titres via une URL de Blob
Pour afficher les sous-titres, encapsulez la chaîne VTT dans un Blob de type text/vtt, créez une URL d’objet correspondante et utilisez-la comme src d’un élément <track>. Attribuez à la piste kind="captions", srclang et default. Le constructeur Blob encode les chaînes JavaScript en UTF-8, l’encodage exigé par WebVTT.
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 vaut "en" parce que whisper-tiny.en est un modèle exclusivement anglophone. La piste étant ajoutée après le chargement de la vidéo, définir explicitement mode sur "showing" permet de l’afficher immédiatement. Appelez la fonction renvoyée lorsque vous remplacez ou supprimez la vidéo, afin de libérer l’URL d’objet. Avec React, la même piste se place à l’intérieur de l’élément <video> que vous rendez, comme expliqué dans intégrer une vidéo dans React et créer un lecteur vidéo avec React.
Quelles sont les limites de Whisper dans le navigateur ?
Whisper dans le navigateur fonctionne bien pour des clips de quelques minutes, mais mal pour des enregistrements d’une heure.
- Taille du téléchargement. La première exécution télécharge des poids de modèle allant de quelques dizaines à plusieurs centaines de mégaoctets selon la variante. La page du modèle
whisper-tiny.enrenvoie vers ses fichiers. Les variantes de Whisper plus volumineuses disponibles sur le Hugging Face Hub sont plus précises, mais aussi plus lourdes : consultez chaque model card avant d’en changer. - Vitesse. Les fichiers longs sont lents à traiter, surtout avec WASM. Quelques minutes d’audio ne posent pas de problème ; une heure d’audio monopolise l’onglet pendant longtemps.
- Mémoire. L’intégralité de l’audio décodé est conservée en mémoire sous forme de tableau typé.
- Médias longs ou en volume. Pour des enregistrements d’une heure ou des traitements par lots, un service de transcription hébergé est le meilleur choix.
Conclusion
Vous pouvez sous-titrer une courte vidéo entièrement dans le navigateur : décodez et rééchantillonnez son audio, exécutez Whisper dans un worker sur WebGPU (ou sur WASM si aucun adaptateur n’est disponible), formatez les chunks en WebVTT et associez le fichier sous forme de piste adossée à un Blob. Commencez avec whisper-tiny.en sur un clip de deux minutes et vérifiez la synchronisation des cues avec l’audio. Ne passez à un modèle plus volumineux du Hub que si la précision est insuffisante et que vos utilisateurs peuvent supporter un téléchargement plus lourd.
FAQ
Transformers.js télécharge-t-il le modèle Whisper à chaque chargement de page ?
Non. Lors de la première exécution, transformers.js télécharge les fichiers du modèle et les stocke dans le cache du navigateur : les chargements suivants les lisent depuis le cache plutôt que depuis le réseau. Ce comportement est contrôlé par le paramètre env.useBrowserCache. Dans transformers.js v4, ModelRegistry.is_pipeline_cached indique si les fichiers d'un pipeline sont déjà en cache, et ModelRegistry.clear_pipeline_cache permet de les supprimer.
Comment afficher la progression du téléchargement du modèle pendant le chargement de Whisper dans un worker ?
Passez une fonction progress_callback dans les options du pipeline, à côté de device. Transformers.js l'appelle avec des mises à jour d'état au fil du téléchargement de chaque fichier du modèle. Dans un worker, transmettez chaque mise à jour au thread principal avec postMessage et affichez-y la barre de progression. Transformers.js v4 ajoute un événement progress_total, qui indique la progression globale du chargement : vous n'avez donc pas à additionner vous-même les mises à jour fichier par fichier.
Puis-je générer des sous-titres pour des vidéos qui ne sont pas en anglais ?
Oui, mais il vous faut un checkpoint Whisper multilingue plutôt qu'un modèle exclusivement anglophone se terminant par .en. Passez language et task lors de l'appel au transcriber, par exemple language: 'french' avec task: 'transcribe'. Définir task sur 'translate' amène Whisper à produire un texte en anglais à partir d'une parole dans une autre langue. Réglez le srclang de la piste sur la langue du texte des sous-titres, et non sur celle de l'audio.
L'application de sous-titrage peut-elle fonctionner sans contacter le Hugging Face Hub ni un CDN ?
Oui, à condition d'auto-héberger tous les fichiers. Définissez env.allowRemoteModels sur false et faites pointer env.localModelPath vers un dossier de votre serveur contenant les fichiers du modèle. Par défaut, les binaires WASM d'ONNX Runtime sont également chargés depuis un CDN : configurez donc aussi env.backends.onnx.wasm.wasmPaths pour qu'il pointe vers vos propres copies. Pour un fonctionnement entièrement hors ligne, ajoutez un service worker afin que la page elle-même se charge sans connexion.
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