PTENES
MÓDULO 3.3

⚙️ El generador build-index.mjs

Anatomía completa del composition-template.mjs: cómo AUDIO[] rige todo, el cálculo de S[], la construcción de escenas y tweens de GSAP, el armado del HTML completo, overrides 9:16 y la CTA invariable.

6
Temas
~40
Minutos
Avanzado
Nivel
Técnico
Tipo
AUDIO[] fuente única de timing S[] start · dur · audioStart · end LEAD=0.5 · TAIL=0.9 · FADE=0.45 scenesHTML .scene .clip · sceneN() captionsHTML pistas 2 / 4 audioHTML track 20 · s.audioStart animJS anim(i, t) · GSAP tweens index.html writeFileSync · siempre sincronizado AUDIO[] → S[] → 4 streams → index.html
1

🔢 AUDIO[] como fuente única del timing

El array AUDIO[] contiene las duraciones REALES (medidas con ffprobe) de cada narración WAV. Es la única variable que necesitas completar para que todo el timing del video —HTML, GSAP y audio— se sincronice automáticamente.

Concepto principal

El array de duraciones lo controla todo. Ningún valor de tiempo aparece en otro lugar del código. Cambia un WAV, actualiza la entrada correspondiente en AUDIO[], ejecuta el generador: el video completo se recalibra automáticamente.

Esto es posible porque el generador se ejecuta en Node.js y produce un HTML estático con todos los tiempos ya calculados. El browser recibe números listos — no necesita calcular nada en runtime.

composition-template.mjs — AUDIO[] con duraciones reales medidas por ffprobe
// Duraciones REALES medidas con ffprobe (9 escenas; s9 = CTA — no eliminar)
const AUDIO = [
10.944, // s1 — intro
14.464, // s2 — carpeta + archivo
13.760, // s3 — anatomía de SKILL.md
17.237333, // s4 — divulgación progresiva
10.858667, // s5 — dónde viven
13.525333, // s6 — nivel avanzado
11.114667, // ejemplo real
8.170667, // cierre
3.840 // CTA s9 inema.club (invariable)
];

// Medirlos todos de una vez con un script de shell:
// for i in 1 2 3 4 5 6 7 8 9; do
// echo "s$i: $(ffprobe -v error -show_entries format=duration \
// -of default=noprint_wrappers=1:nokey=1 assets/audio/s$i.wav)"
// listo
✓ Qué controla AUDIO[]
  • ✓ data-start e data-duration de cada .scene.clip
  • ✓ Tiempos absolutos de todos los tweens GSAP mediante anim(i, s.start)
  • ✓ data-start de los elementos <audio> en track 20
  • ✓ TOTAL de la composición y barra de progreso
✗ Por qué nunca estimar
  • ✗ Kokoro genera duraciones distintas según el texto y la velocidad (--speed 0.98)
  • ✗ Una estimación equivocada = audio y elementos visuales desincronizados para siempre
  • ✗ El render headless no avisa: el video simplemente queda mal
  • ✗ Nunca escribas las duraciones de memoria: usa siempre ffprobe
Conceptos clave
🔢
AUDIO[]
Única entrada de timing
📏
ffprobe
Medición obligatoria
🔄
Lo propaga todo
HTML + GSAP + audio
🔒
s9 = CTA
Siempre incluida
2

⏱️ El cálculo de S[] — la serie de timing

La serie S se genera con un único AUDIO.map(). Cada objeto contiene todos los tiempos necesarios para escenas, captions, audio y tweens: se calculan una vez y se propagan por todas partes.

Las tres constantes y el acumulador

Tres constantes controlan todo el ritmo: LEAD=0.5 (el visual entra antes que la voz), TAIL=0.9 (el visual se mantiene después de la voz) y FADE=0.45 (duración del fade-in/out de .scene-inner). Un acumulador t crece con cada escena.

Fórmulas fundamentales: dur = LEAD + a + TAIL · audioStart = t + LEAD · end = t + dur.

composition-template.mjs — cálculo completo de S[] con AUDIO.map()
// Constantes de timing — nunca modificar
const LEAD = 0.5; // visual antes de la voz
const TAIL = 0.9; // mantener después de la voz
const FADE = 0.45; // fade in/out del scene-inner

// helper de redondeo (3 decimales) — evita el offset de frames
const round = (n) => Math.round(n * 1000) / 1000;

