Cómo añadir subtítulos a un vídeo con IA en el navegador
Genera subtítulos de vídeo en el navegador con Transformers.js y Whisper. Extrae el audio, usa WebGPU o WASM y crea archivos WebVTT sin subir los medios.
Transformers.js ejecuta el modelo de reconocimiento de voz Whisper de OpenAI directamente en el navegador, de modo que el audio de un vídeo puede transcribirse en subtítulos sin subir archivos, sin API key y sin servidor.
La mayoría de los tutoriales sobre subtítulos empiezan pidiéndote que envíes el vídeo del usuario a una API de transcripción y que pagues por cada minuto. Si el vídeo es privado, o simplemente no quieres otra factura ni un backend que mantener, ese enfoque no te sirve.
Esta guía construye todo el pipeline en el cliente. Decodificas el audio, ejecutas Whisper en un Web Worker con fallback de WebGPU a WASM, formateas la salida como WebVTT válido y asocias el resultado a un elemento <video>.
Puntos clave
- Whisper en transformers.js espera audio mono a 16 kHz como un
Float32Array, por lo que la pista de audio de un vídeo debe decodificarse y remuestrearse antes de transcribirla. - Que exista
navigator.gpuno significa que WebGPU vaya a funcionar. Usadevice: "webgpu"solo cuandonavigator.gpu.requestAdapter()devuelva un adaptador y recurre a WASM en caso contrario. - Ejecuta el pipeline de transcripción en un Web Worker. De lo contrario, la carga del modelo y la inferencia bloquearían el hilo principal durante todo el proceso.
- Un archivo WebVTT empieza con
WEBVTT, separa los cues con líneas en blanco, usa marcas de tiempohh:mm:ss.ttty exige escapar&y<en el texto de los cues. - Whisper en el navegador es práctico para clips de unos pocos minutos. Para grabaciones de una hora o procesamiento masivo de contenido multimedia, usa un servicio de transcripción alojado.
Qué vas a construir y por qué todo se queda en el dispositivo
La aplicación de subtitulado en el navegador terminada tiene cuatro partes: un input de archivo, un elemento <video>, un worker que ejecuta Whisper y un archivo .vtt generado que se asocia como pista de subtítulos. El contenido multimedia se lee del disco del usuario a memoria y nunca sale de la pestaña. El único tráfico de red es la descarga única del modelo desde el Hugging Face Hub y, salvo que los alojes tú mismo, los archivos WASM de ONNX Runtime, que transformers.js carga desde una CDN por defecto. No se envía audio ni vídeo a ningún sitio.
Este artículo no cubre WebCodecs, subtítulos incrustados (burned-in) ni la exportación a MP4. Los subtítulos se mantienen como una pista de texto independiente que el navegador renderiza sobre el vídeo. El trabajo a nivel de fotograma corresponde a un pipeline de procesamiento de vídeo en tiempo real basado en WebCodecs, que es otra tarea distinta.
¿Cómo se extrae la pista de audio de un vídeo?
Whisper en transformers.js recibe audio mono muestreado a 16 kHz como un Float32Array. El pipeline acepta audio sin procesar como typed array y asume que ya tiene la frecuencia de muestreo correcta. No lo comprueba, así que un audio a cualquier otra frecuencia produce transcripciones erróneas sin ningún error.
decodeAudioData() decodifica el audio del archivo. En el caso de archivos de vídeo, que esto funcione depende de los contenedores y códecs que admita el navegador, por lo que un MP4 que se reproduce sin problemas en un navegador puede fallar al decodificarse en otro. Después, renderizas el buffer decodificado a través de un OfflineAudioContext creado con un canal a 16.000 Hz. Web Audio hace el downmix de la fuente para ajustarla al destino de un solo canal y la remuestrea a la frecuencia del contexto, de modo que un único renderizado te da ambas conversiones.
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 };
}
Conserva duration. El formateador lo usará más adelante.
Ejecuta Whisper de Transformers.js en un Web Worker
Pasa return_timestamps: true al llamar al pipeline ASR de transformers.js. Así, la salida incluye un array chunks, y cada entrada tiene una cadena text y un par timestamp: [start, end] en segundos. Ejecuta el pipeline dentro de un Web Worker para que la página siga respondiendo: de lo contrario, la carga del modelo y la inferencia bloquearían el hilo principal durante todo el proceso.
Instala el paquete con npm install @huggingface/transformers. El worker crea el pipeline una sola vez y lo reutiliza para todos los archivos posteriores:
// 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) });
}
};
En el hilo principal, envía las muestras al worker transfiriendo el buffer en lugar de copiarlo. La forma new URL(..., import.meta.url) es el patrón de workers que reconoce Vite, y webpack 5 también lo admite:
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 trabaja con ventanas de 30 segundos. Si omites chunk_length_s, transformers.js conserva solo los primeros 30 segundos de audio y registra una advertencia, por lo que el resto del vídeo se queda sin subtítulos. Con chunk_length_s: 30 y stride_length_s: 5, el pipeline divide el audio más largo en fragmentos solapados de 30 segundos y une los resultados en un único conjunto de chunks con marcas de tiempo.
WebGPU frente a WASM: elige el dispositivo antes de cargar
Transformers.js debe ejecutar Whisper en WebGPU solo cuando navigator.gpu.requestAdapter() devuelva un adaptador, y en WASM en caso contrario. Pasar a la GPU requiere una sola opción del pipeline, device: "webgpu", que se establece al cargar el modelo. Sin embargo, que exista navigator.gpu no garantiza que WebGPU funcione. Llama a requestAdapter(), que puede resolverse como null, y elige WebGPU solo cuando devuelva un adaptador. navigator.gpu también está disponible en los workers, así que la función pickDevice() anterior se ejecuta allí.
| Condición | Dispositivo | Lo que obtiene el usuario |
|---|---|---|
No hay navigator.gpu | "wasm" | Transcripción más lenta, solo CPU |
Existe navigator.gpu, requestAdapter() devuelve null | "wasm" | Transcripción más lenta, solo CPU |
| Se devuelve un adaptador | "webgpu" | Inferencia acelerada por GPU |
Un adaptador null es habitual en equipos reales. La guía de solución de problemas de WebGPU de Chrome enumera las causas más frecuentes: el usuario ha desactivado la aceleración gráfica en la configuración, la GPU está en la lista de bloqueo de Chrome, WebGPU aún no es compatible con esa plataforma o Chrome no encuentra ninguna GPU. Comprobar tú mismo el adaptador antes de crear el pipeline, como hace pickDevice(), cubre todos estos casos.
La guía de Hugging Face sitúa la compatibilidad global de WebGPU en torno al 85 % a marzo de 2026. La página de estado de implementación de WebGPU indica que está activado por defecto en Chrome 113 y posteriores en Windows, macOS y ChromeOS, y en Chrome 121 y posteriores en la mayoría de los dispositivos Android. En Linux, Chrome lo activa solo para algunas GPU. Firefox lo tiene activado por defecto en Windows desde la versión 141 y en Mac con Apple Silicon desde la versión 147. Safari 26 lo admite en macOS, iOS, iPadOS y visionOS. Firefox para Android todavía lo tiene desactivado por defecto: requiere Firefox Beta o Nightly, además del ajuste gfx.webgpu.ignore-blocklist en about:config.
¿Cómo se convierten los chunks de Whisper a WebVTT?
Cada chunk de Whisper se convierte en un cue de WebVTT: sus tiempos de inicio y fin van en la línea de tiempos, y su texto, en la línea siguiente. La referencia de WebVTT de MDN establece las reglas. El archivo empieza con WEBVTT, una línea en blanco separa cada cue del siguiente y dos marcas de tiempo unidas por --> determinan cuándo se muestra cada cue.
El formateador que aparece a continuación siempre escribe la forma larga hh:mm:ss.ttt. Las horas tienen dos o más dígitos, los minutos y los segundos nunca superan 59 y la parte de milisegundos siempre tiene tres dígitos. Además, cada cue debe terminar después de empezar. Hay dos caracteres que no pueden aparecer tal cual en el texto de un cue: & y <. El código sustituye primero & por & y después < por <. MDN también recomienda escribir > como >, así que el código lo hace igualmente.
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`;
}
Aquí importan algunos detalles:
toVttTimeredondea a milisegundos enteros antes de dividir el valor en horas, minutos y segundos, por lo que nunca puede generar.1000.&se escapa primero porque las demás sustituciones añaden ampersands, y escaparlo al final los escaparía dos veces.- Colapsar los espacios en blanco elimina cualquier salto de línea del texto del modelo. Una línea en blanco dentro del contenido termina el cue antes de tiempo.
- El fallback
?? durationes una precaución por si el último chunk llega sin tiempo de fin.
La salida del formateador tiene este aspecto (a modo ilustrativo):
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.
Una vez que tengas un archivo VTT, también puedes traducir los subtítulos a otros idiomas.
Asocia los subtítulos con una URL de Blob
Para mostrar los subtítulos, envuelve la cadena VTT en un Blob de tipo text/vtt, crea una object URL para él y usa esa URL como src de un elemento <track>. Asigna a la pista kind="captions", srclang y default. El constructor de Blob codifica las cadenas de JavaScript como UTF-8, que es la codificación que exige 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 es "en" porque whisper-tiny.en es un modelo solo para inglés. La pista se añade cuando el vídeo ya se ha cargado, así que establecer mode en "showing" de forma explícita hace que se muestre de inmediato. Llama a la función devuelta cuando sustituyas o elimines el vídeo para que la object URL se libere. En React, la misma pista va dentro del <video> que renderizas, tal como se explica en cómo incrustar vídeo en React y cómo crear un reproductor de vídeo con React.
¿Cuáles son los límites de Whisper en el navegador?
Whisper en el navegador funciona bien con clips de unos pocos minutos y mal con grabaciones de una hora.
- Tamaño de descarga. La primera ejecución descarga pesos del modelo que van desde decenas hasta cientos de megabytes, según la variante. La página del modelo
whisper-tiny.enenlaza a sus archivos. Las variantes más grandes de Whisper en el Hugging Face Hub son más precisas, pero también más pesadas, así que revisa cada model card antes de cambiar. - Velocidad. Los archivos largos son lentos, sobre todo en la ruta de WASM. Unos minutos de audio no suponen problema. Una hora de audio mantiene ocupada la pestaña durante mucho tiempo.
- Memoria. Todo el audio decodificado se mantiene en memoria como un typed array.
- Contenido masivo o largo. Para grabaciones de una hora o trabajos por lotes, un servicio de transcripción alojado es la mejor opción.
Conclusión
Puedes subtitular un vídeo corto íntegramente en el navegador: decodifica y remuestrea su audio, ejecuta Whisper en un worker sobre WebGPU (o WASM cuando no haya adaptador disponible), formatea los chunks en WebVTT y asocia el archivo como una pista respaldada por un Blob. Empieza con whisper-tiny.en en un clip de dos minutos y comprueba los tiempos de los cues frente al audio. Pasa a un modelo más grande del Hub solo si la precisión no es suficiente y tus usuarios pueden asumir una descarga mayor.
Preguntas frecuentes
¿Transformers.js descarga el modelo Whisper cada vez que se carga la página?
No. En la primera ejecución, transformers.js descarga los archivos del modelo y los guarda en la caché del navegador, de modo que las cargas posteriores los leen de la caché en lugar de la red. El ajuste env.useBrowserCache controla este comportamiento. En transformers.js v4, ModelRegistry.is_pipeline_cached indica si los archivos de un pipeline ya están en caché, y ModelRegistry.clear_pipeline_cache los elimina.
¿Cómo muestro el progreso de descarga del modelo mientras Whisper se carga en un worker?
Pasa una función progress_callback en las opciones del pipeline, junto a device. Transformers.js la llama con actualizaciones de estado a medida que se descarga cada archivo del modelo. Dentro de un worker, reenvía cada actualización al hilo principal con postMessage y renderiza allí la barra de progreso. Transformers.js v4 añade un evento progress_total, que informa del progreso global de carga para que no tengas que sumar tú mismo las actualizaciones de cada archivo.
¿Puedo generar subtítulos para vídeos que no estén en inglés?
Sí, pero necesitas un checkpoint multilingüe de Whisper en lugar de un modelo solo para inglés terminado en .en. Pasa language y task en la llamada al transcriptor, por ejemplo language: 'french' con task: 'transcribe'. Si estableces task en 'translate', Whisper genera texto en inglés a partir de voz en otro idioma. Asigna al srclang de la pista el idioma del texto de los subtítulos, no el del audio.
¿Puede la aplicación de subtitulado funcionar sin conectarse al Hugging Face Hub ni a una CDN?
Sí, si alojas tú mismo todos los archivos. Establece env.allowRemoteModels en false y apunta env.localModelPath a una carpeta de tu servidor que contenga los archivos del modelo. Por defecto, los binarios WASM de ONNX Runtime también se cargan desde una CDN, así que configura env.backends.onnx.wasm.wasmPaths para que apunte a tus propias copias. Para un uso totalmente offline, añade un service worker para que la propia página cargue sin conexión.
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