¿Qué hace dev-claude-reel?
Salida: un mp4, 1080x1920, 30 fps, H.264 + AAC, aproximadamente 50 segundos. Una escena 3D de polígonos bajos (low-poly), dos personajes (un desarrollador y un personaje Claude), voces reales, subtítulos incrustados, una cámara que corta según el diálogo y una mezcla limpia.
Cómo: una página de three.js es manejada por una única función setT(t). Chrome sin interfaz (headless) la renderiza un fotograma a la vez, ffmpeg une los fotogramas y el audio.
Lo que esta habilidad no decide: el aspecto de los personajes, dónde están y de qué hablan. Toma eso del usuario si lo ha dado. De lo contrario, elige algo en pocas líneas y ponte en marcha.
Canalización (en este orden, sin saltos): breve → guion → voces → línea de tiempo → escena → fotogramas fijos → verificación de movimiento → mezcla → renderizado completo → multiplexado → verificar → entregar
1. Reglas básicas
Estas anulan la comodidad. No anulan al usuario.
- No dañes la máquina. Es el ordenador diario del usuario. Un navegador sin interfaz, renderizado por GPU, la prioridad de proceso más baja, sin trabajos en paralelo. Si WebGL informa un renderizador por software (SwiftShader / "software" / "Basic Render"), detente e informa al usuario. Nunca fuerces GL por software.
- Los secretos siguen siendo secretos. La clave de ElevenLabs reside en
ELEVENLABS_API_KEY(entorno o.env). Léela solo dentro de los scripts. Nunca la imprimas, registres o escribas en ningún lugar visible. - Primero los fotogramas fijos, luego la película. No se realiza un renderizado completo hasta que una hoja de contactos de fotogramas fijos tenga buen aspecto. Un renderizado completo para corregir un error de una sola toma es el error más costoso de este flujo de trabajo.
- Renderiza en fragmentos en primer plano. Los trabajos en segundo plano mueren cuando termina el turno. Mantén cada llamada de renderizado por debajo de ~8 minutos y hazla reanudable (sección 11).
- Un video por chat. El archivo de la escena crece rápido. Divídelo en partes, no lo releas sin motivo, inicia un chat nuevo para el siguiente reel.
- Entrega un archivo, nunca lo publiques. La publicación es decisión del usuario.
- Sé honesto sobre las carencias. ¿Falta una clave, ffmpeg o Chrome? Di exactamente qué falta y ofrece la opción viable más cercana. Nunca finjas un resultado.
2. Verificación de configuración (primer minuto)
| Necesidad | Notas |
|---|---|
| Node 22+ | Utiliza fetch y WebSocket integrados. Única dependencia npm: three.js. |
| Chrome o Edge | Ejecútalo sin interfaz (headless). |
| ffmpeg + ffprobe | En PATH o una ubicación conocida. |
| three.js | npm i [email protected], servido desde disco por un servidor estático local. Sin CDN, sin red durante el renderizado. |
| Clave de ElevenLabs | El nivel gratuito funciona (~10k créditos/mes, acceso a API, voces predefinidas, sin licencia comercial). ~1 crédito por carácter, la generación de sonido cuesta más. |
| GPU | Pruébala una vez (sección 3). |
Presupuesto de ElevenLabs: termina el guion antes de generar nada, cuenta sus caracteres, solo haz tomas alternativas para frases clave y almacena en caché cada resultado para que no se pague dos veces.
Modelo: eleven_v3. Si la cuenta no puede usarlo, recurre a eleven_multilingual_v2, elimina las [etiquetas de audio] del texto y advierte al usuario que la actuación será más plana.
¿Sin clave? Dilo. Construye el guion, la escena y un renderizado de prueba silencioso hasta donde sea útil, luego pide la clave. Si el usuario insiste en continuar sin ella: síntesis de voz del sistema operativo, tiempos de palabras estimados a partir del recuento de caracteres. Diles que la sincronización de labios y los subtítulos serán más toscos.
Estructura de carpetas
Una carpeta por video, archivos simples, sin paso de compilación.
reel/
package.json three
film.html lienzo + superposición de subtítulos + mapa de importación, carga scene.js
scene.js mundo 3D + setT(t) (dividido en partes cuando se vuelve grande)
timeline.mjs sincronización + tomas, importado por Node Y el navegador
gen-audio.mjs takes.json -> voces de ElevenLabs + ambiente (almacenado en caché en disco)
words.mjs tiempos de palabras a partir de json de alineación
build-film.mjs buses de voz por hablante + film.json (envolventes, subtítulos)
music.mjs pista musical opcional
mix.mjs mezcla final
cdp.mjs controlador de Chrome sin interfaz
render.mjs fotogramas fijos | completo (fragmentado, reanudable)
check.mjs control de calidad automatizado
make.mjs ejecuta gen-audio -> build-film -> music -> mix
vo/ sfx/ frames/ stills/
Cualquier cambio en timeline.mjs implica volver a ejecutar make.mjs de principio a fin. Los fragmentos de audio obsoletos son el error invisible clásico: un sonido que pertenece a una sincronización antigua aparece como un clic aleatorio.
3. Manejo de Chrome sin interfaz (cdp.mjs)
Un pequeño cliente del Protocolo de Herramientas de Desarrollo de Chrome, sin dependencias.
Banderas de lanzamiento:
--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 en Windows, metal en macOS, gl en Linux.
Después de conectar:
Emulation.setDeviceMetricsOverride { width: 1080, height: 1920, deviceScaleFactor: 1, mobile: false }- Prueba de GPU: lee
UNMASKED_RENDERER_WEBGLa través deWEBGL_debug_renderer_info. Vacío, o que coincida con/swiftshader|software|basic render/i→ aborta e informa al usuario. - Sé educado:
os.setPriority(os.constants.priority.PRIORITY_LOW)al principio de cada script de Node. Cada ~5 s, vuelve a bajar la prioridad del navegador y de todos los procesos secundarios (Chrome vuelve a subir su proceso de GPU). Obtén los PID de los procesos secundarios deSystemInfo.getProcessInfoen el websocket del navegador (/json/version→webSocketDebuggerUrl). En Windows, también fija el navegador a un núcleo lógico a través de PowerShell ((Get-Process -Id <pid>).ProcessorAffinity = <single bit>).
Superficie de la API: goto(url), eval(expr) (awaitPromise + returnByValue, lanza una excepción en exceptionDetails), waitFor(expr, timeout), send(method, params), un array logs (llamadas a la consola, excepciones, entradas de registro), close() (mata el proceso, elimina el perfil temporal).
Punto de partida (adáptalo, no lo pegues a ciegas):
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: GPU probe, 5 s priority-lowering timer for proc.pid + children, Windows affinity pin
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 failed: ' + (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. La página de la película (film.html + scene.js)
La regla de oro
window.setT(t) es una función pura del tiempo. Dale segundos, posa cada personaje, accesorio, luz y la cámara, renderiza el fotograma, listo. Eso significa:
- nada de
performance.now(), nada derequestAnimationFrame, nada de estado acumulado, nada de física - nada de
Math.random(): usa un hash con semilla de un índice en su lugar
Debido a esto, los fotogramas se renderizan en cualquier orden, los fragmentos se reanudan limpiamente y un solo fotograma fijo en cualquier marca de tiempo es barato. Una vez que todo (fuentes, texturas) esté cargado, establece window.TOTAL, window.END_T y window.__ready = true.
film.html
Cuerpo negro, margen cero, overflow: hidden. Un mapa de importación que apunta three y three/addons/ a node_modules/three/.... Una superposición de subtítulos #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 must equal uNear/uFar in the DOF shader
La escena se renderiza en un objetivo HDR lineal (half-float, 4x MSAA, textura de profundidad), para que las pantallas y las luces puedan superar 1.0. Luego profundidad de campo, luego bloom en HDR, luego una pasada de composición que aplica mapeo de tonos y corrección gamma. Esa cadena es la diferencia entre "cinematográfico" y "demo de WebGL".
Orden de trabajo dentro de setT
- posar personajes
scene.updateMatrixWorld(true)- conexión a tierra y accesorios (accesorios a las manos, asientos al suelo)
updateMatrixWorldde nuevo- actualizar anclas (posiciones mundiales de cabezas, manos, accesorios, pantallas)
- colocar la cámara desde la lista de tomas
- actualizar subtítulo
- renderizar escena → DOF → polvo → paso brillante → desenfoques → composición al lienzo
Posprocesado
Profundidad de campo. Recolección consciente de la profundidad con 64 muestras de disco de Vogel. Las muestras brillantes obtienen peso adicional para que los reflejos se conviertan en discos de bokeh. No se permite que un fondo borroso se superponga a un primer 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; // background can't cover sharp foreground
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); // bokeh boost
acc += s * w; wsum += w;
}
gl_FragColor = vec4(acc / wsum, 1.0);
}
uFocus: distancia desde la cámara al ancla de enfoque, medida a lo largo de la dirección de la vista, recalculada cada fotograma.uAperture: ~1000–2500. Más alto = más superficial. Primeros planos alto, planos generales bajo. Por defecto moderado; el desenfoque intenso parece un filtro de Instagram.uMaxCoc: 22 píxeles.
Bloom.
- Paso brillante: `c * clamp((max(r,g,b
…
[truncado para la traducción]