PTENES
MÓDULO 2.4

🎞️ Composición de escenas

Adapta el composition-template.mjs para cada video nuevo: completa AUDIO[], escribe sceneN(), codifica los tweens de GSAP en anim() y alinea todo con una única fuente de verdad de timing.

6
Temas
~35
Minutos
Medio
Nivel
Práctico
Tipo
AUDIO[] fuente única · ffprobe LEAD=0.5 · TAIL=0.9 · FADE=0.45 data-start data-duration .scene .clip anim(i,t) Tweens de GSAP scene-inner sceneN() + BODIES HTML de cada escena CAPTIONS[] subtítulo por escena · cap-N index.html node build-index.mjs MP4 ✓ siempre sincronizado AUDIO[] → timing único → audio y animación siempre sincronizados
1

🧩 build-index.mjs — el generador del proyecto

Cada video comienza copiando scripts/composition-template.mjs como build-index.mjs en la raíz del proyecto. Este archivo es el generador: se ejecuta una vez y escribe index.html, y tú renderizas.

Concepto principal

La plantilla ya incluye CSS dark premium, background persistente (glow/grid/grain), caption layer, barra de progreso y overrides para 9:16. Solo tienes que completar 4 cosas: AUDIO[], CAPTIONS[], sceneN() e anim().

Ejecuta node build-index.mjs para 16:9 y node build-index.mjs --vertical para 9:16. Ambos escriben index.html — renderiza justo después de cada generación.

composition-template.mjs — encabezado e imports
// scripts/composition-template.mjs (copia como build-index.mjs)
import { writeFileSync, readFileSync } from "node:fs";

// @font-face local — rutas relativas a la raíz del proyecto
const FONT_CSS = readFileSync(
new URL("./assets/fonts/fonts.css", import.meta.url), "utf8")
.replace(/\.\/fonts\//g, "assets/fonts/");

// Formato: predeterminado 16:9; pasa --vertical para 9:16 (Shorts)
const VERT = process.argv.includes("--vertical");
const W = VERT ? 1080 : 1920;
const H = VERT ? 1920 : 1080;
const OUT = "index.html"; // siempre index.html
✓ Buenas prácticas al crear build-index.mjs
  • ✓ Copia la plantilla completa; no la crees desde cero
  • ✓ Mantén la escena 9 (CTA INEMA.CLUB) sin cambios
  • ✓ Renderiza inmediatamente después de cada node build-index.mjs
  • ✓ Usa writeFileSync para index.html — el template ya hace esto
✗ Trampas comunes
  • ✗ No edites index.html directamente — se sobrescribirá
  • ✗ No uses Google Fonts CDN: desaparece en el render headless
  • ✗ No elimines la escena 9 de CTA: es la firma estándar
  • ✗ No mezcles --vertical y 16:9 sin volver a ejecutar el generador
📦 Lo que el template incluye de regalo
CSS dark premium
Paleta --bg:#0D1321, variables de color, fuentes locales, overrides para 9:16.
Capa de fondo
Glow radial, grid de 64px, grain SVG: todos con data-layout-ignore.
Progreso + subtítulos
Barra de progreso animada y caption layer en track alternado (2/4) — listos.
Conceptos clave
📄
build-index.mjs
Generador único
🔄
node → index.html
Cada ronda
📐
bandera --vertical
1080×1920 Shorts
🔒
Escena 9 intacta
CTA INEMA.CLUB
2

🔢 AUDIO[] — duraciones REALES medidas por ffprobe

El array AUDIO[] es la única entrada de datos de timing de todo el proyecto. Cada elemento es la duración en segundos del WAV de narración de la escena correspondiente, incluida la escena 9 de CTA.

¿Por qué usar ffprobe en lugar de estimar?

La duración de un WAV generado por Kokoro varía según el texto, la velocidad (--speed 0.98) y la fonética. Si calculaste mal, el audio y el visual quedan desincronizados para siempre. Mide siempre.

El comando ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 assets/audio/sN.wav imprime el número exacto en segundos — pégalo directamente en el array.

AUDIO[] real + cálculo de la serie S (composition-template.mjs)
// Duraciones REALES medidas con ffprobe (9 escenas; s9 = CTA)
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
];

