Adicionando legendas a um vídeo com IA no navegador
Gere legendas de vídeo no navegador com Transformers.js e Whisper. Extraia o áudio, use WebGPU ou WASM e crie arquivos WebVTT sem enviar os arquivos de mídia.
O Transformers.js executa o modelo de reconhecimento de fala Whisper, da OpenAI, diretamente no navegador. Assim, o áudio de um vídeo pode ser transcrito em legendas sem upload, sem chave de API e sem servidor.
A maioria dos tutoriais de legendagem começa pedindo que você envie o vídeo do usuário para uma API de transcrição e pague por cada minuto. Se o vídeo for privado, ou se você simplesmente não quiser mais uma fatura e um backend para manter, essa abordagem não serve para você.
Este guia constrói todo o pipeline no cliente. Você decodifica o áudio, executa o Whisper em um Web Worker com fallback de WebGPU para WASM, formata a saída como WebVTT válido e anexa o resultado a um elemento <video>.
Principais conclusões
- O Whisper no transformers.js espera áudio mono a 16 kHz como um
Float32Array, então a faixa de áudio de um vídeo precisa ser decodificada e reamostrada antes da transcrição. - A existência de
navigator.gpunão significa que o WebGPU vai funcionar. Usedevice: "webgpu"somente quandonavigator.gpu.requestAdapter()retornar um adaptador e, caso contrário, faça fallback para WASM. - Execute o pipeline de transcrição em um Web Worker. Caso contrário, o carregamento do modelo e a inferência bloqueariam a thread principal durante todo o processo.
- Um arquivo WebVTT começa com
WEBVTT, separa as cues com linhas em branco, usa timestamps no formatohh:mm:ss.ttte exige que&e<sejam escapados no texto das cues. - O Whisper no navegador é viável para clipes de poucos minutos. Para gravações de uma hora ou mídia em grande volume, use um serviço de transcrição hospedado.
O que você vai construir e por que tudo fica no dispositivo
O app de legendagem no navegador finalizado tem quatro partes: um input de arquivo, um elemento <video>, um worker que executa o Whisper e um arquivo .vtt gerado e anexado como faixa de legenda. A mídia é lida do disco do usuário para a memória e nunca sai da aba. O único tráfego de rede é o download único do modelo a partir do Hugging Face Hub e, a menos que você mesmo os hospede, dos arquivos WASM do ONNX Runtime, que o transformers.js carrega de uma CDN por padrão. Nenhum áudio ou vídeo é enviado para lugar nenhum.
Este artigo não aborda WebCodecs, legendas embutidas (burned-in) nem exportação para MP4. As legendas permanecem como uma faixa de texto separada, que o navegador renderiza sobre o vídeo. O trabalho no nível de frames pertence a um pipeline de processamento de vídeo em tempo real construído com WebCodecs, que é outra tarefa.
Como extrair a faixa de áudio de um vídeo?
O Whisper no transformers.js recebe áudio mono amostrado a 16 kHz como um Float32Array. O pipeline aceita áudio bruto como um typed array e pressupõe que ele já esteja na taxa de amostragem correta. Ele não faz essa verificação, então áudio em qualquer outra taxa produz transcrições erradas sem gerar nenhum erro.
O decodeAudioData() decodifica o áudio do arquivo. No caso de arquivos de vídeo, o funcionamento depende dos contêineres e codecs suportados pelo navegador, então um MP4 que reproduz normalmente em um navegador pode falhar na decodificação em outro. Em seguida, você renderiza o buffer decodificado por meio de um OfflineAudioContext criado com um canal a 16.000 Hz. A Web Audio faz o downmix da origem para se ajustar ao destino de canal único e a reamostra para a taxa do contexto, de modo que uma única renderização faz as duas conversões.
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 };
}
Guarde o duration. O formatador vai usá-lo mais adiante.
Execute o Whisper do Transformers.js em um Web Worker
Passe return_timestamps: true ao chamar o pipeline de ASR do transformers.js. A saída passa a incluir um array chunks, e cada entrada tem uma string text e um par timestamp: [start, end] em segundos. Execute o pipeline dentro de um Web Worker para que a página continue responsiva: caso contrário, o carregamento do modelo e a inferência bloqueariam a thread principal durante todo o processo.
Instale o pacote com npm install @huggingface/transformers. O worker cria o pipeline uma única vez e o reutiliza para todos os arquivos seguintes:
// 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) });
}
};
Na thread principal, envie as amostras ao worker transferindo o buffer em vez de copiá-lo. A forma new URL(..., import.meta.url) é o padrão de worker reconhecido pelo Vite, e o webpack 5 também o suporta:
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]);
});
}
O Whisper trabalha com janelas de 30 segundos. Se você omitir chunk_length_s, o transformers.js mantém apenas os primeiros 30 segundos de áudio e registra um aviso, de modo que o restante do vídeo fica sem legendas. Com chunk_length_s: 30 e stride_length_s: 5, o pipeline divide áudios mais longos em trechos sobrepostos de 30 segundos e junta os resultados em um único conjunto de chunks com timestamps.
WebGPU vs. WASM: escolha o dispositivo antes de carregar
O Transformers.js deve executar o Whisper no WebGPU somente quando navigator.gpu.requestAdapter() retornar um adaptador e, caso contrário, no WASM. Mudar para a GPU exige apenas uma opção do pipeline, device: "webgpu", definida no carregamento do modelo. No entanto, a existência de navigator.gpu não garante que o WebGPU funcione. Chame requestAdapter(), que pode resolver para null, e escolha o WebGPU somente quando ele retornar um adaptador. O navigator.gpu também é exposto em workers, então a função pickDevice() acima é executada lá.
| Condição | Dispositivo | O que o usuário obtém |
|---|---|---|
Sem navigator.gpu | "wasm" | Transcrição mais lenta, apenas CPU |
navigator.gpu existe, requestAdapter() retorna null | "wasm" | Transcrição mais lenta, apenas CPU |
| Adaptador retornado | "webgpu" | Inferência acelerada por GPU |
Um adaptador null é comum em máquinas reais. O guia de solução de problemas de WebGPU do Chrome lista as causas mais frequentes: o usuário desativou a aceleração gráfica nas configurações, a GPU está na blocklist do Chrome, o WebGPU ainda não é suportado naquela plataforma ou o Chrome simplesmente não encontra nenhuma GPU. Verificar o adaptador por conta própria antes de criar o pipeline, como faz o pickDevice(), cobre todos esses casos.
O guia da Hugging Face estima o suporte global ao WebGPU em cerca de 85% em março de 2026. A página de status de implementação do WebGPU indica que ele vem ativado por padrão no Chrome 113 e posteriores no Windows, macOS e ChromeOS, e no Chrome 121 e posteriores na maioria dos dispositivos Android. No Linux, o Chrome o ativa apenas para algumas GPUs. O Firefox o tem ativado por padrão no Windows desde a versão 141 e em Macs com Apple Silicon desde a versão 147. O Safari 26 o suporta no macOS, iOS, iPadOS e visionOS. No Firefox para Android, ele continua desativado por padrão: é necessário usar o Firefox Beta ou Nightly e ativar a configuração gfx.webgpu.ignore-blocklist em about:config.
Como converter os chunks do Whisper em WebVTT?
Cada chunk do Whisper se torna uma cue (bloco de legenda) do WebVTT: os tempos de início e fim vão na linha de tempo, e o texto vai na linha abaixo. A referência de WebVTT da MDN apresenta as regras. O arquivo começa com WEBVTT, uma linha em branco separa cada cue da seguinte, e dois timestamps unidos por --> definem quando cada cue é exibida.
O formatador abaixo sempre gera a forma longa hh:mm:ss.ttt. As horas têm dois ou mais dígitos, minutos e segundos nunca passam de 59, e a parte de milissegundos tem sempre três dígitos. Além disso, cada cue precisa terminar depois de começar. Dois caracteres não podem aparecer literalmente no texto da cue: & e <. O código substitui primeiro & por & e depois < por <. A MDN também recomenda escrever > como >, então o código faz isso também.
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`;
}
Alguns detalhes são importantes aqui:
toVttTimearredonda para milissegundos inteiros antes de dividir o valor em horas, minutos e segundos, então nunca pode gerar.1000.&é escapado primeiro porque as outras substituições adicionam e-comerciais, e escapá-lo por último resultaria em escape duplo.- Colapsar os espaços em branco remove qualquer quebra de linha do texto do modelo. Uma linha em branco dentro do conteúdo encerra a cue antes da hora.
- O fallback
?? durationé uma precaução para o caso de o chunk final vir sem tempo de término.
A saída do formatador fica assim (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.
Com o arquivo VTT em mãos, você também pode traduzir as legendas para outros idiomas.
Anexe as legendas com uma Blob URL
Para exibir as legendas, envolva a string VTT em um Blob com tipo text/vtt, crie uma object URL para ele e use essa URL como src de um elemento <track>. Defina na track kind="captions", srclang e default. O construtor de Blob codifica strings JavaScript em UTF-8, que é a codificação exigida pelo 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);
}
O srclang é "en" porque o whisper-tiny.en é um modelo exclusivo para inglês. Como a track é adicionada depois que o vídeo já foi carregado, definir explicitamente mode como "showing" faz com que ela seja exibida imediatamente. Chame a função retornada ao substituir ou remover o vídeo, para que a object URL seja liberada. No React, a mesma track vai dentro do <video> que você renderiza, conforme explicado em como incorporar vídeo no React e como criar um player de vídeo com React.
Quais são os limites do Whisper no navegador?
O Whisper no navegador funciona bem para clipes de poucos minutos e mal para gravações de uma hora.
- Tamanho do download. A primeira execução baixa pesos de modelo que variam de dezenas a centenas de megabytes, dependendo da variante. A página do modelo
whisper-tiny.entraz os links para os arquivos. Variantes maiores do Whisper no Hugging Face Hub são mais precisas, porém maiores, então confira cada model card antes de trocar. - Velocidade. Arquivos longos são lentos, principalmente no caminho WASM. Alguns minutos de áudio são tranquilos. Uma hora de áudio ocupa a aba por muito tempo.
- Memória. Todo o áudio decodificado é mantido em memória como um typed array.
- Mídia longa ou em grande volume. Para gravações de uma hora ou jobs em lote, um serviço de transcrição hospedado é a melhor escolha.
Conclusão
É possível legendar um vídeo curto inteiramente no navegador: decodifique e reamostre o áudio, execute o Whisper em um worker com WebGPU (ou WASM quando não houver adaptador disponível), formate os chunks em WebVTT e anexe o arquivo como uma track baseada em Blob. Comece com o whisper-tiny.en em um clipe de dois minutos e confira os tempos das cues em relação ao áudio. Migre para um modelo maior do Hub somente se a precisão não for suficiente e seus usuários puderem lidar com o download maior.
Perguntas frequentes
O transformers.js baixa o modelo Whisper a cada carregamento de página?
Não. Na primeira execução, o transformers.js baixa os arquivos do modelo e os armazena no cache do navegador, de modo que os carregamentos seguintes leem do cache em vez da rede. A configuração env.useBrowserCache controla esse comportamento. No transformers.js v4, ModelRegistry.is_pipeline_cached informa se os arquivos de um pipeline já estão em cache, e ModelRegistry.clear_pipeline_cache os remove.
Como mostrar o progresso do download do modelo enquanto o Whisper carrega em um worker?
Passe uma função progress_callback nas opções do pipeline, ao lado de device. O transformers.js a chama com atualizações de status conforme cada arquivo do modelo é baixado. Dentro de um worker, encaminhe cada atualização para a thread principal com postMessage e renderize a barra de progresso lá. O transformers.js v4 adiciona um evento progress_total, que informa o progresso geral do carregamento, para que você não precise somar as atualizações de cada arquivo manualmente.
Posso gerar legendas para vídeos que não estão em inglês?
Sim, mas você precisa de um checkpoint multilíngue do Whisper em vez de um modelo exclusivo para inglês terminado em .en. Passe language e task na chamada do transcriber, por exemplo language: 'portuguese' com task: 'transcribe'. Definir task como 'translate' faz o Whisper produzir texto em inglês a partir de fala em outro idioma. Defina o srclang da track como o idioma do texto da legenda, não o idioma do áudio.
O app de legendagem pode funcionar sem acessar o Hugging Face Hub ou uma CDN?
Sim, desde que você hospede todos os arquivos. Defina env.allowRemoteModels como false e aponte env.localModelPath para uma pasta no seu servidor que contenha os arquivos do modelo. Por padrão, os binários WASM do ONNX Runtime também são carregados de uma CDN, então defina env.backends.onnx.wasm.wasmPaths para as suas próprias cópias também. Para uso totalmente offline, adicione um service worker para que a própria página carregue sem conexão.
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