diff --git a/.gitignore b/.gitignore index 286cbaeb..ecb57818 100644 --- a/.gitignore +++ b/.gitignore @@ -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// +# (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/ diff --git a/docs/changelog/2026-09-30-reel-colinha-time-1313.md b/docs/changelog/2026-09-30-reel-colinha-time-1313.md new file mode 100644 index 00000000..ff2d6776 --- /dev/null +++ b/docs/changelog/2026-09-30-reel-colinha-time-1313.md @@ -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. diff --git a/docs/changelog/2026-10-01-reel-card-dobradinhas.md b/docs/changelog/2026-10-01-reel-card-dobradinhas.md new file mode 100644 index 00000000..969fec8d --- /dev/null +++ b/docs/changelog/2026-10-01-reel-card-dobradinhas.md @@ -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//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-/` (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`. diff --git a/docs/changelog/2026-10-01-reel-trilha-e-sem-estadual.md b/docs/changelog/2026-10-01-reel-trilha-e-sem-estadual.md new file mode 100644 index 00000000..31e71508 --- /dev/null +++ b/docs/changelog/2026-10-01-reel-trilha-e-sem-estadual.md @@ -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. diff --git a/package.json b/package.json index a1005039..40d26a65 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-colinha-reel.mjs b/scripts/build-colinha-reel.mjs new file mode 100644 index 00000000..90f0ab06 --- /dev/null +++ b/scripts/build-colinha-reel.mjs @@ -0,0 +1,1028 @@ +/** + * Reel "Sua colinha para 2026" — the deterministic builder of the chat reel + * (`data/reels/colinha-time-1313/reel.mp4` + `capa.png` + `metadata.json`). + * + * Pipeline: the script/timeline (`colinhaReel.mjs`, pure) → the HTML template + * (`colinhaReelTemplate.mjs`, fonts and images inlined as data URIs) → Chromium + * at 540×960 @2x, one screenshot per frame with the page state driven solely by + * `window.colinhaRender(tMs)` (no CSS animation → no drift) → `encodeFrames` + * (H.264, yuv420p, 30 fps, 1080×1920) → the cover (a designed 4:5 crop of a + * chosen reel moment) → the package metadata. + * + * The Facebook/Meta-owned brand assets of the reference reel never enter here: + * the piece carries only the campaign's own kit (public/) and the official + * candidate photos committed under `scripts/reels/assets//`. + * + * Usage: + * node scripts/build-colinha-reel.mjs [--out-dir=…] [--work-dir=…] [--fps=30] + * [--preview] [--dry-run] [--preview-frames=1200,3400] [--keep-frames] + */ + +import { existsSync } from 'node:fs' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { chromium } from '@playwright/test' + +import { dieWithLabel, parseEqualsFlags, sha256Hex } from './lib/cli.mjs' +import { + buildColinhaBeats, + buildColinhaTimeline, + COLINHA_BEAT_DURATIONS, + COLINHA_CANDIDATES, + COLINHA_GENERIC_ESTADUAL, + COLINHA_REEL, +} from './lib/colinhaReel.mjs' +import { colinhaFontCss, colinhaReelHtml } from './lib/colinhaReelTemplate.mjs' +import { + encodeFrames, + PREVIEW_CRF, + PREVIEW_PRESET, + REEL_CRF, + REEL_PRESET, + resolveFfmpeg, + runFfmpeg, +} from './lib/reelFfmpeg.mjs' + +const require = createRequire(import.meta.url) +const sharp = require('sharp') + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const LABEL = 'build-colinha-reel' +const die = dieWithLabel(LABEL) +const DEFAULT_OUT_DIR = 'data/reels' +const DEFAULT_WORK_DIR = 'data/reels/.work' +const ASSETS_DIR = join(ROOT, 'scripts/reels/assets', COLINHA_REEL.slug) +const BRAND_FONT = join(ROOT, 'src/app/(frontend)/fonts/Brexter-Bold.ttf') +const INTER_FONT = join(ASSETS_DIR, 'fonts/inter-latin.woff2') +const ARIMO_FONT = join(process.env.HOME ?? '', '.local/share/fonts/Arimo-Regular.woff2') +const COLINHA_ART = join(ROOT, 'public/cards/modelo-colinha.jpeg') +/** The official Lula mark, shown by the "sem estadual" card. */ +const LULA_MARK = join(ROOT, 'public/Marca-Lula.webp') +const JULIO_LOCKUP_SOURCE = join(ROOT, 'public/cards/estaduais/julio-base.webp') +const GROUP_SOURCE = join(ROOT, 'public/cards/estaduais/julio-fotos.webp') +/** The urna "confirma" beeps played when each vote card appears (`--beeps`). */ +const CONFIRMA_SFX = join(ASSETS_DIR, 'sfx-confirma.mp3') +/** The campaign jingle bed, very low, from the video's 0:04 (`--beeps`). */ +const JINGLE = join(ASSETS_DIR, 'jingle-play.mp3') +const DEFAULT_SITE_URL = 'https://jorgesolla1313.com.br' +/** The state deputy the site's personalized slip is rendered with. */ +const SITE_CARD_DEPUTY = 'Julio Pinheiro' +const VIEWPORT = { width: 540, height: 960 } +const DEVICE_SCALE = 2 +const COVER_VIEWPORT = { width: 540, height: 675 } + +const USAGE = `Uso: node scripts/build-colinha-reel.mjs [--out-dir=…] [--work-dir=…] [--fps=30] + [--finale=motion,static,card] [--base-url=…] [--preview] [--dry-run] + [--preview-frames=1200,3400] [--keep-frames] + [--estadual-name=… --estadual-number=… --estadual-photo=… --site-deputy=… --out-slug=…] + [--beeps] [--audio-only]` + +const dataUri = (buffer, mime) => `data:${mime};base64,${buffer.toString('base64')}` + +const requireFile = async (path, hint) => { + if (!existsSync(path)) die(`fonte/ativo ausente: ${path}\n${hint}`) + return readFile(path) +} + +const buildAssets = async (candidates = COLINHA_CANDIDATES) => { + const assets = {} + for (const candidate of candidates) { + if (candidate.photoPath) { + // A dobradinha's own cutout (the Plenária/estúdio source): normalised to + // the reel's asset shape (trimmed, 1000 px tall, webp) in memory. + const buffer = await sharp( + await requireFile( + candidate.photoPath, + 'A foto da dobradinha não existe no caminho indicado.', + ), + ) + .trim({ threshold: 8 }) + .resize({ height: 1000, fit: 'inside' }) + .webp({ quality: 88 }) + .toBuffer() + assets[`photo:${candidate.id}`] = dataUri(buffer, 'image/webp') + continue + } + if (!candidate.photo) continue + const file = join(ASSETS_DIR, candidate.photo) + const buffer = await requireFile( + file, + `As fotos do reel vivem em scripts/reels/assets/${COLINHA_REEL.slug}/ (ver README.md de lá).`, + ) + assets[`photo:${candidate.id}`] = dataUri(buffer, 'image/webp') + } + if (candidates.some((candidate) => candidate.generic)) { + const mark = await requireFile( + LULA_MARK, + 'A marca oficial de Lula vive em public/Marca-Lula.webp.', + ) + assets.lulaMark = dataUri( + await sharp(mark) + .trim({ threshold: 8 }) + .resize({ width: 640, fit: 'inside' }) + .webp({ quality: 92 }) + .toBuffer(), + 'image/webp', + ) + } + const avatar = await requireFile( + join(ASSETS_DIR, 'logo-o.webp'), + 'A logo "O" da rádio (kit oficial) é derivada para os assets do reel.', + ) + assets.avatar = dataUri(avatar, 'image/webp') + + const colinhaArt = await sharp( + await requireFile( + COLINHA_ART, + 'A arte da colinha é o ativo oficial do site (public/cards/modelo-colinha.jpeg).', + ), + ) + .resize(1080, 1920) + .jpeg({ quality: 92 }) + .toBuffer() + assets.colinhaArt = dataUri(colinhaArt, 'image/jpeg') + + const art = await readArtRaw() + assets.waveClip = buildWaveClip(art) + assets.bandClip = buildBandClip(art) + assets.titlePatch = dataUri(await buildTitlePatch(), 'image/png') + + // The end screen's group is the official photo of the "Time de você" card + // family: one shot, everyone at the same scale — the sharp image is the base + // and each candidate's blurred patch fades out at their reveal. + const group = await requireFile( + GROUP_SOURCE, + 'A foto do grupo da cena final é o fundo oficial da dobradinha (public/cards/estaduais/julio-fotos.webp).', + ) + assets.group = dataUri(await sharp(group).jpeg({ quality: 92 }).toBuffer(), 'image/jpeg') + assets.groupBlur = dataUri( + await sharp(group) + .blur(26) + .modulate({ brightness: 0.98, saturation: 0.92 }) + .jpeg({ quality: 88 }) + .toBuffer(), + 'image/jpeg', + ) + + const bandLogos = await extractBandLogos() + for (const [id, buffer] of Object.entries(bandLogos)) { + if (process.env.COLINHA_DEBUG_LOGOS) await writeFile(`/tmp/opencode/band-${id}.webp`, buffer) + assets[`logo:${id}`] = dataUri(buffer, 'image/webp') + } + + return assets +} + +/** The colinha art as raw RGBA at the reel's 1080×1920 output size. */ +const readArtRaw = async () => { + const art = await requireFile( + COLINHA_ART, + 'A arte da colinha é o ativo oficial do site (public/cards/modelo-colinha.jpeg).', + ) + return sharp(art).resize(1080, 1920).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) +} + +const isWave = (r, g, b) => (r > 200 && g > 175 && b < 135) || (g > 105 && r < 125 && b < 150) + +/** + * The crest of the yellow/green wave, per column, as a `clip-path` polygon on + * the 540×960 stage: everything above it belongs to the sky/group band, so the + * composed sky and the candidate figures stay behind the art's own wave. + */ +const buildWaveClip = ({ data, info }) => { + const { width, channels } = info + const points = [] + for (let x = 0; x <= width; x += 20) { + const column = Math.min(width - 1, x) + let top = 600 + for (let y = 540; y < 760; y++) { + const i = (y * width + column) * channels + if (isWave(data[i], data[i + 1], data[i + 2])) { + top = y + break + } + } + points.push([column / 2, top / 2 + 4]) + } + const polygon = [ + '0px 0px', + ...points.map(([x, y]) => `${x.toFixed(1)}px ${y.toFixed(1)}px`), + '540px 0px', + ] + return `polygon(${polygon.join(',')})` +} + +/** + * The red-band logos of the end scene, lifted from the official overlay art + * (which carries them with real alpha). Everything above the band's own top + * edge is cut per column — the overlay's yellow/green wave never leaks in — + * and the band red is kept, so a logo sits seamlessly anywhere on the band. + */ +/** + * The band's own top edge per column, as a `clip-path` polygon: the red cover + * that hides the art's baked lockups/marks is shaped by it, so the coloured + * crest of the original band (yellow/green waves) is never covered. + */ +const buildBandClip = ({ data, info }) => { + const { width, channels } = info + const points = [] + for (let x = 0; x <= width; x += 20) { + const column = Math.min(width - 1, x) + let top = 640 + for (let y = 560; y < 900; y++) { + const i = (y * width + column) * channels + if (Math.hypot(data[i] - 229, data[i + 1] - 14, data[i + 2] - 47) < 60) { + top = y + break + } + } + points.push([column / 2, top / 2]) + } + const polygon = [ + ...points.map(([x, y]) => `${x.toFixed(1)}px ${y.toFixed(1)}px`), + '540px 435px', + '0px 435px', + ] + return `polygon(${polygon.join(',')})` +} + +/** + * The slip's own title ("MAIS SAÚDE MAIS FUTURO", baked in the art) with a + * feathered edge: it fades in over the group photo when the slip is complete. + */ +const buildTitlePatch = async () => { + const { data, info } = await sharp( + await requireFile(COLINHA_ART, 'A arte da colinha é o ativo oficial do site.'), + ) + .resize(1080, 1920) + .ensureAlpha() + .extract({ left: 180, top: 10, width: 720, height: 270 }) + .raw() + .toBuffer({ resolveWithObject: true }) + const { width, height, channels } = info + const feather = 56 + const ramp = (d) => Math.max(0, Math.min(1, d / feather)) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * channels + const a = Math.min(ramp(x), ramp(width - 1 - x), ramp(y), ramp(height - 1 - y)) + data[i + 3] = Math.round(data[i + 3] * a) + } + } + return sharp(data, { raw: { width, height, channels } }).png().toBuffer() +} + +const extractBandLogos = async () => { + const bandRed = [229, 14, 47] + const dist = (r, g, b) => Math.hypot(r - bandRed[0], g - bandRed[1], b - bandRed[2]) + + /** + * The band's own span: its top edge per column (the wave curves) and the + * first row of the art's white slip below it (a global row, since that edge + * is straight) — so nothing outside the band survives a crop. + */ + const bandBounds = ({ data, info }) => { + const { width, channels } = info + const top = new Int32Array(width).fill(-1) + for (let x = 0; x < width; x++) { + for (let y = 560; y < Math.min(1100, info.height - 1); y++) { + let red = true + for (let k = 0; k < 6; k++) { + const i = ((y + k) * width + x) * channels + const opaque = channels < 4 || data[i + 3] > 200 + if (!opaque || dist(data[i], data[i + 1], data[i + 2]) > 90) { + red = false + break + } + } + if (red) { + top[x] = y + break + } + } + } + let bottom = info.height + for (let y = 700; y < Math.min(1100, info.height); y++) { + let white = 0 + let total = 0 + for (let x = 0; x < width; x += 4) { + const i = (y * width + x) * channels + const opaque = channels < 4 || data[i + 3] > 200 + if (opaque && data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) white++ + total++ + } + if (white / total > 0.7) { + bottom = y + break + } + } + return { top, bottom } + } + + const art = await readArtRaw() + const artSource = await sharp(art.data, { + raw: { width: art.info.width, height: art.info.height, channels: art.info.channels }, + }) + .png() + .toBuffer() + const julioBuffer = await requireFile( + JULIO_LOCKUP_SOURCE, + 'O lockup do Julio vem do overlay oficial (public/cards/estaduais/julio-base.webp).', + ) + const artBounds = bandBounds(art) + const julioBounds = bandBounds( + await sharp(julioBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true }), + ) + + /** + * The Solla lockup and the four marks come from the colinha art itself (they + * sit there clean of each other); the JULIO/PINHEIRO lockup comes from the + * official dobradinha overlay, which carries it with real alpha. + */ + const CROPS = { + solla: { source: julioBuffer, box: [85, 942, 445, 315], bounds: julioBounds }, + julio: { source: julioBuffer, box: [532, 890, 455, 300], bounds: julioBounds }, + lula: { source: artSource, box: [662, 650, 184, 116], bounds: artBounds }, + jeronimo: { source: artSource, box: [848, 650, 186, 92], bounds: artBounds }, + rui: { source: artSource, box: [660, 758, 182, 100], bounds: artBounds }, + wagner: { source: artSource, box: [845, 745, 200, 90], bounds: artBounds }, + } + + const out = {} + for (const [id, { source, box, bounds }] of Object.entries(CROPS)) { + const [left, top, cropWidth, cropHeight] = box + const { data: crop, info: cropInfo } = await sharp(source) + .ensureAlpha() + .extract({ left, top, width: cropWidth, height: cropHeight }) + .raw() + .toBuffer({ resolveWithObject: true }) + const ch = cropInfo.channels + for (let y = 0; y < cropHeight; y++) { + for (let x = 0; x < cropWidth; x++) { + const i = (y * cropWidth + x) * ch + const column = left + x + const bandTop = bounds.top[column] + const bandBottom = bounds.bottom + const inside = bandTop >= 0 && top + y >= bandTop + 2 && top + y <= bandBottom - 1 + if (!inside) { + crop[i + 3] = 0 + continue + } + // The band red becomes transparent with a soft edge; the anti-aliased + // pixels of the art stay untouched, so a logo sits on the band exactly + // as it does in the official piece. + const d = dist(crop[i], crop[i + 1], crop[i + 2]) + crop[i + 3] = Math.round(crop[i + 3] * Math.max(0, Math.min(1, (d - 16) / 34))) + } + } + out[id] = await sharp(crop, { + raw: { width: cropWidth, height: cropHeight, channels: ch }, + }) + .webp({ quality: 95 }) + .toBuffer() + } + return out +} + +/** + * The personalized slip EXACTLY as the site produces it: drives the production + * card studio (Playwright, mobile), picks the state deputy and lifts the + * 1080×1920 preview canvas the site's own renderer painted. + */ +const fetchSiteCard = async ({ baseUrl, outPath, deputy = SITE_CARD_DEPUTY }) => { + const browser = await chromium.launch({ args: ['--disable-dev-shm-usage'] }) + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 2, + }) + const page = await context.newPage() + try { + await page.goto(`${baseUrl}/cards?model=minha-colinha`, { + waitUntil: 'domcontentloaded', + timeout: 60_000, + }) + await page.waitForTimeout(2500) + const combo = page.locator('[role=combobox]').first() + await combo.click() + await combo.fill(deputy) + await page.waitForTimeout(800) + await page + .locator('[role=option]') + .filter({ hasText: deputy }) + .first() + .click({ timeout: 15_000 }) + await page.waitForTimeout(2500) + const dataUrl = await page.evaluate(() => { + const canvas = [...document.querySelectorAll('canvas')].find( + (item) => item.width === 1080 && item.height === 1920, + ) + return canvas ? canvas.toDataURL('image/png') : null + }) + if (!dataUrl) + throw new Error( + `o canvas 1080×1920 da colinha não apareceu na página de cards (estadual "${deputy}")`, + ) + const buffer = Buffer.from(dataUrl.split(',')[1], 'base64') + await writeFile(outPath, buffer) + return buffer + } finally { + await context.close() + await browser.close() + } +} + +/** + * The reel's audio track (muxed with `-c:v copy`, so the video is never + * re-encoded): one urna "confirma" when each vote card appears, over the + * campaign jingle bed — the music enters at 0:04, very low, and fades out at + * the end. `apad` keeps the track as long as the video, so `-shortest` can + * never truncate it. + */ +const buildAudioTrack = async ({ + ffmpegBin, + timeline, + output, + sfx = CONFIRMA_SFX, + music = JINGLE, + musicGain = 0.22, + musicSkipMs = 4000, + beepsGain = 0.7, +}) => { + const cards = timeline.entries.filter((entry) => entry.kind === 'card') + if (cards.length === 0) return null + const totalSeconds = timeline.totalMs / 1000 + const args = ['-hide_banner', '-loglevel', 'error'] + for (const _ of cards) args.push('-i', sfx) + const musicIndex = cards.length + const hasMusic = music !== null && existsSync(music) + // The jingle starts at the video's 0:00, playing from its own 0:04. + if (hasMusic) args.push('-ss', (musicSkipMs / 1000).toFixed(2), '-i', music) + const filters = cards.map( + (entry, index) => + `[${index}]adelay=${Math.round(entry.atMs + 120)}|${Math.round(entry.atMs + 120)}[a${index}]`, + ) + const beepLabels = cards.map((_, index) => `[a${index}]`).join('') + filters.push( + `${beepLabels}amix=inputs=${cards.length},volume=${(cards.length * beepsGain).toFixed(2)}[beeps]`, + ) + let outLabel = 'beeps' + if (hasMusic) { + filters.push(`[${musicIndex}]volume=${musicGain},afade=t=in:st=0:d=0.6[bed]`) + filters.push('[beeps][bed]amix=inputs=2,volume=2[out]') + outLabel = 'out' + } + filters.push( + `[${outLabel}]afade=t=out:st=${Math.max(0, totalSeconds - 2.5).toFixed(1)}:d=2.5,alimiter=limit=0.9:level_out=0.9,apad[mixed]`, + ) + args.push( + '-filter_complex', + filters.join(';'), + '-map', + '[mixed]', + '-t', + totalSeconds.toFixed(3), + '-ac', + '1', + '-ar', + '44100', + '-c:a', + 'aac', + '-b:a', + '128k', + '-y', + output, + ) + const result = await runFfmpeg(ffmpegBin, args, { label: 'audio track' }) + return result.ok ? output : null +} + +const muxAudio = async ({ ffmpegBin, video, audio, output }) => { + const result = await runFfmpeg( + ffmpegBin, + [ + '-hide_banner', + '-loglevel', + 'error', + '-i', + video, + '-i', + audio, + '-c:v', + 'copy', + '-c:a', + 'aac', + '-b:a', + '128k', + '-movflags', + '+faststart', + '-y', + output, + ], + { label: 'mux audio' }, + ) + return result.ok +} + +const buildFontCss = async () => { + const brexter = await requireFile( + BRAND_FONT, + 'A Brexter oficial vive em src/app/(frontend)/fonts/Brexter-Bold.ttf.', + ) + const inter = await requireFile( + INTER_FONT, + 'A Inter (subset latim, OFL) é derivada para os assets do reel (ver README da pasta).', + ) + const arimo = await requireFile( + ARIMO_FONT, + 'Sem a Arimo local (documentada no build-radio-artes): instale a fonte em ~/.local/share/fonts/Arimo-Regular.woff2.', + ) + return colinhaFontCss({ + brexterTtf: brexter.toString('base64'), + interWoff2: inter.toString('base64'), + arimoWoff2: arimo.toString('base64'), + }) +} + +const printDryRun = (timeline) => { + console.log(`\n[${LABEL}] ${COLINHA_REEL.title}`) + console.log( + ` ${timeline.entries.length} beats · ${timeline.totalMs} ms · ${timeline.frameCount} quadros a ${timeline.fps} fps\n`, + ) + for (const entry of timeline.entries) { + const detail = entry.candidateId ?? entry.screen ?? entry.text ?? '' + console.log( + ` ${String(entry.atMs).padStart(6)} ms ${entry.kind.padEnd(9)} ${entry.id.padEnd(18)} ${String(detail).slice(0, 52)}`, + ) + } + console.log('') +} + +const FINALES = { motion: 'reel.mp4', static: 'reel-estatico.mp4', card: 'reel-card.mp4' } + +const main = async () => { + const { flags } = parseEqualsFlags(process.argv.slice(2)) + if (flags.help === true) { + console.log(USAGE) + return + } + const fps = flags.fps === undefined ? COLINHA_REEL.fps : Number(flags.fps) + if (!Number.isFinite(fps) || fps <= 0) die('--fps exige um número positivo.') + const preview = flags.preview === true + const dryRun = flags['dry-run'] === true + const keepFrames = flags['keep-frames'] === true + const coverOnly = flags['cover-only'] === true + const beeps = flags.beeps === true + const audioOnly = flags['audio-only'] === true + const coverAtFlag = flags['cover-at'] === undefined ? undefined : Number(flags['cover-at']) + const finaleFlag = typeof flags.finale === 'string' ? flags.finale : null + const finales = finaleFlag + ? finaleFlag.split(',').map((value) => value.trim()) + : ['motion', 'static', 'card'] + for (const finale of finales) { + if (!(finale in FINALES)) + die('--finale aceita motion, static e/ou card (ex.: --finale=motion,static).') + } + const siteUrl = + (typeof flags['base-url'] === 'string' + ? flags['base-url'] + : process.env.REELS_BASE_URL?.trim()) || DEFAULT_SITE_URL + // `--estadual-*`: the same reel for another dobradinha — the second candidate + // of the ticket is replaced (name, ballot number and cutout) and the site's + // personalized card is rendered for their picker name. `--estadual=livre` + // swaps it for the "sem estadual" step (the "time do Lula" card). + const estadualFlag = typeof flags.estadual === 'string' ? flags.estadual.trim() : '' + const genericEstadual = estadualFlag === 'livre' + const estadualName = + typeof flags['estadual-name'] === 'string' ? flags['estadual-name'].trim() : '' + const estadualNumber = + typeof flags['estadual-number'] === 'string' ? flags['estadual-number'].trim() : '' + const estadualPhoto = + typeof flags['estadual-photo'] === 'string' ? resolve(flags['estadual-photo']) : '' + if (estadualName && (!estadualNumber || !estadualPhoto)) { + die('--estadual-name exige --estadual-number e --estadual-photo.') + } + const siteDeputy = + typeof flags['site-deputy'] === 'string' && flags['site-deputy'].trim() + ? flags['site-deputy'].trim() + : SITE_CARD_DEPUTY + const outSlug = + typeof flags['out-slug'] === 'string' && flags['out-slug'].trim() + ? flags['out-slug'].trim() + : COLINHA_REEL.slug + const candidates = genericEstadual + ? COLINHA_CANDIDATES.map((candidate, index) => + index === 1 ? COLINHA_GENERIC_ESTADUAL : candidate, + ) + : estadualName + ? COLINHA_CANDIDATES.map((candidate, index) => + index === 1 + ? { + ...candidate, + name: estadualName.toLocaleUpperCase('pt-BR'), + number: estadualNumber, + photoPath: estadualPhoto, + // The Plenária cutouts are framed as delivered (bust, head up). + photoZoom: 1, + photoShiftY: 0, + } + : candidate, + ) + : COLINHA_CANDIDATES + const outRoot = resolve( + ROOT, + typeof flags['out-dir'] === 'string' ? flags['out-dir'] : DEFAULT_OUT_DIR, + ) + const workRoot = resolve( + ROOT, + typeof flags['work-dir'] === 'string' ? flags['work-dir'] : DEFAULT_WORK_DIR, + ) + const previewFrames = + typeof flags['preview-frames'] === 'string' + ? flags['preview-frames'] + .split(',') + .map((value) => Number(value.trim())) + .filter((value) => Number.isFinite(value) && value >= 0) + : [] + + const motionBeats = buildColinhaBeats({ candidates }) + const motionTimeline = buildColinhaTimeline(motionBeats, fps) + if (dryRun) { + printDryRun( + finales.length === 1 + ? buildColinhaTimeline(buildColinhaBeats({ finale: finales[0], candidates }), fps) + : motionTimeline, + ) + return + } + + const outDir = join(outRoot, outSlug) + const workDir = join(workRoot, outSlug) + await mkdir(outDir, { recursive: true }) + await mkdir(workDir, { recursive: true }) + + if (audioOnly) { + const ffmpegAudio = await resolveFfmpeg({ encoders: ['libx264'], requirementsLabel: 'reel' }) + if (!ffmpegAudio.ok) die(`ffmpeg indisponível: ${ffmpegAudio.reason}`) + let mixed = 0 + for (const finale of finales) { + // The track follows the finale's own timeline (the card/static finales + // are shorter than the motion one) — the mux then never stretches the + // container past the video. + const finaleTimeline = buildColinhaTimeline(buildColinhaBeats({ finale, candidates }), fps) + const video = join(outDir, FINALES[finale] ?? `reel-${finale}.mp4`) + if (!existsSync(video)) { + console.log(`[${LABEL}] ${finale}: ${video.split('/').pop()} não existe, pulando`) + continue + } + const silent = join(workDir, `silent-${finale}.mp4`) + const stripped = await runFfmpeg( + ffmpegAudio.bin, + [ + '-hide_banner', + '-loglevel', + 'error', + '-i', + video, + '-map', + '0:v', + '-c:v', + 'copy', + '-an', + '-y', + silent, + ], + { label: 'strip audio' }, + ) + if (!stripped.ok) die(`não consegui remover o áudio antigo de ${video}.`) + const track = join(workDir, `audio-${finale}.m4a`) + const built = await buildAudioTrack({ + ffmpegBin: ffmpegAudio.bin, + timeline: finaleTimeline, + output: track, + }) + if (!built) die(`não consegui montar a trilha de bipes (${finale}).`) + const temp = join(workDir, `mux-${finale}.mp4`) + if ( + !(await muxAudio({ ffmpegBin: ffmpegAudio.bin, video: silent, audio: track, output: temp })) + ) { + die(`não consegui mixar o áudio em ${video}.`) + } + await rename(temp, video) + mixed++ + console.log(`[${LABEL}] ${finale}: bipes mixados em ${video.split('/').pop()}`) + } + console.log(`\n[${LABEL}] ${mixed} vídeo(s) com o "confirma"`) + return + } + const [assets, fontCss] = await Promise.all([buildAssets(candidates), buildFontCss()]) + if (finales.includes('card') || finales.includes('static')) { + if (genericEstadual) { + // "Sem estadual": the official art already is the slip with the estadual + // row open — no site render is needed. + assets.siteCard = assets.colinhaArt + console.log(`[${LABEL}] colinha sem estadual: arte oficial com a linha aberta`) + } else { + const cardPath = join(outDir, 'colinha-site.png') + const buffer = await fetchSiteCard({ + baseUrl: siteUrl, + outPath: cardPath, + deputy: siteDeputy, + }) + assets.siteCard = dataUri(buffer, 'image/png') + console.log(`[${LABEL}] colinha do site (${siteDeputy}) → ${cardPath}`) + } + } + + /** The reveal schedule of a finale: the static one lands during the flip. */ + const scheduleFor = (timeline) => { + const endBeat = timeline.byId.get('end-colinha') + const brandBeat = timeline.byId.get('brand') + const revealBeats = timeline.entries.filter((entry) => entry.kind === 'reveal') + if (revealBeats.length === 0 && brandBeat && endBeat) { + const at = endBeat.atMs + return { + reveals: COLINHA_CANDIDATES.map((candidate) => ({ + candidateId: candidate.id, + atMs: at, + durationMs: COLINHA_BEAT_DURATIONS.flip * 2, + })), + brandAtMs: at, + } + } + return { + reveals: revealBeats.map((entry) => ({ + candidateId: entry.candidateId, + atMs: entry.atMs, + durationMs: entry.durationMs * 0.6, + })), + brandAtMs: brandBeat ? brandBeat.atMs : 0, + } + } + + const openFinalePage = async (finale, timeline) => { + const browser = await chromium.launch({ args: ['--disable-dev-shm-usage'] }) + const schedule = scheduleFor(timeline) + const html = colinhaReelHtml({ + beats: timeline.entries, + candidates, + reveals: schedule.reveals, + brandAtMs: schedule.brandAtMs, + drawnRow: candidates[1].id, + totalMs: timeline.totalMs, + flipMs: COLINHA_BEAT_DURATIONS.flip, + fontCss, + assets, + cardFinale: finale === 'card', + }) + const htmlPath = join(workDir, `chat-${finale}.html`) + await writeFile(htmlPath, html) + const context = await browser.newContext({ + viewport: VIEWPORT, + deviceScaleFactor: DEVICE_SCALE, + }) + const page = await context.newPage() + await page.goto(pathToFileURL(htmlPath).href) + await page.waitForFunction(() => window.colinhaReady !== undefined) + await page.evaluate(() => window.colinhaReady) + return { page, context, browser } + } + + const shoot = async (page, timeMs, file) => { + await page.evaluate((t) => window.colinhaRender(t), timeMs) + await page.screenshot({ path: file, type: 'jpeg', quality: 96 }) + } + + /** Default cover moment: the estadual card opening — two cards + the number. */ + const coverBeat = motionTimeline.byId.get(`card-${COLINHA_CANDIDATES[1].id}`) + const coverAt = + coverAtFlag !== undefined && Number.isFinite(coverAtFlag) + ? coverAtFlag + : coverBeat + ? coverBeat.atMs + 1000 + : Math.round(motionTimeline.totalMs * 0.2) + + const renderCoverIn = async (finale, timeline) => { + const { page, context, browser: coverBrowser } = await openFinalePage(finale, timeline) + const coverPath = await renderCover(page) + await context.close() + await coverBrowser.close() + return coverPath + } + + const renderCover = async (page) => { + await page.setViewportSize(COVER_VIEWPORT) + await page.evaluate(() => { + window.colinhaReset() + window.colinhaRender(0) + }) + const coverPath = join(outDir, 'capa.png') + await page.evaluate((t) => window.colinhaRender(t), coverAt) + await page.screenshot({ path: coverPath, type: 'png' }) + return coverPath + } + + if (previewFrames.length > 0) { + const previewFinale = finales.length === 1 ? finales[0] : 'motion' + const previewTimeline = + previewFinale === 'motion' + ? motionTimeline + : buildColinhaTimeline(buildColinhaBeats({ finale: previewFinale, candidates }), fps) + const { + page, + context, + browser: previewBrowser, + } = await openFinalePage(previewFinale, previewTimeline) + const previewDir = join(workDir, 'preview') + await mkdir(previewDir, { recursive: true }) + for (const timeMs of previewFrames) { + const file = join(previewDir, `t-${String(timeMs).padStart(6, '0')}.png`) + await page.evaluate((t) => window.colinhaRender(t), timeMs) + await page.screenshot({ path: file, type: 'png' }) + console.log(`[${LABEL}] quadro t=${timeMs} ms → ${file}`) + } + await context.close() + await previewBrowser.close() + return + } + + if (coverOnly) { + const coverFinale = finales.length === 1 ? finales[0] : 'motion' + const coverTimeline = + coverFinale === 'motion' + ? motionTimeline + : buildColinhaTimeline(buildColinhaBeats({ finale: coverFinale, candidates }), fps) + const { + page, + context, + browser: coverBrowser, + } = await openFinalePage(coverFinale, coverTimeline) + const coverPath = await renderCover(page) + await context.close() + await coverBrowser.close() + console.log(`[${LABEL}] capa (t=${Math.round(coverAt)} ms) → ${coverPath}`) + return + } + + const ffmpeg = await resolveFfmpeg({ encoders: ['libx264'], requirementsLabel: 'reel' }) + if (!ffmpeg.ok) die(`ffmpeg indisponível: ${ffmpeg.reason}`) + + const rendered = [] + for (const finale of finales) { + const timeline = buildColinhaTimeline(buildColinhaBeats({ finale, candidates }), fps) + const framesDir = join(workDir, 'frames', finale) + await rm(framesDir, { recursive: true, force: true }) + await mkdir(framesDir, { recursive: true }) + const frames = [] + // Long renders can lose the browser; one fresh retry keeps the build going. + for (let attempt = 1; attempt <= 2; attempt++) { + let session = null + try { + session = await openFinalePage(finale, timeline) + for (let index = 0; index < timeline.frameCount; index++) { + const timeMs = (index * 1000) / fps + const file = join(framesDir, `f-${String(index).padStart(5, '0')}.jpg`) + await shoot(session.page, timeMs, file) + frames.push({ file, captureMs: timeMs }) + if (index % 120 === 0) { + console.log(`[${LABEL}] ${finale}: quadro ${index + 1}/${timeline.frameCount}`) + } + } + break + } catch (error) { + if (attempt === 2) throw error + frames.length = 0 + console.log( + `[${LABEL}] ${finale}: render interrompido (${String(error.message).split('\n')[0]}) — nova tentativa`, + ) + } finally { + await session?.context.close().catch(() => undefined) + await session?.browser.close().catch(() => undefined) + } + } + const reelPath = join(outDir, FINALES[finale]) + await encodeFrames({ + ffmpegBin: ffmpeg.bin, + frames, + listPath: join(workDir, `frames-${finale}.txt`), + output: reelPath, + fps, + preset: preview ? PREVIEW_PRESET : REEL_PRESET, + crf: preview ? PREVIEW_CRF : REEL_CRF, + }) + if (beeps) { + const track = join(workDir, `audio-${finale}.m4a`) + const built = await buildAudioTrack({ ffmpegBin: ffmpeg.bin, timeline, output: track }) + if (!built) die(`não consegui montar a trilha dos bipes (${finale}).`) + const temp = join(workDir, `mux-${finale}.mp4`) + if ( + !(await muxAudio({ ffmpegBin: ffmpeg.bin, video: reelPath, audio: track, output: temp })) + ) { + die(`não consegui mixar o áudio em ${reelPath}.`) + } + await rename(temp, reelPath) + } + if (finale === 'motion') await renderCoverIn(finale, timeline) + if (!keepFrames) await rm(framesDir, { recursive: true, force: true }) + rendered.push({ finale, timeline, reelPath }) + } + const scriptHash = sha256Hex( + Buffer.from( + JSON.stringify({ + candidates, + beats: motionBeats.map(({ id, kind, durationMs }) => ({ id, kind, durationMs })), + }), + ), + ) + const metadata = { + slug: outSlug, + title: genericEstadual + ? `${COLINHA_REEL.title} · sem estadual (time do Lula)` + : estadualName + ? `${COLINHA_REEL.title} · ${estadualName}` + : COLINHA_REEL.title, + kind: 'chat-colinha', + coverAlt: COLINHA_REEL.coverAlt, + scriptHash, + versions: Object.fromEntries( + rendered.map(({ finale, timeline, reelPath }) => [ + finale, + { + file: reelPath.split('/').pop(), + durationMs: timeline.totalMs, + durationSeconds: Math.round(timeline.totalMs / 100) / 10, + frameCount: timeline.frameCount, + }, + ]), + ), + width: 1080, + height: 1920, + fps, + codec: 'h264', + audio: beeps, + coverAtMs: Math.round(coverAt), + artifacts: [ + ...rendered.map(({ reelPath }) => reelPath.split('/').pop()), + ...(existsSync(join(outDir, 'colinha-site.png')) ? ['colinha-site.png'] : []), + ...(existsSync(join(outDir, 'capa.png')) ? ['capa.png'] : []), + 'roteiro.md', + 'metadata.json', + ], + candidateNumbers: Object.fromEntries( + candidates.map((candidate) => [candidate.id, candidate.number]), + ), + generatedAt: new Date().toISOString(), + ffmpeg: ffmpeg.version, + } + await writeFile(join(outDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`) + + const scriptLines = motionTimeline.entries.map((entry) => { + const at = `${(entry.atMs / 1000).toFixed(1)}s`.padStart(6) + if (entry.kind === 'voter') return `${at} ELEITOR ${entry.text}` + if (entry.kind === 'assistant') return `${at} TIME 1313 ${entry.text}` + if (entry.kind === 'typing') return `${at} TIME 1313 (digitando…)` + if (entry.kind === 'number') return `${at} ELEITOR ${entry.text}` + if (entry.kind === 'card') { + const candidate = candidates.find((item) => item.id === entry.candidateId) + return `${at} TIME 1313 anotei: ${candidate?.office} ${candidate?.name} ${candidate?.number}` + } + if (entry.kind === 'reveal') + return `${at} — colinha: ${entry.candidateId} (figura + linha + logo)` + return `${at} — colinha completa (slogan e Mais Saúde, Mais Futuro)` + }) + const roteiro = `# Roteiro — ${COLINHA_REEL.title} + +Versões alternativas (1080×1920, ${fps} fps, **sem áudio** — a trilha entra no +app na hora de publicar): \`reel.mp4\` (a colinha se monta candidato a +candidato), \`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 PNG cru fica em +\`colinha-site.png\`). Roteiro por beat (tempos do vídeo): + +\`\`\`text +${scriptLines.join('\n')} +\`\`\` + +Ajuste de roteiro, ritmo ou texto é feito no arquivo-fonte +(\`scripts/lib/colinhaReel.mjs\` + \`scripts/build-colinha-reel.mjs\`) e o pacote é +regerado com \`pnpm reels:colinha\` — nunca no MP4. +` + await writeFile(join(outDir, 'roteiro.md'), roteiro) + + console.log(`\n[${LABEL}] pacote em ${outDir}`) + for (const { finale, timeline, reelPath } of rendered) { + console.log( + ` ${reelPath.split('/').pop().padEnd(18)} 1080×1920 · ${fps} fps · ${(timeline.totalMs / 1000).toFixed(1)} s · ${finale}${preview ? ' (preview)' : ''}`, + ) + } + console.log(` capa.png corte 4:5 do quadro t=${Math.round(coverAt)} ms`) + console.log(` roteiro.md · metadata.json · script ${scriptHash.slice(0, 12)}`) + if (existsSync(join(outDir, 'colinha-site.png'))) { + console.log(' colinha-site.png render cru do card personalizado do site') + } + console.log('') +} + +main().catch((error) => die(error?.stack ?? String(error))) diff --git a/scripts/build-dobradinha-reels.mjs b/scripts/build-dobradinha-reels.mjs new file mode 100644 index 00000000..04d6d0f9 --- /dev/null +++ b/scripts/build-dobradinha-reels.mjs @@ -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//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))) diff --git a/scripts/lib/colinhaReel.mjs b/scripts/lib/colinhaReel.mjs new file mode 100644 index 00000000..bc3ac0f1 --- /dev/null +++ b/scripts/lib/colinhaReel.mjs @@ -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//` (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//`. + */ +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} [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])), + } +} diff --git a/scripts/lib/colinhaReelTemplate.mjs b/scripts/lib/colinhaReelTemplate.mjs new file mode 100644 index 00000000..53e75699 --- /dev/null +++ b/scripts/lib/colinhaReelTemplate.mjs @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + +const BACK_ICON = + '' +const VIDEO_ICON = + '' +const INFO_ICON = + '' +const CAMERA_ICON = + '' +const MIC_ICON = + '' +const IMAGE_ICON = + '' +const PARTY_STAR = + '' + +/** + * `@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, logo: Record, row: Record }>, reveals: Array<{ candidateId: string, atMs: number, durationMs: number }>, brandAtMs: number, drawnRow: string, totalMs: number, flipMs: number, fontCss: string, assets: Record, 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 `
` + } + if (beat.kind === 'card') { + const candidate = candidateById.get(beat.candidateId) + if (candidate.generic) { + return `
+
+
+
anotei:
+
+
Marca oficial de Lula
+
+
${escapeHtml(candidate.office)}
+
${escapeHtml(candidate.name)}
+
${escapeHtml(COLINHA_REEL.cardState)}
+
+
ESCOLHA O SEU
+
+
+
` + } + const progress = + ((candidates.findIndex((c) => c.id === beat.candidateId) + 1) / cardCount) * 100 + const digits = [...candidate.number] + .map((digit) => `${escapeHtml(digit)}`) + .join('') + const photoStyle = candidate.photoZoom + ? ` style="transform:translateY(${candidate.photoShiftY ?? 0}%) scale(${candidate.photoZoom});transform-origin:${candidate.photoOrigin ?? '50% 0%'}"` + : '' + return `
+
+
+
anotei:
+
+
${escapeHtml(candidate.name)}
+
+
${escapeHtml(candidate.office)}
+
${escapeHtml(COLINHA_REEL.cardParty)}${PARTY_STAR}
+
${escapeHtml(COLINHA_REEL.cardState)}
+
+
NA URNA
+
${digits}
+
+
${escapeHtml(candidate.name)}
+
+
+
` + } + const seen = beat.seen ? '
Visto
' : '' + const variant = beat.kind === 'number' ? 'number' : beat.kind + return `
+
${escapeHtml(beat.text)}
+ ${seen} +
` + }) + .join('\n') + + const colinhaDigits = [...estadual.number] + .map((digit) => `${escapeHtml(digit)}`) + .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 `
` + }) + .join('\n ') + const logosMarkup = candidates + .map( + (candidate) => + ``, + ) + .join('\n ') + const rowMasksMarkup = candidates + .map( + (candidate) => + `
`, + ) + .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 ` + + + +${escapeHtml(COLINHA_REEL.title)} + + + +
+
+
+ ${BACK_ICON} + Jorge Solla 1313 + ${escapeHtml(COLINHA_REEL.chatTitle)} + ${VIDEO_ICON}${INFO_ICON} +
+
+
HOJE, ${escapeHtml(COLINHA_REEL.chatHour)}
+${rows} +
+
+ ${CAMERA_ICON} + Mensagem...${MIC_ICON}${IMAGE_ICON} +
+
+
+ ${ + cardFinale + ? `Colinha personalizada gerada pelo site, com o time completo` + : `Colinha oficial de votação com o time completo +
+
+ ${figuresMarkup} +
+
+ ${logosMarkup} +
Mais Saúde, Mais Futuro
+ ${rowMasksMarkup} +
+
+
+ ${escapeHtml(estadual.office)} + ${escapeHtml(estadual.name)} +
+
${colinhaDigits}
+
` + } +
+
+
+ + + +` +} diff --git a/scripts/reels/assets/colinha-time-1313/README.md b/scripts/reels/assets/colinha-time-1313/README.md new file mode 100644 index 00000000..ac61ee2f --- /dev/null +++ b/scripts/reels/assets/colinha-time-1313/README.md @@ -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')('').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). diff --git a/scripts/reels/assets/colinha-time-1313/fonts/inter-latin.woff2 b/scripts/reels/assets/colinha-time-1313/fonts/inter-latin.woff2 new file mode 100644 index 00000000..d15208de Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/fonts/inter-latin.woff2 differ diff --git a/scripts/reels/assets/colinha-time-1313/jeronimo.webp b/scripts/reels/assets/colinha-time-1313/jeronimo.webp new file mode 100644 index 00000000..f5b73a17 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/jeronimo.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/jingle-play.mp3 b/scripts/reels/assets/colinha-time-1313/jingle-play.mp3 new file mode 100644 index 00000000..838d8bd1 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/jingle-play.mp3 differ diff --git a/scripts/reels/assets/colinha-time-1313/julio.webp b/scripts/reels/assets/colinha-time-1313/julio.webp new file mode 100644 index 00000000..40d0e59a Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/julio.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/logo-o.webp b/scripts/reels/assets/colinha-time-1313/logo-o.webp new file mode 100644 index 00000000..78c605a5 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/logo-o.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/lula.webp b/scripts/reels/assets/colinha-time-1313/lula.webp new file mode 100644 index 00000000..43f8ba8a Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/lula.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/rui.webp b/scripts/reels/assets/colinha-time-1313/rui.webp new file mode 100644 index 00000000..202f7e28 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/rui.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/sfx-confirma.mp3 b/scripts/reels/assets/colinha-time-1313/sfx-confirma.mp3 new file mode 100644 index 00000000..e7880227 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/sfx-confirma.mp3 differ diff --git a/scripts/reels/assets/colinha-time-1313/solla.webp b/scripts/reels/assets/colinha-time-1313/solla.webp new file mode 100644 index 00000000..bc38376f Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/solla.webp differ diff --git a/scripts/reels/assets/colinha-time-1313/wagner.webp b/scripts/reels/assets/colinha-time-1313/wagner.webp new file mode 100644 index 00000000..39350944 Binary files /dev/null and b/scripts/reels/assets/colinha-time-1313/wagner.webp differ diff --git a/scripts/reels/dobradinhas.json b/scripts/reels/dobradinhas.json new file mode 100644 index 00000000..163ee812 --- /dev/null +++ b/scripts/reels/dobradinhas.json @@ -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//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" + } + ] +} diff --git a/tests/unit/colinhaReel.unit.spec.ts b/tests/unit/colinhaReel.unit.spec.ts new file mode 100644 index 00000000..c4d4123e --- /dev/null +++ b/tests/unit/colinhaReel.unit.spec.ts @@ -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]}`) + } + 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') + 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') + }) +})