feat(C196): skill /reels-tutoriais — captura mobile + composição de vídeo

- skill + command finos: shot list é a fonte única; ajuste regenera, nada publica
- scripts/build-reel.mjs + scripts/lib/reel*.mjs: captura CDP com timestamps de
  parede (sem drift), cursor/halo DOM-injected, zoompan do log de cliques,
  legendas queimadas, capa 1080x1350-safe, concat H.264 1080x1920/30fps
- ffmpeg: FFMPEG_PATH -> PATH -> @ffmpeg-installer (registry-only) com probe fail-closed
- kit oficial da campanha: public/campaign-kit (marcas positiva/negativa, numero,
  estrela) + manual em docs/campaign-kit + README de regras; skill e agente de
  design referenciam o material
- 26 testes unit novos + pin do command; changelog 2026-09-18-c196

Closes #1155
This commit is contained in:
Francisco Solla 2026-09-18 21:50:28 -03:00
parent e5e1ae7ee8
commit 8504b3b13a
33 changed files with 12042 additions and 100 deletions

View file

@ -0,0 +1,141 @@
---
name: reels-tutoriais
description: 'Gera um Reel tutorial vertical (MP4 1080×1920, legenda queimada, zoom/cursor) de uma funcionalidade do site de campanha, dirigindo o site real em viewport de celular e compondo com ffmpeg; mostra o pacote no gate e regenera a partir de ajustes em linguagem natural (o shot list é a fonte única).'
---
# Reels tutoriais do site (C196)
Entrega, numa invocação, um **pacote publicável de Reel vertical** ensinando uma
funcionalidade do site (`jorgesolla1313.com.br`) — o primeiro é o tutorial dos
cards de apoio (`#cards`). O operador de comunicação dirige o site real numa
emulação de celular, a skill captura o fluxo, compõe o MP4 com legenda
queimada, zoom suave nos cliques, cursor sintético e marca da campanha, e
mostra o resultado no gate: **aprovar** (o pacote segue para a biblioteca
privada, C195) ou **ajustar** em linguagem natural — o ajuste edita o **shot
list** e regenera; o arquivo renderizado nunca é editado.
**Nada é publicado em rede social por esta skill.** O Instagram/canais são da
assessoria; aqui o produto é o arquivo no disco.
## Quando usar
- A comunicação pede "o reel de <funcionalidade>" — ex.: `/reels-tutoriais cards`.
- Quem executa é o **agente principal** (orquestrador do gate) + o builder
determinístico (`pnpm reels:build`). Não há sub-agente de pesquisa: o roteiro
vive no shot list versionado e a captura é do site real.
## Pré-requisitos
- **ffmpeg real** com `libx264` e os filtros do compositor. A skill resolve
sozinha, nesta ordem: `FFMPEG_PATH` → `ffmpeg` do PATH →
`@ffmpeg-installer/ffmpeg` (o binário empacotado do registry, padrão da
workstation). Se nenhum servir, o build **falha fechado** com a mensagem
acionável — não há fallback silencioso.
- **Site no ar** e alcançável: a captura navega o site de produção por padrão.
Para outro ambiente: `--base-url=` ou `REELS_BASE_URL` (ex.: staging/local).
- Chromium do `@playwright/test` (já instalado com as dependências do repo).
- O build é local (workstation): **não toca banco, não escreve no site** — o
visitante é anônimo e o download do card é client-side.
## Pipeline (o que o build faz)
```text
pnpm reels:build <slug>
shot list (scripts/reels/shot-lists/<slug>.json) ← fonte única da verdade
└─ captura: Chromium mobile (360×640 @3x = 1080×1920 nativo)
├─ cursor sintético + halo amarelo no alvo do clique
└─ screencast com timestamps de parede (sem drift) → frames JPEG
└─ assets: legendas/etiqueta em PNG transparente + capa.png
└─ clipes por cena: zoompan (do log de cliques) + overlay
└─ concat H.264/yuv420p 30fps → pacote
```
Comandos:
```bash
pnpm reels:build cards # pacote completo em data/reels/cards/
pnpm reels:build cards --capture-only # só a captura (debug do roteiro)
pnpm reels:build cards --dry-run # valida o shot list e imprime as cenas
pnpm reels:build cards --base-url=http://127.0.0.1:3100
```
## Contrato do shot list
`scripts/reels/shot-lists/<slug>.json` é **versionado** e é a fonte única da
verdade. A primeira cena é o `hook`, a última é o `cta` (gráficas) e no meio
ficam as cenas de captura com `badge` (etiqueta de passo), `caption` (legenda
queimada), `setup` (preparo invisível, ex. `scrollIntoView`) e `steps`
(`click`, `fill`, `waitFor`, `download`). O `fixture.cardName` é fictício
(zero PII de terceiros). Qualquer ajuste de roteiro, texto, ordem, ritmo ou
alvo de clique é feito **aqui** — nunca no MP4.
O hash do shot list vai no `metadata.json` e identifica a versão do reel:
re-render com o mesmo hash é a mesma versão; hash novo é reel novo (a ingestão
do C195 usa essa chave).
## Pacote e gate
O build escreve em `data/reels/<slug>/` (gitignored):
- `reel.mp4` — 1080×1920, 30fps, H.264, **sem áudio** (o primário);
- `capa.png` — composição própria (não frame do vídeo), título dentro do
recorte central 1080×1350 (sobrevive ao feed 4:5 e à grade do perfil);
- `metadata.json` — título, funcionalidade, duração, hash do shot list, data.
Intermediários ficam em `data/reels/.work/<slug>/` (frames, log de cliques,
capturas gráficas) e podem ser apagados a qualquer momento.
**Gate (apresente no chat):** mostre os três artefatos e o resumo (duração,
cenas, hash). Então:
- **Aprovar** → o pacote está pronto para a biblioteca privada (C195). Enquanto
o C195 não estiver disponível, o pacote fica no disco e o caminho é o
contrato de entrega; **nada sobe para o Instagram**.
- **Ajustar** → traduza o pedido ("hook mais curto", "a legenda some antes",
"clicar em outro botão") para o **shot list** (`scripts/reels/shot-lists/`),
mostre o que mudou e rode `pnpm reels:build <slug>` de novo. Sempre.
## Limites e troubleshooting
- **Seletor do site mudou** → o build falha fechado dizendo o alvo que não
apareceu; corrija o shot list (ou o site) e regenere.
- **Site fora do ar** → erro de HTTP com a URL; nada é gravado.
- **Fonte de marca** → o build embute as fontes do próprio site (Inter/Brexter)
e usa a Brexter oficial nas cenas gráficas; se o site não servir a fonte, o
build avisa e usa a fallback do sistema (nunca em silêncio).
- **ffmpeg ausente/sem libx264** → mensagem com o que instalar/configurar;
não tente compor sem o probe.
- **Ajuste nunca é no render** — se alguém pedir para "cortar direto no vídeo",
a resposta é editar o shot list e regenerar.
## Design
A superfície gráfica (hook, legenda, etiqueta de passo, cursor/halo, capa,
safe zone) vem do artefato aprovado `docs/plans/reels-tutoriais-ui-design.html`
+ assets; o port é classe-a-classe em `scripts/lib/reelTemplates.mjs`. O
`designer` é dono da estrutura visual — não improvise layout.
## Marca oficial (kit 1313)
Os ativos oficiais da campanha vivem em `public/campaign-kit/` (mapa de uso no
`README.md` de lá; manual completo em
`docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf`). Regras:
- **Positiva** (`marca-positiva-completa`, `jorge-solla-positivo`) em fundo
claro; **negativa** (`marca-negativa-completa`, `jorge-solla-negativo`) em
fundo vermelho/escuro. Nunca recriar lockup tipográfico quando o ativo
oficial existe.
- Paleta oficial nas cenas gráficas: `#e4102f`, `#184e92`, `#ffeb00` (verde
`#009647` só dentro dos ativos). As **cenas de captura preservam a paleta do
site** (`#a21c1c/#ffe607/creme`) — o vídeo mostra o site real.
- Slogan **"Mais Saúde, Mais Futuro"** só dentro das marcas completas — não é
headline nem quarta mensagem.
- A **estrela** oficial (`estrela.png`) é usada a 100% sobre disco branco do
mesmo diâmetro; opacidade reduzida mistura as cores com o fundo e suja o
ativo.
## Fora de escopo
- Áudio, narração e trilha (C197); publicação no Instagram; outras
funcionalidades além da do shot list corrente; editar o MP4 renderizado;
idiomas além de pt-BR.

4
.gitignore vendored
View file

