feat(reels): colinha 1313 determinística e lote por dobradinha (REEL1–REEL3)

Builder do reel de conversa com roteiro/timeline puros em scripts/lib/colinhaReel.mjs (25 beats na ordem da urna) e template HTML/CSS 540×960@2x screenshotado quadro a quadro (sem animação CSS, sem drift); três finais no mesmo pacote (motion, estático e o card EXATO do site dirigido em produção) e lote por dobradinha ativa — 42 curadas da plataforma em scripts/reels/dobradinhas.json, com paralelismo e falha isolada por dobradinha. Trilha de 'confirma' da urna por card + cama do jingle desde o início do vídeo, montada num passe e mixada com -c:v copy.

Derivativos oficiais commitados em scripts/reels/assets/colinha-time-1313/ (proveniência e receitas no README da pasta): fotos/webp, logo da rádio, subset da Inter e os dois áudios do reel, que ganham exceção à regra global de áudio no .gitignore. package.json ganha reels:colinha e reels:dobradinhas.

Verificação: unit colinhaReel 6/6 (ordem da urna, números, timeline, template e ativos), eslint/typecheck/format:check/knip verdes.
This commit is contained in:
Francisco Solla 2026-10-07 11:47:37 -03:00
parent 4d1e305193
commit a662c77072
22 changed files with 2561 additions and 0 deletions

5
.gitignore vendored
View file

@ -148,6 +148,11 @@ next-env.d.ts
# commits; every real jingle lives in the media collection.
!tests/fixtures/*.mp3
# C-reel — derivativos dos reels commitados em scripts/reels/assets/<slug>/
# (proveniência e receitas no README da pasta): o efeito "confirma" do TSE e a
# cama do jingle são derivados reprodutíveis e acompanham os demais ativos.
!/scripts/reels/assets/**/*.mp3
# opencode secrets (Penpot MCP token, etc.)
.opencode/secrets/

View file

@ -0,0 +1 @@
**REEL1 — Reel "Sua colinha para 2026" (Time 1313): o chat que monta a colinha na ordem da urna (2026-09-30):** a comunicação ganha o reel criativo em formato de conversa de WhatsApp, inspirado no reel do `dep_padrejoao` (PT-MG) e refeito com a identidade da campanha. O builder é determinístico e local (`pnpm reels:colinha` → `scripts/build-colinha-reel.mjs`): o roteiro/timeline vivem em `scripts/lib/colinhaReel.mjs` (25 beats, ordem da urna — `1313` Jorge Solla federal, `13999` Julio Pinheiro estadual, `130` Jaques Wagner 1ª vaga e `133` Rui Costa 2ª vaga de senador, `13` Jerônimo governador, `13` Lula presidente), o chat é um template HTML/CSS 540×960@2x (`colinhaReelTemplate.mjs`, fontes Inter/Brexter/Arimo e imagens em data URI) e cada quadro é um screenshot com o estado da página dirigido só por `window.colinhaRender(t)` — sem animação CSS, sem drift. O desenho porta a linguagem do reel de referência com o kit 1313: cabeçalho `⭐ Time 1313` com a logo "O" da rádio (kit oficial) no avatar, balões roxos do eleitor e cinza do time, card de urna por candidato — foto oficial recortada com enquadramento por candidato (`photoZoom`/`photoShiftY`, sempre cabeça inteira), `PT ★`, `BA · ELEIÇÕES 2026`, caixas de dígito centralizadas e o nome em Inter Black (a fonte do site; a primeira passada usou a Brexter e o gate humano pediu a troca) —, bolha "anotei:" com barra de progresso vermelho→azul (1/6 a 6/6), "Visto" e indicador de digitação animados por relógio, e o fecho em flip 3D para a colinha oficial (`public/cards/modelo-colinha.jpeg`), em **três versões alternativas** no mesmo pacote: `reel.mp4` (a colinha se monta sozinha), `reel-estatico.mp4` (a colinha pronta entra de uma vez, só com a transição do flip) e `reel-card.mp4` (a mesma transição, mas com a colinha EXATAMENTE como o card personalizado do site a gera — o builder dirige o estúdio de cards em produção, escolhe o estadual e levanta o canvas 1080×1920 que o próprio renderer do site pintou, guardando o PNG cru em `colinha-site.png`; `--finale=motion,static,card`, `--base-url=` para staging/local). Na versão em motion a cena é composta em camadas — a foto oficial do grupo da família "Time de você"/dobradinha (`julio-fotos.webp`, um só clique, todo mundo na mesma escala) preenche a faixa de foto da arte, recortada pelo polígono da onda (medido coluna a coluna) para o corpo sumir atrás da faixa vermelha; cada candidato entra por foco, com o patch desfocado da própria foto sumindo no seu beat (Solla, Julio, Wagner, Rui, Jerônimo e Lula — Rui e Wagner atrás de Julio, como na foto), cada linha da colinha é mascarada e revelada junto (a do estadual é a linha desenhada pela geometria S31/S34: máscara + `DEPUTADO ESTADUAL` + `JULIO PINHEIRO` + os cinco dígitos nas caixas impressas, na Arimo que espelha o render do site) e cada logo entra junto na faixa vermelha, cuja crista amarela/verde é preservada pelo recorte pelo topo da própria faixa — a logo quadrada do Solla (nome, `DEPUTADO FEDERAL` e `1313` empilhados, do overlay oficial da dobradinha) à esquerda, a do `JULIO PINHEIRO 13999` ao lado e as quatro marcas (LULA, JERO, RUI, WAGNER) menores num grid 2×2 à direita, todas com o vermelho da faixa virando alfa; por último aparece o `MAIS SAÚDE MAIS FUTURO`, com a colinha completa. A tela final do santinho da dobradinha saiu a pedido do gate humano (o reel termina na colinha). O pacote sai em `data/reels/colinha-time-1313/` (gitignored): `reel.mp4` 1080×1920 · 30 fps · H.264 · 30,1 s · 904 quadros · **sem áudio** (a trilha entra no app, como na referência), `capa.png` (corte 4:5 do quadro t=7800 ms, com o card do Solla e o número do estadual) e `metadata.json` (hash do roteiro, números do ticket, `coverAtMs`). As fotos oficiais foram derivadas para `scripts/reels/assets/colinha-time-1313/` (Solla da `FOTO SOLLA CAMISA BRANCA` do kit, Julio da extração oficial em `DOBRADINHAS-ORGANIZADAS`, os demais do site, a logo "O" da rádio e a Inter subset latim — proveniência no README da pasta); nenhum ativo do reel de referência (Meta) entra na peça. Verificação: unit novo (`colinhaReel`: ordem da urna, números, ask→número→card, timeline cumulativa com o `hideAtMs` do typing, template com todos os beats/cards/telas e os ativos commitados), `--dry-run`, `--preview-frames`/`--cover-at` para o gate visual e o pacote real conferido quadro a quadro.

View file

@ -0,0 +1 @@
**REEL2 — Um reel-card por dobradinha ativa (2026-10-01):** o reel de conversa que termina na colinha EXATA do card personalizado do site (`--finale=card`) passa a ser gerado em lote, um por dobradinha registrada na plataforma de gestão. A lista é a da produção (`state_deputy` × `contact`) cujo nome NÃO contém o marcador temporário `(apagar)` da assessoria — 53 registros, 7 marcados, 46 ativos; 4 ficam fora por não terem par no catálogo do site (Gildo Baleia, Juvenilson, Rafael Meireles) e 1 por duplicidade (Geane Vasconcelo ACS), resultando em **42 dobradinhas** curadas em `scripts/reels/dobradinhas.json` (slug, nome do catálogo, número, pasta). As fotos são as dos cards da Plenária da Vitória (`DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png`, recortes oficiais com alfa), normalizadas em memória pelo builder (trim + 1000 px + webp) — nada de foto nova é commitada. O builder ganhou `--estadual-name/--estadual-number/--estadual-photo/--site-deputy/--out-slug` (troca o 2º candidato do ticket e o estadual escolhido no estúdio em produção) e o nome longo do card encolhe em uma linha (medição por canvas) em vez de quebrar. O driver `pnpm reels:dobradinhas` (`scripts/build-dobradinha-reels.mjs`) roda os 42 reels com paralelismo (`--jobs`, padrão 3), falha por dobradinha sem derrubar o lote (uma nova tentativa resolveu 3 falhas transitórias do estúdio) e aceita `--only=slug,slug`, `--photos-dir=`, `--skip-existing` e `--dry-run`. Pacotes em `data/reels/colinha-<slug>/` (gitignored, copiados para o repo principal): `reel-card.mp4` 1080×1920 · 30 fps · 29,1 s · sem áudio, o `colinha-site.png` cru do site, `roteiro.md` e `metadata.json` (título com a dobradinha, hash do roteiro, artefatos reais). Verificação: dry-run do lote, unit do `colinhaReel` (o finale `card` já pinado), checks verdes e conferência visual por amostragem (Coletivo de Enfermagem 13763, Rowenna 13456, Yulo 13234, Osni 13013 e Arthur Lisboa 55255 — este também exercitou o encolhimento do nome longo). Em 2026-10-01 o pacote ganhou o **"confirma" da urna**: o efeito (som do TSE extraído de gravação pública, `scripts/reels/assets/colinha-time-1313/sfx-confirma.mp3`, receita no README da pasta) entra com `--beeps` no beat de cada card via uma trilha AAC montada por `adelay`+`amix` e mixada com `-c:v copy` (o vídeo nunca é re-encodado); `--audio-only` aplica a trilha em vídeos já renderizados (foi assim que os 43 pacotes receberam o som, ~2 s cada). O `metadata.json` passa a registrar `audio: true`.