// acumulador de tiempo absoluto
let t = 0;
const S = AUDIO.map((a, i) => {
const dur = LEAD + a + TAIL; // duración total de la escena
const o = {
i: i + 1, // índice 1-based
start: round(t), // → data-start del .clip
dur: round(dur), // → data-duration del .clip
audioStart: round(t + LEAD), // → data-start del <audio>
audioDur: round(a), // → data-duration del <audio>
end: round(t + dur), // → tween de salida (end - FADE)
};
t += dur; // avanza el acumulador
return o;
});

// Duración total de la composición — alimenta la barra de progreso
const TOTAL = round(t);
Salida real: objetos S[] de las primeras escenas
1
s1: start=0 dur=12.344 audioStart=0.5 audioDur=10.944 end=12.344
2
s2: start=12.344 dur=15.864 audioStart=12.844 audioDur=14.464 end=28.208
…
s3–s8: se acumulan secuencialmente. Cada start = end de la escena anterior.
9
s9: start=~96.x dur=5.240 audioStart=~97.x audioDur=3.840 end=~101.x — CTA
💡
El generador imprime los valores calculados

Tras generar el HTML, la plantilla muestra en la consola: OUT gerado · W×H · TOTAL = Xs · N cenas, seguido de una línea por escena con start, dur, audio@ y audioDur. Verifica estos números antes de renderizar.

Conceptos clave
⏱️
LEAD=0.5
Visual antes que voz
🔚
TAIL=0.9
Se mantiene después de la voz
🌊
FADE=0.45
Transición scene-inner
➕
round(n)
Precisión de 3 decimales
3

🎬 sceneN() y anim(i,t) por escena

Cada escena tiene dos funciones: sceneN() devuelve el HTML interno de .scene-inner, e anim(i,t) genera cadenas de código GSAP que se insertarán en el <script> del HTML final. El fade de .scene-inner siempre lo genera la parte común de anim().

¿Por qué generar tweens como strings de código?

El generador se ejecuta en Node.js, pero GSAP se ejecuta en el navegador (Chrome headless). anim() construye cadenas de JavaScript con los tiempos absolutos ya calculados; cuando el navegador cargue el HTML, esas cadenas se ejecutan con números exactos, sin recalcular. El fade de .scene-inner es común a todas las escenas; el switch interno agrega los tweens específicos de cada una.

sceneN() — ejemplo real de composition-template.mjs (escena 1)
// Cada sceneN() devuelve HTML puro — sin timing, sin GSAP
function scene1() {
return `
<div class="eyebrow" id="s1-eyebrow">
<span class="dot"></span>CLAUDE CODE · SKILLS
</div>
<h1 class="title">
<span class="word" id="s1-w1">Skills</span>
<span class="word accent" id="s1-w2">en Claude Code</span>
</h1>
<div class="rule" id="s1-rule"></div>
<p class="subhead" id="s1-sub">del primer principio a avanzado</p>
`;
}

// BODIES = lista ordenada de funciones (scene9 = CTA, no eliminar)
const BODIES = [scene1, scene2, scene3, scene4, scene5, scene6, scene7, scene8, scene9];
anim(i,t) — fade de scene-inner + tweens específicos por case
// anim() genera cadenas GSAP incrustadas en el <script> del HTML generado
function anim(i, t) {
const L = []; // búfer de cadenas
const P = (s) => L.push(s); // helper push
const at = (d) => round(t + d); // offset relativo

// --- aparición/desaparición gradual de .scene-inner (común a TODAS las escenas) ---
P(`tl.fromTo("#scene-inner-${i}",
{opacity:0},{opacity:1,duration:${FADE},ease:"power2.out"},${t});`);
P(`tl.to("#scene-inner-${i}",
{opacity:0,duration:${FADE},ease:"power2.in"},${round(S[i-1].end - FADE)});`);
P(`tl.set("#scene-inner-${i}",{opacity:0},${round(S[i-1].end)});`);

// --- tweens específicos por escena ---
switch (i) {
case 1:
P(`tl.from("#s1-eyebrow",{y:-24,opacity:0,duration:.55,ease:"power3.out"},${at(0.15)});`);
P(`tl.from("#s1-w1",{y:70,opacity:0,duration:.7,ease:"power4.out"},${at(0.35)});`);
P(`tl.from("#s1-w2",{y:70,opacity:0,duration:.7,ease:"power4.out"},${at(0.55)});`);
P(`tl.fromTo("#s1-rule",{scaleX:0},{scaleX:1,duration:.7,ease:"expo.out",
transformOrigin:"left center"},${at(0.95)});`);
P(`tl.from("#s1-sub",{y:20,opacity:0,duration:.6,ease:"power2.out"},${at(1.15)});`);
break;
// ... casos 2–8 con tweens específicos ...
// case 9 = CTA — no modificar
}
// caption fade in/out
P(`tl.fromTo("#cap-${i}",{opacity:0,y:14},{opacity:1,y:0,duration:.5,ease:"power2.out"},${at(0.35)});`);
P(`tl.to("#cap-${i}",{opacity:0,duration:.4,ease:"power2.in"},${round(S[i-1].end - 0.55)});`);
return L.join("\n ");
}
⚠️
Animar siempre .scene-inner — nunca .clip

