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
141
.agents/skills/reels-tutoriais/SKILL.md
Normal 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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
9
.opencode/commands/reels-tutoriais.md
Normal 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
|
||||
8792
docs/campaign-kit/manual-campanha-jorge-solla-1313.pdf
Normal file
3
docs/changelog/2026-09-18-c196.md
Normal 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.
|
||||
133
docs/plans/reels-tutoriais-impl.md
Normal 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).
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
public/campaign-kit/README.md
Normal 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.
|
||||
BIN
public/campaign-kit/estrela.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/campaign-kit/jorge-solla-negativo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/campaign-kit/jorge-solla-positivo.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
public/campaign-kit/marca-negativa-completa.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
public/campaign-kit/marca-positiva-completa.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/campaign-kit/numero-negativo.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/campaign-kit/numero-positivo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
292
scripts/build-reel.mjs
Normal 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
|
|
@ -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
|
|
@ -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,
|
||||
]
|
||||
28
scripts/lib/reelPackage.mjs
Normal 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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
260
scripts/lib/reelShotList.mjs
Normal 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')
|
||||
153
scripts/lib/reelTemplates.mjs
Normal 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>`,
|
||||
})
|
||||
276
scripts/lib/reelTimeline.mjs
Normal 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)
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
86
scripts/reels/shot-lists/cards.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
224
tests/unit/reelFfmpeg.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
121
tests/unit/reelShotList.unit.spec.ts
Normal 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/)
|
||||
})
|
||||
})
|
||||
160
tests/unit/reelTimeline.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
77
tests/unit/reelsTutoriaisSkill.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||