View file

@ -0,0 +1 @@
**REEL3 — A trilha dos reels: "confirma" por card + jingle de fundo, e a versão sem estadual (2026-10-01):** três ajustes sobre o lote REEL2. (1) **Correção**: quando a trilha dos bipes entrou (REEL2), o `-shortest` do mux cortava os vídeos em ~20,6 s (a trilha terminava no último bipe e levava o vídeo junto — o `-t` só limita, não estende); a trilha agora passa por `apad` antes do corte, então o áudio cobre exatamente a duração do vídeo e o mux não trunca mais nada (todos os 43 pacotes foram re-renderizados). (2) **Jingle**: o axé da campanha (`Jorge solla play.wav`, 2:11) entra como cama sonora desde o **início do vídeo**, tocando a partir dos seus **0:04** (o trecho pedido), um pouco mais presente (`volume=0.22`, fade-in de 0,6 s e fade-out de 2,5 s no fim, `alimiter` a 0,9 para não estourar), mixado com os bipes de "confirma" de cada card — o áudio é montado num único passe (`adelay`+`amix`+`afade`+`alimiter`+`apad`) e mixado com `-c:v copy` (o vídeo nunca é re-encodado); derivado mono do jingle em `scripts/reels/assets/colinha-time-1313/jingle-play.mp3` (receita no README da pasta). (3) **Versão sem estadual** (`--estadual=livre`): o segundo passo do chat vira "e quem é do time do Lula?" e o card mantém o MESMO layout dos candidatos (marca oficial de Lula recortada na janela de foto à esquerda, sobre fundo bege claro — legível e maior; à direita o rótulo `DEPUTADO ESTADUAL`, a chamada `DO TIME DO LULA` e `ESCOLHA O SEU` na linha do nome) — **sem citar partidos**: por decisão de produto no mesmo dia, os chips de partido (PT 13 · PCdoB 65 · PV 43 · PSB 40 · PSOL 50 · REDE 18 · PDT 12) e o rótulo `PARTIDOS DO TIME` foram removidos do card, restando só o sinal "deputado estadual do time de Lula" (o catálogo `COLINHA_PARTIES` morreu junto); o card final é a própria arte oficial da colinha com a linha do estadual aberta (sem drive do site, que ali não teria o que preencher) — pacote em `data/reels/colinha-sem-estadual/`. Verificação: vídeos conferidos com `ffprobe` (durações 29,1 s/30,9 s, stream de áudio AAC presente), spectrograma do mix (bipes limpos no beat de cada card + cama contínua), prévias do card genérico e do fecho da colinha aberta, e os checks de sempre.

View file