HyperFrames fuerza opacity:1 en clip activo. Si intentas animar el .clip o el .scene, la animación de fade no funciona. El wrapper animable siempre es el hijo .scene-inner. La plantilla ya genera los fades para él: no elimines estas líneas.

Conceptos clave
🎭
scene-inner
Único wrapper animable
🕐
at(d)
Offset relativo a la escena
📋
BODIES[]
Orden de las escenas
🔀
switch(i)
Tweens por escena
4

🧱 Montaje del HTML completo

Los cuatro streams (scenesHTML, captionsHTML, audioHTML en track 20, animJS) se ensamblan en una template string final con la timeline de GSAP pausada registrada en window.__timelines["main"]. El ambiente (glow/grid) y la centinela tl.set({},{},TOTAL) también forman parte del montaje.

Cuatro streams, un HTML

El montaje final es una template string gigante que ensambla los cuatro streams. Las captions van en tracks alternados (2/4) y el audio, en el track 20 (especial). La timeline de GSAP se crea en pausa y se registra en window.__timelines["main"] — el reproductor de HyperFrames lo controla externamente.

La centinela tl.set({}, {}, TOTAL) extiende la línea de tiempo hasta el final de la composición, lo que garantiza que la barra de progreso llegue hasta el final incluso sin tweens después de la última escena.

Generación de los cuatro streams a partir de S[]
// composition-template.mjs — los cuatro streams
// 1. scenesHTML: .scene.clip con tracks alternados 1/3
const scenesHTML = S.map((s, idx) => `
<section id="s${s.i}" class="scene clip"
data-start="${s.start}" data-duration="${s.dur}"
data-track-index="${s.i % 2 === 1 ? 1 : 3}">
<div class="scene-inner" id="scene-inner-${s.i}">
${BODIES[idx]()}
</div>
</section>`
).join("");

// 2. captionsHTML: mismo timing que S, tracks 2/4
const captionsHTML = S.map((s, idx) => `
<div class="caption clip" id="cap-${s.i}"
data-start="${s.start}" data-duration="${s.dur}"
data-track-index="${s.i % 2 === 1 ? 2 : 4}">
${CAPTIONS[idx]}
</div>`
).join("");

// 3. audioHTML: data-start = audioStart (comienza después del LEAD visual), track 20
const audioHTML = S.map((s) => `
<audio id="a${s.i}" data-start="${s.audioStart}"
data-duration="${s.audioDur}" data-track-index="20"
src="assets/audio/s${s.i}.wav"></audio>`
).join("");

// 4. animJS: cadenas de código GSAP — integradas en el <script>
const animJS = S.map((s) => anim(s.i, s.start)).join("\n ");
Bloque <script> final — timeline pausada, entorno, centinela
// Fragmento del HTML generado — el <script> final
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const TOTAL = 101.234; // valor calculado por el generador

// animación ambiental — repeat cubre el TOTAL
tl.to("#glow",{scale:1.22,opacity:.55,duration:4.5,yoyo:true,
repeat:Math.ceil(TOTAL/4.5)+1,ease:"sine.inOut"},0);
tl.to("#glow2",{scale:1.18,duration:6,yoyo:true,
repeat:Math.ceil(TOTAL/6)+1,ease:"sine.inOut"},0);
tl.to("#grid",{backgroundPositionY:"+=128",duration:18,
repeat:Math.ceil(TOTAL/18)+1,ease:"none"},0);
tl.fromTo("#progress",{scaleX:0},{scaleX:1,duration:TOTAL,ease:"none"},0);

// tweens de las escenas (generados por animJS)
// ${animJS} — cadenas generadas por anim()