// Constantes de timing — NO modificar
const LEAD = 0.5; // el visual se establece antes de la voz
const TAIL = 0.9; // mantener después de la voz
const FADE = 0.45; // fade in/out del scene-inner

// Serie S: acumula el timing absoluto por escena
let t = 0;
const S = AUDIO.map((a, i) => {
const dur = LEAD + a + TAIL;
const o = { i: i + 1, start: round(t), dur: round(dur),
audioStart: round(t + LEAD), audioDur: round(a), end: round(t + dur) };
t += dur;
return o;
});
Ejemplo de salida: start acumulado de las 9 escenas
1
s1: start=0 dur=12.344 audio@0.5 (10.944s)
2
s2: start=12.344 dur=15.864 audio@12.844 (14.464s)
...
s3–s8: se acumulan secuencialmente — LEAD + audioDur + TAIL cada uno
9
s9: start=~96.x dur=5.240 audio@~97.x (3.840s) — CTA
💡
Medir en lote con un shell script

Usa 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 para generar la lista completa de una sola vez y pegarla en el AUDIO[].

Conceptos clave
📏
ffprobe
Medición exacta
⏱️
LEAD=0.5
Visual antes que voz
🔚
TAIL=0.9
Se mantiene después de la voz
🎯
s9 = CTA
Siempre incluido
3

🎨 sceneN() — HTML de cada escena

Cada escena es una función JavaScript que devuelve una cadena HTML. El contenido se inserta en <div class="scene-inner">, dentro de un <section class="scene clip"> con atributos de timing calculados a partir de AUDIO[].

Estructura generada por la plantilla para cada escena

El montaje transforma cada elemento de S (array derivado de AUDIO[]) en un <section> con tres atributos críticos: data-start, data-duration e data-track-index. Las tracks alternadas (1/3) evitan la superposición en los bordes de escena.