@ -67,6 +67,8 @@
"build:municipality-zone-geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-municipality-zone-geometries.mjs",
"build:tse-city-codes": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-tse-city-codes.mjs",
"reels:build": "node scripts/build-reel.mjs",
"reels:colinha": "node scripts/build-colinha-reel.mjs",
"reels:dobradinhas": "node scripts/build-dobradinha-reels.mjs",
"reels:tts:setup": "node scripts/reels-tts-setup.mjs",
"dev": "node scripts/dev.mjs",
"devsafe": "rm -rf .next && node scripts/dev.mjs",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
/**
* Lote de reels "reel-card" por dobradinha (C-reel): um reel de conversa por
* dobradinha ativa da campanha, terminando na colinha EXATA do card
* personalizado do site para aquele estadual.
*
* A lista vive em `scripts/reels/dobradinhas.json` (curada a partir da
* plataforma: `state_deputy` cujo nome não contém "(apagar)" — ver o `source`
* do arquivo). Cada reel roda o mesmo builder (`--finale=card`), trocando o
* segundo candidato do ticket (nome, número e foto) e o estadual escolhido no
* estúdio de cards em produção.
*
* Uso:
* node scripts/build-dobradinha-reels.mjs [--only=slug,slug] [--jobs=3]
* [--photos-dir=…] [--skip-existing] [--dry-run] [--audio-only]
*
* As fotos são as dos cards da Plenária da Vitória
* (`DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png`).
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dieWithLabel, parseEqualsFlags } from './lib/cli.mjs'
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-dobradinha-reels'
const die = dieWithLabel(LABEL)
const CONFIG = join(ROOT, 'scripts/reels/dobradinhas.json')
const BUILDER = join(ROOT, 'scripts/build-colinha-reel.mjs')
const DEFAULT_JOBS = 3
const USAGE = `Uso: node scripts/build-dobradinha-reels.mjs [--only=slug,slug] [--jobs=3]
[--photos-dir=…] [--skip-existing] [--dry-run] [--audio-only]`
const runBuilder = (args) =>
new Promise((resolvePromise) => {
const child = spawn(process.execPath, [BUILDER, ...args], {
cwd: ROOT,
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
const forward = (chunk) => {
output += chunk.toString()
}
child.stdout.on('data', forward)
child.stderr.on('data', forward)
child.on('close', (code) => {
resolvePromise({ code, output })
})
})
const main = async () => {
const { flags } = parseEqualsFlags(process.argv.slice(2))
if (flags.help === true) {
console.log(USAGE)
return
}
const config = JSON.parse(await readFile(CONFIG, 'utf8'))
const photosDir =
typeof flags['photos-dir'] === 'string' ? resolve(flags['photos-dir']) : config.photosDir
const jobs = flags.jobs === undefined ? DEFAULT_JOBS : Number(flags.jobs)
if (!Number.isFinite(jobs) || jobs < 1) die('--jobs exige um inteiro ≥ 1.')
const only =
typeof flags.only === 'string' ? new Set(flags.only.split(',').map((s) => s.trim())) : null
const dryRun = flags['dry-run'] === true
const skipExisting = flags['skip-existing'] === true
const audioOnly = flags['audio-only'] === true
const entries = config.dobradinhas.filter((entry) => !only || only.has(entry.slug))
if (entries.length === 0) die('Nenhuma dobradinha selecionada (--only sem correspondência?).')
const failures = []
const skipped = []
const queue = [...entries]
const results = []
const runOne = async (entry) => {
const photo = join(photosDir, entry.folder, 'foto.png')
if (!existsSync(photo)) {
failures.push({ slug: entry.slug, reason: `foto ausente: ${photo}` })
console.log(`[${LABEL}] ${entry.slug}: FALHA (foto ausente)`)
return
}
const outSlug = `colinha-${entry.slug}`
const reelPath = join(ROOT, 'data/reels', outSlug, 'reel-card.mp4')
if (skipExisting && existsSync(reelPath)) {
skipped.push(entry.slug)
console.log(`[${LABEL}] ${entry.slug}: já existe, pulando`)
return
}
const args = [
'--finale=card',
'--beeps',
`--estadual-name=${entry.catalog}`,
`--estadual-number=${entry.number}`,
`--estadual-photo=${photo}`,
`--site-deputy=${entry.catalog}`,
`--out-slug=${outSlug}`,
]
if (audioOnly) args.push('--audio-only')
const started = Date.now()
const { code, output } = await runBuilder(args)
const seconds = ((Date.now() - started) / 1000).toFixed(0)
if (code !== 0) {
failures.push({ slug: entry.slug, reason: output.trim().split('\n').slice(-3).join(' / ') })
console.log(`[${LABEL}] ${entry.slug}: FALHA em ${seconds}s`)
return
}
results.push({ slug: entry.slug, seconds })
console.log(`[${LABEL}] ${entry.slug}: ok em ${seconds}s`)
}
if (dryRun) {
for (const entry of entries) {
const photo = join(photosDir, entry.folder, 'foto.png')
console.log(
`${entry.slug.padEnd(14)} ${entry.catalog.padEnd(26)} nº ${entry.number} foto: ${existsSync(photo) ? 'ok' : 'AUSENTE'}`,
)
}
console.log(`\n${entries.length} dobradinhas · ${jobs} em paralelo`)
return
}
const workers = Array.from({ length: Math.min(jobs, queue.length) }, async () => {
while (queue.length > 0) {
const entry = queue.shift()
await runOne(entry)
}
})
await Promise.all(workers)
console.log(`\n[${LABEL}] ${results.length}/${entries.length} reels gerados`)
if (skipped.length > 0) console.log(` pulados (já existiam): ${skipped.join(', ')}`)
if (failures.length > 0) {
for (const failure of failures) console.log(` FALHA ${failure.slug}: ${failure.reason}`)
process.exitCode = 1
}
}
main().catch((error) => die(error?.stack ?? String(error)))

328
scripts/lib/colinhaReel.mjs Normal file
View file

@ -0,0 +1,328 @@
/**
* Reel "Sua colinha para 2026" — the script data and the pure timeline of the
* WhatsApp-style chat reel (a creative piece inspired by the PT-MG "Seu time"
* reel of dep_padrejoao; the chat walks the voter through the urna order:
* federal → estadual → senador 1ª/2ª vaga → governador → presidente).
*
* No DOM, no ffmpeg, no browser: every constant here is data, and
* `buildColinhaTimeline` is the deterministic map beat → [atMs, endsAtMs]. The
* template (`colinhaReelTemplate.mjs`) renders one state per `t`, and the
* builder (`build-colinha-reel.mjs`) walks `frameCount(totalMs, fps)` — the
* three share this module, so the script is the single source of the reel.
*
* Candidate photos and the star badge live in
* `scripts/reels/assets/<slug>/` (provenance in the folder README); the
* official arts (colinha slip, dobradinha santinho) come from `public/`.
*/
export const COLINHA_REEL = {
slug: 'colinha-time-1313',
title: 'Sua colinha para 2026 — Time 1313',
chatTitle: 'Time 1313',
chatHour: '18:30',
/** Cover contract of the private library (C195): alt text of the cover art. */
coverAlt:
'Conversa no WhatsApp do "Time 1313" montando a colinha de votação: Jorge Solla 1313, Julio Pinheiro 13999, Jaques Wagner 130, Rui Costa 133, Jerônimo 13 e Lula 13.',
/** The state + election line inside every candidate card. */
cardState: 'BA · ELEIÇÕES 2026',
cardParty: 'PT',
fps: 30,
}
/**
* The ticket in urna order — the same order the chat asks and the same rows of
* the official colinha art (`public/cards/modelo-colinha.jpeg`). `ask` is the
* office as it is spoken in the chat; `office` is the printed label of the
* card; `number` is the ballot number (digits only). `photo` is a file inside
* `scripts/reels/assets/<slug>/`.
*/
export const COLINHA_CANDIDATES = [
{
id: 'solla',
ask: 'Deputado Federal',
office: 'DEPUTADO FEDERAL',
name: 'JORGE SOLLA',
number: '1313',
photo: 'solla.webp',
/** Card framing: the master is framed shoulder-up from the head down. */
photoZoom: 1.22,
/** End screen: the group figure, the slip row and the red-band logo. */
figure: { cx: 177.5, cy: 235, rx: 67.5, ry: 107.5 },
row: { top: 458, height: 71 },
logo: { left: 14, top: 316, width: 158 },
},
{
id: 'julio',
ask: 'Deputado Estadual',
office: 'DEPUTADO ESTADUAL',
name: 'JULIO PINHEIRO',
number: '13999',
photo: 'julio.webp',
photoZoom: 1.18,
photoShiftY: 3.5,
figure: { cx: 355, cy: 232.5, rx: 67.5, ry: 107.5 },
row: { top: 542, height: 59 },
logo: { left: 185, top: 320, width: 150 },
},
{
id: 'wagner',
ask: 'Senador (1ª vaga)',
office: 'SENADOR (1ª VAGA)',
name: 'JAQUES WAGNER',
number: '130',
photo: 'wagner.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 500, cy: 245, rx: 50, ry: 100 },
row: { top: 607, height: 71 },
logo: { left: 464, top: 374, width: 72 },
},
{
id: 'rui',
ask: 'Senador (2ª vaga)',
office: 'SENADOR (2ª VAGA)',
name: 'RUI COSTA',
number: '133',
photo: 'rui.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 440, cy: 240, rx: 57.5, ry: 102.5 },
row: { top: 694, height: 67 },
logo: { left: 386, top: 370, width: 72 },
},
{
id: 'jeronimo',
ask: 'Governador',
office: 'GOVERNADOR',
name: 'JERÔNIMO',
number: '13',
photo: 'jeronimo.webp',
photoZoom: 1.05,
photoShiftY: 2,
figure: { cx: 35, cy: 250, rx: 45, ry: 100 },
row: { top: 771, height: 68 },
logo: { left: 464, top: 321, width: 72 },
},
{
id: 'lula',
ask: 'Presidente',
office: 'PRESIDENTE',
name: 'LULA',
number: '13',
photo: 'lula.webp',
photoZoom: 1.12,
photoShiftY: 3.2,
figure: { cx: 97.5, cy: 235, rx: 62.5, ry: 107.5 },
row: { top: 847, height: 71 },
logo: { left: 386, top: 318, width: 72 },
},
]
/**
* The "sem estadual" step: instead of one ballot number, the card tells the
* voter to pick a state deputy from Lula's team — no party is named.
*/
export const COLINHA_GENERIC_ESTADUAL = {
id: 'estadual-livre',
ask: 'Deputado Estadual',
office: 'DEPUTADO ESTADUAL',
/** The card headline: no name, the instruction itself. */
name: 'DO TIME DO LULA',
number: '',
generic: true,
/** The chat beat where the voter asks who is on Lula's team. */
voterText: 'e quem é do time do Lula?',
figure: { cx: 355, cy: 232.5, rx: 67.5, ry: 107.5 },
row: { top: 542, height: 59 },
logo: { left: 185, top: 320, width: 150 },
}
/**
* The urna order of the chat, derived from the candidates (the reel never
* repeats the ticket): federal (4 digits) → estadual (5) → two senators (3+3)
* → governador (2) → presidente (2).
*/
export const COLINHA_URNA_ORDER = COLINHA_CANDIDATES.map((candidate) => candidate.id)
/**
* Holding times of the chat beats (ms). The first ask is the full question and
* every following ask is the short "e pra …?"; the holds follow the reference
* reel rhythm (each candidate cycle ≈ 3.2 s).
*/
export const COLINHA_BEAT_DURATIONS = {
hook: 1200,
typing: 650,
reply: 600,
ask: 700,
number: 500,
card: 1950,
closingTyping: 650,
closing: 1250,
/** Screen flip transition between the chat and the end screen. */
flip: 420,
/** The end screen: flip in, then one reveal per candidate, then the slogan. */
endHold: 400,
reveal: 700,
brand: 2200,
/** The static finale: the slip arrives complete and holds. */
staticHold: 5000,
}
/**
* The end screen of the reel — the official colinha art assembles itself: one
* reveal beat per candidate (their figure in the sky band, their row in the
* slip and their logo in the red band), then the "Mais Saúde, Mais Futuro"
* title appears last over the complete slip. Positions are CSS px on the
* 540×960 stage (the art is measured at 1080×1920 → /2).
*/
export const COLINHA_END = {
/** The printed title band of the art (revealed last, with the slogan). */
titleBand: { top: 0, height: 125 },
/** The group/sky band of the art (kept covered by the composed sky). */
groupBand: { top: 125, height: 225 },
/** The red band of the art (its own lockups are covered and redrawn). */
band: { color: '#e50e2f' },
/** The white slip rows are masked per candidate before their reveal. */
rowMask: { left: 100, width: 360 },
}
const candidateById = new Map(COLINHA_CANDIDATES.map((candidate) => [candidate.id, candidate]))
export const getColinhaCandidate = (id) => candidateById.get(id)
/**
* The chat script: voter beats on the right, assistant beats on the left, one
* number bubble + one card per candidate, then the closing and the end screen.
* `finale: 'motion'` assembles the slip candidate by candidate; `finale:
* 'static'` and `'card'` flip straight into the complete slip and hold — the
* latter is the site's own personalized-card output.
*
* @param {{ finale?: 'motion' | 'static' | 'card', candidates?: typeof COLINHA_CANDIDATES }} [options]
* @returns {Array<{ id: string, kind: 'voter' | 'assistant' | 'typing' | 'number' | 'card' | 'end' | 'reveal' | 'brand', text?: string, candidateId?: string, screen?: 'colinha', seen?: boolean, durationMs: number, enterMs: number, hideAtMs?: number }>}
*/
export const buildColinhaBeats = ({ finale = 'motion', candidates = COLINHA_CANDIDATES } = {}) => {
const {
hook,
typing,
reply,
ask,
number,
card,
closingTyping,
closing,
flip,
endHold,
reveal,
brand,
staticHold,
} = COLINHA_BEAT_DURATIONS
const beats = [
{
id: 'hook',
kind: 'voter',
text: 'oi! me ajuda a montar minha colinha pra 2026?',
seen: true,
durationMs: hook,
},
{ id: 'typing-1', kind: 'typing', durationMs: typing },
{ id: 'reply-1', kind: 'assistant', text: 'claro! vamos na ordem da urna.', durationMs: reply },
]
candidates.forEach((candidate, index) => {
beats.push({
id: `ask-${candidate.id}`,
kind: 'assistant',
text: index === 0 ? 'pra Deputado Federal, quem você escolheu?' : `e pra ${candidate.ask}?`,
durationMs: ask,
})
if (candidate.generic) {
// The "sem estadual" step: the voter asks who is on Lula's team and the
// card instructs them to pick a state deputy from that team.
beats.push({
id: `number-${candidate.id}`,
kind: 'voter',
text: candidate.voterText,
seen: true,
durationMs: number,
})
} else {
beats.push({
id: `number-${candidate.id}`,
kind: 'number',
text: candidate.number,
candidateId: candidate.id,
seen: true,
durationMs: number,
})
}
beats.push({
id: `card-${candidate.id}`,
kind: 'card',
candidateId: candidate.id,
durationMs: card,
})
})
beats.push({ id: 'typing-end', kind: 'typing', durationMs: closingTyping })
beats.push({
id: 'closing',
kind: 'assistant',
text: 'prontinho! colinha completa. bom voto! ✅',
durationMs: closing,
})
beats.push({
id: 'end-colinha',
kind: 'end',
screen: 'colinha',
durationMs: flip * 2 + (finale === 'motion' ? endHold : staticHold),
})
if (finale !== 'motion') {
// The static/card finales: the slip arrives complete and holds.
beats.push({ id: 'brand', kind: 'brand', durationMs: 1 })
} else {
for (const candidate of candidates) {
beats.push({
id: `reveal-${candidate.id}`,
kind: 'reveal',
candidateId: candidate.id,
durationMs: reveal,
})
}
beats.push({ id: 'brand', kind: 'brand', durationMs: brand })
}
return beats.map((beat) => ({
...beat,
durationMs: beat.durationMs,
/** Entrance window of the beat (the renderer eases over it). */
enterMs: beat.kind === 'end' ? flip * 2 : 260,
}))
}
/**
* Deterministic timeline: beats in order, each starting where the previous
* ends. `totalMs` is the reel duration; `frameCount` is what the builder
* screenshots at `fps` (the last frame is held for one frame slot).
*
* @param {ReturnType<typeof buildColinhaBeats>} [beats]
* @param {number} [fps]
*/
export const buildColinhaTimeline = (beats = buildColinhaBeats(), fps = COLINHA_REEL.fps) => {
let atMs = 0
const entries = beats.map((beat) => {
const entry = { ...beat, atMs, endsAtMs: atMs + beat.durationMs }
atMs = entry.endsAtMs
return entry
})
entries.forEach((entry, index) => {
const next = entries[index + 1]
if (entry.kind === 'typing' && next) entry.hideAtMs = next.atMs
})
return {
entries,
totalMs: atMs,
fps,
frameCount: Math.max(1, Math.round((atMs / 1000) * fps)),
byId: new Map(entries.map((entry) => [entry.id, entry])),
}
}

