MÓDULO 3.3

⚙️ O gerador build-index.mjs

Anatomia completa do composition-template.mjs: como AUDIO[] governa tudo, o cálculo de S[], a construção de cenas e tweens GSAP, a montagem do HTML completo, overrides 9:16 e a CTA invariável.

6
Tópicos
~40
Minutos
Avançado
Nível
Técnico
Tipo
AUDIO[] fonte ú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 · sempre sincronizado AUDIO[] → S[] → 4 streams → index.html
1

🔢 AUDIO[] como fonte única do timing

O array AUDIO[] contém as durações REAIS (medidas com ffprobe) de cada narração WAV. Ele é a única variável que você precisa preencher para que todo o timing do vídeo — HTML, GSAP e áudio — fique sincronizado automaticamente.

Conceito Principal

O array de durações governa tudo. Nenhum número de tempo aparece em outro lugar do código. Altere um WAV, atualize a entrada correspondente em AUDIO[], rode o gerador — o vídeo inteiro recalibra automaticamente.

Isso é possível porque o gerador roda em Node.js e produz um HTML estático com todos os tempos já calculados. O browser recebe números prontos — não precisa calcular nada em runtime.

composition-template.mjs — AUDIO[] com durações reais medidas pelo ffprobe
// Durações REAIS medidas com ffprobe (9 cenas; s9 = CTA — não remover)
const AUDIO = [
10.944, // s1 — intro
14.464, // s2 — pasta + arquivo
13.760, // s3 — anatomia SKILL.md
17.237333, // s4 — divulgação progressiva
10.858667, // s5 — onde vivem
13.525333, // s6 — nível avançado
11.114667, // s7 — exemplo real
8.170667, // s8 — closing
3.840 // s9 — CTA inema.club (invariável)
];

// Medir todos de uma vez com shell script:
// 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
✓ O que AUDIO[] governa
  • data-start e data-duration de cada .scene.clip
  • Tempos absolutos de todos os tweens GSAP via anim(i, s.start)
  • data-start dos elementos <audio> em track 20
  • TOTAL da composição e barra de progresso
✗ Por que nunca estimar
  • Kokoro gera durações diferentes conforme texto e velocidade (--speed 0.98)
  • Estimativa errada = áudio e visual dessincronizados permanentemente
  • O render headless não avisa — o vídeo simplesmente fica errado
  • Nunca escreva durações de cabeça — use sempre ffprobe
Conceitos-chave
🔢
AUDIO[]
Única entrada de timing
📏
ffprobe
Medição obrigatória
🔄
Propaga tudo
HTML + GSAP + áudio
🔒
s9 = CTA
Sempre incluída
2

⏱️ O cálculo de S[] — a série de timing

A série S é gerada por um único AUDIO.map(). Cada objeto carrega todos os tempos necessários para cenas, captions, áudio e tweens — calculados uma vez, propagados em todo lugar.

As três constantes e o acumulador

Três constantes controlam toda a ritmicidade: LEAD=0.5 (visual entra antes da voz), TAIL=0.9 (visual segura após a voz) e FADE=0.45 (duração do fade-in/out do .scene-inner). Um acumulador t cresce a cada cena.

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

composition-template.mjs — cálculo completo de S[] com AUDIO.map()
// Constantes de timing — nunca alterar
const LEAD = 0.5; // visual antes da voz
const TAIL = 0.9; // segura após a voz
const FADE = 0.45; // fade in/out do scene-inner

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

// acumulador de tempo absoluto
let t = 0;
const S = AUDIO.map((a, i) => {
const dur = LEAD + a + TAIL; // duração total da cena
const o = {
i: i + 1, // índice 1-based
start: round(t), // → data-start do .clip
dur: round(dur), // → data-duration do .clip
audioStart: round(t + LEAD), // → data-start do <audio>
audioDur: round(a), // → data-duration do <audio>
end: round(t + dur), // → tween de saída (end - FADE)
};
t += dur; // avança o acumulador
return o;
});

// Duração total da composição — alimenta a barra de progresso
const TOTAL = round(t);
Saída real: objetos S[] das primeiras cenas
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: acumulam sequencialmente. Cada start = end da cena anterior.
9
s9: start=~96.x dur=5.240 audioStart=~97.x audioDur=3.840 end=~101.x — CTA
💡
O gerador imprime os valores calculados

Após gerar o HTML, o template exibe no console: OUT gerado · W×H · TOTAL = Xs · N cenas, seguido de uma linha por cena com start, dur, audio@ e audioDur. Verifique esses números antes de renderizar.

Conceitos-chave
⏱️
LEAD=0.5
Visual antes da voz
🔚
TAIL=0.9
Segura após voz
🌊
FADE=0.45
Transição scene-inner
round(n)
Precisão 3 casas
3

🎬 sceneN() e anim(i,t) por cena

Cada cena tem duas funções: sceneN() retorna o HTML interno do .scene-inner, e anim(i,t) gera strings de código GSAP que serão embutidas no <script> do HTML final. O fade do .scene-inner é sempre gerado pela parte comum de anim().