Montaje: .scene .clip con data-start/duration/track-index
// ---------- MONTAJE (composition-template.mjs) ----------
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("");
Ejemplo: scene1(), escena de apertura del video Skills
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>
<div class="reg tl" id="s1-r1"></div>
<div class="reg br" id="s1-r2"></div>
`;
}

// BODIES = lista ordenada de funciones (scene9 = CTA, no eliminar)
const BODIES = [scene1, scene2, scene3, scene4, scene5, scene6, scene7, scene8, scene9];
✓ Buenas prácticas de sceneN()
  • ✓ Reutiliza las clases CSS de la plantilla (.kicker, .h2, .grid2)
  • ✓ Da id único para cada elemento animable (s1-word, etc.)
  • ✓ Elementos decorativos fuera del canvas: usa data-layout-ignore
  • ✓ Animar el .scene-inner, nunca el .clip wrapper
✗ Errores que rompen el render
  • ✗ IDs duplicados entre escenas — GSAP animará la equivocada
  • ✗ Animaciones en el .clip — el framework obliga a opacity:1 en clip activo
  • ✗ Reordenar BODIES sin ajustar los ID correspondientes
  • ✗ Eliminar o reubicar scene9 (CTA fija al final)
Conceptos clave
🏷️
IDs únicos
Por escena + elemento
🔁
Tracks 1/3
Alternar escenas
🎭
scene-inner
Wrapper animable
📋
BODIES[]
Orden de las escenas
4

✨ anim(i,t) — tweens GSAP por escena

La función anim(i, t) recibe el índice de la escena y el instante de inicio absoluto. Añade cadenas de código GSAP a un array local que después se concatena en el <script> del HTML generado.

¿Por qué generar código como string y no ejecutarlo directamente?

El generador se ejecuta en Node.js, pero GSAP se ejecuta en el navegador (Chrome headless). anim() construye cadenas de código que se insertarán en el HTML; cuando el navegador cargue, esas cadenas ya tendrán los tiempos absolutos calculados a partir de AUDIO[].

La helper at(d) (atajo para round(t + d)) calcula el offset relativo al inicio de la escena. Úsala en todos los tweens para mantener el código legible.

anim() — patrón de entrada/salida de scene-inner + caso 1
// composition-template.mjs — función anim()
function anim(i, t) {
const L = [];
const P = (s) => L.push(s);
const at = (d) => round(t + d);

// entrada/salida del 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)`});`);

// case 1: escena de apertura
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)`});`);
P(`tl.fromTo("#s1-cur",{opacity:1},{opacity:0,duration:.5,repeat:18,
yoyo:true,ease:"none"},${`at(1.6)`});`);
break;
// ... cases 2-9 ...
}
// 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 ");
}
💡
Reglas de oro de la animación

1. Anima siempre el .scene-inner, nunca el .clip wrapper. 2. Usar tiempos absolutos calculados por at(d) — jamás codifiques números directamente. 3. El FADE=0.45 es el mismo para la entrada y la salida de todas las escenas, lo que garantiza una transición uniforme.

⚠️
Escenas en tracks alternados — no es opcional

Las escenas impares quedan en data-track-index="1" y pares en data-track-index="3". Los captions siguen el mismo patrón (2/4). Esto evita el «solapamiento de borde», donde aparecen simultáneamente dos frames de escenas adyacentes: un error clásico descrito en references/gotchas.md.

Conceptos clave
✨
fromTo / from
Tweens de GSAP
🕐
at(d) helper
Offset relativo
🔀
FADE=0.45
Transición uniforme
🧱
switch(i)
Tweens por escena
5

💬 CAPTIONS[] — un subtítulo corto por escena

El array CAPTIONS[] tiene exactamente la misma longitud que AUDIO[]. Cada string se muestra en la parte inferior de la escena correspondiente como un subtítulo accesible, sincronizado mediante data-start/duration heredados del mismo S[idx].

Subtítulo como refuerzo cognitivo, no como transcripción

Una buena caption captura la idea central de la escena, en 5–10 palabras; no transcribe el audio. Quien mire el video sin sonido debe entender el punto de cada escena solo con los subtítulos y los elementos visuales.

La caption se ejecuta en data-track-index="2" (escenas impares) o "4" (pares) — tracks alternados para evitar la superposición con la escena anterior en el borde de transición.

CAPTIONS[] real + montaje del HTML de subtítulos
// composition-template.mjs — CAPTIONS y montaje
const CAPTIONS = [
"Skills en Claude Code: de lo básico a lo avanzado",
"Una Skill = una carpeta + un archivo SKILL.md",
"name + description — la description es el disparador",
"Divulgación progresiva: carga solo cuando hace falta",
"Dónde se encuentran: .claude/skills (proyecto o global)",
"Avanzado: scripts, referencias y plantillas",
"Este video se hizo con la Skill HyperFrames",
"Empieza con un SKILL.md. Ahora te toca a ti.",
"Más contenido en inema.club", // s9 = CTA
];

// montaje automático — mismo timing que S
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("");
✓ Buenas captions
  • ✓ Frase corta que resume el concepto central
  • ✓ Incluye términos técnicos exactos (nombres de archivo, comandos)
  • ✓ Legible para quienes ven el video sin audio
  • ✓ La caption de s9 menciona el sitio: inema.club
✗ Captions deficientes
  • ✗ Transcripción literal del audio (demasiado larga)
  • ✗ Vagas: "una idea importante sobre el tema"
  • ✗ Todo en mayúsculas — dificulta la lectura rápida
  • ✗ Longitud diferente de AUDIO[] (causará un error)
💡
Caption como indexador del video

En YouTube, el buscador indexa los subtítulos. Los subtítulos precisos con términos técnicos correctos (ffprobe, GSAP, build-index.mjs) mejoran el posicionamiento del video en las búsquedas técnicas.

Conceptos clave
💬
1 caption/escena
La misma longitud que AUDIO[]
🔁
Tracks 2/4
Alternar subtítulos
📐
Sincronización automática
El mismo data-start de la escena
🔍
SEO-friendly
Términos técnicos exactos
6

⏱️ Timing de fuente única — audio y animación siempre sincronizados

El principio más importante de composition-template: AUDIO[] genera todo — data-start, data-duration, los tiempos de los tweens GSAP e los atributos del <audio>. Ningún número se duplica ni se define de forma hardcoded en otro lugar.

Fuente única de verdad — definición formal

Las tres constantes LEAD=0.5, TAIL=0.9 e FADE=0.45 combinadas con los valores en AUDIO[] determinan el tiempo de inicio, la duración y el fade de cada escena — y, por lo tanto, el tiempo exacto de todos los tweens GSAP. Cambiar un valor en AUDIO[] se propaga automáticamente a todo.

Fórmulas: dur = LEAD + audioDur + TAIL · audioStart = start + LEAD · Tween de entrada = t · Tween de salida = end - FADE.

Flujo completo: AUDIO[] → S → atributos HTML + GSAP + <audio>
// TODO deriva de AUDIO[] + LEAD/TAIL/FADE — nunca dupliques
// 1. Serie S: cada objeto contiene todos los tiempos necesarios
const S = AUDIO.map((a, i) => {
const dur = LEAD + a + TAIL; // duración total de la escena
return {
i: i + 1,
start: round(t), // → data-start
dur: round(dur), // → data-duration
audioStart: round(t + LEAD), // → audio data-start
audioDur: round(a), // → audio data-duration
end: round(t + dur), // → tween de salida
};
});

// 2. Audio — data-start = audioStart (comienza DESPUÉS del LEAD visual)
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("");

// 3. Tweens generados mediante anim() — usan S[i-1].start y S[i-1].end
const animJS = S.map((s) => anim(s.i, s.start)).join("\n ");
📐 Fórmulas de timing — tarjeta de referencia rápida
Duración de cada escena
dur = LEAD + audioDur + TAIL
= 0.5 + medido + 0.9
Inicio del audio (retrasa el LEAD)
audioStart = start + LEAD
Lo visual entra antes que la voz
Tween de salida de scene-inner
exitAt = S[i-1].end - FADE
= end - 0.45
Duración total de la composición
TOTAL = sum(all dur)
La barra de progreso usa este valor
✓ Qué garantiza la fuente única
  • ✓ Cambia un WAV → ejecuta el generador → todo se sincroniza
  • ✓ Agrega una escena → agrega una entrada en AUDIO[], CAPTIONS[], sceneN(), anim() case N
  • ✓ Ningún número hardcoded fuera de AUDIO[]/LEAD/TAIL/FADE
  • ✓ npx hyperframes lint detecta desincronizaciones de la pista
✗ Infracciones comunes
  • ✗ Codificar el tiempo directamente en el tween: gsap.to(..., 14.5)
  • ✗ Ajustar data-start en el HTML generado — se sobrescribirá
  • ✗ Arrays AUDIO[] y CAPTIONS[] de longitudes diferentes
  • ✗ Usar round() inconsistente → desfase de frames
Conceptos clave
🎯
AUDIO[] único
Todo se deriva de él
🔗
Serie S
Timing por objeto
⚡
round(n)
Precisión en ms
🔄
Lo propaga todo
Cambia AUDIO[], ejecuta

📋 Resumen del Módulo 2.4

Qué aprendiste
  • ✓ Copiar la plantilla como build-index.mjs y ejecutar con node
  • ✓ Completar AUDIO[] con duraciones reales de ffprobe (incluye CTA s9)
  • ✓ Escribir sceneN() reutilizando las clases CSS de la plantilla
  • ✓ Codificar tweens GSAP en anim(i,t) animando siempre el .scene-inner
  • ✓ Completar CAPTIONS[] con frases cortas y técnicas
  • ✓ Comprender las fórmulas de sincronización: LEAD=0.5, TAIL=0.9, FADE=0.45
Próximo módulo
2.5
✅ Validar y renderizar
Ejecuta npx hyperframes lint para 0 errores, inspect --samples 16 para 0 problemas de diseño, y genera el MP4 final en --quality high para 16:9 y 9:16.
Ir al módulo 2.5 →