Filtros experimentais de webcam com FFmpeg.wasm e WebCodecs
Crie uma prévia local da webcam que executa o filtro de tons de cinza do FFmpeg no seu navegador. Você verá a prévia da câmera e um canvas filtrado, com controles para parar e reiniciar o processamento. Cada quadro pequeno faz uma ida e volta em PNG pelo FFmpeg.wasm, então espere um experimento que exige muito da CPU e tem uma taxa de quadros baixa.
Acompanhe um quadro pelo filtro
O caminho é: vídeo da câmera → VideoFrame → PNG → sistema de arquivos em memória do FFmpeg →
PNG em tons de cinza → canvas. O filtro hue=s=0 do FFmpeg remove a
saturação. O app espera cada processamento terminar antes de solicitar outro quadro; ele não coloca
todos os quadros da câmera na fila para processamento. Cada resultado encerra o worker do FFmpeg,
então o próximo quadro recarrega o core. Chamar exec() repetidamente no mesmo core fixado pode
causar um erro de memória do WebAssembly nesse loop. Usar uma execução por worker evita essa falha,
ao custo de trabalho extra de inicialização.
O WebAssembly nos permite reutilizar os filtros do FFmpeg localmente, mas não deixa esse pipeline tão rápido quanto o FFmpeg nativo. O FAQ do ffmpeg.wasm alerta explicitamente sobre essa diferença de desempenho. A codificação, a decodificação e as cópias de PNG acrescentam ainda mais trabalho.
Aqui, o WebCodecs fornece um instantâneo VideoFrame
do vídeo. Nós o fechamos depois de desenhar seus pixels. Não usamos VideoEncoder nem
VideoDecoder, e este exemplo não afirma usar aceleração por hardware. A saída é uma prévia em
canvas; ela não produz uma gravação nem um MediaStream filtrado para uma chamada de vídeo.
Compatibilidade com navegadores
O passo a passo foi testado no Chromium 145.0.7632.6 no Linux com uma câmera sintética. Uma webcam
física e outros navegadores não foram testados. O código verifica VideoFrame, OffscreenCanvas e
requestVideoFrameCallback()
antes de abrir a câmera.
Esta versão captura instantâneos de um vídeo em reprodução em vez de usar MediaStreamTrackProcessor ou
MediaStreamTrackGenerator. Essas APIs de insertable streams têm
exposição incompatível em window e em workers entre navegadores.
Um callback de quadro é suficiente para esta prévia serial.
Requisitos de segurança
Abra a página por meio de localhost. O acesso à câmera exige um
contexto seguro e a permissão do usuário;
HTTPS é obrigatório ao servi-la a partir de uma origem remota comum. Não abra index.html
diretamente.
Usamos o @ffmpeg/core single-threaded, servido pelo mesmo servidor local da página. Ele não
exige SharedArrayBuffer nem cabeçalhos de isolamento cross-origin. A build
@ffmpeg/core-mt
separada precisa de memória compartilhada, isolamento cross-origin e um asset de worker adicional;
migrar para ela está fora do escopo deste passo a passo. Single-threaded descreve o core do FFmpeg:
o wrapper ainda o executa em um Web Worker.
Crie o projeto local
Você precisa de um terminal compatível com Bash, Node.js 22.12 ou mais recente e o Corepack disponível para executar o Yarn. A configuração testada usou Node.js 26.8.1, Yarn 4.12.0 e Vite 8.3.0. O Vite cuida dos módulos e dos workers; o seu guia de instalação manual explica o ponto de entrada HTML.
Execute isto a partir de um diretório pai onde webcam-filter não exista. A cadeia && para
em uma etapa com falha, e mkdir se recusa a sobrescrever um projeto existente. Continue
somente se o bloco inteiro for bem-sucedido. Os arquivos do core são copiados do pacote fixado, então
o navegador não busca um engine de uma CDN de terceiros.
mkdir webcam-filter &&
cd webcam-filter &&
printf '{"private":true,"type":"module","packageManager":"yarn@4.12.0"}\n' > package.json &&
printf 'nodeLinker: node-modules\n' > .yarnrc.yml &&
touch yarn.lock &&
corepack yarn add --exact @ffmpeg/ffmpeg@0.12.15 @ffmpeg/core@0.12.10 &&
corepack yarn add --dev --exact vite@8.3.0 &&
mkdir -p public/ffmpeg &&
cp node_modules/@ffmpeg/core/dist/esm/ffmpeg-core.{js,wasm} public/ffmpeg/
Em webcam-filter, crie vite.config.ts. Excluir o wrapper do FFmpeg do pré-empacotamento de
dependências mantém a URL do seu module worker vinculada ao pacote.
import { defineConfig } from 'vite'
export default defineConfig({
optimizeDeps: { exclude: ['@ffmpeg/ffmpeg'] },
})
Crie index.html no mesmo diretório:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:," />
<title>FFmpeg webcam experiment</title>
</head>
<body>
<h1>FFmpeg webcam experiment</h1>
<button id="start" type="button">Start camera</button>
<button id="stop" type="button" disabled>Stop</button>
<p id="status" role="status">Ready.</p>
<figure>
<figcaption>Camera</figcaption>
<video id="camera" aria-label="Camera preview" width="320" height="240" muted playsinline></video>
</figure>
<figure>
<figcaption>FFmpeg grayscale</figcaption>
<canvas id="output" aria-label="Grayscale preview" width="320" height="240"></canvas>
</figure>
<script type="module" src="/main.ts"></script>
</body>
</html>
Processe um quadro por vez
Crie main.ts. Cada início tem o seu próprio wrapper do FFmpeg e o seu próprio stream da
câmera. Clicar em “Stop” limpa a sessão atual imediatamente; promises posteriores ainda precisam
pertencer a essa sessão antes de poderem atualizar a prévia. Isso importa quando a permissão, o
carregamento do engine ou a decodificação do PNG terminam depois de clicar em “Stop”.
import { FFmpeg } from '@ffmpeg/ffmpeg'
const startButton = document.querySelector('#start')
const stopButton = document.querySelector('#stop')
const status = document.querySelector('#status')
const camera = document.querySelector('#camera')
const output = document.querySelector('#output')
if (
!(startButton instanceof HTMLButtonElement) ||
!(stopButton instanceof HTMLButtonElement) ||
!(status instanceof HTMLElement) ||
!(camera instanceof HTMLVideoElement) ||
!(output instanceof HTMLCanvasElement)
) {
throw new Error('Missing demo elements')
}
const outputContext = output.getContext('2d')
if (!outputContext) throw new Error('Canvas 2D is unavailable')
interface Session {
ffmpeg: FFmpeg
canvas: OffscreenCanvas
stream?: MediaStream
callbackId?: number
}
let current: Session | undefined
const loadFFmpeg = async (session: Session): Promise<void> => {
if (session.ffmpeg.loaded) return
await session.ffmpeg.load({
coreURL: new URL('/ffmpeg/ffmpeg-core.js', location.href).href,
wasmURL: new URL('/ffmpeg/ffmpeg-core.wasm', location.href).href,
})
}
const stop = (message = 'Stopped.'): void => {
const session = current
if (!session) return
current = undefined
if (session.callbackId !== undefined) {
camera.cancelVideoFrameCallback(session.callbackId)
}
session.ffmpeg.terminate()
for (const track of session.stream?.getTracks() ?? []) track.stop()
camera.pause()
camera.srcObject = null
outputContext.clearRect(0, 0, output.width, output.height)
status.textContent = message
startButton.disabled = false
stopButton.disabled = true
}
const schedule = (session: Session): void => {
if (current !== session) return
session.callbackId = camera.requestVideoFrameCallback((_now, metadata) => {
session.callbackId = undefined
void filterFrame(session, metadata.mediaTime)
})
}
const filterFrame = async (session: Session, mediaTime: number): Promise<void> => {
try {
if (current !== session) return
const context = session.canvas.getContext('2d')
if (!context) throw new Error('Canvas 2D is unavailable')
const frame = new VideoFrame(camera, { timestamp: Math.round(mediaTime * 1_000_000) })
try {
context.drawImage(frame, 0, 0, 320, 240)
} finally {
frame.close()
}
const blob = await session.canvas.convertToBlob({ type: 'image/png' })
const bytes = new Uint8Array(await blob.arrayBuffer())
if (current !== session) return
await loadFFmpeg(session)
if (current !== session) return
await session.ffmpeg.writeFile('in.png', bytes)
const code = await session.ffmpeg.exec([
'-y', '-i', 'in.png', '-vf', 'hue=s=0', '-frames:v', '1', '-update', '1', 'out.png',
])
if (code !== 0) throw new Error('FFmpeg failed')
const data = await session.ffmpeg.readFile('out.png')
if (!(data instanceof Uint8Array) || data.length === 0) {
throw new Error('FFmpeg produced no image')
}
const bitmap = await createImageBitmap(
new Blob([new Uint8Array(data)], { type: 'image/png' }),
)
try {
if (current !== session) return
outputContext.drawImage(bitmap, 0, 0)
status.textContent = 'Filtering…'
} finally {
bitmap.close()
}
// This per-frame experiment uses one exec() per worker.
session.ffmpeg.terminate()
schedule(session)
} catch {
if (current === session) stop('Filter failed. Start again to retry.')
}
}
startButton.onclick = async () => {
if (current) return
if (
!navigator.mediaDevices?.getUserMedia ||
typeof VideoFrame !== 'function' ||
typeof OffscreenCanvas !== 'function' ||
typeof createImageBitmap !== 'function' ||
typeof camera.requestVideoFrameCallback !== 'function' ||
typeof Worker !== 'function' ||
typeof WebAssembly !== 'object'
) {
status.textContent = 'Required browser APIs are unavailable. Use a supported browser on localhost.'
return
}
const session: Session = { ffmpeg: new FFmpeg(), canvas: new OffscreenCanvas(320, 240) }
current = session
startButton.disabled = true
stopButton.disabled = false
status.textContent = 'Loading FFmpeg…'
let failureMessage = 'Could not load FFmpeg. Check the local server and engine files, then retry.'
try {
await loadFFmpeg(session)
if (current !== session) return
status.textContent = 'Requesting camera…'
failureMessage = 'Could not open camera. Check camera permission and availability, then retry.'
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { width: { ideal: 320 }, height: { ideal: 240 }, frameRate: { ideal: 5 } },
})
// getUserMedia has no abort signal; release a stream granted after Stop.
if (current !== session) {
for (const track of stream.getTracks()) track.stop()
return
}
session.stream = stream
const track = stream.getVideoTracks()[0]
if (!track) throw new Error('No video track')
track.addEventListener('ended', () => {
if (current === session) stop('Camera ended. Start again to retry.')
}, { once: true })
camera.srcObject = stream
await camera.play()
if (current !== session) return
status.textContent = 'Waiting for a frame…'
schedule(session)
} catch {
if (current === session) stop(failureMessage)
}
}
stopButton.onclick = () => stop()
window.addEventListener('pagehide', () => stop())
As dimensões e a taxa de quadros da câmera são preferências, não garantias. Desenhar no canvas de
tamanho fixo redimensiona toda entrada para 320 × 240, mesmo que a câmera forneça algo maior ou com
uma proporção diferente. A opção -update 1 grava uma única imagem de saída, e -y
permite substituir esse nome de arquivo temporário. Encerrar o worker após cada quadro, ao clicar em
“Stop” ou em caso de falha, libera o seu sistema de arquivos em memória. O app não salva nenhuma
imagem em disco. VideoFrame.close() e
ImageBitmap.close() liberam os recursos de imagem que criamos.
Execute e pare a prévia
A partir de webcam-filter, inicie o servidor em primeiro plano:
corepack yarn vite --host 127.0.0.1
Abra a URL local exibida pelo Vite. Clique em Start camera e conceda acesso à câmera. Depois de Loading FFmpeg… e Requesting camera…, o status muda para Filtering… quando o primeiro quadro em tons de cinza aparece. Segure algo colorido diante da câmera para comparar as duas prévias. Os quadros ficam no navegador; este app não os envia nem os grava.
Clique em Stop para parar as faixas da câmera deste app, encerrar o worker do FFmpeg e limpar as prévias. Start camera fica disponível novamente, inclusive quando você para durante a inicialização. Os navegadores não oferecem uma API para dispensar um pedido de permissão da câmera pendente. Se você concedê-la depois de parar, o stream tardio é interrompido sem atualizar a página. Sair da página também executa a limpeza. Pare o servidor do terminal com Ctrl+C ao terminar.
Se a permissão for negada, o erro exibido pede que você verifique a permissão e a disponibilidade da
câmera. Permita o acesso nas configurações do site no navegador e clique em
Start camera novamente. Um arquivo de engine ausente gera um erro de
carregamento antes de a câmera ser solicitada; verifique se os dois arquivos existem em
public/ffmpeg. Um erro de processamento encerra a sessão e exibe
Filter failed. Start again to retry.. Se a câmera for encerrada, a
mensagem é Camera ended. Start again to retry..
Decida se o FFmpeg faz sentido na sua prévia
Este loop pula quadros da câmera enquanto o FFmpeg está ocupado. Reduzir a taxa de quadros solicitada pode diminuir o trabalho, mas solicitar cinco quadros por segundo não garante cinco quadros filtrados por segundo. Meça a ida e volta completa em PNG no hardware de destino antes de aumentar as dimensões ou adicionar mais filtros. Um core multithread não elimina as conversões de imagem nem as cópias de dados.
Para uma prévia da câmera em tons de cinza, aplicar um filtro do Canvas 2D ou um shader WebGL evita essa ida e volta pelo PNG e pelo sistema de arquivos. Use esta versão com FFmpeg.wasm para aprender a API baseada em arquivos ou para experimentar um filtro do FFmpeg em instantâneos pequenos. Gravação, áudio e um stream filtrado para WebRTC exigem um caminho de saída diferente.
