O que dev-claude-reel faz?
Saída: um mp4, 1080x1920, 30 fps, H.264 + AAC, cerca de 50 segundos. Uma cena 3D low-poly, dois personagens (um desenvolvedor e um personagem Claude), vozes reais, legendas embutidas, uma câmera que corta nos diálogos e uma mixagem limpa.
Como: uma página three.js é controlada por uma única função setT(t). O Chrome headless renderiza um quadro de cada vez, e o ffmpeg junta os quadros e o áudio.
O que este skill não decide: a aparência dos personagens, onde eles estão e sobre o que conversam. Isso vem do usuário, se for fornecido. Caso contrário, escolha algo em algumas linhas e comece.
Pipeline (nesta ordem, sem pular): briefing → roteiro → vozes → timeline → cena → stills → verificação de movimento → mixagem → renderização completa → multiplexação → verificação → entrega
1. Regras básicas
Elas substituem a conveniência. Não substituem o usuário.
- Não prejudique a máquina. É o computador diário do usuário. Um navegador headless, renderização por GPU, prioridade de processo mais baixa, sem workers paralelos. Se o WebGL reportar um renderizador por software (SwiftShader / "software" / "Basic Render"), pare e avise o usuário. Nunca force software GL.
- Segredos permanecem em segredo. A chave ElevenLabs fica em
ELEVENLABS_API_KEY(env ou.env). Leia-a apenas dentro dos scripts. Nunca imprima, registre ou escreva em qualquer lugar visível. - Stills primeiro, filme depois. Sem renderização completa até que uma folha de contato de stills pareça correta. Uma re-renderização completa por um erro de um único take é o erro mais caro neste fluxo de trabalho.
- Renderize em blocos em primeiro plano. Jobs em segundo plano morrem quando o turno termina. Mantenha cada chamada de renderização abaixo de ~8 minutos e torne-a retomável (seção 11).
- Um vídeo por chat. O arquivo de cena cresce rápido. Divida-o em partes, não o releia sem motivo, inicie um novo chat para o próximo reel.
- Entregue um arquivo, nunca o publique. Publicar é decisão do usuário.
- Seja honesto sobre lacunas. Falta chave, ffmpeg ou Chrome? Diga exatamente o que falta e ofereça a opção viável mais próxima. Nunca finja um resultado.
2. Verificação de configuração (primeiro minuto)
| Necessidade | Observações |
|---|---|
| Node 22+ | Usa fetch e WebSocket nativos. Única dependência npm: three.js. |
| Chrome ou Edge | Execute headless. |
| ffmpeg + ffprobe | No PATH ou em um local conhecido. |
| three.js | npm i [email protected], servido do disco por um servidor local estático. Sem CDN, sem rede durante a renderização. |
| Chave ElevenLabs | O nível gratuito funciona (~10k créditos/mês, acesso à API, vozes pré-definidas, sem licença comercial). ~1 crédito por caractere, geração de som custa extra. |
| GPU | Teste uma vez (seção 3). |
Orçamento ElevenLabs: termine o roteiro antes de gerar qualquer coisa, conte seus caracteres, faça apenas takes alternativos para falas-chave e armazene em cache cada resultado para que nada seja pago duas vezes.
Modelo: eleven_v3. Se a conta não puder usá-lo, recorra a eleven_multilingual_v2, remova as [tags de áudio] do texto e avise o usuário que a atuação será mais plana.
Sem chave? Avise. Construa roteiro, cena e uma renderização de teste silenciosa até onde for útil, depois peça a chave. Se o usuário insistir em continuar sem: TTS do sistema operacional, tempos de palavra estimados por contagem de caracteres. Avise que a sincronia labial e as legendas serão mais imprecisas.
Estrutura de pastas
Uma pasta por vídeo, arquivos simples, sem etapa de build.
reel/
package.json three
film.html canvas + sobreposição de legendas + import map, carrega scene.js
scene.js Mundo 3D + setT(t) (dividido em partes quando ficar grande)
timeline.mjs Temporização + planos, importado pelo Node E pelo navegador
gen-audio.mjs takes.json -> vozes ElevenLabs + ambiente (armazenado em cache no disco)
words.mjs Tempos das palavras a partir do json de alinhamento
build-film.mjs Barramentos de voz por locutor + film.json (envelopes, legendas)
music.mjs Faixa de música opcional
mix.mjs Mixagem final
cdp.mjs Driver Chrome headless
render.mjs Stills | completo (em blocos, retomável)
check.mjs QA automatizado
make.mjs Executa gen-audio -> build-film -> music -> mix
vo/ sfx/ frames/ stills/
Qualquer alteração em timeline.mjs significa executar make.mjs do início ao fim novamente. Stems de áudio desatualizados são o clássico bug invisível: um som que pertence a uma temporização antiga aparece como um clique aleatório.
3. Conduzindo o Chrome headless (cdp.mjs)
Um pequeno cliente Chrome DevTools Protocol, sem dependências.
Flags de inicialização:
--headless=new --remote-debugging-port=<p> --user-data-dir=<tmp> --no-first-run --disable-extensions --disable-background-timer-throttling --disable-renderer-backgrounding --mute-audio --window-size=W,H --use-gl=angle --use-angle=<backend> --ignore-gpu-blocklist --enable-gpu
Backend: d3d11 no Windows, metal no macOS, gl no Linux.
Após conectar:
Emulation.setDeviceMetricsOverride { width: 1080, height: 1920, deviceScaleFactor: 1, mobile: false }- Teste de GPU: leia
UNMASKED_RENDERER_WEBGLviaWEBGL_debug_renderer_info. Vazio, ou correspondendo a/swiftshader|software|basic render/i→ aborta e avisa o usuário. - Seja educado:
os.setPriority(os.constants.priority.PRIORITY_LOW)no topo de cada script Node. A cada ~5 s, reduza a prioridade do navegador e de todos os processos filho novamente (o Chrome aumenta a prioridade do processo GPU de volta). Obtenha os pids filhos deSystemInfo.getProcessInfono websocket do navegador (/json/version→webSocketDebuggerUrl). No Windows, também fixe o navegador a um núcleo lógico via PowerShell ((Get-Process -Id <pid>).ProcessorAffinity = <single bit>).
Superfície da API: goto(url), eval(expr) (awaitPromise + returnByValue, lança erro em exceptionDetails), waitFor(expr, timeout), send(method, params), um array logs (chamadas de console, exceções, entradas de log), close() (mata processo, deleta perfil temporário).
Ponto de partida (adapte, não cole cegamente):
import { spawn } from 'node:child_process';
import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path';
const BROWSERS = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
];
const BIN = BROWSERS.find(p => fs.existsSync(p));
const ANGLE = { win32: 'd3d11', darwin: 'metal' }[process.platform] ?? 'gl';
const sleep = ms => new Promise(r => setTimeout(r, ms));
export async function launch({ width = 1080, height = 1920, port = 9450 } = {}) {
try { os.setPriority(os.constants.priority.PRIORITY_LOW); } catch {}
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'reel-'));
const proc = spawn(BIN, [
`--remote-debugging-port=${port}`, `--user-data-dir=${profile}`, '--headless=new',
'--no-first-run', '--disable-extensions', '--disable-background-timer-throttling',
'--disable-renderer-backgrounding', '--mute-audio', `--window-size=${width},${height}`,
'--use-gl=angle', `--use-angle=${ANGLE}`, '--ignore-gpu-blocklist', '--enable-gpu', 'about:blank',
], { stdio: 'ignore' });
let targets;
for (let i = 0; i < 100 && !targets?.length; i++) {
try { targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); }
catch { await sleep(150); }
}
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = fail; });
let seq = 0; const waiting = new Map(); const logs = [];
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id && waiting.has(msg.id)) {
const { ok, fail } = waiting.get(msg.id); waiting.delete(msg.id);
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
} else if (msg.method === 'Runtime.exceptionThrown') {
const d = msg.params.exceptionDetails;
logs.push({ type: 'exception', text: d.exception?.description || d.text });
} else if (msg.method === 'Runtime.consoleAPICalled') {
logs.push({ type: msg.params.type, text: msg.params.args.map(a => a.value ?? a.description).join(' ') });
}
};
const send = (method, params = {}) => new Promise((ok, fail) => {
const id = ++seq; waiting.set(id, { ok, fail });
ws.send(JSON.stringify({ id, method, params }));
});
await send('Runtime.enable'); await send('Page.enable'); await send('Log.enable');
await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false });
// TODO: teste de GPU, timer de redução de prioridade a cada 5 s para proc.pid + filhos, fixação de afinidade no Windows
const api = {
send, logs, proc,
async eval(expression) {
const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
if (r.exceptionDetails) throw new Error('eval falhou: ' + (r.exceptionDetails.exception?.description || r.exceptionDetails.text));
return r.result.value;
},
async goto(url) { await send('Page.navigate', { url }); await api.waitFor('document.readyState === "complete"', 15000); },
async waitFor(expr, ms = 10000) {
for (const t0 = Date.now(); Date.now() - t0 < ms; await sleep(100)) {
try { if (await api.eval(expr)) return true; } catch {}
}
throw new Error('timeout: ' + expr);
},
async close() {
try { ws.close(); } catch {} try { proc.kill(); } catch {}
setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500);
},
};
return api;
}
4. A página do filme (film.html + scene.js)
A regra de ouro
window.setT(t) é uma função pura do tempo. Dê segundos, ela posiciona cada personagem, objeto, luz e a câmera, renderiza o quadro, pronto. Isso significa:
- sem
performance.now(), semrequestAnimationFrame, sem estado acumulado, sem física - sem
Math.random(): use um hash seeded de um índice
Por causa disso, os quadros são renderizados em qualquer ordem, os blocos retomam de forma limpa e um único still em qualquer timestamp é barato. Assim que tudo (fontes, texturas) estiver carregado, defina window.TOTAL, window.END_T e window.__ready = true.
film.html
Corpo preto, margem zero, overflow: hidden. Um import map apontando three e three/addons/ para node_modules/three/.... Uma sobreposição de legendas #subwrap / #sub. <script type="module" src="/scene.js">.
Renderizador
const W = 1080, H = 1920;
const renderer = new THREE.WebGLRenderer({ antialias: false, preserveDrawingBuffer: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(1); renderer.setSize(W, H); renderer.toneMapping = THREE.NoToneMapping;
document.body.appendChild(renderer.domElement);
const depthTex = new THREE.DepthTexture(W, H); depthTex.type = THREE.UnsignedIntType;
const linear = { type: THREE.HalfFloatType, colorSpace: THREE.LinearSRGBColorSpace };
const rtScene = new THREE.WebGLRenderTarget(W, H, { ...linear, samples: 4, depthTexture: depthTex });
const rt = (w, h) => new THREE.WebGLRenderTarget(w, h, linear);
const rtDof = rt(W, H);
const rtB1 = rt(W >> 2, H >> 2), rtB1b = rt(W >> 2, H >> 2); // bloom 1/4
const rtB2 = rt(W >> 3, H >> 3), rtB2b = rt(W >> 3, H >> 3); // bloom 1/8
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2)); quad.frustumCulled = false;
const qScene = new THREE.Scene(); qScene.add(quad);
const qCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const pass = (mat, target) => { quad.material = mat; renderer.setRenderTarget(target); renderer.render(qScene, qCam); };
const VERT = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }';
camera = new THREE.PerspectiveCamera(30, W / H, 1, 500); // near/far devem ser iguais a uNear/uFar no shader DOF
A cena é renderizada em um target HDR linear (half-float, 4x MSAA, textura de profundidade), para que telas e luzes possam ultrapassar 1.0. Em seguida, profundidade de campo, depois bloom em HDR e, por fim, uma passagem de composição que faz o tone mapping e a correção gama. Essa cadeia é a diferença entre "cinematográfico" e "demo WebGL".
Ordem de trabalho dentro de setT
- posicionar personagens
scene.updateMatrixWorld(true)- aterramento e anexos (objetos às mãos, assentos ao chão)
updateMatrixWorldnovamente- atualizar âncoras (posições mundiais das cabeças, mãos, objetos, telas)
- posicionar câmera a partir da lista de planos
- atualizar legenda
- renderizar cena → DOF → poeira → bright-pass → desfoques → composição no canvas
Pós-processamento
Profundidade de campo. Gather com consciência de profundidade usando 64 taps de Vogel-disc. Amostras brilhantes ganham peso extra para que realces se transformem em discos bokeh. Um fundo borrado não pode vazar sobre um primeiro plano nítido.
uniform sampler2D tColor, tDepth;
uniform vec2 uRes;
uniform float uNear, uFar, uFocus, uAperture, uMaxCoc;
varying vec2 vUv;
float linDepth(float d) { float z = d * 2. - 1.; return 2. * uNear * uFar / (uFar + uNear - z * (uFar - uNear)); }
float coc(float z) { return min(uMaxCoc, uAperture * abs(1. / uFocus - 1. / z)); }
void main() {
float z0 = linDepth(texture2D(tDepth, vUv).x);
float c0 = coc(z0);
float wsum = 1.0 / max(c0 * c0, 1.0);
vec3 acc = texture2D(tColor, vUv).rgb * wsum;
const int N = 64;
for (int i = 0; i < N; i++) {
float fi = float(i) + 0.5;
float r = sqrt(fi / float(N));
float a = fi * 2.39996323;
vec2 off = vec2(cos(a), sin(a)) * r * uMaxCoc;
float dist = length(off);
vec2 uv = vUv + off / uRes;
float zs = linDepth(texture2D(tDepth, uv).x);
float cs = coc(zs);
float eff = (zs > z0) ? min(cs, c0) : cs; // fundo não pode cobrir primeiro plano nítido
float w = smoothstep(dist - 1.0, dist + 1.0, eff) / max(eff * eff, 1.0);
vec3 s = texture2D(tColor, uv).rgb;
w *= 1.0 + 1.6 * clamp(dot(s, vec3(0.3, 0.5, 0.2)) - 0.8, 0.0, 6.0); // boost bokeh
acc += s * w; wsum += w;
}
gl_FragColor = vec4(acc / wsum, 1.0);
}
uFocus: distância da câmera até a âncora de foco, medida ao longo da direção da visualização, recalculada a cada quadro.uAperture: ~1000–2500. Maior = mais raso. Close-ups alto, abertos baixo. Padrão moderado; desfoque pesado parece filtro do Instagram.uMaxCoc: 22 px.
Bloom.
- Passagem brilhante: `c * clamp((max(r,g,b