Extrair miniaturas de vídeos no navegador com ffmpeg.wasm
Selecione um vídeo local, informe marcações de tempo em segundos e exiba prévias PNG sem fazer upload do vídeo. Este passo a passo cria um pequeno app de navegador com ffmpeg.wasm e Vite, incluindo o seletor de arquivos, assets Wasm compatíveis, feedback de erros e limpeza entre execuções.
Por que extrair miniaturas no navegador?
Uma prévia local permite escolher um quadro útil antes de decidir se vale a pena fazer upload de um vídeo. Neste exemplo, o navegador baixa o código da aplicação e o core do FFmpeg do seu servidor local; o vídeo selecionado e as imagens geradas ficam na memória do navegador. Isso descreve o comportamento deste app, não uma garantia de privacidade para toda aplicação que usa ffmpeg.wasm.
Apresentando o ffmpeg.wasm
O wrapper ffmpeg.wasm executa o FFmpeg em seu próprio web worker. Você copia um arquivo para o sistema de arquivos virtual dele, executa um comando FFmpeg conhecido e lê o resultado de volta. O core single-threaded usado aqui ainda roda fora da thread principal do navegador.
Para começar, use um MP4 H.264 curto e sem criptografia. Este exemplo limita as entradas a 25 MiB e as solicitações a seis marcações de tempo. Esses são limites da demonstração, não uma garantia de que todo dispositivo consiga processar um arquivo desse tipo. O ambiente verificado é Linux, Node.js 24.15.0, Yarn 4.12.0 e Chromium 145.0.7632.6. O Node compila e serve o app; o navegador faz o processamento de mídia. Outros navegadores e dispositivos móveis precisam de testes próprios.
Instalar e inicializar
Com o Node.js 24.15.0 e o Corepack disponíveis, cole isto em um shell compatível com Bash em um
diretório temporário. O comando cria um novo projeto wasm-thumbnails e se recusa a sobrescrever um diretório
existente. O subshell mantém inalterados o diretório atual e as opções do seu shell. O lockfile local
e a configuração do TypeScript mantêm o projeto separado da configuração do workspace ao redor.
(
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
)
Mantenha o yarn.lock gerado para uma resolução de dependências reproduzível. Salve os três arquivos a
seguir dentro de wasm-thumbnails. Primeiro, copy-core.ts copia o core ESM do pacote instalado para que os
arquivos JavaScript e Wasm correspondam. O guia de carregamento oficial
especifica assets ESM para o Vite; assets da mesma origem podem ser carregados diretamente, sem
wrappers de blob URL.
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}`)
}
Em seguida, salve 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>
Atender aos requisitos do navegador
Sirva o app via localhost como mostrado abaixo, em vez de abrir index.html como arquivo. Este
@ffmpeg/core single-threaded funciona sem SharedArrayBuffer nem cabeçalhos COOP/COEP. O wrapper
ainda precisa de module workers e WebAssembly. O download do core tem cerca de 31 MiB antes da
compressão HTTP, então a primeira extração pode demorar bem mais do que as seguintes.
Trocar para @ffmpeg/core-mt é uma integração separada: o
exemplo de carregamento com threads
exige um asset de worker adicional e as condições de segurança para SharedArrayBuffer, incluindo
isolamento de origem cruzada. Não adicione esses requisitos a este exemplo single-threaded por
padrão. HTTPS em produção, política de segurança de conteúdo e hospedagem em um subcaminho exigem
configuração separada; este passo a passo serve a partir da raiz da origem.
Extrair uma única miniatura
Salve este programa completo como main.ts. extractThumbnail() seleciona o primeiro fluxo de vídeo,
busca o tempo solicitado e grava um PNG. Cada chamada usa nomes de arquivo virtuais únicos, verifica
o status de saída e os bytes gerados e, depois, remove seus arquivos mesmo em caso de falha.
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
}
})
A opção -ss faz a busca antes da decodificação e, por padrão,
a busca com precisão do FFmpeg descarta os quadros anteriores à posição solicitada durante a
transcodificação. Os quadros de vídeo ocorrem em instantes discretos: uma marcação de tempo entre dois
quadros não cria um quadro interpolado. A saída tem 320 pixels de largura. Isto é um extrator de
prévias, não uma verificação de integridade do arquivo inteiro; uma miniatura inicial bem-sucedida não
comprova que os pacotes posteriores de um vídeo estejam intactos.
Capturar várias miniaturas em sequência
O loop do formulário aguarda cada marcação de tempo em uma única instância do FFmpeg. Ele copia a entrada novamente para cada quadro, priorizando uma função de extração pequena e autocontida em vez de uma API de lote mais elaborada. Os resultados aparecem juntos somente depois que todas as imagens solicitadas são decodificadas. Um novo envio do formulário limpa o conjunto anterior; um lote com falha não deixa prévias parciais.
Execute o seguinte a partir do diretório que contém wasm-thumbnails. Copiar o core sobrescreve apenas
os dois assets do projeto, e o Vite substitui o build dist do projeto. Cada etapa só é executada
se a anterior for bem-sucedida:
(
cd wasm-thumbnails &&
node copy-core.ts &&
corepack yarn vite build &&
corepack yarn vite preview --host 127.0.0.1 --port 4173 --strictPort
)
Abra http://127.0.0.1:4173, escolha um vídeo com mais de 2 segundos, mantenha as marcações de tempo em
0.5, 1.5 e clique em Extract thumbnails. Duas imagens devem
aparecer com os tempos solicitados abaixo delas e 2 thumbnail(s) ready. acima.
Use um único valor, como 0.5, para gerar uma só miniatura. Pare o servidor com Ctrl+C; se a
porta 4173 estiver ocupada, escolha outra porta no comando e na URL. O
servidor de prévia do Vite serve para verificar um build local.
Manter a interface responsiva com um web worker
O wrapper já é dono do worker. O formulário desativa o seletor de arquivos, o campo de marcações de tempo e o botão de envio enquanto o trabalho está em andamento, e ignora envios duplicados. Ele não oferece cancelamento nem permite que uma nova seleção atropele um lote ativo. O prazo de 60 segundos encerra o worker se o carregamento ou a extração travar; o envio seguinte carrega uma nova instância do core.
Dicas de desempenho
O core é carregado no primeiro envio e reutilizado após lotes bem-sucedidos. Os arquivos virtuais temporários são excluídos após cada extração. As URLs das prévias continuam válidas enquanto suas imagens são exibidas e depois são revogadas quando as entradas mudam ou outra execução começa. Recarregar ou fechar a página descarta a sessão; o app não salva miniaturas em disco.
Excluir arquivos virtuais não garante que o runtime Wasm devolva imediatamente toda a memória alocada ao sistema operacional. Um worker pode manter sua capacidade de memória para reutilização. Imagens de saída menores também não eliminam o custo de decodificar uma entrada de alta resolução. Teste arquivos representativos nos dispositivos aos quais você pretende oferecer suporte antes de aumentar os limites da demonstração.
Processamento no navegador versus no servidor
A extração no navegador é útil para uma prévia antes do upload, mas o tempo de download, o uso de memória e a velocidade de processamento dependem do dispositivo do usuário. A extração no servidor exige enviar o vídeo e planejar a capacidade de processamento e a retenção. Ela pode ser adequada para fluxos de trabalho que precisam de resultados persistentes ou de recursos de processamento consistentes. Nenhuma das abordagens, por si só, garante suporte universal a codecs ou capacidade ilimitada.
Solucionar problemas comuns
- Nenhuma prévia: verifique se o vídeo não está vazio, respeita o limite de tamanho e tem um
fluxo de vídeo decodificável. Informe segundos decimais como
1.25, não00:00:01.25. Uma marcação de tempo no final ou depois dele pode não gerar nenhum quadro; tente novamente com um tempo anterior. - Falha ao carregar o core: confirme que tanto
/core/ffmpeg-core.jsquanto/core/ffmpeg-core.wasmsão servidos a partir do build. Repita o comando de cópia e build depois de mudar a versão do core. O wrapper JavaScript e o core têm números de versão separados; eles não precisam compartilhar a mesma string de versão. - A extração falha ou atinge o prazo: tente um vídeo mais curto e de menor resolução. O handler de falha descarta as prévias e encerra o worker para que o próximo envio comece do zero.
- Erros de
SharedArrayBuffer: verifique se você substituiu por engano o core multithread. Adicionar cabeçalhos de isolamento não corrige assets ausentes nesta configuração single-threaded.
Ir além das prévias locais
Se os dispositivos de destino não derem conta dos seus vídeos, considere um fluxo de trabalho baseado em upload com consentimento explícito do usuário. A documentação de miniaturas de vídeo descreve a opção server-side da Transloadit. Mantenha a prévia local útil por si só: uma extração com falha deve permitir que a pessoa escolha outro arquivo ou outra marcação de tempo sem perder o restante do trabalho.
