WebGL / Three.js Scene → MP4 (headless capture)
Convierte una escena Three.js/WebGL animada (no un slideshow de fotos) en un MP4 vertical 9:16 sincronizado con voz. Vía: Puppeteer (Chrome headless) captura frame a frame + ffmpeg. Alternativa a hyperframes-html-to-video cuando la escena usa Three.js y quieres control total (o HyperFrames no está instalado).
No es el pipeline de fotos/slides de short-form-video-generation. Aquí la escena es 3D real (objetos, cámara con dolly, partículas) renderizada a vídeo.
When to Use
- Cuando quieras un vídeo vertical (Shorts/Reels/TikTok) a partir de una escena 3D/animada (Three.js, WebGL, canvas) en vez de un carrusel de fotos.
- Cuando un slideshow plano te parezca "flojo" y quieras movimiento real de cámara (dolly), objetos 3D y un look cinematográfico (p. ej. estética apocalíptica/Mad Max).
- Cuando
hyperframes no esté instalado o la escena use Three.js y prefieras control directo de la captura (frame a frame).
- Requiere: Node ≥ 22, Chrome del sistema (o
puppeteer descargado), ffmpeg en el PATH, y edge_tts (venv Hermes) para la voz.
El patrón clave: escena SEEKABLE
Para capturar frame a frame de forma determinista, la animación NO puede depender del reloj real (performance.now en un bucle). Debe ser una función draw(t) que se pueda fijar en un tiempo arbitrario:
function draw(t){
// TODO el estado depende de t (cámara, objetos, partículas, textos)
renderer.render(scene, camera);
}
window.seek = t => draw(t); // fija y dibuja en el tiempo t
window.play = () => { // para previsualización en vivo
const s=performance.now();
(function f(now){ draw((now-s)/1000); requestAnimationFrame(f); })();
};
window.seek(0); // dibuja el primer frame
- Todo lo que se mueva (cámara, rotación de objetos, partículas, opacidad de textos) debe derivarse de
t de forma determinista, nunca acumulativo.
- Partículas: usar posición base +
(t*v % rango), NO y += 0.01 en cada frame (eso se acumula y rompe el seek).
- Textos superpuestos: controlar su
opacity/transform desde draw(t) en vez de CSS keyframes, para que también sean seekables.
Captura con Puppeteer
const puppeteer = require('puppeteer-core'); // usar el Chrome del sistema, no descargar otro
const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
(async () => {
const browser = await puppeteer.launch({ executablePath: CHROME, headless:'new',
args:['--no-sandbox','--window-size=1080,1920','--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader'] });
const page = await browser.newPage();
await page.setViewport({ width:1080, height:1920, deviceScaleFactor:1 });
await page.goto('http://localhost:8123/teaser.html', { waitUntil:'networkidle2', timeout:60000 });
await page.waitForFunction('typeof window.seek === "function"', { timeout:30000 });
const DUR=14.2, FPS=15, n=Math.ceil(DUR*FPS);
for(let i=0;i<n;i++){
await page.evaluate(tt => window.seek(tt), i/FPS);
await page.screenshot({ path:`frames/f_${String(i).padStart(4,'0')}.png` });
}
await browser.close();
})();
Servir el HTML con un python -m http.server 8123 en el directorio (el importmap/CDN de three.js funciona vía http; file:// puede fallar para módulos). npm i puppeteer-core es suficiente — no hace falta puppeteer completo ni descargar Chrome.
Montaje con ffmpeg
ffmpeg -y -framerate 15 -i frames/f_%04d.png -i voice.mp3 \
-c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k \
-shortest -movflags +faststart out/video.mp4
# fades cinematográficos (opcional): duración = duración del audio
ffmpeg -y -i out/video.mp4 \
-vf "fade=t=in:st=0:d=0.5,fade=t=out:st=<DUR-0.5>:d=0.5" \
-af "afade=t=in:st=0:d=0.3,afade=t=out:st=<DUR-0.3>:d=0.3" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart out/final.mp4
Pitfalls (verificados)
- El bucle
requestAnimationFrame compite con seek(t). Si la página arranca un bucle con el reloj real, corre entre seek(t) y el screenshot y sobrescribe el estado → todos los frames salen iguales / el timing de los textos no sigue. No arranques el bucle automáticamente: deja seek(t) y play() como funciones, y usa seek(t) en la captura.
- GPU nativa vs SwiftShader:
--use-angle=gl (GPU nativa) dio THREE.WebGLProgram: Shader Error ... VALIDATE_STATUS false en Chrome headless. Usar --use-angle=swiftshader --enable-unsafe-swiftshader (software) — compila limpio. El rAF va a ~20 fps bajo SwiftShader, suficiente para la captura (la sincronización la da seek(t), no el fps).
- Mostrar el fondo CSS detrás del canvas WebGL: por defecto el canvas WebGL es opaco y tapa el gradiente CSS. Poner
alpha:true en el WebGLRenderer + renderer.setClearColor(0x000000, 0) para que el gradiente de fondo se vea a través.
- Servir por HTTP (
python -m http.server), no file://, para los imports ES con importmap (CDN) en headless.
Verificación
ffprobe el MP4: 1080×1920, H.264/AAC, duración ≈ voz.
- Extraer 2-3 frames a distintos
t (0.5s, mitad, final) y comprobar visualmente que el timing de los textos cambia y la cámara hace dolly (los objetos se ven más grandes al final). Solo mirar un frame no confirma la animación.
Voz (edge-tts) — notas para vídeos con narración
- El nombre de voz debe llevar el sufijo
Neural (ej. es-US-AlonsoNeural), no es-US-Alonso.
rate y pitch requieren signo: '+0%', '-8Hz'. '0%' falla con ValueError.
rate='-15%' alarga la duración (~22s un guion de 220 chars); para un teaser corto usar rate='+0%' y acortar el guion.
- Elegir voz "de peli": generar 2-3 candidatas y comparar su frecuencia fundamental (f0) — la más baja (más grave) encaja en trailers apocalípticos. Método: ffmpeg a wav 16 kHz + autocorrelación (ver
references/kit72h-teaser-madmax.md).
Preferencia de David (no romper)
- Visual: espectáculo primero; NUNCA gradiente azul→naranja. Usar monocromo ámbar/sepia cálido (fondo oscuro + un solo acento naranja
#f59e0b-ish) — respetado en la estética Mad Max.
- Voz: para TTS general la voz por defecto es
es-ES-AlvaroNeural; para spots/promos cinematográficos David está abierto a una voz más grave (elegida por f0), como es-US-AlonsoNeural.
Referencias
references/kit72h-teaser-madmax.md — receta completa del teaser Mad Max de kit72h: esqueleto draw(t), render.js, ffmpeg, análisis de f0 para elegir voz, y los ajustes de la escena.
1---2name: webgl-scene-to-video3description: Renderiza escena Three.js/WebGL a MP4 (Puppeteer+ffmpeg).4license: MIT5---67# WebGL / Three.js Scene → MP4 (headless capture)89Convierte una escena **Three.js/WebGL animada** (no un slideshow de fotos) en un **MP4 vertical 9:16** sincronizado con voz. Vía: **Puppeteer (Chrome headless) captura frame a frame + ffmpeg**. Alternativa a `hyperframes-html-to-video` cuando la escena usa Three.js y quieres control total (o HyperFrames no está instalado).1011**No** es el pipeline de fotos/slides de `short-form-video-generation`. Aquí la escena es 3D real (objetos, cámara con dolly, partículas) renderizada a vídeo.1213## When to Use1415- Cuando quieras un **vídeo vertical (Shorts/Reels/TikTok)** a partir de una **escena 3D/animada** (Three.js, WebGL, canvas) en vez de un carrusel de fotos.16- Cuando un slideshow plano te parezca "flojo" y quieras **movimiento real de cámara** (dolly), objetos 3D y un look cinematográfico (p. ej. estética apocalíptica/Mad Max).17- Cuando `hyperframes` no esté instalado o la escena use Three.js y prefieras control directo de la captura (frame a frame).18- Requiere: Node ≥ 22, Chrome del sistema (o `puppeteer` descargado), `ffmpeg` en el PATH, y `edge_tts` (venv Hermes) para la voz.1920## El patrón clave: escena SEEKABLE2122Para capturar frame a frame de forma determinista, la animación NO puede depender del reloj real (`performance.now` en un bucle). Debe ser una función `draw(t)` que se pueda fijar en un tiempo arbitrario:2324```js25function draw(t){26 // TODO el estado depende de t (cámara, objetos, partículas, textos)27 renderer.render(scene, camera);28}29window.seek = t => draw(t); // fija y dibuja en el tiempo t30window.play = () => { // para previsualización en vivo31 const s=performance.now();32 (function f(now){ draw((now-s)/1000); requestAnimationFrame(f); })();33};34window.seek(0); // dibuja el primer frame35```3637- Todo lo que se mueva (cámara, rotación de objetos, partículas, opacidad de textos) debe derivarse de `t` **de forma determinista**, nunca acumulativo.38- Partículas: usar posición base + `(t*v % rango)`, NO `y += 0.01` en cada frame (eso se acumula y rompe el `seek`).39- Textos superpuestos: controlar su `opacity`/`transform` desde `draw(t)` en vez de CSS keyframes, para que también sean seekables.4041## Captura con Puppeteer4243```js44const puppeteer = require('puppeteer-core'); // usar el Chrome del sistema, no descargar otro45const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';46(async () => {47 const browser = await puppeteer.launch({ executablePath: CHROME, headless:'new',48 args:['--no-sandbox','--window-size=1080,1920','--use-gl=angle','--use-angle=swiftshader','--enable-unsafe-swiftshader'] });49 const page = await browser.newPage();50 await page.setViewport({ width:1080, height:1920, deviceScaleFactor:1 });51 await page.goto('http://localhost:8123/teaser.html', { waitUntil:'networkidle2', timeout:60000 });52 await page.waitForFunction('typeof window.seek === "function"', { timeout:30000 });53 const DUR=14.2, FPS=15, n=Math.ceil(DUR*FPS);54 for(let i=0;i<n;i++){55 await page.evaluate(tt => window.seek(tt), i/FPS);56 await page.screenshot({ path:`frames/f_${String(i).padStart(4,'0')}.png` });57 }58 await browser.close();59})();60```6162Servir el HTML con un `python -m http.server 8123` en el directorio (el importmap/CDN de three.js funciona vía http; `file://` puede fallar para módulos). `npm i puppeteer-core` es suficiente — **no** hace falta `puppeteer` completo ni descargar Chrome.6364## Montaje con ffmpeg6566```bash67ffmpeg -y -framerate 15 -i frames/f_%04d.png -i voice.mp3 \68 -c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 192k \69 -shortest -movflags +faststart out/video.mp470# fades cinematográficos (opcional): duración = duración del audio71ffmpeg -y -i out/video.mp4 \72 -vf "fade=t=in:st=0:d=0.5,fade=t=out:st=<DUR-0.5>:d=0.5" \73 -af "afade=t=in:st=0:d=0.3,afade=t=out:st=<DUR-0.3>:d=0.3" \74 -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart out/final.mp475```7677## Pitfalls (verificados)7879- **El bucle `requestAnimationFrame` compite con `seek(t)`.** Si la página arranca un bucle con el reloj real, corre entre `seek(t)` y el screenshot y sobrescribe el estado → todos los frames salen iguales / el timing de los textos no sigue. **No arranques el bucle automáticamente**: deja `seek(t)` y `play()` como funciones, y usa `seek(t)` en la captura.80- **GPU nativa vs SwiftShader:** `--use-angle=gl` (GPU nativa) dio `THREE.WebGLProgram: Shader Error ... VALIDATE_STATUS false` en Chrome headless. Usar **`--use-angle=swiftshader --enable-unsafe-swiftshader`** (software) — compila limpio. El rAF va a ~20 fps bajo SwiftShader, suficiente para la captura (la sincronización la da `seek(t)`, no el fps).81- **Mostrar el fondo CSS detrás del canvas WebGL:** por defecto el canvas WebGL es opaco y tapa el gradiente CSS. Poner `alpha:true` en el `WebGLRenderer` + `renderer.setClearColor(0x000000, 0)` para que el gradiente de fondo se vea a través.82- **Servir por HTTP** (`python -m http.server`), no `file://`, para los imports ES con importmap (CDN) en headless.8384## Verificación8586- `ffprobe` el MP4: 1080×1920, H.264/AAC, duración ≈ voz.87- Extraer 2-3 frames a distintos `t` (0.5s, mitad, final) y comprobar visualmente que **el timing de los textos cambia** y la **cámara hace dolly** (los objetos se ven más grandes al final). Solo mirar un frame no confirma la animación.8889## Voz (edge-tts) — notas para vídeos con narración9091- El nombre de voz debe llevar el sufijo `Neural` (ej. `es-US-AlonsoNeural`), no `es-US-Alonso`.92- `rate` y `pitch` requieren signo: `'+0%'`, `'-8Hz'`. `'0%'` falla con `ValueError`.93- `rate='-15%'` alarga la duración (~22s un guion de 220 chars); para un teaser corto usar `rate='+0%'` y acortar el guion.94- Elegir voz "de peli": generar 2-3 candidatas y comparar su **frecuencia fundamental (f0)** — la más baja (más grave) encaja en trailers apocalípticos. Método: ffmpeg a wav 16 kHz + autocorrelación (ver `references/kit72h-teaser-madmax.md`).9596## Preferencia de David (no romper)9798- **Visual:** espectáculo primero; **NUNCA gradiente azul→naranja**. Usar monocromo ámbar/sepia cálido (fondo oscuro + un solo acento naranja `#f59e0b`-ish) — respetado en la estética Mad Max.99- **Voz:** para TTS general la voz por defecto es `es-ES-AlvaroNeural`; para **spots/promos cinematográficos David está abierto a una voz más grave** (elegida por f0), como `es-US-AlonsoNeural`.100101## Referencias102- `references/kit72h-teaser-madmax.md` — receta completa del teaser Mad Max de kit72h: esqueleto `draw(t)`, render.js, ffmpeg, análisis de f0 para elegir voz, y los ajustes de la escena.