Por que gerar tweens como strings de código?

O gerador roda em Node.js mas o GSAP executa no browser (Chrome headless). anim() constrói strings JavaScript com os tempos absolutos já calculados — quando o browser carregar o HTML, essas strings executam com números exatos, sem recálculo. O fade do .scene-inner é comum a todas as cenas; o switch interno adiciona os tweens específicos de cada uma.

sceneN() — exemplo real do composition-template.mjs (cena 1)
// Cada sceneN() retorna HTML puro — sem timing, sem 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">no Claude Code</span>
</h1>
<div class="rule" id="s1-rule"></div>
<p class="subhead" id="s1-sub">do primeiro princípio ao avançado</p>
`;
}

// BODIES = lista ordenada das funções (scene9 = CTA, não remover)
const BODIES = [scene1, scene2, scene3, scene4, scene5, scene6, scene7, scene8, scene9];
anim(i,t) — fade do scene-inner + tweens específicos por case
// anim() gera strings GSAP embutidas no <script> do HTML gerado
function anim(i, t) {
const L = []; // buffer de strings
const P = (s) => L.push(s); // push helper
const at = (d) => round(t + d); // offset relativo

// --- fade in/out do .scene-inner (comum a TODAS as cenas) ---
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 cena ---
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 com tweens específicos ...
// case 9 = CTA — não 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 sempre o .scene-inner — nunca o .clip

O HyperFrames força opacity:1 no clip ativo. Se você tentar animar o .clip ou o .scene, a animação de fade não funciona. O wrapper animável é sempre o filho .scene-inner. O template já gera os fades para ele — não remova essas linhas.

Conceitos-chave
🎭
scene-inner
Único wrapper animável
🕐
at(d)
Offset relativo à cena
📋
BODIES[]
Ordem das cenas
🔀
switch(i)
Tweens por cena
4

🧱 Montagem do HTML completo

Os quatro streams (scenesHTML, captionsHTML, audioHTML em track 20, animJS) são montados numa template string final com a timeline GSAP pausada registrada em window.__timelines["main"]. O ambiente (glow/grid) e a sentinela tl.set({},{},TOTAL) também fazem parte da montagem.

Quatro streams, um HTML

A montagem final é uma template string gigante que costura os quatro streams. As captions ficam em tracks alternados (2/4), o áudio em track 20 (especial). A timeline GSAP é criada pausada e registrada em window.__timelines["main"] — o HyperFrames player a controla externamente.

A sentinela tl.set({}, {}, TOTAL) estende a timeline até o fim da composição, garantindo que a barra de progresso chegue até o fim mesmo sem tweens após a última cena.

Geração dos quatro streams a partir de S[]
// composition-template.mjs — os quatro streams
// 1. scenesHTML: .scene.clip com 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: mesmo timing de 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 (começa após 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 — embutidas no <script>
const animJS = S.map((s) => anim(s.i, s.start)).join("\n ");
Bloco <script> final — timeline pausada, ambiente, sentinela
// Fragmento do HTML gerado — o <script> final
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
const TOTAL = 101.234; // valor calculado pelo gerador

// animação de ambiente — repeat cobre o 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 das cenas (gerados por animJS)
// ${animJS} — strings geradas por anim()

// sentinela: estende a timeline até o fim da composição
tl.set({}, {}, TOTAL);
window.__timelines["main"] = tl;
</script>
🏗️ Estrutura do .bg-layer (ambiente persistente)
#glow + #glow2
Dois radiais pulsantes com yoyo:true cobrindo todo o TOTAL. data-layout-ignore — não interfere no layout do HyperFrames.
#grid
Padrão de grade que scroll infinitamente com backgroundPositionY+=128 ao longo de todo o vídeo. Cria sensação de movimento.
#grain + #ghost
Grain SVG sutil e texto fantasma SKILL.md decorativo. Ambos com data-layout-ignore.
Conceitos-chave
⏸️
paused:true
Player controla externamente
🔊
Track 20
Reservado para áudio
🏁
tl.set({},TOTAL)
Sentinela de fim
📌
__timelines
API pública do player
5

📱 Overrides 9:16 via body.v e flag --vertical

O gerador suporta dois formatos: 16:9 (1920×1080) padrão e 9:16 (1080×1920) para Shorts. A flag --vertical troca W e H, adiciona a classe body.v no HTML gerado e aplica overrides CSS automáticos. O output é sempre index.html.

Um gerador, dois formatos

O mesmo build-index.mjs gera 16:9 e 9:16. A lógica de timing é idêntica — só as dimensões W/H mudam e o CSS do body.v ajusta fontes, padding e layout. Você renderiza duas vezes com a mesma fonte, obtendo dois formatos sincronizados.

Fluxo típico: node build-index.mjs → renderiza 16:9 → node build-index.mjs --vertical → renderiza 9:16. Ambos sobrescrevem index.html — nunca edite o HTML diretamente.

composition-template.mjs — detecção de --vertical e troca de W/H
// Detecção de formato no topo do arquivo
const VERT = process.argv.includes("--vertical");
const W = VERT ? 1080 : 1920; // largura da composição
const H = VERT ? 1920 : 1080; // altura da composição
const OUT = "index.html"; // sempre index.html — ambos os formatos

// body recebe classe "v" quando --vertical
// No HTML gerado: <body${VERT ? ' class="v"' : ''}>

// Atributos da composição raiz
<div id="composition"
data-start="0" data-duration="${TOTAL}"
data-width="${W}" data-height="${H}">

// Log de confirmação no 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 automaticamente quando body tem 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
Gera 1920×1080. Sem classe v no body. CSS padrão com fontes grandes para tela de 1080p.
📱 9:16 — Shorts / Reels
node build-index.mjs --vertical
Gera 1080×1920. Adiciona class="v" ao body. Overrides CSS ajustam fontes e layout para mobile.
Conceitos-chave
📐
1920×1080
16:9 padrão
📱
1080×1920
9:16 Shorts
🏷️
body.v
Classe CSS de modo
💾
index.html
Sempre sobrescrito
6

🏁 A CTA scene9() / case 9 — assinatura invariável

A cena 9 é a assinatura padrão de todos os vídeos INEMA.CLUB. Ela mostra "CONTINUA EM" + INEMA.CLUB com glow e a URL 🌐 inema.club. Nunca deve ser removida, reposicionada ou alterada — é parte da identidade do canal.

Por que a CTA é invariável

A cena 9 funciona como uma assinatura de marca: quem assiste qualquer vídeo INEMA.CLUB sempre vê o mesmo encerramento. Isso cria reconhecimento e direciona o espectador para o site. O template já inclui a narração de CTA (s9.wav) e o HTML — você só fornece a duração real no AUDIO[].

Regra: AUDIO[8] (índice 8, cena 9) sempre é a duração de assets/audio/s9.wav. Narração padrão: "Isso é conteúdo do INEMA ponto CLUB. Acesse: inema ponto club."

composition-template.mjs — scene9() e case 9 de anim()
// scene9() — CTA INEMA.CLUB — não modificar
function scene9() {
return `
<div class="cta-wrap" id="s9-wrap">
<div class="cta-eyebrow" id="s9-eye">CONTINUA EM</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 em anim() — animações da 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;
⚠️
Regra de ouro: scene9() nunca sai

