MÓDULO 2.4

🎞️ Composição de cenas

Adapte o composition-template.mjs para cada novo vídeo: preencha AUDIO[], escreva sceneN(), codifique os tweens GSAP em anim() e alinhe tudo com uma única fonte de verdade de timing.

6
Tópicos
~35
Minutos
Médio
Nível
Prático
Tipo
AUDIO[] fonte única · ffprobe LEAD=0.5 · TAIL=0.9 · FADE=0.45 data-start data-duration .scene .clip anim(i,t) GSAP tweens scene-inner sceneN() + BODIES HTML de cada cena CAPTIONS[] legenda por cena · cap-N index.html node build-index.mjs MP4 ✓ sempre sincronizado AUDIO[] → timing único → áudio e animação sempre batidos
1

🧩 build-index.mjs — o gerador do projeto

Cada vídeo começa copiando scripts/composition-template.mjs como build-index.mjs na raiz do projeto. Esse arquivo é o gerador: roda uma vez, escreve index.html, e você renderiza.

Conceito Principal

O template já entrega CSS dark premium, background persistente (glow/grid/grain), caption layer, barra de progresso e overrides para 9:16. Você só precisa preencher 4 coisas: AUDIO[], CAPTIONS[], sceneN() e anim().

Rode node build-index.mjs para 16:9 e node build-index.mjs --vertical para 9:16. Os dois escrevem index.html — renderize logo após cada geração.

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

// @font-face local — caminhos relativos à raiz do projeto
const FONT_CSS = readFileSync(
new URL("./assets/fonts/fonts.css", import.meta.url), "utf8")
.replace(/\.\/fonts\//g, "assets/fonts/");

// Formato: padrão 16:9; passe --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"; // sempre index.html
✓ Boas práticas ao criar build-index.mjs
  • Copie o template inteiro — não crie do zero
  • Mantenha a cena 9 (CTA INEMA.CLUB) inalterada
  • Renderize imediatamente após cada node build-index.mjs
  • Use writeFileSync para index.html — o template já faz isso
✗ Armadilhas comuns
  • Não edite index.html diretamente — ele será sobrescrito
  • Não use Google Fonts CDN — some no render headless
  • Não remova a cena 9 de CTA — é a assinatura padrão
  • Não misture --vertical e 16:9 sem re-rodar o gerador
📦 O que o template entrega de brinde
CSS dark premium
Paleta --bg:#0D1321, variáveis de cor, fontes locais, overrides para 9:16.
Background layer
Glow radial, grid 64px, grain SVG — todos com data-layout-ignore.
Progress + captions
Barra de progresso animada e caption layer em track alternado (2/4) — prontos.
Conceitos-chave
📄
build-index.mjs
Gerador único
🔄
node → index.html
Cada rodada
📐
--vertical flag
1080×1920 Shorts
🔒
Cena 9 intocada
CTA INEMA.CLUB
2

🔢 AUDIO[] — durações REAIS medidas por ffprobe

O array AUDIO[] é a única entrada de dados de timing de todo o projeto. Cada elemento é a duração em segundos do WAV de narração da cena correspondente, incluindo a cena 9 de CTA.

Por que usar ffprobe e não estimar?

A duração de um WAV gerado pelo Kokoro varia conforme o texto, a velocidade (--speed 0.98) e a fonética. Se você estimou errado, áudio e visual ficam dessincronizados para sempre. Meça sempre.

O comando ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 assets/audio/sN.wav imprime o número exato em segundos — cole direto no array.

AUDIO[] real + cálculo da série S (composition-template.mjs)
// Durações REAIS medidas com ffprobe (9 cenas; s9 = CTA)
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
];

// Constantes de timing — NÃO alterar
const LEAD = 0.5; // visual estabelece antes da voz
const TAIL = 0.9; // segura depois da voz
const FADE = 0.45; // fade in/out do scene-inner

// Série S: acumula timing absoluto por cena
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;
});
Exemplo de saída: start acumulado das 9 cenas
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: acumulam sequencialmente — LEAD + audioDur + TAIL cada
9
s9: start=~96.x dur=5.240 audio@~97.x (3.840s) — CTA
💡
Medir em lote com shell script

Use 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 gerar a lista inteira de uma vez e colar no AUDIO[].

Conceitos-chave
📏
ffprobe
Medição exata
⏱️
LEAD=0.5
Visual antes da voz
🔚
TAIL=0.9
Segura após voz
🎯
s9 = CTA
Sempre incluído
3

🎨 sceneN() — HTML de cada cena