// centinela: extiende la línea de tiempo hasta el final de la composición
tl.set({}, {}, TOTAL);
window.__timelines["main"] = tl;
</script>
🏗️ Estructura de .bg-layer (ambiente persistente)
#glow + #glow2
Dos elementos radiales pulsantes con yoyo:true cubriendo todo el TOTAL. data-layout-ignore — no interfiere con el diseño de HyperFrames.
#grid
Patrón de cuadrícula que se desplaza infinitamente con backgroundPositionY+=128 a lo largo de todo el video. Crea una sensación de movimiento.
#grain + #ghost
Grain SVG sutil y texto fantasma SKILL.md decorativo. Ambos con data-layout-ignore.
Conceptos clave
⏸️
paused:true
El reproductor controla externamente
🔊
Track 20
Reservado para audio
🏁
tl.set({},TOTAL)
Centinela de fin
📌
__timelines
API pública del reproductor
5

📱 Overrides 9:16 vía body.v y flag --vertical

El generador admite dos formatos: 16:9 (1920×1080) estándar y 9:16 (1080×1920) para Shorts. La flag --vertical cambia W y H, agrega la clase body.v en el HTML generado y aplica overrides CSS automáticos. El output siempre es index.html.

Un generador, dos formatos

El mismo build-index.mjs genera 16:9 y 9:16. La lógica de timing es idéntica: solo cambian las dimensiones W/H y el CSS de body.v ajusta fuentes, padding y diseño. Renderizas dos veces con la misma fuente y obtienes dos formatos sincronizados.

Flujo típico: node build-index.mjs → renderiza 16:9 → node build-index.mjs --vertical → renderiza 9:16. Ambos sobrescriben index.html — nunca edites el HTML directamente.

composition-template.mjs — detección de --vertical y cambio de W/H
// Detección de formato en la parte superior del archivo
const VERT = process.argv.includes("--vertical");
const W = VERT ? 1080 : 1920; // ancho de la composición
const H = VERT ? 1920 : 1080; // altura de la composición
const OUT = "index.html"; // siempre index.html — ambos formatos

// body recibe la clase "v" cuando se usa --vertical
// En el HTML generado: <body${VERT ? ' class="v"' : ''}>

// Atributos de la composición raíz
<div id="composition"
data-start="0" data-duration="${TOTAL}"
data-width="${W}" data-height="${H}">

// Registro de confirmación en la terminal
console.log(`${OUT} gerado · ${W}×${H} · TOTAL = ${TOTAL}s · ${S.length} cenas`);
S.forEach(s => console.log(
` s${s.i}: start=${s.start} dur=${s.dur} audio@${s.audioStart} (${s.audioDur}s)`));
CSS overrides para body.v — ajustes de diseño 9:16
/* Overrides aplicados automáticamente cuando body tiene class="v" */
body.v .title { font-size: 88px; }
body.v .subhead { font-size: 38px; }
body.v .kicker { font-size: 28px; }
body.v .h2 { font-size: 62px; }
body.v .reg { font-size: 32px; }
body.v .caption { font-size: 36px; bottom: 140px; }
body.v .grid2 { grid-template-columns: 1fr; }
body.v #ghost { font-size: 340px; opacity: .018; }
📐 16:9 — YouTube principal
node build-index.mjs
Genera 1920×1080. Sin clase v en body. CSS estándar con fuentes grandes para pantalla de 1080p.
📱 9:16 — Shorts / Reels
node build-index.mjs --vertical
Genera 1080×1920. Añade class="v" al body. Los overrides CSS ajustan las fuentes y el diseño para móviles.
Conceptos clave
📐
1920×1080
16:9 estándar
📱
1080×1920
9:16 Shorts
🏷️
body.v
Clase CSS de modo
💾
index.html
Siempre sobrescrito
6

🏁 CTA scene9() / case 9 — firma invariable

La escena 9 es la firma predeterminada de todos los videos de INEMA.CLUB. Muestra «CONTINÚA EN» + INEMA.CLUB con glow y la URL 🌐 inema.club. Nunca debe eliminarse, reubicarse ni modificarse: es parte de la identidad del canal.

Por qué la CTA es invariable

La escena 9 funciona como una firma de marca: quien ve cualquier video de INEMA.CLUB siempre ve el mismo cierre. Esto genera reconocimiento y dirige al espectador al sitio. La plantilla ya incluye la narración de la CTA (s9.wav) y el HTML: solo proporcionas la duración real en el AUDIO[].

Regla: AUDIO[8] (índice 8, escena 9) siempre es la duración de assets/audio/s9.wav. Narración predeterminada: "Esto es contenido de INEMA punto CLUB. Accede: inema punto club."

