PTENES
MÓDULO 3.3

⚙️ El generador build-index.mjs

Anatomía completa del composition-template.mjs: cómo AUDIO[] controla todo, el cálculo de S[], la construcción de escenas y tweens de GSAP, el ensamblado 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 tracks 2 / 4 audioHTML track 20 · s.audioStart animJS anim(i, t) · GSAP tweens index.html writeFileSync · siempre sincrónico 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. No aparece ningún valor de tiempo en otra parte del código. Cambia un WAV, actualiza la entrada correspondiente en AUDIO[], ejecuta el generador: todo el video 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 con 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 SKILL.md
17.237333, // s4 — divulgación progresiva
10.858667, // s5 — dónde viven
13.525333, // s6 — nivel avanzado
11.114667, // s7 — ejemplo real
8.170667, // s8 — cierre
3.840 // s9 — CTA inema.club (invariable)
];

// Medir todos a la 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)"
// done
✓ 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 diferentes según el texto y la velocidad (--speed 0.98)
  • ✗ Una estimación incorrecta = audio y elementos visuales permanentemente desincronizados
  • ✗ El render headless no avisa: el video simplemente queda mal
  • ✗ Nunca calcules 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 a 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 permanece después de la voz) y FADE=0.45 (la duración del fundido de entrada/salida del .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; // espera después de la voz
const FADE = 0.45; // fade in/out del scene-inner

// helper de redondeo (3 decimales) — evita el offset de fotogramas
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

Después de 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
Lo visual antes que la 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 incrustarán en el <script> del HTML final. El fade del .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 strings de JavaScript con los tiempos absolutos ya calculados — cuando el browser cargue el HTML, estos strings 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
función 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">desde el primer principio hasta el nivel avanzado</p>
`;
}

// BODIES = lista ordenada de las 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 insertadas en el <script> del HTML generado
función anim(i, t) {
const L = []; // búfer de cadenas
const P = (s) => L.push(s); // push helper
const at = (d) => round(t + d); // offset relativo

// --- fade in/out 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;
// ... cases 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 el 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 fundidos para él: no elimines esas 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

🧱 Ensamblaje del HTML completo

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

Cuatro streams, un HTML

El montaje final es una enorme template string que ensambla los cuatro streams. Las captions se alternan en tracks (2/4), y el audio está en el track 20 (especial). La timeline de GSAP se crea pausada 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 (empieza 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: strings de código GSAP — integradas en <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 de ambiente — 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} — strings 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 (entorno persistente)
#glow + #glow2
Dos 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 se controla externamente
🔊
Track 20
Reservado para audio
🏁
tl.set({},TOTAL)
Centinela de fin
📌
__timelines
API pública del reproductor
5

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

El generador admite dos formatos: 16:9 (1920×1080), el estándar, y 9:16 (1080×1920) para Shorts. La flag --vertical cambia W y H, añade 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 del body.v ajusta las fuentes, el padding y el 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 e intercambio de W/H
// Detección de formato al inicio 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 --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 layout 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. Agrega class="v" al body. Los overrides de 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. El template ya incluye la narración de la CTA (s9.wav) y el HTML; tú 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
función 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[]. Eliminarla o reubicarla rompe la sincronización de todo el video y elimina la firma del canal. Al adaptar la plantilla para un nuevo video, 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 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; }
✓ Checklist 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 verifica el registro de timing antes de renderizar
✗ Nunca hagas esto 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 del CTA
🔒
case 9 intacto
Animaciones de la CTA

📋 Resumen del Módulo 3.3

Qué aprendiste
  • ✓ AUDIO[] controla 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 el .scene-inner
  • ✓ Cuatro streams ensamblados en el HTML: scenesHTML, captionsHTML, audioHTML (track 20), animJS
  • ✓ Timeline de GSAP pausada en window.__timelines["main"], centinela tl.set({},{},TOTAL)
  • ✓ Flag --vertical cambia W/H y añade body.v — output siempre index.html
  • ✓ scene9() / case 9 es la CTA INEMA.CLUB —nunca la elimines
Próximo módulo
3.4
🧯 Errores comunes y correcciones
Los errores más comunes al trabajar con el generador — tracks incorrectos, IDs duplicados, desajustes de timing — y cómo diagnosticar y corregir cada uno antes de renderizar.
Ir al módulo 3.4 →