Animar .scene-inner, nunca el .clip
El framework HyperFrames obliga a opacity:1 en todo .clip que está activo. Si animas directamente el wrapper, el fade no ocurre: el engine sobrescribe tu animación cuadro a cuadro. La solución es siempre animar un hijo interno.
HyperFrames itera sobre los clips activos en cada frame y aplica element.style.opacity = "1" directamente en .clip. Cualquier animación de GSAP que intente hacer gsap.to(".clip", {opacity:0}) se sobrescribirá en el próximo tick de render — lo que producirá un fade fantasma que nunca ocurre.
tl.to("#clip-cena-2", {
opacity: 0,
duration: 0.45
}, 4.5);
- ✗ Opacity sobrescrita en el siguiente frame
- ✗ El fade nunca es visible en el video renderizado
- ✗ Difícil de depurar (parece funcionar en la vista previa)
tl.to("#scene-inner-2", {
opacity: 0,
duration: 0.45
}, 4.5);
// hard-kill obligatorio:
tl.set("#scene-inner-2", {
opacity: 0
}, 4.95);
- ✓ Fade ejecutado en el DOM hijo — el framework no interfiere
- ✓ Terminación forzada con
tl.setgarantiza opacity:0 al final - ✓ Cubre el gotcha
gsap_exit_missing_hard_kill
gsap_exit_missing_hard_killIncluso usando scene-inner, si el tween GSAP termina antes del clip desaparecer, la opacidad puede volver. Siempre agrega un tl.set(target, {opacity:0}, tempoFinal) como centinela. El composition-template.mjs ya incluye esa línea automáticamente.
<div id="clip-cena-2"
class="clip"
data-start="4.0"
data-duration="3.0"
data-track-index="1">
<!-- hijo animable -->
<div id="scene-inner-2" class="scene-inner">
<!-- contenido de la escena -->
</div>
</div>
Tracks alternados para evitar overlapping_clips_same_track
Las escenas adyacentes que se tocan en el límite temporal — aunque sea por fracciones de float — activan el error overlapping_clips_same_track durante el lint. La corrección canónica es alternar los data-track-index: escenas en 1/3, captions en 2/4.
0s → 4s
8s → 12s
16s → 20s
0s → 4s
8s → 12s
16s → 20s
4s → 8s
12s → 16s
20s → 24s
4s → 8s
12s → 16s
20s → 24s
data-start="0" data-duration="4.0"
data-track-index="1"
data-start="4.0" data-duration="4.0"
data-track-index="1"
// → lint: overlapping_clips_same_track
data-start="0" data-duration="4.0"
data-track-index="1" // escena 1 (impar)
data-start="4.0" data-duration="4.0"
data-track-index="3" // escena 2 (par)
// → lint: ok
La tentación es agregar un pequeño gap (data-start="4.001") para evitar la colisión. Esto crea un fotograma negro visible en el video y además puede provocar otros errores del lint. La solución correcta siempre es cambiar el track, nunca el tiempo.
data-layout-ignore en elementos decorativos fuera del lienzo
Ghost text, glows y bg-layers ubicados fuera del canvas visible (translateX negativo, left:-200px, etc.) hacen que el inspect detecte overflow, aunque sean invisibles visualmente. El atributo data-layout-ignore indica al engine que esos elementos están intencionalmente fuera del canvas.
El inspect de HyperFrames calcula el bounding box de todos los elementos visibles. Los elementos decorativos fuera del canvas, incluso con overflow:hidden en el padre — pueden sobresalir y ampliar el bounding box medido. Resultado: video renderizado con barras negras en los bordes.
<div
class="bg-layer"
style="left:-240px;top:0">
GHOST TEXT
</div>
// → inspect: overflow detectado
<div
class="bg-layer"
style="left:-240px;top:0"
data-layout-ignore>
GHOST TEXT
</div>
// → inspect: ok
blur() que exceden los bordes del frame para crear un halo suave.Fuentes locales: nunca Google Fonts mediante <link> o @import
HyperFrames renderiza en un entorno offline (Chrome headless sin red). Un <link> de Google Fonts falla silenciosamente — el video se renderiza con la fuente fallback del sistema, sin errores visibles en la terminal. El lint lo detecta google_fonts_import e font_family_without_font_face.
Chrome headless no informa los fallos de red como errores de GSAP o JS. La página simplemente recurre a la fuente de respaldo (sans-serif → Arial/Helvetica). El video se renderiza con normalidad, pero con la tipografía incorrecta. Puede que no lo notes hasta ver el resultado final en alta resolución.
<link href="https://fonts.googleapis.com/
css2?family=Inter:wght@400;700"
rel="stylesheet">
// → lint: google_fonts_import
@font-face {
font-family: 'Inter';
src: url('../fonts/inter-400.woff2')
format('woff2');
font-weight: 400;
font-display: block;
}
fetch-fonts.mjsnode scripts/fetch-fonts.mjs \
--family Inter \
--weights 400,500,600,700,800 \
--subset latin \
--out assets/fonts/
# resultado: assets/fonts/inter-400.woff2
# assets/fonts/inter-700.woff2
# assets/fonts/fonts.css (importación lista)
El subset latin incluye todos los caracteres usados en portugués brasileño (ã, ç, õ, á, é, etc.). No es necesario descargar el subset completo. Usa font-display: block para asegurarte de que Chrome headless espere a que se cargue la fuente antes de capturar el frame.
ffmpeg -nostdin en Windows / git-bash
En Windows, al ejecutar ffmpeg mediante git-bash (o cualquier emulador POSIX sobre Win32), ffmpeg puede leer stdin inadvertidamente, interpretar EOF como entrada válida y devolver exit 0 sin generar ningún archivo de salida. El problema es completamente silencioso.
El pipeline continúa sin fallar. El siguiente paso intenta leer un archivo que no existe. El error real aparece varios pasos después y es difícil de rastrear. La flag -nostdin lo resuelve por completo y no tiene costo en entornos normales.
ffmpeg -y \
-framerate 30 \
-i frames/%04d.png \
-i audio.wav \
output.mp4
# → exit 0, output.mp4 no existe
ffmpeg -nostdin -y \
-framerate 30 \
-i frames/%04d.png \
-i audio.wav \
output.mp4
# → output.mp4 generado correctamente
-ss 2.5 \
-i renders/video-16x9.mp4 \
-vframes 1 \
-update 1 \
thumbnail.png
Si necesitas una ruta absoluta en Windows: /c/ffmpeg/bin/ffmpeg.exe -nostdin ...
Determinismo: prohibido Date.now(), Math.random() e fetch()
El render de HyperFrames es determinístico por diseño: el mismo HTML debe producir exactamente los mismos frames en cualquier máquina y en cualquier momento. Cualquier fuente de no determinismo — tiempo real, aleatoriedad, datos externos — rompe esa garantía y produce frames inconsistentes o un error de render.
El engine captura frames individuales recorriendo el tiempo de la timeline GSAP de forma sintética; no reproduce el video en tiempo real. Esto significa que Date.now() devuelve valores diferentes para cada frame capturado, Math.random() nunca es seeded de forma reproducible y fetch() puede fallar o devolver datos diferentes en cada ejecución.
-
✗
Date.now()— devuelve un timestamp diferente por frame -
✗
Math.random()— secuencia no reproducible -
✗
fetch()— los datos externos pueden cambiar o fallar -
✗
setInterval()— basado en la hora real del SO -
✗
new Date()— el mismo problema que Date.now()
-
✓
Posiciones calculadas:
gsap.utils.mapRange() -
✓
Datos externos: preintegrados en el HTML en tiempo de build
-
✓
Seudoaleatorio:
seededRand(n)con una semilla fija -
✓
Contadores: variables JS actualizadas por
tl.call() -
✓
Timeline registrada:
window.__timelines["main"]
function seededRand(seed) {
let s = seed;
return () => {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 0xffffffff;
};
}
const rand = seededRand(42);
// rand() siempre devuelve la misma secuencia
multiple_root_compositionsSolo puede existir un único archivo con data-composition-id en la raíz del proyecto. Si dejas index-vertical.html, index-backup.html o cualquier variante, el generador build-index.mjs entra en conflicto y el lint falla. Usa siempre solo index.html como raíz de la composición.
Qué aprendiste en este módulo
Completaste la Ruta 3 — Por dentro. Ahora que entiendes la estructura interna, los gotchas y cómo funciona el generador, es hora de ponerlo en práctica: YouTube y Shorts, onboarding, clases y la biblioteca de prompts para acelerar tu producción.