A CTA está na posição 9 de BODIES[] e no índice 8 de AUDIO[]. Remover ou reposicionar quebra o timing de todo o vídeo e elimina a assinatura do canal. Ao adaptar o template para um novo vídeo, mude apenas as cenas 1–8 e atualize as durações em AUDIO[0..7]. AUDIO[8] é sempre o WAV da CTA.

CSS da CTA — identidade visual INEMA.CLUB
/* Paleta da CTA — creme + âmbar com glow */
.cta-eyebrow { font-size: 28px; letter-spacing: .25em; color: #9ca3af; }
.cta-inema { font-size: 128px; font-weight: 800; color: #fef3c7; /* creme */ }
.cta-club { font-size: 128px; font-weight: 800; color: #f59e0b; /* âmbar */ }
.cta-url { font-size: 36px; color: #60a5fa; letter-spacing: .05em; }
✓ Checklist ao adaptar o template
  • Copie o template inteiro como build-index.mjs
  • Preencha AUDIO[0..7] com ffprobe — mantenha AUDIO[8]
  • Escreva scene1()scene8() — mantenha scene9()
  • Codifique case 1case 8 em anim() — mantenha case 9
  • Rode o gerador e verifique o log de timing antes de renderizar
✗ Nunca faça com a CTA
  • Remover scene9() de BODIES[]
  • Remover AUDIO[8] ou deixar o array com menos de 9 entradas
  • Substituir a cena 9 por conteúdo de outro vídeo
  • Alterar as cores .cta-inema / .cta-club
Conceitos-chave
🏁
scene9() fixa
Assinatura padrão
🎨
Creme + âmbar
Identidade INEMA.CLUB
🔊
AUDIO[8]
WAV da CTA
🔒
case 9 intocado
Animações da CTA

📋 Resumo do Módulo 3.3

O que você aprendeu
  • AUDIO[] governa 100% do timing — HTML, GSAP e áudio
  • S[] = AUDIO.map() com LEAD/TAIL/FADE calcula start, dur, audioStart, end
  • sceneN() retorna HTML; anim(i,t) gera strings GSAP com fade no .scene-inner
  • Quatro streams montados no HTML: scenesHTML, captionsHTML, audioHTML (track 20), animJS
  • Timeline GSAP pausada em window.__timelines["main"], sentinela tl.set({},{},TOTAL)
  • Flag --vertical troca W/H e adiciona body.v — output sempre index.html
  • scene9() / case 9 é a CTA INEMA.CLUB — nunca remover
Próximo módulo
3.4
🧯 Gotchas & correções
Os erros mais comuns ao trabalhar com o gerador — tracks errados, IDs duplicados, dessincronias de timing — e como diagnosticar e corrigir cada um antes de renderizar.
Ir para o módulo 3.4 →