Cada cena é uma função JavaScript que retorna uma string HTML. O conteúdo é inserido em <div class="scene-inner">, dentro de um <section class="scene clip"> com atributos de timing calculados a partir de AUDIO[].

Estrutura gerada pelo template para cada cena

A montagem transforma cada item de S (array derivado de AUDIO[]) em um <section> com três atributos críticos: data-start, data-duration e data-track-index. Tracks alternados (1/3) evitam sobreposição nas bordas de cena.

Montagem: .scene .clip com data-start/duration/track-index
// ---------- MONTAGEM (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("");
Exemplo: scene1() — cena de abertura do vídeo 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">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>
<div class="reg tl" id="s1-r1"></div>
<div class="reg br" id="s1-r2"></div>
`;
}

// BODIES = lista ordenada das funções (scene9 = CTA, não remover)
const BODIES = [scene1, scene2, scene3, scene4, scene5, scene6, scene7, scene8, scene9];
✓ Boas práticas de sceneN()
  • Reutilize as classes CSS do template (.kicker, .h2, .grid2)
  • id único a cada elemento animável (s1-word, etc.)
  • Decorativos off-canvas: use data-layout-ignore
  • Animar o .scene-inner, nunca o .clip wrapper
✗ Erros que quebram o render
  • IDs duplicados entre cenas — o GSAP vai animar a errada
  • Animações no .clip — o framework força opacity:1 no clip ativo
  • Reordenar BODIES sem ajustar os IDs correspondentes
  • Remover ou reposicionar scene9 (CTA fixa no fim)
Conceitos-chave
🏷️
IDs únicos
Por cena + elemento
🔁
Tracks 1/3
Alternância de cenas
🎭
scene-inner
Wrapper animável
📋
BODIES[]
Ordem das cenas
4

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

A função anim(i, t) recebe o índice da cena e o instante de início absoluto. Ela empurra strings de código GSAP para um array local que depois é concatenado no <script> do HTML gerado.

Por que gerar código como string e não executar diretamente?

O gerador roda em Node.js mas o GSAP executa no browser (Chrome headless). anim() constrói strings de código que serão embutidas no HTML — quando o browser carregar, essas strings já têm os tempos absolutos calculados a partir de AUDIO[].

A helper at(d) (atalho para round(t + d)) calcula offset relativo ao início da cena. Use ela em todos os tweens para manter o código legível.

anim() — padrão de entrada/saída do scene-inner + caso 1
// composition-template.mjs — função anim()
function anim(i, t) {
const L = [];
const P = (s) => L.push(s);
const at = (d) => round(t + d);

// entrada/saída do 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)`});`);

// case 1: cena de abertura
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 ");
}
💡
Regras de ouro de animação

1. Animar sempre o .scene-inner, nunca o .clip wrapper. 2. Usar tempos absolutos calculados por at(d) — jamais hardcodar números. 3. O FADE=0.45 é o mesmo para entrada e saída de todas as cenas, garantindo transição uniforme.

⚠️
Cenas em tracks alternados — não é opcional

Cenas ímpares ficam em data-track-index="1" e pares em data-track-index="3". Captions seguem o mesmo padrão (2/4). Isso evita o "overlap de borda" onde dois frames de cenas adjacentes aparecem simultaneamente — erro clássico descrito em references/gotchas.md.

Conceitos-chave
fromTo / from
Tweens GSAP
🕐
at(d) helper
Offset relativo
🔀
FADE=0.45
Transição uniforme
🧱
switch(i)
Tweens por cena
5

💬 CAPTIONS[] — uma legenda curta por cena

O array CAPTIONS[] tem exatamente o mesmo comprimento que AUDIO[]. Cada string é exibida na parte inferior da cena correspondente como uma legenda acessível, sincronizada via data-start/duration herdados do mesmo S[idx].

Legenda como reforço cognitivo, não como transcrição

Uma boa caption captura a ideia central da cena em 5–10 palavras — não transcreve o áudio. O espectador que assiste sem som deve entender o ponto de cada cena só pela legenda + visual.

A caption roda em data-track-index="2" (cenas ímpares) ou "4" (pares) — tracks alternados para evitar sobreposição com a cena anterior na borda de transição.

CAPTIONS[] real + montagem do HTML de legendas
// composition-template.mjs — CAPTIONS e montagem
const CAPTIONS = [
"Skills no Claude Code — do básico ao avançado",
"Uma Skill = uma pasta + um arquivo SKILL.md",
"name + description — a description é o gatilho",
"Divulgação progressiva: carrega só quando precisa",
"Onde vivem: .claude/skills (projeto ou global)",
"Avançado: scripts, referências e templates",
"Este vídeo foi feito pela Skill HyperFrames",
"Comece com um SKILL.md. Agora é com você.",
"Mais conteúdo em inema.club", // s9 = CTA
];

// montagem automática — mesmo timing de 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("");
✓ Boas captions
  • Frase curta que resume o conceito central
  • Inclui termos técnicos exatos (nomes de arquivo, comandos)
  • Legível para quem assiste sem áudio
  • A caption da s9 menciona o site: inema.club
✗ Captions ruins
  • Transcrição literal do áudio (longo demais)
  • Vagos: "uma ideia importante sobre o tema"
  • Caps inteira — dificulta leitura rápida
  • Comprimento diferente de AUDIO[] (vai causar erro)
💡
Caption como indexador do vídeo

No YouTube, as captions são indexadas pelo buscador. Captions precisas com termos técnicos corretos (ffprobe, GSAP, build-index.mjs) melhoram o ranqueamento do vídeo para buscas técnicas.

Conceitos-chave
💬
1 caption/cena
Mesmo comprimento que AUDIO[]
🔁
Tracks 2/4
Alternância de legendas
📐
Sync automático
Mesmo data-start da cena
🔍
SEO-friendly
Termos técnicos exatos
6

⏱️ Timing fonte-única — áudio e animação sempre batidos

O princípio mais importante do composition-template: AUDIO[] gera tudo — data-start, data-duration, os tempos dos tweens GSAP e os atributos do <audio>. Nenhum número é duplicado nem hardcoded em outro lugar.

Fonte única de verdade — definição formal

As três constantes LEAD=0.5, TAIL=0.9 e FADE=0.45 combinadas com os valores em AUDIO[] determinam o tempo de início, duração e fade de cada cena — e portanto o tempo exato de todos os tweens GSAP. Alterar um valor em AUDIO[] propaga automaticamente para tudo.

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

Fluxo completo: AUDIO[] → S → HTML attributes + GSAP + <audio>
// TUDO deriva de AUDIO[] + LEAD/TAIL/FADE — nunca duplique
// 1. Série S: cada objeto carrega todos os tempos necessários
const S = AUDIO.map((a, i) => {
const dur = LEAD + a + TAIL; // duração total da cena
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 saída
};
});

// 2. Áudio — data-start = audioStart (começa APÓS o 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 gerados via anim() — usam S[i-1].start e S[i-1].end
const animJS = S.map((s) => anim(s.i, s.start)).join("\n ");
📐 Fórmulas de timing — cartão de referência rápida
Duração de cada cena
dur = LEAD + audioDur + TAIL
= 0.5 + medido + 0.9
Início do áudio (atrasa o LEAD)
audioStart = start + LEAD
O visual entra antes da voz
Tween de saída do scene-inner
exitAt = S[i-1].end - FADE
= end - 0.45
Duração total da composição
TOTAL = sum(all dur)
Barra de progresso usa esse valor
✓ O que a fonte única garante
  • Mude um WAV → rode o gerador → tudo sincroniza
  • Adicione uma cena → adicione entry em AUDIO[], CAPTIONS[], sceneN(), anim() case N
  • Nenhum número hardcoded fora de AUDIO[]/LEAD/TAIL/FADE
  • npx hyperframes lint detecta dessincronias de track
✗ Violações comuns
  • Hardcodar tempo em tween: gsap.to(..., 14.5)
  • Ajustar data-start no HTML gerado — será sobrescrito
  • Arrays AUDIO[] e CAPTIONS[] de comprimentos diferentes
  • Usar round() inconsistente → offset de frames
Conceitos-chave
🎯
AUDIO[] único
Tudo deriva dele
🔗
Série S
Timing por objeto
round(n)
Precisão ms
🔄
Propaga tudo
Mude AUDIO[], rode

📋 Resumo do Módulo 2.4

O que você aprendeu
  • Copiar o template como build-index.mjs e rodar com node
  • Preencher AUDIO[] com durações reais do ffprobe (inclui CTA s9)
  • Escrever sceneN() reutilizando as classes CSS do template
  • Codificar tweens GSAP em anim(i,t) animando sempre o .scene-inner
  • Preencher CAPTIONS[] com frases curtas e técnicas
  • Entender as fórmulas de timing: LEAD=0.5, TAIL=0.9, FADE=0.45
Próximo módulo
2.5
✅ Validar & renderizar
Rode npx hyperframes lint para 0 erros, inspect --samples 16 para 0 problemas de layout, e gere o MP4 final em --quality high para 16:9 e 9:16.
Ir para o módulo 2.5 →