composition-template.mjs — scene9() y case 9 de anim()
// scene9() — CTA INEMA.CLUB — no modificar
function scene9() {
return `
<div class="cta-wrap" id="s9-wrap">
<div class="cta-eyebrow" id="s9-eye">CONTINÚA EN</div>
<div class="cta-logo" id="s9-logo">
<span class="cta-inema" id="s9-inema">INEMA</span>
<span class="cta-club" id="s9-club">.CLUB</span>
</div>
<div class="cta-url" id="s9-url">🌐 inema.club</div>
</div>
`;
}

// case 9 en anim() — animaciones de la CTA
case 9:
P(`tl.from("#s9-eye",{y:-20,opacity:0,duration:.5,ease:"power3.out"},${at(0.12)});`);
P(`tl.from("#s9-inema",{y:60,opacity:0,duration:.7,ease:"power4.out"},${at(0.28)});`);
P(`tl.from("#s9-club",{y:60,opacity:0,duration:.7,ease:"power4.out"},${at(0.42)});`);
P(`tl.from("#s9-url",{opacity:0,duration:.5,ease:"power2.out"},${at(0.85)});`);
P(`tl.to("#s9-logo",{textShadow:"0 0 40px #facc15",duration:1,
yoyo:true,repeat:4,ease:"sine.inOut"},${at(1.0)});`);
break;
⚠️
Regla de oro: scene9() nunca se elimina

La CTA está en la posición 9 de BODIES[] y en el índice 8 de AUDIO[]. Eliminarlo o reubicarlo rompe el timing de todo el video y elimina la firma del canal. Al adaptar la plantilla para un video nuevo, cambia solo las escenas 1–8 y actualiza las duraciones en AUDIO[0..7]. AUDIO[8] siempre es el WAV de la CTA.

CSS de la CTA — identidad visual de INEMA.CLUB
/* Paleta de la CTA — crema + ámbar con glow */
.cta-eyebrow { font-size: 28px; letter-spacing: .25em; color: #9ca3af; }
.cta-inema { font-size: 128px; font-weight: 800; color: #fef3c7; /* crema */ }
.cta-club { font-size: 128px; font-weight: 800; color: #f59e0b; /* ámbar */ }
.cta-url { font-size: 36px; color: #60a5fa; letter-spacing: .05em; }
✓ Lista de verificación al adaptar la plantilla
  • ✓ Copia la plantilla completa como build-index.mjs
  • ✓ Completa AUDIO[0..7] con ffprobe — mantén AUDIO[8]
  • ✓ Escribe scene1()–scene8() — mantén scene9()
  • ✓ Codifica case 1–case 8 en anim() — mantén case 9
  • ✓ Ejecuta el generador y revisa el registro de timing antes de renderizar
✗ Nunca lo hagas con la CTA
  • ✗ Eliminar scene9() de BODIES[]
  • ✗ Eliminar AUDIO[8] o dejar el array con menos de 9 entradas
  • ✗ Reemplazar la escena 9 por contenido de otro video
  • ✗ Cambiar los colores .cta-inema / .cta-club
Conceptos clave
🏁
scene9() fija
Firma predeterminada
🎨
Crema + ámbar
Identidad de INEMA.CLUB
🔊
AUDIO[8]
WAV de la CTA
🔒
case 9 intacto
Animaciones de la CTA

📋 Resumen del Módulo 3.3

Qué aprendiste
  • ✓ AUDIO[] rige el 100% del timing — HTML, GSAP y audio
  • ✓ S[] = AUDIO.map() con LEAD/TAIL/FADE calcula start, dur, audioStart, end
  • ✓ sceneN() devuelve HTML; anim(i,t) genera cadenas GSAP con fade en .scene-inner
  • ✓ Cuatro streams ensamblados en el HTML: scenesHTML, captionsHTML, audioHTML (track 20), animJS
  • ✓ Timeline GSAP pausada en window.__timelines["main"], centinela tl.set({},{},TOTAL)
  • ✓ Flag --vertical cambia W/H y agrega body.v — el output siempre index.html
  • ✓ scene9() / case 9 es la CTA de INEMA.CLUB; nunca la elimines
Próximo módulo
3.4
🧯 Problemas frecuentes y correcciones
Los errores más comunes al trabajar con el generador —tracks incorrectos, IDs duplicados, desfases de sincronización— y cómo diagnosticar y corregir cada uno antes de renderizar.
Ir al módulo 3.4 →