@ -96,6 +96,10 @@ next-env.d.ts
/data/dossie-solla-tema/
/docs/research/dossie-solla-tema/
# C196 — reels tutoriais: o shot list é versionado (scripts/reels/shot-lists);
# o pacote (reel.mp4/capa.png/metadata.json) e os intermediários são regeneráveis.
/data/reels/
# Local-only sensitive material (agents may read; never commit content)
/private/**
!/private/README.md

View file

@ -22,6 +22,7 @@ Você é o diretor de design e conversão do site de campanha do Deputado Federa
3. Atividade parlamentar: `https://www.camara.leg.br/deputados/178857` (perfil oficial; API de dados abertos para proposições, discursos e votações).
4. Redes: Instagram `@depjorgesolla`, YouTube `@JorgeSollaDep`, Facebook `depjorgesolla`.
5. **Regra de ouro do mandato: "sem fonte, não publica".** Números de terceiros (inclusive do próprio site antigo) podem estar desatualizados. Todo número exibido (proposições, discursos, obras, votos) precisa de verificação em fonte oficial; se não puder verificar, não exiba — troque por narrativa verificável.
6. **Kit de marca oficial** (`public/campaign-kit/` + manual em `docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf`): paleta `#e4102f`/`#184e92`/`#ffeb00`/`#009647`; marcas positiva (fundo claro) e negativa (fundo vermelho/escuro), número e estrela; slogan "Mais Saúde, Mais Futuro" vive dentro das marcas completas. Não recriar lockups quando o ativo oficial existe; a estrela é usada a 100% sobre disco branco.
## O candidato em uma página (resumo para decisões de design)

View file

@ -0,0 +1,9 @@
---
description: Gera um Reel tutorial vertical (MP4 1080×1920 com legenda queimada, zoom e cursor) de uma funcionalidade do site — captura o site real em viewport de celular, compõe o pacote e abre o gate de aprovar/ajustar
---
Carregue a skill `reels-tutoriais` (ferramenta de skills, nome exato) e siga o fluxo dela de ponta a ponta: leia/edite o shot list versionado, rode `pnpm reels:build <slug>` (captura mobile do site real + composição ffmpeg) e apresente no gate o pacote (`reel.mp4` + `capa.png` + `metadata.json`) para aprovar ou ajustar em linguagem natural — ajuste edita o shot list e regenera; nada é publicado em rede social. A fonte canônica é `.agents/skills/reels-tutoriais/SKILL.md` — não a transcreva nem recrie o fluxo.
Uso: `/reels-tutoriais <funcionalidade>` — ex.: `/reels-tutoriais cards`.
$ARGUMENTS

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,3 @@
**C196 — skill `/reels-tutoriais`: captura + composição do vídeo (2026-09-18):** a skill `.agents/skills/reels-tutoriais/SKILL.md` (+ command fino `.opencode/commands/reels-tutoriais.md`) gera um Reel tutorial vertical de uma funcionalidade do site dirigindo o **site real** em viewport de celular e compondo com ffmpeg. Pipeline determinístico em `scripts/build-reel.mjs` (`pnpm reels:build <slug>`) + módulos puros em `scripts/lib/reel*.mjs`: o shot list versionado (`scripts/reels/shot-lists/cards.json`) é a fonte única da verdade; a captura usa o Chromium do `@playwright/test` com viewport 360×640 + `--force-device-scale-factor=3` (frames nativos 1080×1920) e um screencast CDP próprio que grava JPEGs com timestamp de parede + encode offline (o `recordVideo` do Playwright grava CFR 25 e derrapa o relógio — o zoom/cursor derivam do log de cliques e ficariam dessincronizados); o cursor sintético e o halo do alvo são DOM-injected (headless não desenha cursor) e um ticker invisível garante frames durante as pausas. A composição faz um clipe H.264 por cena com `zoompan` (easing do clique) + legendas queimadas em PNG transparente (port classe-a-classe do hi-fi `docs/plans/reels-tutoriais-ui-design.html`) e concatena; o pacote sai em `data/reels/<slug>/` (`reel.mp4` sem áudio 1080×1920/30fps, `capa.png` que sobrevive ao recorte 1080×1350 e `metadata.json` com o hash do shot list). FFmpeg resolve `FFMPEG_PATH` → PATH → `@ffmpeg-installer/ffmpeg` (nova devDependency registry-only; `ffmpeg-static` foi rejeitado porque baixa de GitHub releases, que o homeserver não alcança) com probe fail-closed de libx264 + filtros. Ajustes passam só pelo shot list; o gate aprova/regenera e **nada é publicado em rede social** (biblioteca privada é o C195). Sem schema/migration; o DB e o site público não são tocados. Verificação: `pnpm test:unit` (26 testes novos em `reelShotList`/`reelTimeline`/`reelFfmpeg`/`reelsTutoriaisSkill` + pin do command) e `pnpm gate:fast`.
**C196 — identidade oficial do kit 1313 (2026-09-19):** o kit da campanha (manual + PNGs oficiais) entrou no repo: ativos em `public/campaign-kit/` (marca positiva/negativa completa, lockups do nome, número e estrela) + manual em `docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf` + README com paleta (`#e4102f`/`#184e92`/`#ffeb00`/`#009647`) e regras de uso. As cenas gráficas do reel passaram a usar a paleta e as marcas oficiais (positiva em fundo claro, negativa em fundo vermelho/escuro; slogan só dentro das marcas completas; estrela a 100% sobre disco branco), mantendo a paleta do site nas cenas de captura; a skill e o agente `designer-campanha-solla` referenciam o kit.

View file

@ -0,0 +1,133 @@
# Impl: Skill de geração de reels: captura + composição do vídeo
Status: aprovado
Atualizado em: 2026-09-18
Issue: #1155
Intenção: docs/plans/reels-tutoriais.md
Appetite restante: ~3–4 dias (herdado). Um reel `cards` publicável de ponta a ponta; se estourar, corta refino de encoding e cenas extras — nunca o tracer.
## Leitura da intenção
- **Outcome:** `/reels-tutoriais cards` entrega, a um operador não-técnico, um pacote publicável — `reel.mp4` (1080×1920, 30fps, H.264, mudo), `capa.png` e `metadata.json` com o hash do shot list — com legenda queimada, zoom/cursor nos cliques e safe zone do Instagram; o gate mostra o render e aceita "aprovar" ou ajuste em linguagem natural (que edita o shot list e regenera).
- **O que NÃO negociar:** shot list é a fonte única da verdade (ajuste só nele; o MP4 é re-executável); nada é publicado em rede social nem no Instagram (biblioteca via C195 é outro item); sem áudio (C197); sem PII real — fixture fictícia versionada; artefatos de mídia gitignored; zero mudança no site público/Payload (sem schema/migration).
- **O que reavaliar:** (1) a hipótese "ffmpeg real é pré-requisito de ambiente" cai — a workstation não tem ffmpeg no PATH e o do Playwright é stripped, então a dep empacotada do registry é fallback obrigatório (desvio registrado abaixo); (2) "recordVideo.size = viewport" da intenção é falso na prática — size 1080×1920 + viewport CSS 360×640 + `deviceScaleFactor:3` + `--force-device-scale-factor=3`; (3) "templates de legenda/capa" não existem prontos — portam o hi-fi aprovado para HTML renderizado por Playwright.
## Abordagem recomendada
```mermaid
flowchart LR
A["shot list versionado<br/>scripts/reels/shot-lists/cards.json"] --> B["captura mobile<br/>recordVideo 1080×1920 + cursor + click log"]
B --> C["timeline pura<br/>zoom · legendas · safe-area · drift"]
C --> D["clips por cena<br/>zoompan + overlay de legenda PNG"]
D --> E["concat com fade<br/>H.264/yuv420p 30fps +faststart"]
E --> F["pacote /data/reels/cards/<br/>reel.mp4 + capa.png + metadata.json"]
F --> G{"gate"}
G -->|"aprovar"| H["biblioteca privada (C195)"]
G -->|"ajustar (linguagem natural)"| A
```
**Decisão 1 — Captura.**
Opções: A) `recordVideo` do contexto com `viewport 360×640` + `deviceScaleFactor:3` + `--force-device-scale-factor=3` | B) recorder próprio via CDP `Page.startScreencast` → pipe ffmpeg com `-use_wallclock_as_timestamps` (mesmo viewport/force-dsf) | C) upscalar CSS/sequência de screenshots.
Recomendação: **B (revisada na execução)** — o spike provou que A grava frames nativos 1080×1920 **mas em CFR 25 fixo ignorando o relógio de parede** (1,2s de parede → 0,96s de vídeo; trecho estático no fim é descartado), o que desalinha zoom/cursor (derivados do log de paredes) das cenas. B usa o mesmo screencast nativo do force-dsf, preserva o tempo real (buraco estático vira frame duplicado) e entrega os frames como JPEG 90 para um H.264 intermediário ultrafast; o frame final é forçado via `page.screenshot` para não truncar a espera final. Validado: 3,05s de parede → 2,80s de timeline contínua (offset do 1º frame), sem drift.
Rejeitadas: A pela perda de sincronia (acima); C porque perde o movimento contínuo e o cursor injetado.
**Decisão 2 — Binário ffmpeg.**
Opções: A) ordem `FFMPEG_PATH` → `ffmpeg` do PATH → `@ffmpeg-installer/ffmpeg` (nova devDependency) | B) `ffmpeg-static` | C) exigir ffmpeg instalado pelo operador.
Recomendação: A — pacotes de plataforma vêm do registry npm (validado com pnpm); probe fail-closed de `libx264` + filtros usados.
Rejeitadas: B porque baixa de GitHub releases (`objects.githubusercontent.com`), host que o homeserver não alcança — mesmo motivo do override `sharp` em `pnpm-workspace.yaml:6-11`; quebraria o `pnpm install --frozen-lockfile` do deploy. C porque a persona é não-técnica e a workstation não tem ffmpeg no PATH — o fluxo tem de funcionar na primeira invocação.
**Decisão 3 — Zoom/câmera.**
Opções: A) `zoompan` em pós-processo, `d=1, s=1080x1920, fps=30`, z com easing do click log | B) `sendcmd` alterando `crop` | C) `transform` no DOM durante a captura.
Recomendação: A — comprovado no binário escolhido (H.264 High 1080×1920 30fps); manter B como fallback documentado (o crop só avalia w/h no init; x/y por frame).
Rejeitadas: C porque apps Next com portais ignoram o transform e o cursor sintético desalinha (rabbit hole já cortado na intenção); B porque a semântica de w/h fixos no init torna o zoom (escala) limitado — fica só se o zoompan regredir.
**Decisão 4 — Legenda.**
Opções: A) overlay de PNGs transparentes 1080×1920 (um por legenda, `omitBackground`) com `overlay ... enable='between(t,a,b)'` | B) `drawtext` | C) faixa de legenda em HTML sobreposta no DOM.
Recomendação: A — reproduz o estilo do design (arredondado, bicolor branco/amarelo, Brexter) e o spike confirmou o overlay do binário.
Rejeitadas: B porque o binário tem `drawtext` mas não reproduz o bicolor/peso do design (sem rico styling por palavra); C porque queimaria a legenda na captura e impediria re-render só do shot list.
**Decisão 5 — Layout de arquivos.**
Opções: A) shot list versionado em `scripts/reels/shot-lists/<slug>.json` + saídas em `/data/reels/` gitignored | B) tudo gitignored | C) tudo versionado.
Recomendação: A — o shot list é o roteiro (determinismo/revisão) e deve ser commitado; o pacote de mídia é regenerável e pode carregar imagem do mandato → gitignored, seguindo o padrão dos blocos `/data/*` em `.gitignore:78-97`.
Rejeitadas: B porque perderia a fonte da verdade e o hash; C porque `.mp4`/`.png` são artefatos e o repo é público. **Não** usar `/docs/research/reels/` — não há saída de pesquisa.
**Decisão 6 — Granularidade da composição.**
Opções: A) grafo único de filtros por cena com `concat`/`fade` num passe | B) clip H.264 intermediário por cena + concat final | C) passe único com demuxer concat.
Recomendação: B — cada cena tem timestamp local (t=0), o que isola o drift do relógio e o easing do zoompan; re-render de uma cena não recaptura o resto, e o `fade` in/out substitui o `xfade` ausente no binário 4.1.
Rejeitadas: A porque um erro de expressão numa cena derruba o render inteiro e mistura relógios; C porque o demuxer não aplica `fade` sem reencode e complica o overlay de legenda.
### Componentes / mudanças
- **`scripts/build-reel.mjs`** (novo, entrada `pnpm reels:build <slug>`): orquestra shot list → captura → clipes → concat → capa → pacote; usa `dieWithLabel`/`parseEqualsFlags`/`loadCliEnv`/`sha256Hex` (`scripts/lib/cli.mjs:18-55,23`); flags `--base-url=`, `--out-dir=`, `--work-dir=`, `--dry-run`, `--capture-only`.
- **`scripts/lib/reelShotList.mjs`** (novo): parse/normalização/validação fail-closed do shot list (cenas, steps, legendas com janelas, alvos, fixture, hook/CTA) + hash determinístico. Unit-testável sem browser.
- **`scripts/lib/reelCapture.mjs`** (novo): driver Playwright; `chromium.launch({ args: ['--force-device-scale-factor=3'] })` (precedente `launchPdfBrowser`, `scripts/lib/buildPdf.mjs:22`), context `{viewport, deviceScaleFactor:3, isMobile, hasTouch, recordVideo}`; injeta cursor SVG + halo via `addInitScript`; `page.mouse` move-antes-de-clicar; grava `click-log.json` `{t,x,y,kind,scene}`.
- **`scripts/lib/reelTimeline.mjs`** (novo, puro): deriva z/easing do click log, janelas de `overlay enable`, mapeamento de relógio (t0/clamp), matemática de safe-area (~250/672/6%) e do recorte central da capa (1080×1350). Coração testável.
- **`scripts/lib/reelFfmpeg.mjs`** (novo): resolve binário (ordem da Decisão 2, precedente `FFMPEG_PATH` em `src/utilities/speech/speechMediaPipeline.ts:17-18`); `probeFfmpeg` fail-closed (libx264 + scale,crop,zoompan,overlay,fade,concat) com erro acionável pt-BR; `buildSceneClip` e `concatScenes`.
- **`scripts/lib/reelRender.mjs`** + **`reelTemplates.mjs`** (novos): portam as seções legenda/cursor/safe zone/capa do hi-fi para HTML puro e renderizam via Playwright (`omitBackground` p/ legenda; `capa.png` com crop-guard); as fontes de marca vêm dos `@font-face` do próprio site (embutidas em base64, fail-closed se ausentes); marca rasterizada de `public/LOGO_SOLLA_BRANCO.svg` com o recorte de viewBox do design.
- **`scripts/lib/reelPackage.mjs`** (novo, puro): monta `metadata.json` (slug, shotListHash, resolução/fps/codec, versão do ffmpeg, timings).
- **`scripts/reels/shot-lists/cards.json`** (novo, versionado): roteiro real do e2e (`tests/e2e/frontend.e2e.spec.ts:1694-1767`) — `goto('/')` → `section#cards` → `[data-card-model-tile="eu-sou-solla"]` (aria-label "Card com seu nome", `CardModelGallery.tsx:33-34`) → dialog → "Seu nome" (`CardComposer.tsx:318`) → "Criar meu card" (`:479`) → pronto → "Baixar meu card" (`:493`); fixture fictícia.
- **`.agents/skills/reels-tutoriais/SKILL.md`** (novo; molde `dossie-solla-cidade/SKILL.md`): pipeline, contrato do shot list, gate, ajustes e troubleshooting.
- **`.opencode/commands/reels-tutoriais.md`** (novo; fina, referencia a skill pelo nome exato e passa `$ARGUMENTS`, formatação prettier).
- **`tests/unit/reels*.unit.spec.ts`** (novos) + pin `'reels-tutoriais'` na lista de `tests/unit/opencodeCommands.unit.spec.ts:13-22`.
- **`package.json`** (script `reels:build` + devDependency `@ffmpeg-installer/ffmpeg`); **`.gitignore`** (bloco `/data/reels/` com comentário C196); **`docs/changelog/2026-09-18-c196.md`**.
- **Migration:** sem migration. **Access/Consent:** não se aplica — script local, site visitado como anônimo, download client-side, zero escrita no servidor (nenhum `Consent`/PII). **UI:** sem UI de app; a superfície gráfica do reel é o produto e é port do design aprovado (shape→craft pelo pipeline, sem `DEGRADED`).
### Dados → forma (se aplicável)
Não se aplica: a intenção declara "não vou apresentar dados" — sem gráfico/KPI; aqui só forma de vídeo.
## Fases verificáveis
1. **Tracer bullet (shot list mínimo → captura):** `reelShotList.mjs` + `cards.json` mínimo (hook + passo 1) + `reelCapture.mjs`; verificação: units do schema/hash + execução real imprime webm nativo 1080×1920 e `click-log.json`.
2. **Composição base:** `reelFfmpeg.mjs` (resolução/probe) + `reelRender.mjs` (legendas) + clipes por cena + concat com fade; verificação: MP4 H.264/yuv420p 30fps faststart com legenda legível no mudo.
3. **Câmera, hook/CTA e capa:** `reelTimeline.mjs` (zoompan do log) + cena hook/CTA + `reelRender.mjs` (capa) + `reelPackage.mjs`; verificação: zoom sem cortar o alvo, capa sobrevive ao recorte 1080×1350, `metadata.json` com hash.
4. **Skill/command/pins:** `SKILL.md`, command, pin unit, script/gitignore/changelog; `pnpm gate:fast` na iteração e entrega via `pnpm push` (CI/deploy cuidam do resto).
## Rabbit holes / Não escopo (engenharia)
- Áudio, narração, trilha e sync (C197) — o binário só precisa de `-an`/mudo.
- Publicação Instagram/biblioteca (C195) — o gate termina no disco.
- `xfade` (não existe no binário 4.1): usar `fade` + concat, nunca tentar instalar ffmpeg novo.
- `ffmpeg-static`/qualquer install que baixe de GitHub releases (quebra o deploy).
- Determinismo bit-a-bit entre máquinas (assumido A na intenção).
- Segunda funcionalidade além de `cards`; editor de vídeo; editar o MP4 renderizado.
- Tocar UI/site público, schema, migration, int/e2e novos (captura depende do site real; não roda em CI).
## Riscos e mitigação
- **Drift do relógio do vídeo vs click log:** gravar um marcador de t0 no primeiro frame (atributo do cursor) e clampar janelas por cena em `reelTimeline` (units cobrem o clamp; cena B isola o erro).
- **Jitter do zoompan:** `d=1`, s/fps fixos, easing monotônico, janela mínima por clique; clips por cena reduzem re-render.
- **Brexter ausente no HTML:** o TTF está versionado (`fonts/Brexter-Bold.ttf`); embutir base64 e falhar fechado com mensagem pt-BR se ausente — sem fallback silencioso (a superfície é o produto).
- **Download assistant durante a gravação:** `acceptDownloads` + `page.waitForEvent('download')`; o alvo visual é o estado do botão, não o arquivo; log do evento.
- **Site fora do ar/seletor mudou:** preflight antes de gravar (HTTP 200 + presença de `section#cards`/tile) falha fechado em pt-BR; `REELS_BASE_URL` para local/staging (default prod).
- **Dep nova quebrar deploy:** `@ffmpeg-installer/ffmpeg` é registry-only (validado); lockfile commitado; `knip.json:24` já ignora o binário `ffmpeg`.
- **Safe zone violada pelo zoom:** zoom ≤122% e clamp do alvo dentro do miolo (matemática pura testada).
- **Logo branco só funciona sobre vermelho** (o único asset oficial é `LOGO_SOLLA_BRANCO.svg`; cena CTA é creme): usar sobre painel vermelho ou disparar o trigger (b) do `designer` — se o design aprovado não puder ser seguido como está, registrar e consultar antes de improvisar.
- **PII/commit acidental:** fixture fictícia no shot list versionado; `/data/reels/` gitignored; unit do hash garante reprodutibilidade sem mídia.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto (pacote + gate + ajuste via shot list).
- [ ] Invariantes AGENTS/engineering-standards: identificadores em inglês/strings pt-BR; `scripts/**` em `.mjs`; knip `exports: error` sem export órfão; command prettier-formatado; zero PII/schema/site público.
- [ ] Testes de domínio previstos: unit para shot list/parse/hash, zoom por log, timing de legendas, safe-area/cover, resolução+probe do ffmpeg com runner stub; sem int/e2e novo; pin do command em `opencodeCommands.unit.spec.ts`.
## Decisões assumidas (auto-aprovadas)
Sob `work-issue --auto`, as decisões em aberto do design e da intenção ficam **assumidas e registradas** (sem novo gate):
- **Capa por composição própria (recomendação B da intenção)** — não frame do vídeo; título sobrevive ao recorte central 1080×1350.
- **Hook "Seu apoio vira card em segundos"** — aprovado como copy do design; o rodapé do hi-fi tem 2 itens em aberto que estas duas cobrem.
- **Determinismo na mesma máquina** (A da intenção) — o hash do shot list identifica a versão; reprodutibilidade bit-a-bit não é exigida.
- **Ajuste sempre via shot list**, mesmo quando o operador descrever "corta 1s do fim" — a skill traduz e mostra o shot list resultante.
## Self-score decision-quality
| # | Critério | Nota | Por quê |
| --- | ----------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------- |
| 1 | Decisões caras com rejeitadas | 5 | As 6 decisões caras têm opções, recomendação ancorada em spike e rejeitadas explícitas. |
| 2 | Cabe no appetite | 5 | ~3–4 dias; tracer cedo; cortes declarados (encoding fino/cenas extras). |
| 3 | Rabbit holes nomeados | 5 | Áudio, C195, `xfade`, `ffmpeg-static`, bit-exact, segunda feature. |
| 4 | Depth check | 4 | Reusa `scripts/lib/cli.mjs`, `buildPdf.mjs`/chromium, precedente `FFMPEG_PATH`; nada gêmeo; só o módulo de reel nasce. |
| 5 | Intenção preservada | 5 | Outcome, gate e anti-goals intactos; engenharia não reescreveu produto. |
Média: **4,8** (gate ≥4 satisfeito).

View file

@ -10,7 +10,7 @@
--font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--pt-red);
--color-primary: var(--kit-red);
--color-primary-foreground: #ffffff;
--color-muted: #ebe9e9;
--color-muted-foreground: var(--campaign-muted);
@ -19,9 +19,16 @@
[data-theme='campaign-site'] {
--background: #fff8f2;
--foreground: #000000;
--pt-red: #a21c1c;
--pt-red-dark: #47130e;
--pt-yellow: #ffe607;
--kit-red: #e4102f;
--kit-blue: #184e92;
--kit-yellow: #ffeb00;
--kit-green: #009647;
--kit-orange: #f89c0e;
--site-red: #a21c1c;
--site-yellow: #ffe607;
--pt-red: var(--kit-red);
--pt-red-dark: #184e92;
--pt-yellow: var(--kit-yellow);
--campaign-cream: #fff8f2;
--campaign-muted: #6c615e;
--campaign-surface: #f9faff;
@ -35,9 +42,9 @@
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
.brexter {
/* Brexter is the approved face; Impact is the standalone gate fallback. */
/* Brexter Bold 700 is the approved face; Impact is only the standalone fallback. */
font-family: Brexter, Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif;
font-weight: 900;
font-weight: 700;
}
.artifact-grid {
background-color: #ebe9e9;
@ -68,9 +75,10 @@
.crop-line {
border-color: rgb(255 255 255 / 78%);
}
.logo-lockup {
letter-spacing: -0.055em;
transform: skew(-4deg);
.kit-asset {
display: block;
height: auto;
object-fit: contain;
}
.cursor {
filter: drop-shadow(0 2px 2px rgb(0 0 0 / 35%));
@ -97,7 +105,8 @@
<p class="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
Um sistema de frames para ensinar o site com ritmo, contraste e marca — sem competir com a
gravação. O vermelho enquadra a história; amarelo aponta a ação; Brexter carrega o
impacto.
impacto. O kit oficial 2026 assina as cenas gráficas; a captura conserva a aparência real
do site.
</p>
<div class="mt-5 flex flex-wrap gap-2 text-xs">
<a
@ -105,20 +114,25 @@
class="rounded-full border border-black/10 bg-white px-3 py-1.5 font-medium"
>01–03 · Sequência</a
>
<a
href="#kit"
class="rounded-full border border-black/10 bg-white px-3 py-1.5 font-medium"
>04 · Kit oficial</a
>
<a
href="#safe-zone"
class="rounded-full border border-black/10 bg-white px-3 py-1.5 font-medium"
>04 · Safe zone</a
>05 · Safe zone</a
>
<a
href="#capa"
class="rounded-full border border-black/10 bg-white px-3 py-1.5 font-medium"
>05 · Capa e recortes</a
>06 · Capa e recortes</a
>
<a
href="#spec"
class="rounded-full border border-black/10 bg-white px-3 py-1.5 font-medium"
>06 · Sistema</a
>07 · Sistema</a
>
</div>
</header>
@ -150,11 +164,13 @@
class="frame frame-shadow mx-auto w-full max-w-[360px] rounded-[22px] border border-black/15 bg-(--pt-red)"
>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_85%_18%,rgb(255_230_7/28%),transparent_22%),linear-gradient(160deg,#a21c1c_0%,#8f1717_55%,#47130e_100%)]"
></div>
<div
class="absolute -right-[12%] top-[15%] size-[52%] rotate-12 rounded-[30%] border-[14px] border-(--pt-yellow)/90"
class="absolute inset-0 bg-[linear-gradient(160deg,#e4102f_0%,#e4102f_62%,#184e92_145%)]"
></div>
<img
src="../../public/campaign-kit/estrela.png"
alt=""
class="kit-asset absolute left-[250px] top-[54px] size-[88px] rounded-full bg-white"
/>
<div class="absolute left-[6%] right-[6%] top-[14%]">
<div
class="inline-flex rounded-full bg-(--pt-yellow) px-[4%] py-[1.7%] text-[3.5cqw] font-black text-black"
@ -162,29 +178,44 @@
FAÇA PARTE
</div>
<h3
class="brexter mt-[8%] max-w-[88%] text-[12cqw] leading-[0.88] uppercase tracking-[-0.045em] text-white"
class="brexter mt-[8%] max-w-full text-[9.5cqw] leading-[0.9] uppercase tracking-[-0.05em] text-white"
>
Seu apoio<br />vira card<br /><span class="text-(--pt-yellow)">em segundos.</span>
<span class="block whitespace-nowrap">Seu apoio</span>
<span class="block whitespace-nowrap">vira card</span>
<span class="block whitespace-nowrap text-(--pt-yellow)">em segundos.</span>
</h3>
</div>
<div class="absolute inset-x-[6%] bottom-[15%] flex items-end justify-between gap-3">
<p class="max-w-[58%] text-[3.8cqw] font-semibold leading-[1.2] text-white">
<div class="absolute left-[22px] top-[356px] w-[170px]">
<p class="text-[3.8cqw] font-semibold leading-[1.2] text-white">
Veja como criar o seu pelo celular.
</p>
<div class="text-right">
<p class="logo-lockup brexter text-[8cqw] leading-none text-white">SOLLA</p>
<p class="text-[2.6cqw] font-bold text-(--pt-yellow)">13 13</p>
</div>
</div>
<img
src="../../public/campaign-kit/marca-negativa-completa.png"
alt="Jorge Solla 1313 · Mais Saúde, Mais Futuro"
class="kit-asset absolute left-[206px] top-[322px] w-[132px]"
/>
<span
class="absolute bottom-[5%] left-[6%] rounded bg-black/45 px-2 py-1 text-[2.5cqw] text-white"
>NEEDS ASSET · logo oficial</span
>KIT OFICIAL · marca negativa completa</span
>
</div>
<p class="mx-auto mt-3 max-w-[360px] text-xs leading-5 text-muted-foreground">
<strong class="text-foreground">Movimento:</strong> título entra em dois beats; aro
amarelo expande 96% → 100%. Sem zoom de site ainda.
<strong class="text-foreground">Movimento:</strong> título entra em dois beats; a
estrela oficial deriva 8px para dentro com fade, sem pulsar. Sem zoom de site ainda.
</p>
<div
class="mx-auto mt-3 max-w-[360px] rounded-xl border border-(--pt-red)/25 bg-(--campaign-cream) p-3 text-xs leading-5"
>
<p class="font-bold text-(--pt-red)">CALIBRAÇÃO · BREXTER BOLD OFICIAL @ 360PX</p>
<p class="mt-1 text-muted-foreground">
Título: <strong class="text-foreground">34,2px (9,5cqw)</strong> · entrelinha
<strong class="text-foreground">30,78px (0,9)</strong> · tracking
<strong class="text-foreground">−1,71px (−0,05em)</strong> · largura máxima
<strong class="text-foreground">316,8px (100% do bloco entre insets de 6%)</strong>.
As três linhas são explícitas e não quebram internamente.
</p>
</div>
</article>
<!-- PASSO -->
@ -203,7 +234,7 @@
class="frame frame-shadow mx-auto w-full max-w-[360px] rounded-[22px] border border-black/15 bg-black"
>
<div class="site-placeholder absolute inset-0">
<div class="absolute inset-x-0 top-0 h-[7%] bg-(--pt-red)">
<div class="absolute inset-x-0 top-0 h-[7%] bg-(--site-red)">
<div
class="absolute left-[6%] top-1/2 h-[18%] w-[28%] -translate-y-1/2 rounded-full bg-white/75"
></div>
@ -212,7 +243,7 @@
></div>
</div>
<div class="absolute inset-x-[6%] top-[11%] text-center">
<p class="text-[2.4cqw] font-black tracking-[0.1em] text-(--pt-red) uppercase">
<p class="text-[2.4cqw] font-black tracking-[0.1em] text-(--site-red) uppercase">
Faça parte
</p>
<h4 class="mt-[1%] text-[5cqw] font-black leading-tight">
@ -228,7 +259,7 @@
<div
class="aspect-square flex-1 rounded-[6%] border border-black/10 bg-(--campaign-cream) p-[3%]"
>
<div class="h-full rounded-[5%] bg-(--pt-red)"></div>
<div class="h-full rounded-[5%] bg-(--site-red)"></div>
<p class="mt-[3%] text-center text-[2.1cqw] font-bold">Card com seu nome</p>
</div>
<div
@ -247,7 +278,7 @@
Seu nome
</div>
<div
class="mt-[4%] h-[10cqw] rounded-lg bg-(--pt-red) pt-[3%] text-center text-[3.2cqw] font-bold text-white"
class="mt-[4%] h-[10cqw] rounded-lg bg-(--site-red) pt-[3%] text-center text-[3.2cqw] font-bold text-white"
>
Criar meu card
</div>
@ -264,7 +295,7 @@
</div>
<!-- zoom target -->
<div
class="absolute left-[5%] top-[27%] aspect-square w-[47%] rounded-[7%] border-[3px] border-(--pt-yellow) shadow-[0_0_0_5px_rgb(255_230_7/18%)]"
class="absolute left-[87px] top-[196px] h-[246px] w-[186px] rounded-[16px] border-[3px] border-(--kit-yellow) shadow-[0_0_0_5px_rgb(255_235_0/20%)]"
></div>
<svg
class="cursor absolute left-[40%] top-[49%] w-[8%] text-white"
@ -282,21 +313,26 @@
Toque em <span class="text-(--pt-yellow)">“Card com seu nome”</span>.
</p>
</div>
<div class="absolute bottom-[13%] right-[6%] text-right">
<p class="logo-lockup brexter text-[6cqw] leading-none text-white drop-shadow">
SOLLA
</p>
<p class="text-[2.1cqw] font-black text-(--pt-yellow)">13 13</p>
<div
class="absolute left-[214px] top-[22px] grid h-[68px] w-[124px] place-items-center rounded-[8px] bg-white/92 p-[6px] shadow-sm"
>
<img
src="../../public/campaign-kit/jorge-solla-positivo.png"
alt="Jorge Solla · Deputado Federal"
class="kit-asset h-[56px] w-[112px]"
/>
</div>
<span
class="absolute bottom-[5%] left-[6%] rounded bg-black/55 px-2 py-1 text-[2.5cqw] text-white"
>NEEDS ASSET · captura real + logo</span
>CAPTURA REAL · chrome com marca positiva</span
>
</div>
<p class="mx-auto mt-3 max-w-[360px] text-xs leading-5 text-muted-foreground">
<strong class="text-foreground">Câmera:</strong> zoom suave até 118% no tile; cursor
chega 180ms antes do clique; halo amarelo pulsa uma vez. A legenda fica a ~1160px no
arquivo final.
chega 180ms antes do clique; halo acompanha o tile real em
<strong class="text-foreground">x87 · y196 · 186 × 246px @360</strong> e pulsa uma
vez. Após o clique, some em até 300ms e não permanece sobre o drawer aberto. A legenda
fica a ~1160px no arquivo final.
</p>
</article>
@ -312,18 +348,22 @@
>
</div>
<div
class="frame frame-shadow mx-auto w-full max-w-[360px] rounded-[22px] border border-black/15 bg-(--campaign-cream)"
class="frame frame-shadow mx-auto w-full max-w-[360px] rounded-[22px] border border-black/15 bg-white"
>
<div class="absolute inset-x-0 top-0 h-[47%] bg-(--pt-red)">
<div
class="absolute -left-[10%] top-[22%] size-[55%] -rotate-12 rounded-[32%] border-[14px] border-(--pt-yellow)/90"
></div>
<img
src="../../public/campaign-kit/estrela.png"
alt=""
class="kit-asset absolute left-[20px] top-[48px] size-[72px] rounded-full bg-white"
/>
</div>
<div class="absolute inset-x-[7%] top-[14%] text-center text-white">
<p class="text-[3cqw] font-black tracking-[0.12em] uppercase text-(--pt-yellow)">
Seu card, sua voz
</p>
<h3 class="brexter mt-[4%] text-[12cqw] leading-[0.9] uppercase">
<h3
class="brexter mx-auto mt-[4%] max-w-full text-[11.5cqw] leading-[0.9] uppercase"
>
Agora é<br />com você.
</h3>
</div>
@ -345,27 +385,26 @@
</svg>
</div>
<p class="mt-[5%] text-[4cqw] font-semibold">Acesse o site pelo</p>
<p class="brexter mt-[1%] text-[8cqw] leading-none uppercase text-(--pt-red)">
<p class="brexter mt-[1%] text-[8cqw] leading-none uppercase text-(--kit-blue)">
link na bio
</p>
<p class="mt-[4%] text-[3cqw] leading-[1.35] text-(--campaign-muted)">
Escolha seu modelo, crie e compartilhe.
</p>
</div>
<div class="absolute inset-x-[7%] bottom-[11%] flex items-end justify-between">
<p
class="max-w-[52%] text-[2.8cqw] font-semibold leading-[1.3] text-(--pt-red-dark)"
>
<div class="absolute inset-x-[7%] bottom-[5%] flex items-end justify-between">
<p class="max-w-[52%] text-[2.8cqw] font-semibold leading-[1.3] text-(--kit-blue)">
jorgesolla1313.com.br
</p>
<div class="text-right">
<p class="logo-lockup brexter text-[7cqw] leading-none text-(--pt-red)">SOLLA</p>
<p class="text-[2.5cqw] font-black">13 13</p>
</div>
<img
src="../../public/campaign-kit/marca-positiva-completa.png"
alt="Jorge Solla 1313 · Mais Saúde, Mais Futuro"
class="kit-asset w-[132px]"
/>
</div>
<span
class="absolute bottom-[4%] left-[7%] rounded bg-black/75 px-2 py-1 text-[2.5cqw] text-white"
>NEEDS ASSET · logo oficial</span
>KIT OFICIAL · marca positiva completa</span
>
</div>
<p class="mx-auto mt-3 max-w-[360px] text-xs leading-5 text-muted-foreground">
@ -376,10 +415,110 @@
</div>
</section>
<!-- KIT OFICIAL -->
<section id="kit" class="mt-20" aria-labelledby="kit-title">
<div class="mb-5">
<p class="text-xs font-semibold text-(--kit-red)">CENA 04 · KIT OFICIAL 2026</p>
<h2 id="kit-title" class="mt-1 text-xl font-semibold tracking-tight">
Uma assinatura oficial para cada superfície
</h2>
<p class="mt-1 max-w-3xl text-sm text-muted-foreground">
Medidas abaixo são CSS px no compositor 360 × 640; o arquivo final multiplica tudo por
3. Não recortar, recolorir, redesenhar ou aplicar sombra aos PNGs do kit.
</p>
</div>
<div class="overflow-hidden rounded-2xl border border-black/10 bg-white">
<div class="overflow-x-auto">
<table class="w-full min-w-[900px] border-collapse text-left text-sm">
<thead class="bg-(--campaign-band)">
<tr>
<th class="px-4 py-3 font-semibold">Superfície</th>
<th class="px-4 py-3 font-semibold">Ativo oficial</th>
<th class="px-4 py-3 font-semibold">Caixa @360</th>
<th class="px-4 py-3 font-semibold">Regra</th>
</tr>
</thead>
<tbody class="divide-y divide-black/10">
<tr>
<td class="px-4 py-3 font-semibold">Hook</td>
<td class="px-4 py-3 font-mono text-xs">marca-negativa-completa.png</td>
<td class="px-4 py-3 font-mono text-xs">x206 · y322 · 132×88</td>
<td class="px-4 py-3 text-muted-foreground">
Sobre vermelho; slogan só no próprio ativo.
</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">Captura / chrome</td>
<td class="px-4 py-3 font-mono text-xs">jorge-solla-positivo.png</td>
<td class="px-4 py-3 font-mono text-xs">x220 · y28 · 112×56</td>
<td class="px-4 py-3 text-muted-foreground">
Plate branco 92% em x214 · y22 · 124×68, no fundo dimmed acima do drawer; nunca
cobrir input, ajuda ou botão. Nunca usar a negativa sobre o sheet branco.
</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">CTA</td>
<td class="px-4 py-3 font-mono text-xs">marca-positiva-completa.png</td>
<td class="px-4 py-3 font-mono text-xs">x203 · y520 · 132×88</td>
<td class="px-4 py-3 text-muted-foreground">
Sobre branco, 6px abaixo do card; URL e “link na bio” em azul oficial.
</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">Capa</td>
<td class="px-4 py-3 font-mono text-xs">marca-negativa-completa.png</td>
<td class="px-4 py-3 font-mono text-xs">x205 · y448 · 133×89</td>
<td class="px-4 py-3 text-muted-foreground">
Inteira dentro do recorte central 4:5.
</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">Badges 1 / 2 / 3</td>
<td class="px-4 py-3 font-mono text-xs">nenhum</td>
<td class="px-4 py-3 font-mono text-xs">x22 · y96 · h36</td>
<td class="px-4 py-3 text-muted-foreground">
Controle funcional amarelo/preto; não miniaturizar marca, número oficial ou
estrela.
</td>
</tr>
<tr>
<td class="px-4 py-3 font-semibold">Decoração gráfica</td>
<td class="px-4 py-3 font-mono text-xs">estrela.png</td>
<td class="px-4 py-3 font-mono text-xs">hook x250 · y54 · 88²</td>
<td class="px-4 py-3 text-muted-foreground">
PNG a 100%, sem filtro/recoloração, sobre disco branco do mesmo diâmetro para a
estrela vermelha não sumir no fundo. Capa: x250 · y54 · 88². CTA: x20 · y48 ·
72². Fade + deriva de 8px; sem rotação.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mt-5 grid gap-5 md:grid-cols-2">
<article class="rounded-2xl bg-(--kit-red) p-5 text-white">
<p class="text-xs font-bold text-(--kit-yellow)">FUNDO POSITIVO · VERMELHO</p>
<img
src="../../public/campaign-kit/marca-negativa-completa.png"
alt="Jorge Solla 1313 · Mais Saúde, Mais Futuro"
class="kit-asset mx-auto mt-5 w-[220px]"
/>
</article>
<article class="rounded-2xl border border-black/10 bg-white p-5">
<p class="text-xs font-bold text-(--kit-blue)">FUNDO CLARO</p>
<img
src="../../public/campaign-kit/marca-positiva-completa.png"
alt="Jorge Solla 1313 · Mais Saúde, Mais Futuro"
class="kit-asset mx-auto mt-5 w-[220px]"
/>
</article>
</div>
</section>
<!-- SAFE ZONE -->
<section id="safe-zone" class="mt-20" aria-labelledby="safe-zone-title">
<div class="mb-5">
<p class="text-xs font-semibold text-(--pt-red)">CENA 04 · SAFE ZONE</p>
<p class="text-xs font-semibold text-(--pt-red)">CENA 05 · SAFE ZONE</p>
<h2 id="safe-zone-title" class="mt-1 text-xl font-semibold tracking-tight">
O Instagram ocupa as bordas; o tutorial ocupa o miolo
</h2>
@ -392,7 +531,7 @@
class="frame frame-shadow mx-auto w-full max-w-[400px] rounded-[22px] border border-black/15 bg-black"
>
<div class="site-placeholder absolute inset-0 opacity-85">
<div class="absolute inset-x-0 top-0 h-[7%] bg-(--pt-red)"></div>
<div class="absolute inset-x-0 top-0 h-[7%] bg-(--site-red)"></div>
<div class="absolute inset-x-[8%] top-[23%] h-[31%] rounded-[4%] bg-white/70"></div>
</div>
<div
@ -483,10 +622,10 @@
</li>
</ol>
<div class="mt-6 rounded-xl border border-(--pt-red)/25 bg-white p-4">
<p class="text-xs font-bold text-(--pt-red)">NEEDS ASSET</p>
<p class="text-xs font-bold text-(--pt-red)">ATIVOS RESOLVIDOS</p>
<p class="mt-1 text-sm leading-6 text-muted-foreground">
Captura real do site em viewport mobile, logo oficial transparente e, se aprovada no
futuro, foto oficial. Nenhum desses ativos foi inventado neste gate.
Captura real do site em viewport mobile + PNGs de `public/campaign-kit/`. Foto
oficial continua fora deste template; não é necessária para o reel de cards.
</p>
</div>
</div>
@ -496,7 +635,7 @@
<!-- CAPA -->
<section id="capa" class="mt-20" aria-labelledby="capa-title">
<div class="mb-5">
<p class="text-xs font-semibold text-(--pt-red)">CENA 05 · CAPA</p>
<p class="text-xs font-semibold text-(--pt-red)">CENA 06 · CAPA</p>
<h2 id="capa-title" class="mt-1 text-xl font-semibold tracking-tight">
Título no núcleo que todos os recortes preservam
</h2>
@ -509,11 +648,13 @@
class="frame frame-shadow mx-auto w-full max-w-[400px] rounded-[22px] border border-black/15 bg-(--pt-red)"
>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_80%_25%,rgb(255_230_7/30%),transparent_22%),linear-gradient(160deg,#a21c1c,#47130e)]"
></div>
<div
class="absolute -right-[9%] top-[18%] size-[48%] rotate-12 rounded-[30%] border-[14px] border-(--pt-yellow)"
class="absolute inset-0 bg-[linear-gradient(160deg,#e4102f_0%,#e4102f_62%,#184e92_145%)]"
></div>
<img
src="../../public/campaign-kit/estrela.png"
alt=""
class="kit-asset absolute left-[250px] top-[54px] size-[88px] rounded-full bg-white"
/>
<!-- central 1080x1350 = 70.3125% height, centered -->
<div
class="absolute inset-x-0 top-[14.84375%] h-[70.3125%] border-y-2 border-dashed crop-line"
@ -530,13 +671,16 @@
Seu nome ou sua foto, direto pelo celular.
</p>
</div>
<div class="absolute inset-x-[7%] bottom-[18%] flex items-end justify-between">
<p class="rounded bg-black/40 px-[3%] py-[1.5%] text-[2.4cqw] text-white">#cards</p>
<div class="text-right">
<p class="logo-lockup brexter text-[7cqw] leading-none text-white">SOLLA</p>
<p class="text-[2.4cqw] font-black text-(--pt-yellow)">13 13</p>
</div>
</div>
<p
class="absolute left-[25px] top-[500px] rounded bg-black/40 px-[11px] py-[5px] text-[8.6px] text-white"
>
#cards
</p>
<img
src="../../public/campaign-kit/marca-negativa-completa.png"
alt="Jorge Solla 1313 · Mais Saúde, Mais Futuro"
class="kit-asset absolute left-[205px] top-[448px] w-[133px]"
/>
<div
class="absolute left-[2%] top-[15.5%] rounded bg-white px-2 py-1 text-[2.5cqw] font-bold text-black"
>
@ -544,7 +688,7 @@
</div>
<span
class="absolute bottom-[4%] left-[7%] rounded bg-black/55 px-2 py-1 text-[2.5cqw] text-white"
>NEEDS ASSET · logo oficial</span
>KIT OFICIAL · marca negativa completa</span
>
</div>
<div class="grid content-start gap-5 md:grid-cols-2">
@ -559,7 +703,7 @@
>
</div>
<div
class="crop-preview relative mx-auto aspect-[4/5] max-w-[270px] overflow-hidden rounded-xl bg-[linear-gradient(155deg,#a21c1c,#47130e)] p-[7%] text-white"
class="crop-preview relative mx-auto aspect-[4/5] max-w-[270px] overflow-hidden rounded-xl bg-[linear-gradient(155deg,#e4102f,#184e92)] p-[7%] text-white"
>
<span
class="inline-flex rounded-full bg-(--pt-yellow) px-[4%] py-[2%] text-[2.3cqw] font-black text-black"
@ -568,9 +712,11 @@
<p class="brexter mt-[9%] text-[9cqw] leading-[0.88] uppercase">
Crie seu<br />card de<br /><span class="text-(--pt-yellow)">apoio.</span>
</p>
<p class="absolute bottom-[8%] right-[7%] logo-lockup brexter text-[5.5cqw]">
SOLLA
</p>
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla · Deputado Federal"
class="kit-asset absolute bottom-[7%] right-[7%] w-[35%]"
/>
</div>
<p class="mt-3 text-xs leading-5 text-muted-foreground">
Eyebrow, título inteiro e marca continuam visíveis. Copy auxiliar pode sair.
@ -587,7 +733,7 @@
>
</div>
<div
class="crop-preview relative mx-auto aspect-square max-w-[270px] overflow-hidden rounded-xl bg-[linear-gradient(155deg,#a21c1c,#47130e)] p-[7%] text-white"
class="crop-preview relative mx-auto aspect-square max-w-[270px] overflow-hidden rounded-xl bg-[linear-gradient(155deg,#e4102f,#184e92)] p-[7%] text-white"
>
<span
class="inline-flex rounded-full bg-(--pt-yellow) px-[4%] py-[2%] text-[2.3cqw] font-black text-black"
@ -596,9 +742,11 @@
<p class="brexter mt-[9%] text-[9cqw] leading-[0.88] uppercase">
Crie seu<br />card de<br /><span class="text-(--pt-yellow)">apoio.</span>
</p>
<p class="absolute bottom-[7%] right-[7%] logo-lockup brexter text-[5.5cqw]">
SOLLA
</p>
<img
src="../../public/campaign-kit/jorge-solla-negativo.png"
alt="Jorge Solla · Deputado Federal"
class="kit-asset absolute bottom-[7%] right-[7%] w-[35%]"
/>
</div>
<p class="mt-3 text-xs leading-5 text-muted-foreground">
Título nunca depende do terço inferior. A marca reduz, mas não desaparece.
@ -617,7 +765,7 @@
<!-- SISTEMA -->
<section id="spec" class="mt-20" aria-labelledby="spec-title">
<div class="mb-5">
<p class="text-xs font-semibold text-(--pt-red)">CENA 06 · SISTEMA VISUAL</p>
<p class="text-xs font-semibold text-(--pt-red)">CENA 07 · SISTEMA VISUAL</p>
<h2 id="spec-title" class="mt-1 text-xl font-semibold tracking-tight">
Receita consistente, não vinheta pesada
</h2>
@ -636,24 +784,25 @@
<div
class="aspect-[3/2] rounded-lg bg-(--pt-red) p-2 text-[10px] font-bold text-white"
>
#A21C1C
#E4102F
</div>
<div class="aspect-[3/2] rounded-lg bg-(--pt-yellow) p-2 text-[10px] font-bold">
#FFE607
#FFEB00
</div>
<div
class="aspect-[3/2] rounded-lg bg-(--campaign-cream) p-2 text-[10px] font-bold ring-1 ring-black/10"
class="aspect-[3/2] rounded-lg bg-(--kit-blue) p-2 text-[10px] font-bold text-white"
>
CREME
#184E92
</div>
<div
class="aspect-[3/2] rounded-lg bg-(--pt-red-dark) p-2 text-[10px] font-bold text-white"
class="aspect-[3/2] rounded-lg bg-(--kit-green) p-2 text-[10px] font-bold text-white"
>
#47130E
#009647
</div>
</div>
<p class="mt-4 text-sm leading-6 text-muted-foreground">
Amarelo sinaliza ação; nunca vira fundo longo de legenda.
Cenas gráficas usam o kit. Capturas não são recoloridas: nelas, o site mantém
#A21C1C/#FFE607/creme e só o chrome usa amarelo oficial.
</p>
</article>
<article class="rounded-2xl border border-black/10 bg-white p-5">
@ -673,7 +822,7 @@
<p class="text-xs font-bold text-(--pt-red)">GESTO</p>
<div class="relative mt-4 aspect-[3/2] overflow-hidden rounded-xl bg-stone-300">
<div
class="absolute left-[18%] top-[18%] size-[58%] rounded-xl border-[3px] border-(--pt-yellow) shadow-[0_0_0_6px_rgb(255_230_7/22%)]"
class="absolute left-[18%] top-[12%] h-[76%] w-[58%] rounded-xl border-[3px] border-(--pt-yellow) shadow-[0_0_0_6px_rgb(255_235_0/22%)]"
></div>
<svg
class="cursor absolute left-[61%] top-[56%] w-[12%] text-white"
@ -686,27 +835,54 @@
</svg>
</div>
<p class="mt-4 text-sm leading-6 text-muted-foreground">
Zoom 108–122%; cursor chega antes; halo pulsa uma vez; easing suave.
Zoom 108–122%; cursor chega antes; halo retangular 3:4 acompanha o alvo e pulsa uma
vez; easing suave. No frame de Passo 1: x87 · y196 · 186 × 246px @360.
</p>
</article>
</div>
<div
class="mt-5 rounded-2xl border border-black/10 bg-(--campaign-cream) p-5 text-sm leading-6"
>
<p class="text-xs font-bold text-(--pt-red)">FIT CHECK · BREXTER BOLD OFICIAL @ 360PX</p>
<p class="mt-2 text-muted-foreground">
<strong class="text-foreground">Capa:</strong> mantém 43,2px (12cqw), entrelinha 38,02px
(0,88), tracking 0 e largura útil 309,6px. “Crie seu” (255px), “card de” (246px) e
“apoio.” (191px) cabem sem ajuste.
</p>
<p class="mt-1 text-muted-foreground">
<strong class="text-foreground">CTA:</strong> título ajustado para 41,4px (11,5cqw),
entrelinha 37,26px (0,9), tracking 0 e largura máxima 309,6px; “com você.” passa de
306px para 293,25px. “Link na bio” permanece em 28,8px (8cqw), entrelinha 28,8px,
tracking 0, dentro dos 259,2px úteis do card.
</p>
<p class="mt-1 text-muted-foreground">
<strong class="text-foreground">Marca e slogan:</strong> hook e capa usam
`marca-negativa-completa.png`; CTA usa `marca-positiva-completa.png`; chrome usa
`jorge-solla-positivo.png`. “Mais Saúde, Mais Futuro” entra somente dentro dos três
ativos completos, com altura visual aproximada de 8px @360 (24px no render), nunca como
headline concorrente. Não aplicar recorte, filtro, sombra ou recoloração.
</p>
</div>
</section>
<footer class="mt-16 border-t border-black/15 pt-6 text-xs leading-5 text-muted-foreground">
<p>
<strong class="text-foreground">NEEDS ASSET:</strong> captura real do fluxo `#cards`, logo
oficial transparente e qualquer foto oficial aprovada. A composição usa placeholders
explícitos.
<strong class="text-foreground">ATIVOS:</strong> o único conteúdo ainda capturado em tempo
de geração é o fluxo real `#cards`. Marca, número, slogan e decoração vêm exclusivamente
de `public/campaign-kit/`; o SVG branco legado e lockups tipográficos deixam de ser
usados.
</p>
<p class="mt-1">
<strong class="text-foreground">Fonte:</strong> o port deve carregar o Brexter Bold
oficial. Este HTML autossuficiente usa uma fallback condensada apenas quando Brexter não
estiver instalada localmente.
oficial em peso 700. As medidas de hook, capa e CTA acima foram recalibradas com essa
face; este HTML usa a fallback condensada apenas quando Brexter não estiver instalada
localmente.
</p>
<p class="mt-1">
<strong class="text-foreground">Em aberto no gate:</strong> confirmar composição própria
da capa (recomendação B do plano) e aprovar a frase do hook “Seu apoio vira card em
segundos”.
<strong class="text-foreground">Revalidação no PR:</strong> esta integração do kit é uma
mudança material: o gate humano deve confirmar a estrela oficial em vez do aro, as marcas
completas com slogan e a paleta #E4102F/#184E92/#FFEB00. A métrica aprovada da Brexter
permanece: hook 9,5cqw, capa 12cqw e CTA 11,5cqw.
</p>
</footer>
</main>

View file

@ -50,6 +50,7 @@
"build:geometries": "cross-env NODE_OPTIONS=\"--no-deprecation --import=tsx/esm\" node scripts/build-bahia-geometries.mjs",
"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",
"dev": "node scripts/dev.mjs",
"devsafe": "rm -rf .next && node scripts/dev.mjs",
"predev": "node scripts/predev-importmap.mjs",
@ -149,6 +150,7 @@
"devDependencies": {
"@aws-sdk/client-s3": "^3.1112.0",
"@eslint/eslintrc": "^3.3.6",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@playwright/test": "1.58.2",
"@smithy/node-http-handler": "^4.11.2",
"@tailwindcss/postcss": "^4.1.18",

View file

@ -168,6 +168,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3.3.6
version: 3.3.6
'@ffmpeg-installer/ffmpeg':
specifier: ^1.1.0
version: 1.1.0
'@playwright/test':
specifier: 1.58.2
version: 1.58.2
@ -1311,6 +1314,49 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@ffmpeg-installer/darwin-arm64@4.1.5':
resolution: {integrity: sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==}
cpu: [arm64]
os: [darwin]
'@ffmpeg-installer/darwin-x64@4.1.0':
resolution: {integrity: sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==}
cpu: [x64]
os: [darwin]
'@ffmpeg-installer/ffmpeg@1.1.0':
resolution: {integrity: sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==}
'@ffmpeg-installer/linux-arm64@4.1.4':
resolution: {integrity: sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==}
cpu: [arm64]
os: [linux]
'@ffmpeg-installer/linux-arm@4.1.3':
resolution: {integrity: sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==}
cpu: [arm]
os: [linux]
'@ffmpeg-installer/linux-ia32@4.1.0':
resolution: {integrity: sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==}
cpu: [ia32]
os: [linux]
'@ffmpeg-installer/linux-x64@4.1.0':
resolution: {integrity: sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==}
cpu: [x64]
os: [linux]
'@ffmpeg-installer/win32-ia32@4.1.0':
resolution: {integrity: sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==}
cpu: [ia32]
os: [win32]
'@ffmpeg-installer/win32-x64@4.1.0':
resolution: {integrity: sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==}
cpu: [x64]
os: [win32]
'@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
@ -8998,6 +9044,41 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@ffmpeg-installer/darwin-arm64@4.1.5':
optional: true
'@ffmpeg-installer/darwin-x64@4.1.0':
optional: true
'@ffmpeg-installer/ffmpeg@1.1.0':
optionalDependencies:
'@ffmpeg-installer/darwin-arm64': 4.1.5
'@ffmpeg-installer/darwin-x64': 4.1.0
'@ffmpeg-installer/linux-arm': 4.1.3
'@ffmpeg-installer/linux-arm64': 4.1.4
'@ffmpeg-installer/linux-ia32': 4.1.0
'@ffmpeg-installer/linux-x64': 4.1.0
'@ffmpeg-installer/win32-ia32': 4.1.0
'@ffmpeg-installer/win32-x64': 4.1.0
'@ffmpeg-installer/linux-arm64@4.1.4':
optional: true
'@ffmpeg-installer/linux-arm@4.1.3':
optional: true
'@ffmpeg-installer/linux-ia32@4.1.0':
optional: true
'@ffmpeg-installer/linux-x64@4.1.0':
optional: true
'@ffmpeg-installer/win32-ia32@4.1.0':
optional: true
'@ffmpeg-installer/win32-x64@4.1.0':
optional: true
'@floating-ui/core@1.7.4':
dependencies:
'@floating-ui/utils': 0.2.10

View file

@ -0,0 +1,43 @@
# Kit oficial da campanha Jorge Solla 1313
Ativos oficiais de marca, recebidos no kit "kit solla 1313" (2026-09-19) e
versionados aqui para o site, os reels e as peças da campanha. O manual de
marca completo está em [`docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf`](../../docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf).
## Paleta oficial (extraída dos próprios ativos)
| Cor | Hex | Uso |
| ------ | --------- | --- |
| Vermelho | `#e4102f` | fundo positivo, nome na marca positiva |
| Azul | `#184e92` | "1313", "DEPUTADO FEDERAL", fundo do gradiente |
| Amarelo | `#ffeb00` | "1313" na marca negativa, destaques |
| Verde | `#009647` | detalhe da bandeira na estrela (só dentro dos ativos) |
O laranja do manual (`#f89c0e`) só existe nas variações de paleta; não é usado
nos reels.
## Ativos e quando usar
- **`marca-negativa-completa.png`** — marca completa (nome + 1313 + slogan),
branco/amarelo, para **fundos escuros/vermelhos** (hook, capa, CTA no topo
vermelho).
- **`marca-positiva-completa.png`** — mesma marca em vermelho/azul, para
**fundos claros** (card do CTA sobre creme).
- **`jorge-solla-positivo.png`** — lockup do nome em vermelho/azul, para
**fundos claros** (plate do chrome sobre o site capturado).
- **`jorge-solla-negativo.png`** — lockup do nome em branco, para fundos
escuros.
- **`numero-positivo.png`** / **`numero-negativo.png`** — "1313" com o slogan,
quando o número for o protagonista.
- **`estrela.png`** — a estrela oficial (roundel amarelo/verde com estrela
vermelha). Usar a 100% sobre disco branco do mesmo diâmetro — nunca com
opacidade reduzida, que mistura as cores com o fundo e suja o ativo.
## Regras do manual
- A identidade combina **vermelho + azul** (histórico progressista + saúde
pública) com detalhe **verde/amarelo** na estrela.
- O slogan **"Mais Saúde, Mais Futuro"** vive dentro das marcas completas —
não é uma quarta mensagem nem headline solta.
- O nome e o número são os elementos de memorização: não recriar lockups
tipográficos quando o ativo oficial existe.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

292
scripts/build-reel.mjs Normal file
View file

@ -0,0 +1,292 @@
/**
* C196 — reel builder: one shot list in, one publishable package out
* (`reel.mp4` + `capa.png` + `metadata.json`).
*
* Pipeline: capture (real site, mobile viewport, timestamped screencast) →
* overlays/cover/graphic scenes (Playwright + the site's own fonts) → one
* H.264 clip per scene (zoompan + burned captions) → concat → package.
*
* Runs on the workstation: the database is never touched and the site is
* visited as an anonymous reader (the card download is client-side).
*
* Usage:
* node scripts/build-reel.mjs cards [--base-url=…] [--out-dir=…] [--work-dir=…]
* [--dry-run] [--capture-only]
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs'
import {
gotoReelSite,
installCursor,
launchReelBrowser,
openReelContext,
REEL_VIEWPORT,
runCaptureScenes,
startFrameRecorder,
} from './lib/reelCapture.mjs'
import {
buildConcatArgs,
buildSceneClipArgs,
concatListContent,
encodeFrames,
PREVIEW_CRF,
PREVIEW_PRESET,
REEL_CRF,
REEL_PRESET,
resolveFfmpeg,
runFfmpeg,
} from './lib/reelFfmpeg.mjs'
import { buildReelMetadata } from './lib/reelPackage.mjs'
import {
extractFontFaces,
loadFontCss,
openRenderContext,
readKitAssets,
recordGraphicScene,
renderCover,
renderOverlay,
} from './lib/reelRender.mjs'
import { captureScenes, graphicScenes, loadShotList } from './lib/reelShotList.mjs'
import {
captionOverlayHtml,
chromeOverlayHtml,
coverHtml,
ctaHtml,
hookHtml,
} from './lib/reelTemplates.mjs'
import { captureScenePlan, graphicScenePlan, REEL, totalDurationMs } from './lib/reelTimeline.mjs'
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
const LABEL = 'build-reel'
const die = dieWithLabel(LABEL)
const DEFAULT_OUT_DIR = 'data/reels'
const DEFAULT_WORK_DIR = 'data/reels/.work'
const DEFAULT_BASE_URL = 'https://jorgesolla1313.com.br'
const FADE_IN_MS = 300
const FADE_OUT_MS = 400
const main = async () => {
loadCliEnv()
const { flags, positional } = parseEqualsFlags(process.argv.slice(2))
const slug = positional[0]
if (!slug) {
die(
'Uso: node scripts/build-reel.mjs <slug> [--base-url=…] [--out-dir=…] [--work-dir=…] [--dry-run] [--capture-only]',
)
}
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 baseUrl =
(typeof flags['base-url'] === 'string'
? flags['base-url']
: process.env.REELS_BASE_URL?.trim()) || DEFAULT_BASE_URL
const dryRun = flags['dry-run'] === true
const captureOnly = flags['capture-only'] === true
const { shotList, hash } = await loadShotList({ slug, root: ROOT })
const outDir = join(outRoot, slug)
const workDir = join(workRoot, slug)
const capturePath = join(workDir, 'capture.mp4')
console.log(`[${LABEL}] shot list ${slug} — hash ${hash.slice(0, 12)}`)
if (dryRun) {
for (const scene of shotList.scenes) {
const detail =
scene.kind === 'capture'
? `captura · ${scene.steps.length} passo(s)`
: `gráfica · ${scene.template}`
console.log(` - ${scene.id}: ${detail}`)
}
return
}
const ffmpeg = await resolveFfmpeg()
console.log(`[${LABEL}] ffmpeg: ${ffmpeg.source} (${ffmpeg.version || ffmpeg.bin})`)
await mkdir(workDir, { recursive: true })
await mkdir(outDir, { recursive: true })
const assets = await readKitAssets({ root: ROOT })
const browser = await launchReelBrowser()
const plans = new Map()
const graphics = new Map()
let fontCss = ''
try {
const context = await openReelContext(browser)
const page = await context.newPage()
await installCursor(page)
const recorder = await startFrameRecorder({ context, page, framesDir: join(workDir, 'frames') })
const url = await gotoReelSite({ page, baseUrl, route: shotList.route })
console.log(`[${LABEL}] capturando ${url}`)
const firstCapture = captureScenes(shotList)[0]
const surfaceSelector = firstCapture?.setup[0]?.selector ?? firstCapture?.steps[0]?.selector
if (surfaceSelector) {
try {
await page.locator(surfaceSelector).first().waitFor({ state: 'attached', timeout: 15000 })
} catch {
throw new Error(
`Superfície do reel não encontrada no site (${surfaceSelector}). O layout público mudou?`,
)
}
}
const fontFaces = await extractFontFaces(page)
const actions = await runCaptureScenes({ page, shotList, log: [] })
const stats = await recorder.stop()
if (stats.firstFrameWallMs === null)
throw new Error('Nenhum frame foi gravado — o site não renderizou?')
await encodeFrames({
ffmpegBin: ffmpeg.bin,
frames: stats.frames,
listPath: join(workDir, 'frames.txt'),
output: capturePath,
fps: REEL.fps,
preset: PREVIEW_PRESET,
crf: PREVIEW_CRF,
})
await writeFile(
join(workDir, 'capture.log.json'),
`${JSON.stringify({ firstFrameWallMs: stats.firstFrameWallMs, frames: stats.frames, actions, fontFaces }, null, 2)}\n`,
)
for (const scene of captureScenes(shotList)) {
plans.set(
scene.id,
captureScenePlan({
scene,
actions,
firstFrameWallMs: stats.firstFrameWallMs,
viewportWidth: REEL_VIEWPORT.width,
}),
)
}
console.log(`[${LABEL}] captura: ${stats.frameCount} frames, ${capturePath}`)
if (!captureOnly) {
const renderContext = await openRenderContext(browser)
const renderPage = await renderContext.newPage()
fontCss = await loadFontCss({
fontFaces,
cacheDir: join(workRoot, '.cache', 'fonts'),
pageUrl: url,
})
for (const scene of captureScenes(shotList)) {
await renderOverlay({
page: renderPage,
html: chromeOverlayHtml({ fontCss, badge: scene.badge, assets }),
output: join(workDir, `chrome-${scene.id}.png`),
})
await renderOverlay({
page: renderPage,
html: captionOverlayHtml({ fontCss, caption: scene.caption }),
output: join(workDir, `caption-${scene.id}.png`),
})
}
await renderCover({
page: renderPage,
html: coverHtml({ fontCss, assets }),
output: join(outDir, 'capa.png'),
})
for (const scene of graphicScenes(shotList)) {
const html =
scene.template === 'hook' ? hookHtml({ fontCss, assets }) : ctaHtml({ fontCss, assets })
const graphic = await recordGraphicScene({
context: renderContext,
template: scene.template,
html,
durationMs: scene.durationMs,
output: join(workDir, `${scene.id}.mp4`),
framesDir: join(workDir, 'frames', scene.id),
ffmpegBin: ffmpeg.bin,
crf: PREVIEW_CRF,
preset: PREVIEW_PRESET,
})
const plan = graphicScenePlan({
scene,
readyAtMs: graphic.readyAtMs,
firstFrameWallMs: graphic.stats.firstFrameWallMs,
})
plans.set(scene.id, plan)
graphics.set(scene.id, graphic.output)
}
await renderPage.close()
await renderContext.close()
}
} finally {
await browser.close()
}
if (captureOnly) {
console.log(`[${LABEL}] --capture-only: ${capturePath}`)
return
}
const clips = []
const firstScene = shotList.scenes[0].id
const lastScene = shotList.scenes[shotList.scenes.length - 1].id
for (const scene of shotList.scenes) {
const plan = plans.get(scene.id)
const output = join(workDir, `clip-${scene.id}.mp4`)
const input = scene.kind === 'capture' ? capturePath : graphics.get(scene.id)
const captions =
scene.kind === 'capture'
? plan.captions.map((window) => ({
path: join(workDir, `caption-${scene.id}.png`),
...window,
}))
: []
if (scene.kind === 'capture') {
captions.unshift({
path: join(workDir, `chrome-${scene.id}.png`),
startMs: 0,
endMs: plan.durationMs,
})
}
await runFfmpeg(
ffmpeg.bin,
buildSceneClipArgs({
input,
output,
startMs: plan.startMs,
durationMs: plan.durationMs,
zoom: plan.zoom,
captions,
fadeInMs: scene.id === firstScene ? FADE_IN_MS : 0,
fadeOutMs: scene.id === lastScene ? FADE_OUT_MS : 0,
fps: REEL.fps,
width: REEL.width,
height: REEL.height,
preset: REEL_PRESET,
crf: REEL_CRF,
}),
{ label: `clip ${scene.id}` },
)
console.log(`[${LABEL}] cena ${scene.id}: ${(plan.durationMs / 1000).toFixed(1)}s`)
clips.push(output)
}
const reelPath = join(outDir, 'reel.mp4')
const listPath = join(workDir, 'concat.txt')
await writeFile(listPath, concatListContent(clips))
await runFfmpeg(ffmpeg.bin, buildConcatArgs({ listPath, output: reelPath }), { label: 'concat' })
const durationMs = totalDurationMs([...plans.values()])
const metadata = buildReelMetadata({
shotList,
hash,
durationMs,
ffmpeg: ffmpeg.version || ffmpeg.bin,
})
await writeFile(join(outDir, 'metadata.json'), `${JSON.stringify(metadata, null, 2)}\n`)
console.log(`[${LABEL}] pacote: ${outDir}`)
console.log(` reel.mp4 (${(durationMs / 1000).toFixed(1)}s) · capa.png · metadata.json`)
}
await main().catch((error) => {
die(error instanceof Error ? error.message : String(error))
})

369
scripts/lib/reelCapture.mjs Normal file
View file

@ -0,0 +1,369 @@
/**
* C196 — the reel capture: drives the real site in the mobile viewport
* (deviceScaleFactor 3 + `--force-device-scale-factor=3` → native 1080×1920
* frames) and records numbered JPEG frames with their wall-clock capture
* timestamps via CDP screencast. The video is encoded offline from those
* timestamps (`encodeFrames`), so ffmpeg never backpressures the browser.
*
* Why not Playwright's `recordVideo`: it writes fixed-25fps frames ignoring the
* wall clock (a static tail is dropped and the timeline drifts), which
* desynchronizes the zoom/cursor driven by the click log. See the implementation
* plan (Decisão 1, revisada na execução).
*
* The synthetic cursor and the target halo are DOM-injected (headless draws no
* cursor) and therefore captured by the screencast.
*/
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { chromium } from '@playwright/test'
import { REEL } from './reelTimeline.mjs'
export const REEL_VIEWPORT = REEL.viewport
export const REEL_DEVICE_SCALE = REEL.deviceScale
const CURSOR_STYLE_ID = 'reel-cursor-style'
const CURSOR_ID = 'reel-cursor'
const RING_ID = 'reel-ring'
const TICKER_ID = 'reel-ticker'
const CURSOR_SVG =
'<svg viewBox="0 0 24 30" fill="#ffffff" stroke="#000000" stroke-width="1.4" aria-hidden="true">' +
'<path d="M3 2v22l6-6 4 9 4-2-4-9h8L3 2Z"/></svg>'
const CURSOR_INIT = `
;(() => {
const install = () => {
if (!document.body || document.getElementById('${CURSOR_ID}')) return
const style = document.createElement('style')
style.id = '${CURSOR_STYLE_ID}'
style.textContent = [
'#${CURSOR_ID}{position:fixed;left:0;top:0;width:28px;height:35px;z-index:2147483647;pointer-events:none;',
'transition:transform 90ms linear;filter:drop-shadow(0 2px 2px rgb(0 0 0 / 35%));will-change:transform}',
'#${CURSOR_ID} svg{width:100%;height:100%;display:block}',
'#${RING_ID}{position:fixed;z-index:2147483646;pointer-events:none;border:3px solid #ffeb00;border-radius:16px;',
'box-shadow:0 0 0 5px rgb(255 235 0 / 20%);opacity:0;transition:opacity 250ms ease-out}',
'#${RING_ID}.reel-ring-on{opacity:1}',
'#${RING_ID}.reel-ring-pulse{animation:reel-halo 700ms ease-out 1}',
'@keyframes reel-halo{0%{transform:scale(.96)}55%{transform:scale(1.02)}100%{transform:scale(1)}}',
'#${TICKER_ID}{position:fixed;right:0;bottom:0;width:2px;height:2px;background:#fff;opacity:.996;pointer-events:none;z-index:2147483645}',
'.reel-ticking #${TICKER_ID}{animation:reel-tick 66ms steps(2,end) infinite}',
'@keyframes reel-tick{0%{opacity:.996}100%{opacity:1}}',
].join('')
document.head.appendChild(style)
const cursor = document.createElement('div')
cursor.id = '${CURSOR_ID}'
cursor.innerHTML = '${CURSOR_SVG}'
document.body.appendChild(cursor)
const ticker = document.createElement('div')
ticker.id = '${TICKER_ID}'
document.body.appendChild(ticker)
document.addEventListener(
'mousemove',
(event) => {
cursor.style.transform = 'translate(' + event.clientX + 'px,' + event.clientY + 'px)'
},
{ passive: true },
)
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install)
else install()
})()
`
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
export const launchReelBrowser = () => chromium.launch({ args: ['--force-device-scale-factor=3'] })
export const openReelContext = (browser) =>
browser.newContext({
viewport: REEL_VIEWPORT,
deviceScaleFactor: REEL_DEVICE_SCALE,
isMobile: true,
hasTouch: true,
acceptDownloads: true,
})
export const installCursor = async (page) => {
await page.addInitScript({ content: CURSOR_INIT })
}
const cursorPositions = new WeakMap()
const cursorPosition = (page) =>
cursorPositions.get(page) ?? { x: REEL_VIEWPORT.width / 2, y: REEL_VIEWPORT.height / 2 }
/** Smooth DOM-cursor movement: the cursor arrives before the click. */
const moveCursor = async ({ page, x, y, steps = 14, stepMs = 28 }) => {
const from = cursorPosition(page)
for (let index = 1; index <= steps; index += 1) {
const progress = index / steps
const nextX = from.x + (x - from.x) * progress
const nextY = from.y + (y - from.y) * progress
await page.mouse.move(Math.round(nextX), Math.round(nextY))
await sleep(stepMs)
}
cursorPositions.set(page, { x, y })
}
/** Yellow ring around the clicked target; it pulses once on the click. */
const showTargetRing = async ({ page, box, pulse = false }) => {
await page.evaluate(
({ ringId, box: target, pulse: shouldPulse }) => {
let ring = document.getElementById(ringId)
if (!ring) {
ring = document.createElement('div')
ring.id = ringId
document.body.appendChild(ring)
}
ring.style.left = `${Math.max(0, target.x - 3)}px`
ring.style.top = `${Math.max(0, target.y - 3)}px`
ring.style.width = `${target.width + 6}px`
ring.style.height = `${target.height + 6}px`
ring.classList.add('reel-ring-on')
if (shouldPulse) {
ring.classList.remove('reel-ring-pulse')
void ring.offsetWidth
ring.classList.add('reel-ring-pulse')
}
},
{ ringId: RING_ID, box, pulse },
)
}
const hideTargetRing = (page) =>
page
.evaluate(
(ringId) => document.getElementById(ringId)?.classList.remove('reel-ring-on'),
RING_ID,
)
.catch(() => undefined)
const centerOf = async (locator) => {
const box = await locator.boundingBox()
if (!box) return null
return {
box,
x: Math.min(Math.max(box.x + box.width / 2, 4), REEL_VIEWPORT.width - 4),
y: Math.min(Math.max(box.y + box.height / 2, 4), REEL_VIEWPORT.height - 4),
}
}
const resolveLocator = async (page, selector) => {
const locator = page.locator(selector).filter({ visible: true }).first()
await locator.waitFor({ state: 'visible', timeout: 20000 })
return locator
}
const waitForEnabled = async (locator, timeoutMs = 12000) => {
const deadline = Date.now() + timeoutMs
for (;;) {
if (await locator.isEnabled().catch(() => false)) return
if (Date.now() > deadline) throw new Error('Alvo continuou desabilitado.')
await sleep(120)
}
}
const pushClick = ({ log, scene, selector, center, zoom, approachAt, arriveAt, at = Date.now() }) =>
log.push({
scene,
kind: 'click',
at,
approachAt: approachAt ?? at,
arriveAt: arriveAt ?? at,
x: center.x,
y: center.y,
zoom,
target: selector,
})
const clickTarget = async ({ page, selector, log, scene, zoom }) => {
const locator = await resolveLocator(page, selector)
await waitForEnabled(locator)
await locator.scrollIntoViewIfNeeded()
const center = await centerOf(locator)
if (!center) throw new Error(`Cena "${scene}": alvo "${selector}" sem bounding box.`)
const approachAt = Date.now()
await moveCursor({ page, x: center.x, y: center.y })
const arriveAt = Date.now()
await showTargetRing({ page, box: center.box })
await sleep(180)
const at = Date.now()
pushClick({ log, scene, selector, center, zoom, approachAt, arriveAt, at })
await page.mouse.click(center.x, center.y)
await showTargetRing({ page, box: center.box, pulse: true })
await sleep(250)
await hideTargetRing(page)
}
const runStep = async ({ page, scene, step, log, fixture }) => {
if (step.action === 'click') {
await clickTarget({ page, selector: step.selector, log, scene: scene.id, zoom: step.zoom })
await sleep(step.pauseMs ?? 650)
return
}
if (step.action === 'fill') {
const value = step.value ?? fixture.cardName
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`Cena "${scene.id}": fill sem valor e sem fixture.cardName.`)
}
const locator = await resolveLocator(page, step.selector)
await locator.scrollIntoViewIfNeeded()
const center = await centerOf(locator)
if (!center) throw new Error(`Cena "${scene.id}": campo "${step.selector}" sem bounding box.`)
await moveCursor({ page, x: center.x, y: center.y })
await page.mouse.click(center.x, center.y)
pushClick({ log, scene: scene.id, selector: step.selector, center, zoom: false })
await page.keyboard.type(value, { delay: 90 })
await sleep(step.pauseMs ?? 450)
return
}
if (step.action === 'waitFor') {
await resolveLocator(page, step.selector)
await sleep(step.pauseMs ?? 450)
return
}
if (step.action === 'download') {
const locator = await resolveLocator(page, step.selector)
await waitForEnabled(locator)
await locator.scrollIntoViewIfNeeded()
const center = await centerOf(locator)
if (!center)
throw new Error(`Cena "${scene.id}": download "${step.selector}" sem bounding box.`)
const approachAt = Date.now()
await moveCursor({ page, x: center.x, y: center.y })
const arriveAt = Date.now()
await showTargetRing({ page, box: center.box })
await sleep(180)
const at = Date.now()
pushClick({
log,
scene: scene.id,
selector: step.selector,
center,
zoom: true,
approachAt,
arriveAt,
at,
})
const downloadPromise = page.waitForEvent('download', { timeout: 20000 })
await page.mouse.click(center.x, center.y)
await showTargetRing({ page, box: center.box, pulse: true })
await sleep(250)
await hideTargetRing(page)
const download = await downloadPromise
await download.path()
await sleep(step.pauseMs ?? 650)
return
}
throw new Error(`Cena "${scene.id}": ação "${step.action}" não implementada.`)
}
/**
* Forces periodic compositor damage while a scene runs (a 2×2px invisible
* opacity ticker), so the screencast delivers frames at ~30fps even during
* holds — without it, a DOM change between frames (the target ring) would only
* appear in the next damaged frame and the zoom would look late.
*/
const setTicking = (page, on) =>
page
.evaluate((ticking) => document.documentElement.classList.toggle('reel-ticking', ticking), on)
.catch(() => undefined)
/**
* Drives the recorded capture scenes. `setup` steps run before the scene's
* `sceneStart` marker (invisible prep), then the visible steps are paced.
*/
export const runCaptureScenes = async ({ page, shotList, log = [] }) => {
const capture = shotList.scenes.filter((scene) => scene.kind === 'capture')
for (const scene of capture) {
for (const step of scene.setup) {
const locator = await resolveLocator(page, step.selector)
await locator.scrollIntoViewIfNeeded()
}
await sleep(500)
await setTicking(page, true)
log.push({ scene: scene.id, kind: 'sceneStart', at: Date.now() })
await sleep(scene.leadInMs)
for (const step of scene.steps) {
await runStep({ page, scene, step, log, fixture: shotList.fixture })
}
await sleep(scene.trailOutMs)
log.push({ scene: scene.id, kind: 'sceneEnd', at: Date.now() })
await setTicking(page, false)
}
return log
}
/**
* Records the page as numbered JPEG frames plus their capture timestamps (CDP
* screencast, native 1080×1920 thanks to the forced device scale). The video is
* built offline from those timestamps, so encoding never backpressures the
* browser (the pipe version compressed bursts and drifted the timeline).
* `stop()` forces a final frame so the trailing hold is not truncated.
*/
export const startFrameRecorder = async ({ context, page, framesDir, quality = 90 }) => {
await rm(framesDir, { recursive: true, force: true })
await mkdir(framesDir, { recursive: true })
const frames = []
let queue = Promise.resolve()
let writeError = null
let stopping = false
const client = await context.newCDPSession(page)
const enqueue = (buffer, captureMs) => {
const index = frames.length + 1
const file = join(framesDir, `frame-${String(index).padStart(5, '0')}.jpg`)
frames.push({ file, captureMs })
queue = queue
.then(() => writeFile(file, buffer))
.catch((error) => {
writeError = writeError ?? error
})
}
const onFrame = ({ data, sessionId, metadata }) => {
if (stopping) return
enqueue(Buffer.from(data, 'base64'), Math.round(metadata.timestamp * 1000))
void client.send('Page.screencastFrameAck', { sessionId }).catch(() => undefined)
}
client.on('Page.screencastFrame', onFrame)
await client.send('Page.startScreencast', {
format: 'jpeg',
quality,
maxWidth: REEL_VIEWPORT.width * REEL_DEVICE_SCALE,
maxHeight: REEL_VIEWPORT.height * REEL_DEVICE_SCALE,
})
return {
stop: async () => {
stopping = true
try {
const finalFrame = await page.screenshot({ type: 'jpeg', quality })
enqueue(finalFrame, Date.now())
} catch {
// Page closed before the final frame: the recording still ends.
}
await client.send('Page.stopScreencast').catch(() => undefined)
client.off('Page.screencastFrame', onFrame)
await queue
if (writeError) throw new Error(`Falha ao gravar frames da captura: ${writeError.message}`)
return {
frames: [...frames],
frameCount: frames.length,
firstFrameWallMs: frames[0]?.captureMs ?? null,
}
},
}
}
export const gotoReelSite = async ({ page, baseUrl, route }) => {
const url = new URL(route, baseUrl).toString()
const response = await page.goto(url, { waitUntil: 'load', timeout: 45000 })
if (!response || !response.ok()) {
throw new Error(
`Site indisponível: ${url} respondeu ${response ? response.status() : 'sem resposta'}.`,
)
}
await sleep(600)
return url
}

310
scripts/lib/reelFfmpeg.mjs Normal file
View file

@ -0,0 +1,310 @@
/**
* C196 — ffmpeg plumbing of the reel composer: binary resolution with a
* fail-closed capability probe, the per-scene clip command (zoompan + burned
* caption overlays) and the concat that stitches the scenes into `reel.mp4`.
*
* Resolution order (see `docs/plans/reels-tutoriais-impl.md`, Decisão 2):
* `FFMPEG_PATH` → `ffmpeg` on PATH → the registry-packaged
* `@ffmpeg-installer/ffmpeg` binary. The packed fallback keeps the
* non-technical operator flow working on a workstation without a system
* ffmpeg; the homeserver Docker build installs it from npm (never from GitHub
* releases, which the homeserver cannot reach — see the sharp override note in
* `pnpm-workspace.yaml`).
*/
import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { promisify } from 'node:util'
import { seconds } from './reelTimeline.mjs'
const execFileAsync = promisify(execFile)
const MAX_BUFFER_BYTES = 16 * 1024 * 1024
const REQUIRED_FILTERS = ['fade', 'overlay', 'scale', 'zoompan']
export const REEL_PRESET = 'medium'
export const REEL_CRF = 18
export const PREVIEW_PRESET = 'ultrafast'
export const PREVIEW_CRF = 14
/**
* @typedef {{ z: string, x: string, y: string, inputWidth: number, inputHeight: number }} ZoomLike
*/
/**
* @typedef {(bin: string, args: string[]) => Promise<{ ok: boolean, stdout: string, stderr: string, error?: { code?: string } }>} RunFn
*/
const defaultRun = async (bin, args) => {
try {
const { stdout, stderr } = await execFileAsync(bin, args, { maxBuffer: MAX_BUFFER_BYTES })
return { ok: true, stdout, stderr }
} catch (error) {
return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '', error }
}
}
const versionLine = (stdout) =>
stdout.split('\n').find((line) => line.startsWith('ffmpeg version')) ?? ''
/**
* Capability probe: the binary must encode H.264 (libx264) and ship the
* filters the composer uses. Missing capabilities are reported, never assumed.
*
* @param {string} bin
* @param {{ run?: RunFn }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string } | { ok: false, bin: string, reason: string }>}
*/
export const probeFfmpeg = async (bin, { run = defaultRun } = {}) => {
const version = await run(bin, ['-hide_banner', '-version'])
if (!version.ok)
return { ok: false, bin, reason: `não executou (${version.error?.code ?? 'erro'})` }
const encoders = await run(bin, ['-hide_banner', '-encoders'])
if (!encoders.ok || !/\blibx264\b/.test(encoders.stdout)) {
return { ok: false, bin, reason: 'sem encoder libx264' }
}
const filters = await run(bin, ['-hide_banner', '-filters'])
if (!filters.ok) return { ok: false, bin, reason: 'não listou filtros' }
const missing = REQUIRED_FILTERS.filter(
(filter) => !new RegExp(`\\b${filter}\\b`).test(filters.stdout),
)
if (missing.length > 0) return { ok: false, bin, reason: `sem filtro(s): ${missing.join(', ')}` }
return { ok: true, bin, version: versionLine(version.stdout) }
}
const installHint =
'Defina FFMPEG_PATH, instale o ffmpeg (ex.: `sudo apt install ffmpeg`) ou rode `pnpm install` para o binário empacotado.'
/**
* Resolves the first candidate that passes the probe. Explicit `FFMPEG_PATH` is strict.
*
* @param {{ env?: Record<string, string | undefined>, run?: RunFn, installer?: { path?: string } | null }} [options]
* @returns {Promise<{ ok: true, bin: string, version: string, source: string }>}
*/
export const resolveFfmpeg = async ({ env = process.env, run = defaultRun, installer } = {}) => {
let packaged = installer
if (packaged === undefined) {
try {
packaged = (await import('@ffmpeg-installer/ffmpeg')).default
} catch {
// The packaged binary throws on import when its platform package is
// missing; the probe below decides, never the import.
packaged = null
}
}
const candidates = []
const explicit = env.FFMPEG_PATH?.trim()
if (explicit) candidates.push({ bin: explicit, source: 'FFMPEG_PATH', strict: true })
candidates.push({ bin: 'ffmpeg', source: 'PATH', strict: false })
if (packaged?.path) candidates.push({ bin: packaged.path, source: 'empacotado', strict: false })
const failures = []
for (const candidate of candidates) {
const probe = await probeFfmpeg(candidate.bin, { run })
if (probe.ok) {
return { ok: true, bin: probe.bin, version: probe.version, source: candidate.source }
}
failures.push(`${candidate.source}: ${probe.reason}`)
if (candidate.strict) break
}
throw new Error(
`Nenhum ffmpeg utilizável (libx264 + filtros do reel). ${failures.join('; ')}. ${installHint}`,
)
}
/**
* Runs ffmpeg and fails with the stderr tail — never swallows the exit code.
*
* @param {string} bin
* @param {string[]} args
* @param {{ run?: RunFn, label?: string }} [options]
*/
export const runFfmpeg = async (bin, args, { run = defaultRun, label = 'ffmpeg' } = {}) => {
const result = await run(bin, args)
if (!result.ok) {
const tail = (result.stderr || result.error?.message || '').trim().slice(-400)
throw new Error(`${label} falhou${tail ? `: ${tail}` : ''}`)
}
return result
}
/**
* One scene clip: seek the source, apply the zoom camera (when the scene has
* clicks), burn the caption overlays with their timelines and encode H.264.
* Pure: the caller runs the returned args.
*
* @param {{ input: string, output: string, startMs: number, durationMs: number, zoom?: ZoomLike | null, captions?: Array<{ path: string, startMs: number, endMs: number }>, fadeInMs?: number, fadeOutMs?: number, fps: number, width: number, height: number, preset?: string, crf?: number }} options
* @returns {string[]}
*/
export const buildSceneClipArgs = ({
input,
output,
startMs,
durationMs,
zoom = null,
captions = [],
fadeInMs = 0,
fadeOutMs = 0,
fps,
width,
height,
preset = REEL_PRESET,
crf = REEL_CRF,
}) => {
const filters = []
const videoFilters = [`fps=${fps}`]
if (zoom) {
videoFilters.push(`scale=${zoom.inputWidth}:${zoom.inputHeight}`)
videoFilters.push(
`zoompan=z='${zoom.z}':x='${zoom.x}':y='${zoom.y}':d=1:s=${width}x${height}:fps=${fps}`,
)
} else {
videoFilters.push(`scale=${width}:${height}`)
}
if (fadeInMs > 0) videoFilters.push(`fade=t=in:st=0:d=${seconds(fadeInMs)}`)
if (fadeOutMs > 0) {
videoFilters.push(
`fade=t=out:st=${seconds(Math.max(0, durationMs - fadeOutMs))}:d=${seconds(fadeOutMs)}`,
)
}
filters.push(`[0:v]${videoFilters.join(',')}[v0]`)
captions.forEach((caption, index) => {
filters.push(
`[v${index}][${index + 1}:v]overlay=0:0:enable='between(t,${seconds(caption.startMs)},${seconds(caption.endMs)})'[v${index + 1}]`,
)
})
const captionInputs = captions.flatMap((caption) => [
'-loop',
'1',
'-framerate',
String(fps),
'-i',
caption.path,
])
return [
'-hide_banner',
'-loglevel',
'error',
'-ss',
String(seconds(startMs)),
'-t',
String(seconds(durationMs)),
'-i',
input,
...captionInputs,
'-filter_complex',
filters.join(';'),
'-map',
`[v${captions.length}]`,
'-an',
'-t',
String(seconds(durationMs)),
'-r',
String(fps),
'-c:v',
'libx264',
'-preset',
preset,
'-crf',
String(crf),
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-y',
output,
]
}
/** `concat` demuxer list entry; single quotes in paths are escaped. */
const concatListEntry = (path) => `file '${path.replaceAll("'", "'\\''")}'`
export const concatListContent = (paths) => `${paths.map(concatListEntry).join('\n')}\n`
/**
* Offline frame encode: the screencast writes JPEG frames with their capture
* timestamps, and the video is built later from those exact times. Encoding
* after capture keeps ffmpeg's CPU work from backpressuring the browser and
* shifting the timeline (the pipe version drifted; see the impl plan).
*
* `frames`: `[{ file, captureMs }]` in capture order. The last frame is
* repeated so the concat demuxer honors its duration.
*
* @param {{ frames: Array<{ file: string, captureMs: number }>, fps: number }} options
* @returns {string}
*/
export const buildFrameListContent = ({ frames, fps }) => {
const minimum = 1 / fps
const lines = []
frames.forEach((frame, index) => {
const next = frames[index + 1]
const duration = next ? Math.max(0.001, (next.captureMs - frame.captureMs) / 1000) : minimum
lines.push(concatListEntry(frame.file), `duration ${seconds(duration * 1000)}`)
})
if (frames.length > 0) lines.push(concatListEntry(frames[frames.length - 1].file))
return `${lines.join('\n')}\n`
}
/**
* @param {{ ffmpegBin: string, frames: Array<{ file: string, captureMs: number }>, listPath: string, output: string, fps: number, preset?: string, crf?: number, writeFileImpl?: (path: string, content: string) => Promise<unknown>, run?: RunFn }} options
*/
export const encodeFrames = async ({
ffmpegBin,
frames,
listPath,
output,
fps,
preset = PREVIEW_PRESET,
crf = PREVIEW_CRF,
writeFileImpl = writeFile,
run = defaultRun,
}) => {
await writeFileImpl(listPath, buildFrameListContent({ frames, fps }))
await runFfmpeg(
ffmpegBin,
[
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-r',
String(fps),
'-c:v',
'libx264',
'-preset',
preset,
'-crf',
String(crf),
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-y',
output,
],
{ run, label: 'encode frames' },
)
}
/** Stream-copy concat of the scene clips (same codec/params) into `reel.mp4`. */
export const buildConcatArgs = ({ listPath, output }) => [
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
listPath,
'-c',
'copy',
'-movflags',
'+faststart',
'-y',
output,
]

View file

@ -0,0 +1,28 @@
/**
* C196 — the reel package metadata: the contract the private library (C195)
* ingests. `shotListHash` identifies the reel version; re-rendering the same
* shot list updates the same entry.
*/
import { REEL } from './reelTimeline.mjs'
export const buildReelMetadata = ({
shotList,
hash,
durationMs,
ffmpeg,
generatedAt = new Date(),
}) => ({
slug: shotList.slug,
title: shotList.title,
feature: shotList.feature,
shotListHash: hash,
durationMs: Math.round(durationMs),
width: REEL.width,
height: REEL.height,
fps: REEL.fps,
codec: 'h264',
audio: false,
generatedAt: generatedAt.toISOString(),
ffmpeg,
})

196
scripts/lib/reelRender.mjs Normal file
View file

@ -0,0 +1,196 @@
/**
* C196 — Playwright rendering of the reel assets: downloads the site's own
* Inter/Brexter `@font-face` rules (extracted during the capture) and embeds
* them as data URIs, renders the transparent caption/chrome overlays and the
* cover PNG, and records the graphic scenes (hook/CTA) with the same
* timestamped screencast recorder used for the site capture.
*
* No database, no app: the site is visited as an anonymous reader.
*/
import { existsSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { REEL_DEVICE_SCALE, REEL_VIEWPORT, sleep, startFrameRecorder } from './reelCapture.mjs'
import { encodeFrames, PREVIEW_CRF, PREVIEW_PRESET } from './reelFfmpeg.mjs'
import { REEL } from './reelTimeline.mjs'
export const openRenderContext = (browser) =>
browser.newContext({ viewport: REEL_VIEWPORT, deviceScaleFactor: REEL_DEVICE_SCALE })
/** `@font-face` rules of the loaded site page (URL sources only, no local()). */
export const extractFontFaces = (page) =>
page.evaluate(() => {
const faces = []
for (const sheet of document.styleSheets) {
let rules
try {
rules = [...sheet.cssRules]
} catch {
continue
}
for (const rule of rules) {
if (!(rule instanceof CSSFontFaceRule)) continue
const src = rule.style.getPropertyValue('src')
if (!src.includes('url(')) continue
faces.push({
family: rule.style.getPropertyValue('font-family').replace(/^["']|["']$/g, ''),
weight: rule.style.getPropertyValue('font-weight') || '400',
style: rule.style.getPropertyValue('font-style') || 'normal',
unicodeRange: rule.style.getPropertyValue('unicode-range'),
src,
})
}
}
return faces
})
const FONT_FORMATS = [
['.woff2', 'woff2'],
['.woff', 'woff'],
['.ttf', 'truetype'],
]
const formatOf = (url) =>
FONT_FORMATS.find(([extension]) => url.includes(extension))?.[1] ?? 'woff2'
const remapFamily = (family) => {
if (/brexter/i.test(family)) return 'ReelBrexter'
if (/inter/i.test(family)) return 'ReelInter'
return null
}
const downloadFont = async ({ url, cacheDir, fetchImpl }) => {
const name = url.split('/').pop().split('?')[0]
const path = join(cacheDir, name)
if (existsSync(path)) return readFile(path)
const response = await fetchImpl(url)
if (!response.ok) throw new Error(`fonte ${url} respondeu ${response.status}`)
const bytes = Buffer.from(await response.arrayBuffer())
await mkdir(dirname(path), { recursive: true })
await writeFile(path, bytes)
return bytes
}
/**
* Builds the `@font-face` CSS with the site's own font files embedded as data
* URIs (relative font URLs resolve against the captured page URL). The brand
* faces are part of the product surface: if the site serves no Brexter or no
* Inter face, the render fails closed instead of silently changing the type.
*/
export const loadFontCss = async ({
fontFaces,
cacheDir,
pageUrl,
fetchImpl = fetch,
warn = console.warn,
}) => {
const css = []
const embedded = new Set()
for (const face of fontFaces) {
const family = remapFamily(face.family)
if (!family) continue
const src = face.src.match(/url\((?:"|')?([^"')]+)(?:"|')?\)/)?.[1]
if (!src) continue
const url = pageUrl ? new URL(src, pageUrl).toString() : src
try {
const bytes = await downloadFont({ url, cacheDir, fetchImpl })
embedded.add(family)
css.push(
`@font-face{font-family:${family};font-style:${face.style};font-weight:${face.weight};` +
`${face.unicodeRange ? `unicode-range:${face.unicodeRange};` : ''}` +
`src:url(data:font/${formatOf(url)};base64,${bytes.toString('base64')}) format('${formatOf(url)}')}`,
)
} catch (error) {
warn(
`Reel: falha ao embutir a fonte ${face.family} (${url.split('/').pop()}): ${error.message}`,
)
}
}
const missing = ['ReelBrexter', 'ReelInter'].filter((family) => !embedded.has(family))
if (missing.length > 0) {
throw new Error(
`O site não serviu as fontes de marca do reel (${missing.join(', ')}). ` +
'A superfície gráfica depende da Brexter/Inter oficiais; verifique o site e regenere.',
)
}
return css.join('\n')
}
const KIT_ASSETS = {
namePositive: 'jorge-solla-positivo.png',
nameNegative: 'jorge-solla-negativo.png',
completePositive: 'marca-positiva-completa.png',
completeNegative: 'marca-negativa-completa.png',
star: 'estrela.png',
}
/** Official campaign kit marks as data URIs (committed under `public/campaign-kit`). */
export const readKitAssets = async ({ root = process.cwd() } = {}) => {
const entries = await Promise.all(
Object.entries(KIT_ASSETS).map(async ([key, file]) => {
const bytes = await readFile(resolve(root, 'public/campaign-kit', file))
return [key, `data:image/png;base64,${bytes.toString('base64')}`]
}),
)
return Object.fromEntries(entries)
}
const settle = async (page) => {
await page.evaluate(() => document.fonts.ready)
await sleep(80)
}
const renderPng = async ({ page, html, output, transparent = true }) => {
await page.setContent(html, { waitUntil: 'load' })
await settle(page)
await page.screenshot({ path: output, omitBackground: transparent })
return output
}
/** Transparent PNG asset (captions, chrome) at the physical 1080×1920. */
export const renderOverlay = (options) => renderPng(options)
/** Opaque PNG asset (cover) at the physical 1080×1920. */
export const renderCover = (options) => renderPng({ ...options, transparent: false })
/**
* Records a graphic scene (hook/CTA): sets the content, waits for the fonts,
* starts the CSS animation by adding `.play` and records `durationMs`.
*/
export const recordGraphicScene = async ({
context,
template,
html,
durationMs,
output,
framesDir,
ffmpegBin,
fps = REEL.fps,
preset = PREVIEW_PRESET,
crf = PREVIEW_CRF,
}) => {
const page = await context.newPage()
try {
const recorder = await startFrameRecorder({ context, page, framesDir })
await page.setContent(html, { waitUntil: 'load' })
await settle(page)
await page.evaluate(() => document.body.classList.add('play'))
const readyAtMs = Date.now()
await sleep(durationMs)
const stats = await recorder.stop()
await encodeFrames({
ffmpegBin,
frames: stats.frames,
listPath: join(framesDir, 'frames.txt'),
output,
fps,
preset,
crf,
})
return { template, output, readyAtMs, stats }
} finally {
await page.close()
}
}

View file

@ -0,0 +1,260 @@
/**
* C196 — the reel shot list: the single source of truth of one tutorial reel.
* The skill edits this file (natural-language adjustment is translated here)
* and re-runs the build; the rendered MP4 is never edited.
*
* A shot list has a `hook` (graphic), N `capture` scenes (the real site driven
* in a mobile viewport) and a `cta` (graphic). Capture scenes declare invisible
* `setup` steps (scroll/prepare, outside the recorded clip) and visible `steps`
* (paced clicks/typing that the viewer sees), plus the burned caption and the
* step badge of the design artifact.
*
* Pure module: no browser, no ffmpeg, no I/O beyond reading the JSON file.
*/
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { sha256Hex } from './cli.mjs'
const SHOT_LIST_DIR = 'scripts/reels/shot-lists'
/**
* @typedef {{
* slug: string, title: string, feature: string, route: string,
* fixture: Record<string, unknown>,
* scenes: Array<{
* id: string, kind: string,
* template?: string, durationMs?: number,
* badge?: { number: number, total: number, label: string },
* caption?: { parts: Array<{ text: string, accent?: boolean }> } | null,
* setup: Array<{ action: string, selector: string }>,
* steps: Array<{ action: string, selector: string, value?: string, pauseMs?: number, zoom?: boolean }>,
* leadInMs: number, trailOutMs: number, captionDelayMs: number, captionDurationMs: number | null,
* }>,
* }} ShotList
*/
const SCENE_KINDS = new Set(['capture', 'graphic'])
const GRAPHIC_TEMPLATES = new Set(['hook', 'cta'])
const SETUP_ACTIONS = new Set(['scrollIntoView'])
const STEP_ACTIONS = new Set(['click', 'fill', 'waitFor', 'download'])
const fail = (message) => {
throw new Error(`Shot list: ${message}`)
}
const assertObject = (value, label) => {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${label} deve ser um objeto.`)
}
}
const assertString = (value, label) => {
if (typeof value !== 'string' || value.trim() === '') fail(`${label} deve ser string não vazia.`)
return value.trim()
}
const assertPositiveInt = (value, label, { max = 120000 } = {}) => {
if (!Number.isInteger(value) || value <= 0 || value > max) {
fail(`${label} deve ser inteiro entre 1 e ${max}.`)
}
return value
}
const assertSelector = (value, label) => {
const selector = assertString(value, label)
if (selector.length > 240) fail(`${label} longo demais.`)
return selector
}
const normalizeCaption = (raw, label) => {
if (raw === undefined || raw === null) return null
assertObject(raw, label)
if (!Array.isArray(raw.parts) || raw.parts.length === 0) fail(`${label}.parts deve ter itens.`)
const parts = raw.parts.map((part, index) => {
assertObject(part, `${label}.parts[${index}]`)
const text = assertString(part.text, `${label}.parts[${index}].text`)
return part.accent === true ? { text, accent: true } : { text }
})
return { parts }
}
const normalizeBadge = (raw, label) => {
assertObject(raw, label)
const number = assertPositiveInt(raw.number, `${label}.number`, { max: 99 })
const total = assertPositiveInt(raw.total, `${label}.total`, { max: 99 })
if (number > total) fail(`${label}.number não pode ser maior que total.`)
const labelText = assertString(raw.label, `${label}.label`)
return { number, total, label: labelText }
}
const normalizeSetup = (raw, label) => {
if (raw === undefined) return []
if (!Array.isArray(raw)) fail(`${label} deve ser uma lista.`)
return raw.map((step, index) => {
assertObject(step, `${label}[${index}]`)
const action = assertString(step.action, `${label}[${index}].action`)
if (!SETUP_ACTIONS.has(action)) {
fail(`${label}[${index}].action "${action}" não é um setup válido (${[...SETUP_ACTIONS]}).`)
}
return { action, selector: assertSelector(step.selector, `${label}[${index}].selector`) }
})
}
const normalizeSteps = (raw, label) => {
if (!Array.isArray(raw) || raw.length === 0) fail(`${label} deve ter ao menos um passo.`)
const allowedKeys = new Set(['action', 'selector', 'value', 'pauseMs', 'zoom'])
return raw.map((step, index) => {
assertObject(step, `${label}[${index}]`)
for (const key of Object.keys(step)) {
if (!allowedKeys.has(key)) fail(`${label}[${index}].${key} não é uma chave válida.`)
}
const action = assertString(step.action, `${label}[${index}].action`)
if (!STEP_ACTIONS.has(action)) {
fail(`${label}[${index}].action "${action}" inválida (${[...STEP_ACTIONS]}).`)
}
const normalized = {
action,
selector: assertSelector(step.selector, `${label}[${index}].selector`),
}
if (action === 'fill' && step.value !== undefined) {
normalized.value = assertString(step.value, `${label}[${index}].value`)
}
if (step.zoom !== undefined) {
if (typeof step.zoom !== 'boolean') fail(`${label}[${index}].zoom deve ser boolean.`)
normalized.zoom = step.zoom
}
if (step.pauseMs !== undefined) {
normalized.pauseMs = assertPositiveInt(step.pauseMs, `${label}[${index}].pauseMs`, {
max: 10000,
})
}
return normalized
})
}
const normalizeCaptureScene = (scene, index) => {
const label = `scenes[${index}]`
const id = assertString(scene.id, `${label}.id`)
const normalized = {
id,
kind: 'capture',
badge: normalizeBadge(scene.badge, `${label}.badge`),
caption: normalizeCaption(scene.caption, `${label}.caption`),
setup: normalizeSetup(scene.setup, `${label}.setup`),
steps: normalizeSteps(scene.steps, `${label}.steps`),
leadInMs:
scene.leadInMs === undefined
? 700
: assertPositiveInt(scene.leadInMs, `${label}.leadInMs`, { max: 10000 }),
trailOutMs:
scene.trailOutMs === undefined
? 800
: assertPositiveInt(scene.trailOutMs, `${label}.trailOutMs`, { max: 10000 }),
captionDelayMs:
scene.captionDelayMs === undefined
? 250
: assertPositiveInt(scene.captionDelayMs, `${label}.captionDelayMs`, { max: 10000 }),
captionDurationMs:
scene.captionDurationMs === undefined
? null
: assertPositiveInt(scene.captionDurationMs, `${label}.captionDurationMs`, { max: 30000 }),
}
if (normalized.caption === null) fail(`${label}.caption é obrigatória em cena de captura.`)
return normalized
}
const normalizeGraphicScene = (scene, index) => {
const label = `scenes[${index}]`
const template = assertString(scene.template, `${label}.template`)
if (!GRAPHIC_TEMPLATES.has(template)) {
fail(`${label}.template "${template}" inválido (${[...GRAPHIC_TEMPLATES]}).`)
}
return {
id: assertString(scene.id, `${label}.id`),
kind: 'graphic',
template,
durationMs: assertPositiveInt(scene.durationMs, `${label}.durationMs`, { max: 15000 }),
}
}
/**
* Validates and normalizes the raw JSON of a shot list (pure).
*
* @param {Record<string, unknown>} raw
* @returns {ShotList}
*/
export const normalizeShotList = (raw) => {
assertObject(raw, 'raiz')
const slug = assertString(raw.slug, 'slug')
if (!/^[a-z0-9-]+$/.test(slug)) fail('slug deve ser kebab-case (a-z, 0-9, -).')
const route = assertString(raw.route ?? '/', 'route')
if (!route.startsWith('/')) fail('route deve começar com "/".')
assertObject(raw.fixture ?? {}, 'fixture')
const fixture = raw.fixture ?? {}
if (!Array.isArray(raw.scenes) || raw.scenes.length === 0)
fail('scenes deve ter ao menos uma cena.')
const scenes = raw.scenes.map((scene, index) => {
assertObject(scene, `scenes[${index}]`)
const kind = assertString(scene.kind, `scenes[${index}].kind`)
if (!SCENE_KINDS.has(kind)) fail(`scenes[${index}].kind "${kind}" inválido.`)
return kind === 'capture'
? normalizeCaptureScene(scene, index)
: normalizeGraphicScene(scene, index)
})
const ids = new Set(scenes.map((scene) => scene.id))
if (ids.size !== scenes.length) fail('ids de cena precisam ser únicos.')
const usesFixtureName = scenes.some(
(scene) =>
scene.kind === 'capture' &&
scene.steps.some((step) => step.action === 'fill' && step.value === undefined),
)
if (usesFixtureName && typeof fixture.cardName !== 'string') {
fail('fixture.cardName é obrigatório quando um passo fill não declara value.')
}
if (scenes[0].kind !== 'graphic' || scenes[0].template !== 'hook')
fail('a primeira cena deve ser o hook.')
const last = scenes[scenes.length - 1]
if (last.kind !== 'graphic' || last.template !== 'cta') fail('a última cena deve ser o CTA.')
return {
slug,
title: assertString(raw.title ?? slug, 'title'),
feature: assertString(raw.feature ?? slug, 'feature'),
route,
fixture,
scenes,
}
}
/** Deterministic sha256 of the normalized shot list — the reel version. */
export const shotListHash = (shotList) => sha256Hex(Buffer.from(JSON.stringify(shotList), 'utf8'))
const shotListPath = ({ slug, root = process.cwd() }) =>
resolve(root, SHOT_LIST_DIR, `${slug}.json`)
/**
* Reads + normalizes a shot list; returns it with its hash.
*
* @param {{ slug: string, root?: string }} options
* @returns {Promise<{ shotList: ShotList, hash: string }>}
*/
export const loadShotList = async ({ slug, root = process.cwd() }) => {
const path = shotListPath({ slug, root })
let raw
try {
raw = JSON.parse(await readFile(path, 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT')
fail(`não encontrei ${path}. Rode a skill a partir da raiz do repo.`)
throw new Error(`Shot list inválido em ${path}: ${error.message}`)
}
const shotList = normalizeShotList(raw)
if (shotList.slug !== slug) fail(`slug do arquivo ("${shotList.slug}") difere de "${slug}".`)
return { shotList, hash: shotListHash(shotList) }
}
export const captureScenes = (shotList) =>
shotList.scenes.filter((scene) => scene.kind === 'capture')
export const graphicScenes = (shotList) =>
shotList.scenes.filter((scene) => scene.kind === 'graphic')

View file

@ -0,0 +1,153 @@
/**
* C196 — the visual templates of the reel, ported class-by-class from the
* approved artifact `docs/plans/reels-tutoriais-ui-design.html` (official
* campaign kit: palette, marks, star, Brexter display face, caption box, step
* badge, safe area, cover). Every template is a pure string builder rendered at
* 360×640 with deviceScaleFactor 3, so the physical asset is 1080×1920.
*
* Pure module: the caller passes the font CSS and the kit asset data URIs.
*/
import { REEL } from './reelTimeline.mjs'
/** Official kit palette (extracted from the campaign assets). */
const BRAND = {
red: '#e4102f',
blue: '#184e92',
yellow: '#ffeb00',
cream: '#fff8f2',
}
/** Design §Safe zone: the side inset and the caption center in CSS pixels. */
const SIDE_INSET_PX = REEL.safeArea.sidePx / REEL.deviceScale
const CAPTION_CENTER_PX = REEL.caption.centerYPx / REEL.deviceScale
const BRAND_GRADIENT = `linear-gradient(160deg, ${BRAND.red} 0%, ${BRAND.red} 62%, ${BRAND.blue} 145%)`
const BASE_CSS = `
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{width:${REEL.viewport.width}px;height:${REEL.viewport.height}px;overflow:hidden;background:transparent}
body{font-family:ReelInter,Inter,ui-sans-serif,system-ui,sans-serif;color:#000;-webkit-font-smoothing:antialiased}
.frame{position:relative;width:${REEL.viewport.width}px;height:${REEL.viewport.height}px;overflow:hidden}
.brexter{font-family:ReelBrexter,Impact,Haettenschweiler,'Arial Narrow Bold',sans-serif;font-weight:700;text-transform:uppercase}
.accent{color:${BRAND.yellow}}
.star-disc{position:absolute;border-radius:50%;overflow:hidden;background:#fff;pointer-events:none}
.star-disc img{position:absolute;width:186.92%;height:auto;left:-47.38%;top:-12.8%}
.brand-plate{position:absolute;left:214px;top:22px;display:flex;align-items:center;justify-content:center;width:124px;height:68px;border-radius:8px;background:rgb(255 255 255 / 92%);box-shadow:0 4px 14px rgb(0 0 0 / 18%)}
.brand-plate img{display:block;width:112px;height:auto}
.badge{position:absolute;left:${SIDE_INSET_PX}px;top:15%;display:flex;align-items:center;gap:7.2px;border-radius:999px;background:${BRAND.yellow};padding:7.2px 14.4px;font-size:11.6px;font-weight:900;color:#000;box-shadow:0 6px 18px rgb(0 0 0 / 28%)}
.badge b{display:grid;place-items:center;width:21.6px;height:21.6px;border-radius:999px;background:#000;color:#fff;font-size:10px}
.caption-box{position:absolute;left:50%;top:${CAPTION_CENTER_PX}px;transform:translate(-50%,-50%);width:max-content;max-width:${REEL.caption.maxWidthPx / REEL.deviceScale}px;border-radius:14.4px;background:rgb(0 0 0 / 88%);padding:12.6px 18px;text-align:center;color:#fff;font-size:14.8px;font-weight:700;line-height:1.18;box-shadow:0 10px 30px rgb(0 0 0 / 25%)}
@keyframes reel-beat{0%{opacity:0;transform:translateY(14px)}100%{opacity:1;transform:translateY(0)}}
@keyframes reel-fade{0%{opacity:0}100%{opacity:1}}
@keyframes reel-star{0%{opacity:0;transform:translateY(8px)}100%{opacity:1;transform:translateY(0)}}
@keyframes reel-rise{0%{opacity:0;transform:translateY(22px)}100%{opacity:1;transform:translateY(0)}}
body.play .beat-1{animation:reel-beat .55s cubic-bezier(.2,.7,.2,1) both}
body.play .beat-2{animation:reel-beat .55s cubic-bezier(.2,.7,.2,1) .28s both}
body.play .fade-1{animation:reel-fade .45s ease-out .1s both}
body.play .fade-2{animation:reel-fade .45s ease-out .65s both}
body.play .star-1{animation:reel-star .7s ease-out .15s both}
body.play .rise-1{animation:reel-rise .5s cubic-bezier(.2,.7,.2,1) .35s both}
`
const htmlDocument = ({ title, fontCss, body }) => `<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${title}</title>
${fontCss ? `<style>${fontCss}</style>` : ''}
<style>${BASE_CSS}</style>
</head>
<body>${body}</body>
</html>
`
/** Scene 01 — hook (recorded): official red field, star, Brexter in two beats. */
export const hookHtml = ({ fontCss, assets }) =>
htmlDocument({
title: 'Reel — hook',
fontCss,
body: `
<div class="frame" style="background:${BRAND_GRADIENT}">
<div class="star-disc star-1" style="left:250px;top:54px;width:88px;height:88px"><img src="${assets.star}" alt="" /></div>
<div style="position:absolute;left:6%;right:6%;top:14%">
<span class="fade-1" style="display:inline-flex;border-radius:999px;background:${BRAND.yellow};color:#000;padding:6.1px 14.4px;font-size:12.6px;font-weight:900">FAÇA PARTE</span>
<h1 class="brexter" style="font-size:34.2px;line-height:30.78px;letter-spacing:-1.71px;color:#fff;margin-top:28.8px;max-width:100%">
<span class="beat-1" style="display:block;white-space:nowrap">Seu apoio</span>
<span class="beat-1" style="display:block;white-space:nowrap">vira card</span>
<span class="beat-2 accent" style="display:block;white-space:nowrap">em segundos.</span>
</h1>
</div>
<p class="fade-2" style="position:absolute;left:22px;top:356px;width:170px;font-size:13.7px;font-weight:600;line-height:1.2;color:#fff">Veja como criar o seu pelo celular.</p>
<img class="fade-2" style="position:absolute;left:206px;top:322px;width:132px;height:auto" src="${assets.completeNegative}" alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro" />
</div>`,
})
/** Scene last — CTA (recorded): official red header, white card with "link na bio". */
export const ctaHtml = ({ fontCss, assets }) =>
htmlDocument({
title: 'Reel — CTA',
fontCss,
body: `
<div class="frame" style="background:${BRAND.cream}">
<div style="position:absolute;left:0;right:0;top:0;height:47%;background:${BRAND.red}"></div>
<div class="star-disc star-1" style="left:20px;top:48px;width:72px;height:72px"><img src="${assets.star}" alt="" /></div>
<div style="position:absolute;left:7%;right:7%;top:14%;text-align:center;color:#fff">
<p class="fade-1" style="font-size:10.8px;font-weight:900;letter-spacing:.12em;color:${BRAND.yellow};text-transform:uppercase">Seu card, sua voz</p>
<h3 class="brexter beat-1" style="font-size:41.4px;line-height:37.26px;margin-top:14.4px;max-width:100%">Agora é<br />com você.</h3>
</div>
<div class="rise-1" style="position:absolute;left:7%;right:7%;top:51%;background:#fff;border-radius:21.6px;padding:25.2px;text-align:center;box-shadow:0 14px 40px rgb(71 19 14 / 14%);border:1px solid rgb(0 0 0 / 10%)">
<div style="display:grid;place-items:center;width:50.4px;height:50.4px;margin:0 auto;border-radius:999px;background:${BRAND.yellow}">
<svg width="25.2" height="25.2" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2.3"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>
</div>
<p style="margin-top:12.6px;font-size:14.4px;font-weight:600;color:#000">Acesse o site pelo</p>
<p class="brexter" style="font-size:28.8px;line-height:1;color:${BRAND.red};margin-top:2.5px">link na bio</p>
<p style="margin-top:10px;font-size:10.8px;line-height:1.35;color:#6c615e">Escolha seu modelo, crie e compartilhe.</p>
</div>
<p style="position:absolute;left:7%;bottom:11%;max-width:52%;font-size:10.1px;font-weight:600;line-height:1.3;color:${BRAND.blue}">jorgesolla1313.com.br</p>
<img style="position:absolute;left:203px;top:520px;width:132px;height:auto" src="${assets.completePositive}" alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro" />
</div>`,
})
/** Transparent overlay of a capture scene: step badge + positive mark on a plate. */
export const chromeOverlayHtml = ({ fontCss, badge, assets }) =>
htmlDocument({
title: `Reel — chrome ${badge.number}`,
fontCss,
body: `
<div class="frame">
<div class="badge"><b>${badge.number}</b>${badge.label}</div>
<div class="brand-plate"><img src="${assets.namePositive}" alt="Jorge Solla — Deputado Federal" /></div>
</div>`,
})
/** Transparent overlay of one burned caption (accent parts in brand yellow). */
export const captionOverlayHtml = ({ fontCss, caption }) => {
const parts = caption.parts
.map((part) => (part.accent ? `<span class="accent">${part.text}</span>` : part.text))
.join('')
return htmlDocument({
title: 'Reel — caption',
fontCss,
body: `<div class="frame"><p class="caption-box">${parts}</p></div>`,
})
}
/** Scene 05 — cover: own composition, title inside the central 1080×1350 crop. */
export const coverHtml = ({ fontCss, assets }) =>
htmlDocument({
title: 'Reel — capa',
fontCss,
body: `
<div class="frame" style="background:${BRAND_GRADIENT}">
<div class="star-disc" style="left:250px;top:54px;width:88px;height:88px"><img src="${assets.star}" alt="" /></div>
<div style="position:absolute;left:7%;right:7%;top:22%">
<span style="display:inline-flex;border-radius:999px;background:${BRAND.yellow};color:#000;padding:5.4px 14.4px;font-size:10.8px;font-weight:900">TUTORIAL DO SITE</span>
<h3 class="brexter" style="font-size:43.2px;line-height:.88;color:#fff;margin-top:25.2px">Crie seu<br />card de<br /><span class="accent">apoio.</span></h3>
<p style="margin-top:21.6px;max-width:65%;font-size:13.3px;font-weight:600;line-height:1.2;color:#fff">Seu nome ou sua foto, direto pelo celular.</p>
</div>
<p style="position:absolute;left:7%;bottom:18%;border-radius:4px;background:rgb(0 0 0 / 40%);padding:5.4px 10.8px;font-size:8.6px;color:#fff">#cards</p>
<img style="position:absolute;left:205px;top:448px;width:133px;height:auto" src="${assets.completeNegative}" alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro" />
</div>`,
})

View file

@ -0,0 +1,276 @@
/**
* C196 — pure timeline math of the reel: maps the wall-clock action log onto
* the recorded video timeline, derives the smooth zoom (zoompan expressions)
* from the click log and places the burned captions inside the Instagram safe
* area of the design artifact.
*
* No browser, no ffmpeg: every function here is deterministic and unit-tested.
*/
/** Physical frame at the CSS viewport scale used by the mobile capture. */
const DEVICE_SCALE = 3
export const REEL = {
width: 1080,
height: 1920,
fps: 30,
deviceScale: DEVICE_SCALE,
viewport: { width: 1080 / DEVICE_SCALE, height: 1920 / DEVICE_SCALE },
/** Design §Safe zone: top ~250px, conservative bottom 672px, sides 6%. */
safeArea: { topPx: 250, bottomPx: 672, sidePx: 65 },
/** Design §Legenda: center between 1100–1250px, width ≤900px. */
caption: { centerYPx: 1160, maxWidthPx: 900 },
/** Design §Gesto: zoom 108–122% (18%), cursor arrives before, smooth easing. */
zoom: { scale: 1.18, holdMs: 900, releaseMs: 500 },
/** zoompan runs on a 2x upscale for subpixel precision (see impl plan). */
upscale: 2,
}
const round = (value, digits = 4) => {
const factor = 10 ** digits
return Math.round(value * factor) / factor
}
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
/** Wall clock → video timeline (ms), relative to the first recorded frame. */
export const wallToTimelineMs = (atMs, firstFrameWallMs) => atMs - firstFrameWallMs
const marker = (actions, sceneId, kind) =>
actions.find((entry) => entry.scene === sceneId && entry.kind === kind) ?? null
/**
* One capture scene window, in video-timeline ms: from `sceneStart` (setup done,
* page settled) to `sceneEnd` (after the trailing pause). Clicks carry the
* approach/arrival/click triple the driver logged.
*
* @param {{ scene: { id: string }, actions: Array<{ scene: string, kind: string, at: number, approachAt?: number, arriveAt?: number, x?: number, y?: number, zoom?: boolean }>, firstFrameWallMs: number }} options
*/
export const captureSceneWindow = ({ scene, actions, firstFrameWallMs }) => {
const start = marker(actions, scene.id, 'sceneStart')
const end = marker(actions, scene.id, 'sceneEnd')
if (!start || !end) throw new Error(`Cena "${scene.id}": marcadores de início/fim ausentes.`)
const at = (wallMs) => wallToTimelineMs(wallMs, firstFrameWallMs)
const clicks = actions
.filter((entry) => entry.scene === scene.id && entry.kind === 'click' && entry.zoom !== false)
.map((entry) => ({
approachAtMs: at(entry.approachAt),
arriveAtMs: at(entry.arriveAt),
clickAtMs: at(entry.at),
x: entry.x,
y: entry.y,
}))
return {
id: scene.id,
startMs: Math.max(0, Math.round(at(start.at))),
endMs: Math.round(at(end.at)),
clicks,
}
}
/**
* @typedef {{ approachAtMs: number, arriveAtMs: number, clickAtMs: number, x: number, y: number }} ReelClick
* @typedef {{ atMs: number, scale: number, x: number | null, y: number | null }} ZoomKeyframe
* @typedef {{ scale: number, holdMs: number, releaseMs: number }} ZoomConfig
* @typedef {{ z: string, x: string, y: string, inputWidth: number, inputHeight: number }} ZoomExpressions
*/
/**
* Zoom breakpoints (video-timeline ms) for a scene: ramp to `scale` while the
* cursor approaches, hold through the click, release back to 1. Multiple
* clicks chain, and every breakpoint is clamped inside the clip.
*
* @param {{ clicks: ReelClick[], clipDurationMs: number, zoom?: ZoomConfig }} options
* @returns {ZoomKeyframe[]}
*/
export const zoomKeyframes = ({ clicks, clipDurationMs, zoom = REEL.zoom }) => {
const frames = []
for (const click of clicks) {
frames.push({ atMs: click.approachAtMs, scale: 1, x: null, y: null })
frames.push({ atMs: click.arriveAtMs, scale: zoom.scale, x: click.x, y: click.y })
frames.push({
atMs: click.clickAtMs + zoom.holdMs,
scale: zoom.scale,
x: click.x,
y: click.y,
})
frames.push({
atMs: click.clickAtMs + zoom.holdMs + zoom.releaseMs,
scale: 1,
x: null,
y: null,
})
}
const clamped = frames
.map((frame) => ({ ...frame, atMs: clamp(frame.atMs, 0, clipDurationMs) }))
.sort((a, b) => a.atMs - b.atMs)
const deduped = []
for (const frame of clamped) {
if (deduped.length > 0 && frame.atMs === deduped[deduped.length - 1].atMs) {
deduped[deduped.length - 1] = frame
} else {
deduped.push(frame)
}
}
return deduped
}
/**
* Piecewise-linear ffmpeg expression over the output frame number `on`.
*
* @param {Array<{ frame: number, value: number }>} points
* @param {string} [variable]
* @returns {string}
*/
export const piecewiseLinear = (points, variable = 'on') => {
if (points.length === 0) return '0'
let expression = `${round(points[points.length - 1].value)}`
for (let index = points.length - 1; index > 0; index -= 1) {
const previous = points[index - 1]
const current = points[index]
const span = current.frame - previous.frame
const segment =
previous.value === current.value || span === 0
? `${round(previous.value)}`
: `${round(previous.value)}+(${round(current.value)}-${round(previous.value)})*(${variable}-${previous.frame})/${span}`
expression = `if(lt(${variable},${current.frame}),${segment},${expression})`
}
return expression
}
/**
* ffmpeg `zoompan` expressions for a scene. `z` ramps between the keyframe
* scales while `x`/`y` keep the clicked target centered (clamped so the crop
* window never leaves the frame). Returns null when nothing zooms.
*
* @param {{ keyframes: ZoomKeyframe[], clipDurationMs: number, viewportWidth: number, width?: number, height?: number, fps?: number }} options
* @returns {ZoomExpressions | null}
*/
export const buildZoomExpressions = ({
keyframes,
clipDurationMs,
viewportWidth,
width = REEL.width,
height = REEL.height,
fps = REEL.fps,
}) => {
if (keyframes.length < 2) return null
const totalFrames = Math.max(1, Math.round((clipDurationMs / 1000) * fps))
const inputWidth = width * REEL.upscale
const inputHeight = height * REEL.upscale
const scale = inputWidth / viewportWidth
const toFrame = (atMs) => clamp(Math.round((atMs / 1000) * fps), 0, totalFrames)
const scalePoints = keyframes.map((frame) => ({ frame: toFrame(frame.atMs), value: frame.scale }))
if (scalePoints.every((point) => point.value === 1)) return null
const offsetPoints = (axis, size) =>
keyframes.map((frame) => {
const target = frame[axis]
const window = size / frame.scale
const center = target === null ? size / 2 : target * scale
const value = clamp(center, window / 2, size - window / 2) - window / 2
return { frame: toFrame(frame.atMs), value: round(value) }
})
// Guard point at frame 0: without it the first segment extrapolates below
// the zoompan valid range (z >= 1) during the scene lead-in.
const withStartGuard = (points, value) =>
points[0].frame === 0 ? points : [{ frame: 0, value }, ...points]
return {
z: piecewiseLinear(withStartGuard(scalePoints, 1)),
x: piecewiseLinear(withStartGuard(offsetPoints('x', inputWidth), 0)),
y: piecewiseLinear(withStartGuard(offsetPoints('y', inputHeight), 0)),
inputWidth,
inputHeight,
}
}
/**
* Caption window inside the clip, in ms.
*
* @param {{ clipDurationMs: number, delayMs: number, durationMs?: number | null }} options
* @returns {{ startMs: number, endMs: number }}
*/
export const captionWindow = ({ clipDurationMs, delayMs, durationMs = null }) => {
const startMs = clamp(delayMs, 0, clipDurationMs)
const endMs =
durationMs === null ? clipDurationMs : clamp(startMs + durationMs, startMs, clipDurationMs)
return { startMs, endMs }
}
/**
* Full capture-scene plan consumed by the composer: clip window, zoom
* expressions and the burned caption window (all relative to the clip).
*
* @param {{ scene: { id: string, caption: unknown, captionDelayMs: number, captionDurationMs: number | null }, actions: Array<{ scene: string, kind: string, at: number, approachAt?: number, arriveAt?: number, x?: number, y?: number, zoom?: boolean }>, firstFrameWallMs: number, viewportWidth?: number, zoom?: ZoomConfig }} options
* @returns {{ id: string, source: string, startMs: number, durationMs: number, zoom: ZoomExpressions | null, captions: Array<{ startMs: number, endMs: number }> }}
*/
export const captureScenePlan = ({
scene,
actions,
firstFrameWallMs,
viewportWidth = 360,
zoom = REEL.zoom,
}) => {
const window = captureSceneWindow({ scene, actions, firstFrameWallMs })
const clipDurationMs = Math.max(1, window.endMs - window.startMs)
const keyframes = zoomKeyframes({ clicks: window.clicks, clipDurationMs, zoom })
const zoomExpressions = buildZoomExpressions({
keyframes,
clipDurationMs,
viewportWidth,
})
const caps = scene.caption
? [
captionWindow({
clipDurationMs,
delayMs: scene.captionDelayMs,
durationMs: scene.captionDurationMs,
}),
]
: []
return {
id: scene.id,
source: 'capture',
startMs: window.startMs,
durationMs: clipDurationMs,
zoom: zoomExpressions,
captions: caps,
}
}
/**
* Window of a graphic scene recording (hook/CTA): from `readyAt`, fixed span.
*
* @param {{ readyAtMs: number, firstFrameWallMs: number, durationMs: number }} options
* @returns {{ startMs: number, durationMs: number }}
*/
export const graphicClipWindow = ({ readyAtMs, firstFrameWallMs, durationMs }) => {
const startMs = Math.max(0, Math.round(readyAtMs - firstFrameWallMs))
return { startMs, durationMs }
}
/**
* Plan of a graphic scene (hook/CTA): its own recording, fixed duration and no
* zoom/captions (the template carries the motion and the copy).
*
* @param {{ scene: { id: string, durationMs: number }, readyAtMs: number, firstFrameWallMs: number }} options
* @returns {{ id: string, source: string, startMs: number, durationMs: number, zoom: null, captions: never[] }}
*/
export const graphicScenePlan = ({ scene, readyAtMs, firstFrameWallMs }) => {
const window = graphicClipWindow({ readyAtMs, firstFrameWallMs, durationMs: scene.durationMs })
return {
id: scene.id,
source: 'graphic',
startMs: window.startMs,
durationMs: window.durationMs,
zoom: null,
captions: [],
}
}
/**
* @param {Array<{ durationMs: number }>} plans
* @returns {number}
*/
export const totalDurationMs = (plans) => plans.reduce((sum, plan) => sum + plan.durationMs, 0)
export const seconds = (ms) => round(ms / 1000, 3)

View file

@ -79,6 +79,10 @@ export const SCRIPTS_SPEC_PINNED = [
'scripts/lib/playwright-e2e-args.mjs',
'scripts/lib/portalTransparenciaEmendas.mjs',
'scripts/lib/readOnlyExtract.mjs',
'scripts/lib/reelFfmpeg.mjs',
'scripts/lib/reelPackage.mjs',
'scripts/lib/reelShotList.mjs',
'scripts/lib/reelTimeline.mjs',
'scripts/lib/reportText.mjs',
'scripts/lib/seed-minimal-manifest.mjs',
'scripts/lib/sollaCeuciSalvadorMetrics.mjs',

View file

@ -0,0 +1,86 @@
{
"slug": "cards",
"title": "Crie seu card de apoio",
"feature": "cards",
"route": "/",
"fixture": {
"cardName": "Joana"
},
"scenes": [
{
"id": "hook",
"kind": "graphic",
"template": "hook",
"durationMs": 2400
},
{
"id": "step-1",
"kind": "capture",
"badge": { "number": 1, "total": 3, "label": "Escolha um modelo" },
"caption": {
"parts": [
{ "text": "Toque em " },
{ "text": "“Card com seu nome”", "accent": true },
{ "text": "." }
]
},
"captionDelayMs": 500,
"captionDurationMs": 3200,
"leadInMs": 700,
"trailOutMs": 900,
"setup": [{ "action": "scrollIntoView", "selector": "#cards-title" }],
"steps": [
{ "action": "click", "selector": "[data-card-model-tile=\"eu-sou-solla\"]" },
{ "action": "waitFor", "selector": "role=heading[name=\"Personalize com seu nome\"]" }
]
},
{
"id": "step-2",
"kind": "capture",
"badge": { "number": 2, "total": 3, "label": "Escreva seu nome" },
"caption": {
"parts": [
{ "text": "Digite seu nome e toque em " },
{ "text": "“Criar meu card”", "accent": true },
{ "text": "." }
]
},
"captionDelayMs": 500,
"captionDurationMs": 3600,
"leadInMs": 600,
"trailOutMs": 1000,
"setup": [{ "action": "scrollIntoView", "selector": "role=textbox[name=\"Seu nome\"]" }],
"steps": [
{ "action": "fill", "selector": "role=textbox[name=\"Seu nome\"]" },
{ "action": "click", "selector": "role=button[name=\"Criar meu card\"]" },
{ "action": "waitFor", "selector": "text=Seu card está pronto para compartilhar." }
]
},
{
"id": "step-3",
"kind": "capture",
"badge": { "number": 3, "total": 3, "label": "Baixe seu card" },
"caption": {
"parts": [
{ "text": "Baixe e compartilhe o " },
{ "text": "seu card", "accent": true },
{ "text": "." }
]
},
"captionDelayMs": 400,
"captionDurationMs": 2800,
"leadInMs": 600,
"trailOutMs": 900,
"setup": [
{ "action": "scrollIntoView", "selector": "role=button[name=\"Baixar meu card\"]" }
],
"steps": [{ "action": "download", "selector": "role=button[name=\"Baixar meu card\"]" }]
},
{
"id": "cta",
"kind": "graphic",
"template": "cta",
"durationMs": 3200
}
]
}

View file

@ -19,6 +19,7 @@ const commands = [
'dossie-solla-cidade',
'dossie-solla-instituicao',
'dossie-solla-tema',
'reels-tutoriais',
] as const
describe('opencode commands couple to their skill by exact name', () => {

View file

@ -0,0 +1,224 @@
import { describe, expect, it } from 'vitest'
import {
buildConcatArgs,
buildFrameListContent,
buildSceneClipArgs,
concatListContent,
encodeFrames,
probeFfmpeg,
resolveFfmpeg,
} from '../../scripts/lib/reelFfmpeg.mjs'
// C196 — ffmpeg resolution/args are pinned: the capability probe fails closed
// (libx264 + the filters the composer uses) and the clip/concat commands are
// deterministic.
type RunResult = { ok: boolean; stdout: string; stderr: string; error?: { code?: string } }
const okRun = async (_bin: string, args: string[]): Promise<RunResult> => {
if (args.includes('-version'))
return { ok: true, stdout: 'ffmpeg version 7.0.2-test\n', stderr: '' }
if (args.includes('-encoders')) return { ok: true, stdout: ' V....D libx264 H.264\n', stderr: '' }
if (args.includes('-filters')) {
return {
ok: true,
stdout: [
' ... overlay VV->V',
' ..C crop V->V',
' ..C scale V->V',
' ... zoompan V->V',
' .S. fade V->V',
].join('\n'),
stderr: '',
}
}
return { ok: true, stdout: '', stderr: '' }
}
const zoom = {
z: 'if(lt(on,3),1,1.18)',
x: 'max(0,min(iw-iw/zoom,0))',
y: 'max(0,min(ih-ih/zoom,0))',
inputWidth: 2_160,
inputHeight: 3_840,
}
describe('reelFfmpeg', () => {
it('probes version, libx264 and the required filters', async () => {
await expect(probeFfmpeg('/ffmpeg', { run: okRun })).resolves.toMatchObject({
ok: true,
bin: '/ffmpeg',
})
})
it('fails the probe without libx264 or a required filter', async () => {
const no264 = async (_bin: string, args: string[]): Promise<RunResult> => {
if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' }
if (args.includes('-encoders')) return { ok: true, stdout: ' V....D h264\n', stderr: '' }
return { ok: true, stdout: '', stderr: '' }
}
await expect(probeFfmpeg('/ffmpeg', { run: no264 })).resolves.toMatchObject({
ok: false,
reason: 'sem encoder libx264',
})
const noZoompan = async (_bin: string, args: string[]): Promise<RunResult> => {
if (args.includes('-version')) return { ok: true, stdout: 'ffmpeg version x\n', stderr: '' }
if (args.includes('-encoders')) return { ok: true, stdout: 'libx264\n', stderr: '' }
return { ok: true, stdout: 'overlay\ncrop\nscale\nfade\n', stderr: '' }
}
await expect(probeFfmpeg('/ffmpeg', { run: noZoompan })).resolves.toMatchObject({
ok: false,
reason: 'sem filtro(s): zoompan',
})
})
it('resolves FFMPEG_PATH strictly, then PATH, then the packaged binary', async () => {
const explicit = await resolveFfmpeg({
env: { FFMPEG_PATH: '/custom/ffmpeg' },
run: okRun,
installer: { path: '/packed/ffmpeg' },
})
expect(explicit).toMatchObject({ bin: '/custom/ffmpeg', source: 'FFMPEG_PATH' })
const pathFirst = await resolveFfmpeg({
env: {},
run: okRun,
installer: { path: '/packed/ffmpeg' },
})
expect(pathFirst).toMatchObject({ bin: 'ffmpeg', source: 'PATH' })
const packed = async (bin: string, args: string[]): Promise<RunResult> => {
if (bin === 'ffmpeg') return { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } }
return okRun(bin, args)
}
const fallback = await resolveFfmpeg({
env: {},
run: packed,
installer: { path: '/packed/ffmpeg' },
})
expect(fallback).toMatchObject({ bin: '/packed/ffmpeg', source: 'empacotado' })
const brokenExplicit = async (bin: string, args: string[]): Promise<RunResult> =>
bin === '/custom/ffmpeg'
? { ok: false, stdout: '', stderr: '', error: { code: 'ENOENT' } }
: okRun(bin, args)
await expect(
resolveFfmpeg({
env: { FFMPEG_PATH: '/custom/ffmpeg' },
run: brokenExplicit,
installer: { path: '/packed/ffmpeg' },
}),
).rejects.toThrow(/Nenhum ffmpeg utilizável/)
})
it('builds the scene clip with zoom, burned captions and fades', () => {
const args = buildSceneClipArgs({
input: 'in.mp4',
output: 'out.mp4',
startMs: 1_500,
durationMs: 3_000,
zoom,
captions: [
{ path: 'chrome.png', startMs: 0, endMs: 3_000 },
{ path: 'caption.png', startMs: 300, endMs: 1_800 },
],
fadeInMs: 300,
fadeOutMs: 400,
fps: 30,
width: 1_080,
height: 1_920,
})
const graph = args[args.indexOf('-filter_complex') + 1]
expect(graph).toContain('zoompan=')
expect(graph).toContain("overlay=0:0:enable='between(t,0,3)'")
expect(graph).toContain("overlay=0:0:enable='between(t,0.3,1.8)'")
expect(graph).toContain('fade=t=in:st=0:d=0.3')
expect(graph).toContain('fade=t=out:st=2.6:d=0.4')
expect(args).toContain('-loop')
expect(args.filter((arg: string) => arg === '-loop')).toHaveLength(2)
expect(args).toContain('libx264')
expect(args).toContain('[v2]')
expect(args[args.indexOf('-ss') + 1]).toBe('1.5')
})
it('skips zoompan for scenes without clicks', () => {
const args = buildSceneClipArgs({
input: 'in.mp4',
output: 'out.mp4',
startMs: 0,
durationMs: 2_400,
fps: 30,
width: 1_080,
height: 1_920,
})
expect(args[args.indexOf('-filter_complex') + 1]).not.toContain('zoompan')
expect(args[args.indexOf('-filter_complex') + 1]).toContain('scale=1080:1920')
})
it('escapes concat entries and streams the final concat', () => {
expect(concatListContent(['/tmp/a.mp4', "/tmp/o'brien.mp4"])).toBe(
"file '/tmp/a.mp4'\nfile '/tmp/o'\\''brien.mp4'\n",
)
expect(buildConcatArgs({ listPath: 'list.txt', output: 'reel.mp4' })).toEqual([
'-hide_banner',
'-loglevel',
'error',
'-f',
'concat',
'-safe',
'0',
'-i',
'list.txt',
'-c',
'copy',
'-movflags',
'+faststart',
'-y',
'reel.mp4',
])
})
it('writes frame durations from capture deltas and repeats the last frame', () => {
const content = buildFrameListContent({
frames: [
{ file: '/f/1.jpg', captureMs: 0 },
{ file: '/f/2.jpg', captureMs: 50 },
{ file: '/f/3.jpg', captureMs: 250 },
],
fps: 30,
})
expect(content).toBe(
"file '/f/1.jpg'\nduration 0.05\nfile '/f/2.jpg'\nduration 0.2\nfile '/f/3.jpg'\nduration 0.033\nfile '/f/3.jpg'\n",
)
})
it('encodes frames offline through the injected runner', async () => {
const calls: Array<{ bin: string; args: string[] }> = []
const writes: Array<{ path: string; content: string }> = []
const run = async (bin: string, args: string[]): Promise<RunResult> => {
calls.push({ bin, args })
return { ok: true, stdout: '', stderr: '' }
}
await encodeFrames({
ffmpegBin: '/ffmpeg',
frames: [
{ file: '/f/1.jpg', captureMs: 0 },
{ file: '/f/2.jpg', captureMs: 40 },
],
listPath: '/w/frames.txt',
output: '/w/cap.mp4',
fps: 30,
run,
writeFileImpl: async (path: string, content: string) => {
writes.push({ path, content })
},
})
expect(writes).toHaveLength(1)
expect(writes[0].content).toContain("file '/f/1.jpg'")
expect(calls).toHaveLength(1)
expect(calls[0].args).toContain('concat')
expect(calls[0].args).toContain('/w/cap.mp4')
})
})

View file

@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import {
captureScenes,
graphicScenes,
normalizeShotList,
shotListHash,
} from '../../scripts/lib/reelShotList.mjs'
// C196 — the shot list is the single source of truth of a reel: the schema is
// fail-closed and the hash identifies the rendered version (C195 ingests by it).
const rawShotList = (overrides: Record<string, unknown> = {}) => ({
slug: 'cards',
title: 'Crie seu card de apoio',
feature: 'cards',
route: '/',
fixture: { cardName: 'Joana' },
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 2400 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 3, label: 'Escolha um modelo' },
caption: { parts: [{ text: 'Toque em ' }, { text: '“Card”', accent: true }] },
setup: [{ action: 'scrollIntoView', selector: '#cards-title' }],
steps: [{ action: 'click', selector: '[data-card-model-tile="eu-sou-solla"]' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 3200 },
],
...overrides,
})
describe('reelShotList', () => {
it('normalizes a valid shot list with defaults', () => {
const shotList = normalizeShotList(rawShotList())
expect(shotList.slug).toBe('cards')
expect(shotList.route).toBe('/')
expect(captureScenes(shotList)).toHaveLength(1)
expect(graphicScenes(shotList)).toHaveLength(2)
const scene = captureScenes(shotList)[0]
expect(scene.leadInMs).toBe(700)
expect(scene.trailOutMs).toBe(800)
expect(scene.captionDelayMs).toBe(250)
expect(scene.captionDurationMs).toBeNull()
expect(scene.setup).toEqual([{ action: 'scrollIntoView', selector: '#cards-title' }])
})
it('hashes the normalized shot list deterministically', () => {
const first = normalizeShotList(rawShotList())
const second = normalizeShotList(rawShotList())
expect(shotListHash(first)).toBe(shotListHash(second))
const changed = normalizeShotList(rawShotList({ fixture: { cardName: 'Outra Pessoa' } }))
expect(shotListHash(changed)).not.toBe(shotListHash(first))
})
it('requires the hook first and the CTA last', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'Toque.' }] },
steps: [{ action: 'click', selector: '#a' }],
},
],
}),
),
).toThrow(/primeira cena deve ser o hook/)
})
it('rejects unknown actions and empty selectors', () => {
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'x' }] },
steps: [{ action: 'explode', selector: '#a' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
),
).toThrow(/action "explode" inválida/)
expect(() =>
normalizeShotList(
rawShotList({
scenes: [
{ id: 'hook', kind: 'graphic', template: 'hook', durationMs: 1000 },
{
id: 'step-1',
kind: 'capture',
badge: { number: 1, total: 1, label: 'Passo' },
caption: { parts: [{ text: 'x' }] },
steps: [{ action: 'click', selector: '' }],
},
{ id: 'cta', kind: 'graphic', template: 'cta', durationMs: 1000 },
],
}),
),
).toThrow(/selector deve ser string não vazia/)
})
it('requires fixture.cardName when a fill step has no value', () => {
const withoutFixture = rawShotList({ fixture: {} })
const scenes = (withoutFixture.scenes as Array<Record<string, unknown>>).slice()
scenes[1] = {
...scenes[1],
steps: [{ action: 'fill', selector: 'role=textbox[name="Seu nome"]' }],
}
expect(() => normalizeShotList({ ...withoutFixture, scenes })).toThrow(/fixture.cardName/)
})
})

View file

@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import {
buildZoomExpressions,
captionWindow,
captureScenePlan,
captureSceneWindow,
graphicClipWindow,
piecewiseLinear,
REEL,
totalDurationMs,
wallToTimelineMs,
zoomKeyframes,
} from '../../scripts/lib/reelTimeline.mjs'
// C196 — the timeline maps the wall-clock action log onto the recorded video
// timeline; the zoom expressions and caption windows are pure and pinned here.
const firstFrameWallMs = 1_000
const scene = (overrides: Record<string, unknown> = {}) => ({
id: 'step-1',
kind: 'capture' as const,
badge: { number: 1, total: 3, label: 'Escolha um modelo' },
caption: { parts: [{ text: 'Toque.' }] },
setup: [],
steps: [],
leadInMs: 700,
trailOutMs: 800,
captionDelayMs: 250,
captionDurationMs: null,
...overrides,
})
const actions = [
{ scene: 'step-1', kind: 'sceneStart', at: 2_000 },
{
scene: 'step-1',
kind: 'click',
at: 3_000,
approachAt: 2_500,
arriveAt: 2_820,
x: 180,
y: 320,
},
{
scene: 'step-1',
kind: 'click',
at: 3_200,
approachAt: 3_200,
arriveAt: 3_200,
x: 10,
y: 10,
zoom: false,
},
{ scene: 'step-1', kind: 'sceneEnd', at: 4_500 },
]
describe('reelTimeline', () => {
it('maps wall clock to the video timeline and the safe-area constants', () => {
expect(wallToTimelineMs(2_000, firstFrameWallMs)).toBe(1_000)
expect(REEL.safeArea.topPx).toBe(250)
expect(REEL.safeArea.bottomPx).toBe(672)
expect(REEL.caption.centerYPx).toBe(1_160)
})
it('builds the scene window and drops non-zoom clicks', () => {
const window = captureSceneWindow({ scene: scene(), actions, firstFrameWallMs })
expect(window.startMs).toBe(1_000)
expect(window.endMs).toBe(3_500)
expect(window.clicks).toHaveLength(1)
expect(window.clicks[0]).toEqual({
approachAtMs: 1_500,
arriveAtMs: 1_820,
clickAtMs: 2_000,
x: 180,
y: 320,
})
})
it('errors when the scene markers are missing', () => {
expect(() => captureSceneWindow({ scene: scene(), actions: [], firstFrameWallMs })).toThrow(
/marcadores/,
)
})
it('chains zoom keyframes and clamps them into the clip', () => {
const keyframes = zoomKeyframes({
clicks: [{ approachAtMs: 1_500, arriveAtMs: 1_820, clickAtMs: 2_000, x: 180, y: 320 }],
clipDurationMs: 3_500,
})
expect(keyframes.map((frame) => frame.scale)).toEqual([1, 1.18, 1.18, 1])
expect(keyframes[0].atMs).toBe(1_500)
expect(keyframes[keyframes.length - 1].atMs).toBe(3_400)
})
it('keeps the clicked target centered and clamped (never leaves the frame)', () => {
const keyframes = zoomKeyframes({
clicks: [{ approachAtMs: 1_500, arriveAtMs: 1_820, clickAtMs: 2_000, x: 180, y: 320 }],
clipDurationMs: 2_500,
})
const zoom = buildZoomExpressions({ keyframes, clipDurationMs: 2_500, viewportWidth: 360 })
expect(zoom).not.toBeNull()
expect(zoom!.z).toContain('1.18')
expect(zoom!.z).toContain('if(lt(on,')
expect(zoom!.x).toContain('if(lt(on,')
expect(zoom!.y).toContain('if(lt(on,')
expect(zoom!.inputWidth).toBe(2_160)
expect(zoom!.inputHeight).toBe(3_840)
})
it('returns no zoom when every keyframe is 1', () => {
const zoom = buildZoomExpressions({
keyframes: [
{ atMs: 0, scale: 1, x: null, y: null },
{ atMs: 500, scale: 1, x: null, y: null },
],
clipDurationMs: 1_000,
viewportWidth: 360,
})
expect(zoom).toBeNull()
expect(piecewiseLinear([{ frame: 0, value: 1 }])).toBe('1')
})
it('clamps the caption window inside the clip', () => {
expect(captionWindow({ clipDurationMs: 3_000, delayMs: 500, durationMs: 2_000 })).toEqual({
startMs: 500,
endMs: 2_500,
})
expect(captionWindow({ clipDurationMs: 3_000, delayMs: 500 })).toEqual({
startMs: 500,
endMs: 3_000,
})
expect(captionWindow({ clipDurationMs: 1_000, delayMs: 9_000, durationMs: 500 })).toEqual({
startMs: 1_000,
endMs: 1_000,
})
})
it('composes the capture scene plan', () => {
const plan = captureScenePlan({
scene: scene({ captionDelayMs: 300, captionDurationMs: 1_500 }),
actions,
firstFrameWallMs,
viewportWidth: 360,
})
expect(plan.source).toBe('capture')
expect(plan.startMs).toBe(1_000)
expect(plan.durationMs).toBe(2_500)
expect(plan.zoom).not.toBeNull()
expect(plan.captions).toEqual([{ startMs: 300, endMs: 1_800 }])
})
it('computes the graphic scene window from its own recording', () => {
expect(
graphicClipWindow({ readyAtMs: 5_000, firstFrameWallMs: 1_000, durationMs: 2_400 }),
).toEqual({ startMs: 4_000, durationMs: 2_400 })
expect(totalDurationMs([{ durationMs: 2_400 }, { durationMs: 3_200 }])).toBe(5_600)
})
})

View file

@ -0,0 +1,77 @@
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { buildReelMetadata } from '../../scripts/lib/reelPackage.mjs'
import { loadShotList, shotListHash } from '../../scripts/lib/reelShotList.mjs'
// C196 — the delivery contract of the reel builder: the package metadata the
// private library ingests, the versioned shot list of the first reel and the
// skill/command coupling (the reel is generated, never edited by hand).
const repoRoot = process.cwd()
const read = (path: string): string => readFileSync(resolve(repoRoot, path), 'utf8')
describe('reel package metadata', () => {
it('carries the identity the library ingests', () => {
const metadata = buildReelMetadata({
shotList: { slug: 'cards', title: 'Crie seu card de apoio', feature: 'cards' },
hash: 'a'.repeat(64),
durationMs: 18_418.6,
ffmpeg: 'ffmpeg version test',
generatedAt: new Date('2026-09-18T12:00:00.000Z'),
})
expect(metadata).toEqual({
slug: 'cards',
title: 'Crie seu card de apoio',
feature: 'cards',
shotListHash: 'a'.repeat(64),
durationMs: 18_419,
width: 1_080,
height: 1_920,
fps: 30,
codec: 'h264',
audio: false,
generatedAt: '2026-09-18T12:00:00.000Z',
ffmpeg: 'ffmpeg version test',
})
})
})
describe('shot list of the first reel', () => {
it('loads, validates and hashes the versioned cards shot list', async () => {
const { shotList, hash } = await loadShotList({ slug: 'cards', root: repoRoot })
expect(hash).toBe(shotListHash(shotList))
expect(shotList.scenes[0]).toMatchObject({ kind: 'graphic', template: 'hook' })
expect(shotList.scenes[shotList.scenes.length - 1]).toMatchObject({
kind: 'graphic',
template: 'cta',
})
const capture = shotList.scenes.filter((scene) => scene.kind === 'capture')
expect(capture).toHaveLength(3)
expect(capture.map((scene) => scene.badge?.number)).toEqual([1, 2, 3])
})
})
describe('skill /reels-tutoriais contract', () => {
it('ships the command coupled to the canonical skill', () => {
const command = read('.opencode/commands/reels-tutoriais.md')
expect(existsSync(resolve(repoRoot, '.agents/skills/reels-tutoriais/SKILL.md'))).toBe(true)
expect(command).toContain('`reels-tutoriais`')
expect(command).toContain('$ARGUMENTS')
expect(command).toContain('.agents/skills/reels-tutoriais/SKILL.md')
expect(command).toMatch(/^description: .+$/m)
expect(command).not.toMatch(/^\s*["']?model["']?\s*:/m)
})
it('keeps the gate contract: shot list is the source, adjustments regenerate', () => {
const skill = read('.agents/skills/reels-tutoriais/SKILL.md')
expect(skill).toContain('shot list')
expect(skill).toContain('Ajuste')
expect(skill).toContain('pnpm reels:build')
expect(skill).toContain('reel.mp4')
expect(skill).toContain('capa.png')
expect(skill).toContain('metadata.json')
expect(skill).toContain('Nada é publicado')
})
})