View file

@ -0,0 +1,538 @@
/**
* Reel "Sua colinha para 2026" — the visual template of the chat reel: the
* WhatsApp-style conversation (voter on the right, "Time 1313" on the left),
* the printed-urna candidate cards and the two end screens (the filled colinha
* slip and the official dobradinha santinho). Structure ports the reference
* reel (dep_padrejoao) with the campaign kit language: official star badge,
* Brexter display face, kit palette.
*
* Pure string builder: fonts and image data URIs are passed in by the builder
* (`build-colinha-reel.mjs`); the page is rendered at 540×960 with
* deviceScaleFactor 2 → physical 1080×1920 frames. `window.colinhaRender(t)`
* sets the whole page state for a millisecond of the timeline (no CSS
* animations — every frame is deterministic), and `window.colinhaReady` is the
* promise the builder awaits before screenshotting.
*/
import { COLINHA_END, COLINHA_REEL } from './colinhaReel.mjs'
const BRAND = {
red: '#e4102f',
blue: '#184e92',
ink: '#111111',
chatGradient: 'linear-gradient(118deg,#5a67ec 0%,#8d37db 58%,#b52fb5 100%)',
}
const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
const BACK_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 5l-7 7 7 7"/></svg>'
const VIDEO_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="6.5" width="12.5" height="11" rx="2.6"/><path d="M15 10.6l6-3.1v9l-6-3.1z"/></svg>'
const INFO_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9.2"/><path d="M12 11v5.4"/><path d="M12 7.6h.01"/></svg>'
const CAMERA_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7.6h2.6l1.5-2.4h7.8l1.5 2.4H20a1.6 1.6 0 0 1 1.6 1.6v8.2A1.6 1.6 0 0 1 20 19H4a1.6 1.6 0 0 1-1.6-1.6V9.2A1.6 1.6 0 0 1 4 7.6z"/><circle cx="12" cy="13.2" r="3.4"/></svg>'
const MIC_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.6 11.4a6.4 6.4 0 0 0 12.8 0"/><path d="M12 17.8V21"/></svg>'
const IMAGE_ICON =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="15" rx="2.6"/><circle cx="9" cy="10" r="1.7"/><path d="M4.2 17.5l4.6-4.4 3.4 3.2 3.2-3 4.4 4.2"/></svg>'
const PARTY_STAR =
'<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 1.8l2.9 6.9 7 .6-5.3 4.6 1.6 6.9L12 17l-6.2 3.8 1.6-6.9L2.1 9.3l7-.6z"/></svg>'
/**
* `@font-face` blocks as data URIs. Inter (the site's own grotesque) carries
* the whole reel — chat, cards, labels; Brexter is the kit display face, kept
* for the chat title; Arimo is the local metric-compatible Arial substitute
* used only where the drawn row must match the site's own colinha render.
*/
export const colinhaFontCss = ({ brexterTtf, interWoff2, arimoWoff2 }) => `
@font-face{font-family:ReelBrexter;src:url(data:font/ttf;base64,${brexterTtf}) format('truetype');font-weight:400;font-style:normal}
@font-face{font-family:ReelInter;src:url(data:font/woff2;base64,${interWoff2}) format('woff2');font-weight:100 900;font-style:normal}
@font-face{font-family:ReelArimo;src:url(data:font/woff2;base64,${arimoWoff2}) format('woff2');font-weight:100 900;font-style:normal}
`
const BASE_CSS = `
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{width:540px;height:960px;overflow:hidden;background:#fff}
body{font-family:ReelInter,Inter,system-ui,sans-serif;color:${BRAND.ink};-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision}
.stage{position:relative;width:540px;height:960px;overflow:hidden;background:#fff;perspective:1600px}
.brexter{font-family:ReelBrexter,Impact,Haettenschweiler,'Arial Narrow Bold',sans-serif;font-weight:400}
img{display:block}
/* ---------------- chat ---------------- */
.chat{position:absolute;inset:0;z-index:1;background:#fff;transform-origin:50% 50%}
.chat-top{position:absolute;z-index:6;inset:0 0 auto;height:52px;display:flex;align-items:center;gap:9px;padding:0 13px 0 10px;background:#fff;border-bottom:1px solid #eceef1}
.chat-top__back{width:18px;height:18px;color:#15181d}
.chat-top__avatar{width:35px;height:35px;flex:none;overflow:hidden;border-radius:50%;background:#fff;box-shadow:0 0 0 1px #e7eaee}
.chat-top__avatar img{width:100%;height:100%;object-fit:cover}
.chat-top__name{font-size:24px;line-height:1;letter-spacing:.005em;white-space:nowrap}
.chat-top__icons{display:flex;align-items:center;gap:16px;margin-left:auto;color:#15181d}
.chat-top__icons svg{width:22px;height:22px}
.messages{position:absolute;z-index:4;top:52px;bottom:53px;left:0;right:0;padding:0 12px 14px;overflow:hidden;background:#fff}
.day{margin:13px 0 11px;text-align:center;font-size:13px;font-weight:700;letter-spacing:.19em;color:#8b9099;text-transform:uppercase}
.row{display:flex;margin:0 0 8px}
.row--right{flex-direction:column;align-items:flex-end}
.row--left{flex-direction:column;align-items:flex-start}
.row--card{margin-bottom:11px}
.bubble{max-width:440px;padding:11px 15px;border-radius:18px;font-size:20px;line-height:1.34;letter-spacing:.001em}
.bubble--assistant{background:#f0f1f3;color:#141414;border-bottom-left-radius:5px}
.bubble--voter{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px}
.bubble--number{background:${BRAND.chatGradient};color:#fff;border-bottom-right-radius:5px;font-weight:700;letter-spacing:.02em}
.seen{display:none;margin:2px 5px 0 0;font-size:15px;color:#9aa0a8}
.typing{display:flex;align-items:center;gap:3.8px;padding:12px 13px}
.typing i{width:5.4px;height:5.4px;border-radius:50%;background:#b9bec6;display:block}
.bubble--card{background:#f0f1f3;padding:7px 7px 8px;border-radius:16px;border-bottom-left-radius:5px;max-width:none}
.anotei-bar{height:3px;margin:-7px -7px 7px;border-radius:16px 16px 0 0;background:#dfe3e9;overflow:hidden}
.anotei-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,${BRAND.red},${BRAND.blue})}
.anotei-label{margin:0 2px 8px;font-size:19px;color:#141414}
/* candidate card (urna language) */
.card{width:420px;padding:11px;display:grid;grid-template-columns:168px minmax(0,1fr);grid-template-rows:auto auto;column-gap:13px;row-gap:9px;background:#fff;border-radius:13px;box-shadow:0 1px 2px rgb(16 24 40 / 10%)}
.card__photo{position:relative;grid-column:1;grid-row:1;width:168px;height:208px;overflow:hidden;border-radius:9px;background:linear-gradient(168deg,#fdfaf4 0%,#f2e9da 100%);box-shadow:inset 0 0 0 1px rgb(16 24 40 / 5%)}
.card__photo img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 0}
.card__info{grid-column:2;grid-row:1;display:flex;flex-direction:column;min-width:0;padding:3px 1px 0 0}
.card__office{font-size:14px;font-weight:700;letter-spacing:.11em;color:#31363d;white-space:nowrap}
.card__party{display:flex;align-items:center;gap:7px;margin-top:7px;font-size:28px;font-weight:900;line-height:1;letter-spacing:-.01em;color:#0f0f0f}
.card__party svg{width:22px;height:22px;color:${BRAND.red}}
.card__state{margin-top:9px;font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86}
.card__spacer{flex:1 1 auto;min-height:8px}
.card__urna{font-size:12.5px;font-weight:700;letter-spacing:.145em;color:#767d86;margin-bottom:5px}
.card__digits{display:flex;gap:5px}
.card__digit{display:grid;place-items:center;width:43px;height:53px;border-radius:8px;background:#1c1c1c;color:#fff;font-size:32px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
/* "Sem estadual": the very same card shell as the candidates — photo window on
the left (the official Lula mark on a light neutral background) and the info
column on the right, with the instruction to pick a state deputy from Lula's
team where the urna digits sit. No party is named. */
.card__photo--mark{background:linear-gradient(160deg,#fdfbf6 0%,#efe9dd 100%);display:flex;align-items:center;justify-content:center;padding:0}
.card__photo--mark img{position:static;width:96%;height:auto;object-fit:contain}
.card__lead{margin-top:6px;font-size:26px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f}
.card__name{grid-column:1/-1;grid-row:2;font-size:34px;font-weight:900;line-height:1;letter-spacing:-.012em;color:#0f0f0f;padding:3px 1px 1px}
/* composer */
.composer{position:absolute;z-index:6;inset:auto 0 0;height:53px;display:flex;align-items:center;gap:9px;padding:8px 11px 9px;background:#fff}
.composer__cam{flex:none;display:grid;place-items:center;width:32px;height:32px;border-radius:50%;background:${BRAND.chatGradient};color:#fff}
.composer__cam svg{width:17px;height:17px}
.composer__pill{flex:1 1 auto;display:flex;align-items:center;gap:12px;height:35px;padding:0 13px;border:1px solid #e7e9ed;border-radius:18px;color:#9aa0a8}
.composer__pill span{font-size:16px}
.composer__icons{display:flex;align-items:center;gap:14px;margin-left:auto;color:#15181d}
.composer__icons svg{width:19px;height:19px}
/* ---------------- end screen: the colinha assembles itself ---------------- */
.screen{position:absolute;inset:0;z-index:2;background:#fff;transform-origin:50% 50%;backface-visibility:hidden;visibility:hidden}
.screen--colinha{z-index:3}
.colinha-art{position:absolute;inset:0;width:540px;height:960px;object-fit:cover}
/* The official group photo (the "Time de você" family shot) fills the art's
baked photo band; the wave clip keeps the art's crest in front of it. Each
candidate's patch of the blurred copy fades out at their reveal. */
.figures{position:absolute;inset:0;clip-path:var(--wave)}
.group{position:absolute;inset:0;background-image:var(--group);background-size:540px 720px;background-position:0 -60px;background-repeat:no-repeat}
.group--blur{background-image:var(--group-blur);opacity:1;mask-repeat:no-repeat;-webkit-mask-repeat:no-repeat}
/* The art's own red band content is covered, but its coloured crest stays: the
cover is clipped by the band's own top curve. */
.band-cover{position:absolute;inset:0;clip-path:var(--band)}
.band-logo{position:absolute;opacity:0}
.band-logo img{display:block;width:100%;height:auto}
/* The slip's own title (with its sky), feathered; it appears last. */
.title-patch{position:absolute;left:90px;top:5px;width:360px;height:135px;opacity:0}
.title-patch img{display:block;width:100%;height:100%}
.row-mask{position:absolute;background:#fff}
.colinha-row{position:absolute;inset:0;opacity:0}
.colinha-mask{position:absolute;left:132.3px;top:536.64px;width:309.96px;height:27.84px;background:#fff}
.colinha-copy{position:absolute;left:145.26px;top:542.02px;width:285.12px;height:24px;display:flex;align-items:baseline;justify-content:space-between;gap:8.1px}
.colinha-copy__office{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:13.45px;font-weight:400;line-height:1;color:#202020;white-space:nowrap;flex:none}
.colinha-copy__name{font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:18.36px;font-weight:900;line-height:1;color:${BRAND.red};letter-spacing:-.04em;white-space:nowrap;text-align:right}
.colinha-digits{position:absolute;left:139.86px;top:560.64px;display:flex;gap:4.38px}
.colinha-digit{display:grid;place-items:center;width:32.94px;height:41.76px;font-family:ReelArimo,Arial,Helvetica,sans-serif;font-size:22.95px;font-weight:900;line-height:1;color:#171717}
/* intro fade (white cover over the very first frames) */
.fade{position:absolute;z-index:9;inset:0;background:#fff;pointer-events:none}
`
const RENDER_JS = `
;(() => {
const CFG = window.__colinhaConfig
const clamp01 = (v) => Math.min(1, Math.max(0, v))
const easeOutCubic = (p) => 1 - Math.pow(1 - p, 3)
const easeInOutCubic = (p) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2)
const beats = CFG.beats
const nodeById = new Map()
for (const el of document.querySelectorAll('[data-beat]')) nodeById.set(el.dataset.beat, el)
const messages = document.getElementById('messages')
const chat = document.getElementById('chat')
const screens = { colinha: document.getElementById('screen-colinha') }
const fade = document.getElementById('fade')
const ends = beats.filter((beat) => beat.kind === 'end')
const titlePatch = document.getElementById('title-patch')
const figures = new Map()
const logos = new Map()
const rowMasks = new Map()
for (const el of document.querySelectorAll('[data-figure]')) figures.set(el.dataset.figure, el)
for (const el of document.querySelectorAll('[data-logo]')) logos.set(el.dataset.logo, el)
for (const el of document.querySelectorAll('[data-row]')) rowMasks.set(el.dataset.row, el)
const drawnRow = document.getElementById('colinha-drawn')
let scrollTargets = []
let prepared = false
const prepare = () => {
for (const el of nodeById.values()) el.style.display = ''
for (const el of document.querySelectorAll('.seen')) el.style.display = 'block'
const max = Math.max(0, messages.scrollHeight - messages.clientHeight)
scrollTargets = beats.map((beat) => {
const el = nodeById.get(beat.id)
if (!el) return max
const bottom = el.offsetTop + el.offsetHeight + 12
return Math.min(max, Math.max(0, bottom - messages.clientHeight))
})
for (const el of nodeById.values()) el.style.display = ''
for (const el of document.querySelectorAll('.seen')) el.style.display = ''
fitColinhaName()
fitCardNames()
prepared = true
}
/** Long ballot names shrink on the card's one name line instead of wrapping. */
function fitCardNames() {
const max = 420 - 22
const ctx = document.createElement('canvas').getContext('2d')
if (!ctx) return
for (const el of document.querySelectorAll('.card__name')) {
el.style.fontSize = ''
el.style.whiteSpace = ''
const size = Number.parseFloat(getComputedStyle(el).fontSize)
ctx.font =
getComputedStyle(el).fontWeight + ' ' + size + 'px ReelInter, Inter, system-ui, sans-serif'
const width = ctx.measureText(el.textContent ?? '').width
if (width > max && width > 0) {
el.style.fontSize = Math.max(20, Math.floor((size * max) / width)) + 'px'
}
el.style.whiteSpace = 'nowrap'
}
}
function fitColinhaName() {
const name = document.querySelector('.colinha-copy__name')
const office = document.querySelector('.colinha-copy__office')
if (!name || !office) return
const band = 285.12
const gap = 8.1
const max = band - office.getBoundingClientRect().width - gap
const width = name.getBoundingClientRect().width
if (width > max && width > 0) {
name.style.fontSize = Math.floor((18.36 * max) / width) + 'px'
}
}
const renderTyping = (el, t, enter) => {
const dots = el.querySelectorAll('i')
dots.forEach((dot, index) => {
const phase = ((t - enter) / 420) * Math.PI * 2 - index * 0.7
const lift = Math.max(0, Math.sin(phase)) * 2.6
dot.style.transform = 'translateY(' + (-lift).toFixed(2) + 'px)'
dot.style.opacity = (0.55 + 0.45 * Math.max(0, Math.sin(phase))).toFixed(3)
})
}
window.colinhaReset = () => {
prepared = false
for (const el of nodeById.values()) el.style.display = ''
}
window.colinhaRender = (t) => {
if (!prepared) prepare()
const fadeP = clamp01(t / 180)
fade.style.opacity = String(1 - fadeP)
let active = -1
for (let i = 0; i < beats.length; i++) {
if (t >= beats[i].atMs) active = i
else break
}
for (let i = 0; i < beats.length; i++) {
const beat = beats[i]
if (beat.kind === 'end') continue
const el = nodeById.get(beat.id)
if (!el) continue
const visible = t >= beat.atMs && (beat.hideAtMs === undefined || t < beat.hideAtMs)
if (!visible) { el.style.display = 'none'; continue }
el.style.display = ''
const p = easeOutCubic(clamp01((t - beat.atMs) / beat.enterMs))
if (beat.kind === 'card') {
el.style.opacity = String(p)
el.style.transform = 'translateY(' + ((1 - p) * 15).toFixed(2) + 'px) scale(' + (0.965 + 0.035 * p).toFixed(4) + ')'
} else if (beat.kind === 'typing') {
renderTyping(el, t, beat.atMs)
} else {
el.style.opacity = String(p)
el.style.transform = 'translateY(' + ((1 - p) * 9).toFixed(2) + 'px)'
}
if (beat.seen) {
const seen = el.querySelector('.seen')
if (seen) {
const sp = easeOutCubic(clamp01((t - beat.atMs - 720) / 260))
seen.style.display = sp > 0 ? 'block' : 'none'
seen.style.opacity = String(sp)
}
}
if (beat.kind === 'card') {
const bar = el.querySelector('.anotei-bar i')
if (bar) {
const from = (beat.cardProgressFrom ?? 0) * 100
const to = beat.cardProgress * 100
bar.style.width = (from + (to - from) * easeOutCubic(clamp01((t - beat.atMs) / 620))).toFixed(2) + '%'
}
}
}
if (active >= 0) {
const from = active > 0 ? scrollTargets[active - 1] : 0
const to = scrollTargets[active]
const p = easeInOutCubic(clamp01((t - beats[active].atMs) / 460))
messages.scrollTop = from + (to - from) * p
} else {
messages.scrollTop = 0
}
if (ends.length > 0) {
const flip = CFG.flipMs
const first = ends[0]
const second = ends[1]
const out1 = clamp01((t - first.atMs) / flip)
if (t < first.atMs) {
chat.style.visibility = 'visible'
chat.style.transform = 'rotateY(0deg)'
} else if (out1 < 1) {
chat.style.visibility = 'visible'
chat.style.transform = 'rotateY(' + (-90 * easeInOutCubic(out1)).toFixed(3) + 'deg)'
} else {
chat.style.visibility = 'hidden'
}
const in1 = clamp01((t - first.atMs - flip) / flip)
if (in1 > 0) {
screens.colinha.style.visibility = 'visible'
const out2 = second ? clamp01((t - second.atMs) / flip) : 0
const rot = second && t >= second.atMs ? -90 * easeInOutCubic(out2) : 90 * (1 - easeInOutCubic(in1))
screens.colinha.style.transform = 'rotateY(' + rot.toFixed(3) + 'deg)'
}
}
for (const reveal of CFG.reveals) {
const p = easeOutCubic(clamp01((t - reveal.atMs) / reveal.durationMs))
const figure = figures.get(reveal.candidateId)
if (figure) figure.style.opacity = String(1 - p)
const logo = logos.get(reveal.candidateId)
if (logo) {
logo.style.opacity = String(p)
logo.style.transform = 'scale(' + (0.82 + 0.18 * p).toFixed(4) + ')'
}
const mask = rowMasks.get(reveal.candidateId)
if (mask) mask.style.opacity = String(1 - p)
if (drawnRow && reveal.candidateId === CFG.drawnRow) drawnRow.style.opacity = String(p)
}
if (CFG.brandAtMs !== undefined) {
const brandP = easeInOutCubic(clamp01((t - CFG.brandAtMs) / 900))
if (titlePatch) titlePatch.style.opacity = String(brandP)
}
}
window.colinhaReady = (async () => {
await document.fonts.ready
await Promise.all([...document.images].map((img) => img.decode().catch(() => undefined)))
prepare()
window.colinhaRender(0)
return true
})()
})()
`
/**
* @param {{ beats: Array<{ id: string, kind: string, atMs: number, enterMs: number, text?: string, candidateId?: string, screen?: string, seen?: boolean, hideAtMs?: number, cardProgress?: number }>, candidates: Array<{ id: string, office: string, name: string, number: string, photo: string, figure: Record<string, number>, logo: Record<string, number>, row: Record<string, number> }>, reveals: Array<{ candidateId: string, atMs: number, durationMs: number }>, brandAtMs: number, drawnRow: string, totalMs: number, flipMs: number, fontCss: string, assets: Record<string, string>, cardFinale?: boolean }} options
*/
export const colinhaReelHtml = ({
beats,
candidates,
reveals,
brandAtMs,
drawnRow,
totalMs,
flipMs,
fontCss,
assets,
cardFinale = false,
}) => {
const candidateById = new Map(candidates.map((candidate) => [candidate.id, candidate]))
const estadual = candidates[1]
const cardCount = candidates.length
const rows = beats
.filter((beat) => beat.kind !== 'end')
.map((beat) => {
if (beat.kind === 'typing') {
return `<div class="row row--left" data-beat="${beat.id}"><div class="bubble bubble--assistant typing"><i></i><i></i><i></i></div></div>`
}
if (beat.kind === 'card') {
const candidate = candidateById.get(beat.candidateId)
if (candidate.generic) {
return `<div class="row row--left row--card" data-beat="${beat.id}">
<div class="bubble bubble--card">
<div class="anotei-bar"><i style="width:${(((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100).toFixed(2)}%"></i></div>
<div class="anotei-label">anotei:</div>
<article class="card">
<div class="card__photo card__photo--mark"><img src="${assets.lulaMark}" alt="Marca oficial de Lula"></div>
<div class="card__info">
<div class="card__office">${escapeHtml(candidate.office)}</div>
<div class="card__lead">${escapeHtml(candidate.name)}</div>
<div class="card__state">${escapeHtml(COLINHA_REEL.cardState)}</div>
</div>
<div class="card__name">ESCOLHA O SEU</div>
</article>
</div>
</div>`
}
const progress =
((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100
const digits = [...candidate.number]
.map((digit) => `<span class="card__digit">${escapeHtml(digit)}</span>`)
.join('')
const photoStyle = candidate.photoZoom
? ` style="transform:translateY(${candidate.photoShiftY ?? 0}%) scale(${candidate.photoZoom});transform-origin:${candidate.photoOrigin ?? '50% 0%'}"`
: ''
return `<div class="row row--left row--card" data-beat="${beat.id}">
<div class="bubble bubble--card">
<div class="anotei-bar"><i style="width:${progress.toFixed(2)}%"></i></div>
<div class="anotei-label">anotei:</div>
<article class="card">
<div class="card__photo"><img src="${assets[`photo:${candidate.id}`]}" alt="${escapeHtml(candidate.name)}"${photoStyle}></div>
<div class="card__info">
<div class="card__office">${escapeHtml(candidate.office)}</div>
<div class="card__party">${escapeHtml(COLINHA_REEL.cardParty)}${PARTY_STAR}</div>
<div class="card__state">${escapeHtml(COLINHA_REEL.cardState)}</div>
<div class="card__spacer"></div>
<div class="card__urna">NA URNA</div>
<div class="card__digits">${digits}</div>
</div>
<div class="card__name">${escapeHtml(candidate.name)}</div>
</article>
</div>
</div>`
}
const seen = beat.seen ? '<div class="seen">Visto</div>' : ''
const variant = beat.kind === 'number' ? 'number' : beat.kind
return `<div class="row ${beat.kind === 'voter' || beat.kind === 'number' ? 'row--right' : 'row--left'}" data-beat="${beat.id}">
<div class="bubble bubble--${variant}">${escapeHtml(beat.text)}</div>
${seen}
</div>`
})
.join('\n')
const colinhaDigits = [...estadual.number]
.map((digit) => `<span class="colinha-digit">${escapeHtml(digit)}</span>`)
.join('')
const figuresMarkup = candidates
.map((candidate) => {
const { cx, cy, rx, ry } = candidate.figure
const mask = `radial-gradient(ellipse ${rx}px ${ry}px at ${cx}px ${cy}px, #000 52%, transparent 100%)`
return `<div class="group group--blur" data-figure="${candidate.id}" style="mask-image:${mask};-webkit-mask-image:${mask}"></div>`
})
.join('\n ')
const logosMarkup = candidates
.map(
(candidate) =>
`<div class="band-logo" data-logo="${candidate.id}" style="left:${candidate.logo.left}px;top:${candidate.logo.top}px;width:${candidate.logo.width}px"><img src="${assets[`logo:${candidate.id}`]}" alt=""></div>`,
)
.join('\n ')
const rowMasksMarkup = candidates
.map(
(candidate) =>
`<div class="row-mask" data-row="${candidate.id}" style="left:${COLINHA_END.rowMask.left}px;top:${candidate.row.top}px;width:${COLINHA_END.rowMask.width}px;height:${candidate.row.height}px"></div>`,
)
.join('\n ')
let cardIndex = -1
const config = {
totalMs,
flipMs,
reveals: reveals.map((reveal) => ({ ...reveal })),
brandAtMs,
drawnRow,
beats: beats.map((beat) => {
const cardProgress = beat.kind === 'card' ? (++cardIndex + 1) / cardCount : undefined
return {
id: beat.id,
kind: beat.kind,
atMs: beat.atMs,
enterMs: beat.enterMs,
...(beat.candidateId ? { candidateId: beat.candidateId } : {}),
...(beat.screen ? { screen: beat.screen } : {}),
...(beat.seen ? { seen: true } : {}),
...(beat.hideAtMs !== undefined ? { hideAtMs: beat.hideAtMs } : {}),
...(cardProgress !== undefined
? { cardProgress, cardProgressFrom: Math.max(0, cardProgress - 1 / cardCount) }
: {}),
}
}),
}
return `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<title>${escapeHtml(COLINHA_REEL.title)}</title>
<style>${fontCss}${BASE_CSS}</style>
</head>
<body>
<div class="stage" style="--wave:${assets.waveClip};--band:${assets.bandClip};--group:url(&quot;${assets.group}&quot;);--group-blur:url(&quot;${assets.groupBlur}&quot;)">
<div class="chat" id="chat">
<header class="chat-top">
<span class="chat-top__back">${BACK_ICON}</span>
<span class="chat-top__avatar"><img src="${assets.avatar}" alt="Jorge Solla 1313"></span>
<span class="chat-top__name brexter">${escapeHtml(COLINHA_REEL.chatTitle)}</span>
<span class="chat-top__icons">${VIDEO_ICON}${INFO_ICON}</span>
</header>
<div class="messages" id="messages">
<div class="day">HOJE, ${escapeHtml(COLINHA_REEL.chatHour)}</div>
${rows}
</div>
<footer class="composer">
<span class="composer__cam">${CAMERA_ICON}</span>
<span class="composer__pill"><span>Mensagem...</span><span class="composer__icons">${MIC_ICON}${IMAGE_ICON}</span></span>
</footer>
</div>
<section class="screen screen--colinha" id="screen-colinha">
${
cardFinale
? `<img class="colinha-art" src="${assets.siteCard}" alt="Colinha personalizada gerada pelo site, com o time completo">`
: `<img class="colinha-art" src="${assets.colinhaArt}" alt="Colinha oficial de votação com o time completo">
<div class="figures">
<div class="group group--sharp"></div>
${figuresMarkup}
</div>
<div class="band-cover" style="background:${COLINHA_END.band.color}"></div>
${logosMarkup}
<div class="title-patch" id="title-patch"><img src="${assets.titlePatch}" alt="Mais Saúde, Mais Futuro"></div>
${rowMasksMarkup}
<div class="colinha-row" id="colinha-drawn">
<div class="colinha-mask"></div>
<div class="colinha-copy">
<span class="colinha-copy__office">${escapeHtml(estadual.office)}</span>
<span class="colinha-copy__name">${escapeHtml(estadual.name)}</span>
</div>
<div class="colinha-digits">${colinhaDigits}</div>
</div>`
}
</section>
<div class="fade" id="fade"></div>
</div>
<script>window.__colinhaConfig=${JSON.stringify(config)};${RENDER_JS}</script>
</body>
</html>
`
}

View file

@ -0,0 +1,58 @@
# Ativos do reel "Sua colinha para 2026" (colinha-time-1313)
Fotos oficiais recortadas e o selo da estrela usados pelo builder
[`scripts/build-colinha-reel.mjs`](../../../build-colinha-reel.mjs) — o reel de
chat que monta a colinha do time 1313 na ordem da urna. Os derivativos são
commitados (o reel é reprodutível a partir deles); as origens pesadas ficam fora
do repo.
| Arquivo | Origem | Receita |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `solla.webp` | `FOTO SOLLA CAMISA BRANCA.png` (kit oficial, `OneDrive_2026-09-19/kit solla 1313/`) | `trim` + altura 1000 px + webp q88 |
| `julio.webp` | `DOBRADINHAS-ORGANIZADAS/Júlio Pinheiro/foto.png` (extração oficial da campanha; `FONTES.txt` da pasta documenta a camada do adesivo) | idem |
| `wagner.webp` | `public/WAGNER - 2-9 final.webp` (site) | idem |
| `rui.webp` | `public/RUI - 2-2.webp` (site) | idem |
| `jeronimo.webp` | `public/Jeronimo.webp` (site) | idem |
| `lula.webp` | `public/Lula.webp` (site) | idem |
| `logo-o.webp` | `public/campaign-kit/radio/radio-jorge-solla-1313-logo.png` (kit oficial — a logo "O" da rádio, usada no avatar do header) | `trim` + 256 px + webp q90 |
| `sfx-confirma.mp3` | o "confirma" da urna eletrônica (som do TSE), extraído de uma gravação pública do efeito (YouTube `SALMI6_CSYk`): trecho 2,98–3,98 s (a sequência rápida de bipes após a tecla verde), high-pass 350 Hz / low-pass 9 kHz, mono, loudness −15 LUFS | `ffmpeg -ss 2.98 -t 1.00 -af "highpass=f=350,lowpass=f=9000" -ac 1 -ar 44100` + `loudnorm=I=-15:TP=-1.5` |
| `jingle-play.mp3` | o jingle de axé da campanha (`/home/fsolla/Downloads/Jorge solla play.wav`, 2:11) — derivado mono 112 kbps; entra como cama sonora a partir de 0:04 do vídeo, bem baixo | `ffmpeg -i "Jorge solla play.wav" -ac 1 -ar 44100 -b:a 112k` |
| `fonts/inter-latin.woff2` | Inter (Google Fonts, OFL) — subset latim, eixo `wght` 100–900 | baixado do CSS `https://fonts.googleapis.com/css2?family=Inter:wght@100..900` (bloco `latin`) |
Os demais ativos do reel são lidos direto do site: a arte da colinha
(`public/cards/modelo-colinha.jpeg`), o fundo oficial da dobradinha
(`public/cards/estaduais/julio-fotos.webp`, céu da cena final) e o overlay
(`public/cards/estaduais/julio-base.webp`, lockup do Julio) e as fontes Brexter
(`src/app/(frontend)/fonts/`), Inter (os `fonts/` daqui) e Arimo
(`~/.local/share/fonts`, dependência já documentada pelo `build-radio-artes`
para a linha desenhada da colinha).
A cena final é remontada pelo builder a partir dessas artes: o grupo é a foto
oficial `julio-fotos.webp` (com uma cópia desfocada para o reveal por foco), a
faixa vermelha tem a crista preservada (recorte pelo topo da própria faixa) e
suas logos saem das artes com o vermelho (`#e50e2f`) virando alfa — a quadrada
do Solla e as quatro marcas da colinha, o lockup do Julio do overlay da
dobradinha. Nada disso é commitado — são derivados em memória a cada build.
O enquadramento da foto de cada candidato no card é dado pelo par
`photoZoom`/`photoShiftY` (e `photoOrigin`, quando preciso) em
`scripts/lib/colinhaReel.mjs`: o card usa `object-fit: cover` com o topo
ancorado e o zoom/deslocamento garantem cabeça inteira com respiro.
Regenerar um derivativo (exemplo da foto do Solla):
```bash
node -e "require('sharp')('<origem>').trim({threshold:8}).resize({height:1000,fit:'inside'}).webp({quality:88}).toFile('scripts/reels/assets/colinha-time-1313/solla.webp')"
```
A terceira versão do reel (`--finale=card`) não é composta aqui: o builder
dirige o estúdio de cards em produção (`/cards?model=minha-colinha`), escolhe o
estadual Julio Pinheiro e levanta o canvas 1080×1920 que o renderer do próprio
site pintou — o PNG cru sai no pacote como `colinha-site.png`.
O `--beeps` do builder mixa esse efeito (um bipe por card, no beat de cada
candidato) na trilha do vídeo, sem re-encodar o vídeo (`-c:v copy`); o lote
`pnpm reels:dobradinhas` já liga a flag.
Uso no reel: `pnpm reels:colinha` (ver o cabeçalho do builder para as flags de
finale, preview, capa, beeps e dry-run).

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,258 @@
{
"source": "Produção (state_deputy × contact) em 2026-10-01: as dobradinhas cujo nome NÃO contém \"(apagar)\" (marcador temporário da assessoria para as que saíram). 46 registros ativos; 4 sem par no catálogo do site (Gildo Baleia, Juvenilson, Rafael Meireles) e 1 duplicata (Geane Vasconcelo ACS) ficam fora — o reel-card exige o card do site e a foto oficial. As fotos vêm de DOBRADINHAS-ORGANIZADAS/<pasta>/foto.png (as mesmas dos cards da Plenária da Vitória).",
"photosDir": "/home/fsolla/Downloads/DOBRADINHAS-ORGANIZADAS",
"dobradinhas": [
{
"slug": "enfermagem",
"catalog": "Coletivo de Enfermagem",
"number": "13763",
"folder": "Coletivo de Enfermagem da Bahia"
},
{
"slug": "andrea",
"catalog": "Andréa Castro",
"number": "55670",
"folder": "Andréa Castro"
},
{
"slug": "andre",
"catalog": "André Fidalgo",
"number": "13131",
"folder": "André Fidalgo"
},
{
"slug": "arthur",
"catalog": "Artur Barachisio Lisbôa",
"number": "55255",
"folder": "Arthur Lisboa"
},
{
"slug": "bobo",
"catalog": "Bobô",
"number": "65888",
"folder": "Bobô"
},
{
"slug": "carol",
"catalog": "Carol da Pitanguinha",
"number": "13110",
"folder": "Carol Pitanguinha"
},
{
"slug": "cicero",
"catalog": "Cícero Monteiro",
"number": "70133",
"folder": "Cicero Monteiro"
},
{
"slug": "denise",
"catalog": "Denise Menezes",
"number": "55111",
"folder": "Denise Menezes"
},
{
"slug": "euclides",
"catalog": "Euclides Fernandes",
"number": "13123",
"folder": "Euclides Fernandes"
},
{
"slug": "fabiola",
"catalog": "Dra. Fabíola Mansur",
"number": "43123",
"folder": "Fabíola Mansur"
},
{
"slug": "fatima",
"catalog": "Fátima Nunes",
"number": "13567",
"folder": "Fátima Nunes"
},
{
"slug": "geane",
"catalog": "Geane Vasconcelos",
"number": "12120",
"folder": "Geane"
},
{
"slug": "hilton",
"catalog": "Hilton",
"number": "50150",
"folder": "Hilton"
},
{
"slug": "ivana",
"catalog": "Ivana Bastos",
"number": "55555",
"folder": "Ivana Bastos"
},
{
"slug": "jamille",
"catalog": "Jamile da Saúde",
"number": "45456",
"folder": "Jamille"
},
{
"slug": "josafa",
"catalog": "Josafá Marinho",
"number": "12222",
"folder": "Josafá"
},
{
"slug": "juliete",
"catalog": "Juliete Barreto",
"number": "65100",
"folder": "Juliete"
},
{
"slug": "julio",
"catalog": "Julio Pinheiro",
"number": "13999",
"folder": "Júlio Pinheiro"
},
{
"slug": "junior",
"catalog": "Júnior Muniz",
"number": "13000",
"folder": "Junior Muniz"
},
{
"slug": "kleber",
"catalog": "Kleber Rosa",
"number": "50500",
"folder": "Kleber"
},
{
"slug": "leninha",
"catalog": "Lenínha Valente",
"number": "13111",
"folder": "Leninha"
},
{
"slug": "leo",
"catalog": "Leo de Neco",
"number": "70700",
"folder": "Leo de Neco"
},
{
"slug": "magno",
"catalog": "Magno",
"number": "18000",
"folder": "Magno"
},
{
"slug": "mestre",
"catalog": "Mestre Reginaldo",
"number": "18273",
"folder": "Mestre Reginaldo"
},
{
"slug": "neusa",
"catalog": "Neusa Cadore",
"number": "13690",
"folder": "Neusa"
},
{
"slug": "niltinho",
"catalog": "Niltinho",
"number": "55999",
"folder": "Niltinho"
},
{
"slug": "osni",
"catalog": "Osni",
"number": "13013",
"folder": "Osni"
},
{
"slug": "patrick",
"catalog": "Patrick Lopes",
"number": "70123",
"folder": "Patrick Lopes"
},
{
"slug": "pinheiro",
"catalog": "Pinheiro",
"number": "13700",
"folder": "Pinheiro"
},
{
"slug": "radiovaldo",
"catalog": "Radiovaldo",
"number": "13050",
"folder": "Radiovaldo Costa"
},
{
"slug": "robinson",
"catalog": "Robinson",
"number": "13500",
"folder": "Robinson"
},
{
"slug": "rogerio",
"catalog": "Rogério Andrade",
"number": "15444",
"folder": "Rogério Andrade"
},
{
"slug": "rosemberg",
"catalog": "Rosemberg",
"number": "13444",
"folder": "Rosemberg"
},
{
"slug": "rosival",
"catalog": "Rosival Leite",
"number": "13333",
"folder": "Rosival Leite"
},
{
"slug": "rowenna",
"catalog": "Rowenna",
"number": "13456",
"folder": "Rowenna"
},
{
"slug": "silva",
"catalog": "Silva Neto",
"number": "70444",
"folder": "Silva Neto"
},
{
"slug": "silvio",
"catalog": "Silvio Dias",
"number": "13113",
"folder": "Sílvio Dias"
},
{
"slug": "vilma",
"catalog": "Vilma Reis",
"number": "13033",
"folder": "Vilma Reis"
},
{
"slug": "wenceslau",
"catalog": "Wenceslau",
"number": "65111",
"folder": "Wenceslau"
},
{
"slug": "yulo",
"catalog": "Yulo",
"number": "13234",
"folder": "Yulo"
},
{
"slug": "ze-raimundo",
"catalog": "Zé Raimundo",
"number": "13222",
"folder": "Zé Raimundo"
},
{
"slug": "angelo",
"catalog": "Angelo Almeida",
"number": "13100",
"folder": "Angelo Almeida"
}
]
}

View file

@ -0,0 +1,198 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildColinhaBeats,
buildColinhaTimeline,
COLINHA_BEAT_DURATIONS,
COLINHA_CANDIDATES,
COLINHA_REEL,
COLINHA_URNA_ORDER,
getColinhaCandidate,
} from '../../scripts/lib/colinhaReel.mjs'
import { colinhaReelHtml } from '../../scripts/lib/colinhaReelTemplate.mjs'
// The colinha chat reel — the script is the single source of the piece: the
// urna order, the ballot numbers, the beat timing and the two end screens are
// pinned here, and the template must render every beat of the script.
const ASSETS_DIR = join(process.cwd(), 'scripts/reels/assets', 'colinha-time-1313')
const REQUIRED_ASSETS = [
'solla.webp',
'julio.webp',
'wagner.webp',
'rui.webp',
'jeronimo.webp',
'lula.webp',
'logo-o.webp',
'fonts/inter-latin.woff2',
]
describe('colinhaReel', () => {
it('keeps the ticket in urna order with the official ballot numbers', () => {
expect(COLINHA_URNA_ORDER).toEqual(['solla', 'julio', 'wagner', 'rui', 'jeronimo', 'lula'])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.number)).toEqual([
'1313',
'13999',
'130',
'133',
'13',
'13',
])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.office)).toEqual([
'DEPUTADO FEDERAL',
'DEPUTADO ESTADUAL',
'SENADOR (1ª VAGA)',
'SENADOR (2ª VAGA)',
'GOVERNADOR',
'PRESIDENTE',
])
expect(COLINHA_CANDIDATES.map((candidate) => candidate.ask)).toEqual([
'Deputado Federal',
'Deputado Estadual',
'Senador (1ª vaga)',
'Senador (2ª vaga)',
'Governador',
'Presidente',
])
expect(COLINHA_REEL.slug).toBe('colinha-time-1313')
})
it('ships the committed photo for every candidate and the star badge', () => {
for (const candidate of COLINHA_CANDIDATES) {
expect(existsSync(join(ASSETS_DIR, candidate.photo))).toBe(true)
}
for (const asset of REQUIRED_ASSETS) {
expect(existsSync(join(ASSETS_DIR, asset))).toBe(true)
}
expect(getColinhaCandidate('solla')?.name).toBe('JORGE SOLLA')
expect(getColinhaCandidate('nobody')).toBeUndefined()
})
it('asks the full question once, then the short "e pra …?" for every candidate', () => {
const beats = buildColinhaBeats()
const asks = beats.filter((beat) => beat.id.startsWith('ask-'))
expect(asks).toHaveLength(COLINHA_CANDIDATES.length)
expect(asks[0].text).toBe('pra Deputado Federal, quem você escolheu?')
expect(asks.slice(1).map((beat) => beat.text)).toEqual([
'e pra Deputado Estadual?',
'e pra Senador (1ª vaga)?',
'e pra Senador (2ª vaga)?',
'e pra Governador?',
'e pra Presidente?',
])
})
it('walks every candidate through ask → number → card and ends on the assembling slip', () => {
const beats = buildColinhaBeats({ finale: 'motion' })
const kinds = beats.map((beat) => beat.kind)
expect(kinds[0]).toBe('voter')
expect(beats.at(-1)?.kind).toBe('brand')
const reveals = beats.filter((beat) => beat.kind === 'reveal')
expect(reveals.map((beat) => beat.candidateId)).toEqual(COLINHA_URNA_ORDER)
expect(beats.filter((beat) => beat.kind === 'end')).toHaveLength(1)
expect(beats.find((beat) => beat.kind === 'end')?.screen).toBe('colinha')
const staticBeats = buildColinhaBeats({ finale: 'static' })
expect(staticBeats.filter((beat) => beat.kind === 'reveal')).toHaveLength(0)
expect(staticBeats.at(-1)?.kind).toBe('brand')
expect(staticBeats.at(-2)?.kind).toBe('end')
const staticEnd = staticBeats.find((beat) => beat.kind === 'end')
expect((staticEnd?.durationMs ?? 0) > 4000).toBe(true)
for (const candidate of COLINHA_CANDIDATES) {
const start = beats.findIndex((beat) => beat.id === `ask-${candidate.id}`)
expect(start).toBeGreaterThan(-1)
expect(beats[start + 1]).toMatchObject({
id: `number-${candidate.id}`,
kind: 'number',
text: candidate.number,
candidateId: candidate.id,
})
expect(beats[start + 2]).toMatchObject({ id: `card-${candidate.id}`, kind: 'card' })
}
const voterNumbers = beats.filter((beat) => beat.kind === 'number')
expect(voterNumbers.map((beat) => beat.text)).toEqual(
COLINHA_CANDIDATES.map((candidate) => candidate.number),
)
})
it('derives the timeline cumulatively and hides each typing beat when the reply lands', () => {
const beats = buildColinhaBeats()
const timeline = buildColinhaTimeline(beats, 30)
const expectedTotal = beats.reduce((sum, beat) => sum + beat.durationMs, 0)
expect(timeline.totalMs).toBe(expectedTotal)
expect(timeline.frameCount).toBe(Math.round((expectedTotal / 1000) * 30))
expect(timeline.entries[0].atMs).toBe(0)
timeline.entries.forEach((entry, index) => {
if (index > 0) expect(entry.atMs).toBe(timeline.entries[index - 1].endsAtMs)
if (entry.kind === 'typing') {
expect(entry.hideAtMs).toBe(timeline.entries[index + 1]?.atMs)
}
})
for (const [id, entry] of timeline.byId) {
expect(entry.id).toBe(id)
}
expect(COLINHA_BEAT_DURATIONS.flip).toBeGreaterThan(0)
expect(timeline.totalMs).toBeGreaterThan(20_000)
})
it('renders every beat, card and the assembling end screen in the template', () => {
const timeline = buildColinhaTimeline(buildColinhaBeats(), 30)
const reveals = timeline.entries.flatMap((entry) =>
entry.kind === 'reveal' && entry.candidateId
? [{ candidateId: entry.candidateId, atMs: entry.atMs, durationMs: entry.durationMs }]
: [],
)
const html = colinhaReelHtml({
beats: timeline.entries,
candidates: COLINHA_CANDIDATES,
reveals,
brandAtMs: timeline.byId.get('brand')?.atMs ?? 0,
drawnRow: COLINHA_CANDIDATES[1].id,
totalMs: timeline.totalMs,
flipMs: COLINHA_BEAT_DURATIONS.flip,
fontCss: '',
assets: { waveClip: 'polygon(0px 0px,540px 0px)' },
})
for (const entry of timeline.entries) {
if (entry.kind === 'end') continue
expect(html).toContain(`data-beat="${entry.id}"`)
}
for (const candidate of COLINHA_CANDIDATES) {
expect(html).toContain(candidate.name)
expect(html).toContain(candidate.office)
expect(html).toContain(`>${candidate.number[0]}</span>`)
}
for (const candidate of COLINHA_CANDIDATES) {
expect(html).toContain(`data-figure="${candidate.id}"`)
expect(html).toContain(`data-logo="${candidate.id}"`)
expect(html).toContain(`data-row="${candidate.id}"`)
}
expect(html).toContain('id="screen-colinha"')
expect(html).toContain('id="title-patch"')
expect(html).toContain('>Time 1313</span>')
expect(html).not.toContain('⭐')
expect(html).toContain('prontinho! colinha completa. bom voto! ✅')
expect(html).toContain('window.__colinhaConfig=')
const cardHtml = colinhaReelHtml({
beats: timeline.entries,
candidates: COLINHA_CANDIDATES,
reveals,
brandAtMs: timeline.byId.get('brand')?.atMs ?? 0,
drawnRow: COLINHA_CANDIDATES[1].id,
totalMs: timeline.totalMs,
flipMs: COLINHA_BEAT_DURATIONS.flip,
fontCss: '',
assets: { siteCard: 'data:image/png;base64,AAAA' },
cardFinale: true,
})
expect(cardHtml).toContain('data:image/png;base64,AAAA')
expect(cardHtml).not.toContain('group--sharp')
})
})