diff --git a/.agents/skills/reels-tutoriais/SKILL.md b/.agents/skills/reels-tutoriais/SKILL.md index c6499f9c..deb32c4d 100644 --- a/.agents/skills/reels-tutoriais/SKILL.md +++ b/.agents/skills/reels-tutoriais/SKILL.md @@ -57,7 +57,7 @@ pnpm reels:build [--audio] └─ 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 + └─ assets: trilho de comando/cap em PNG transparente + capa.png └─ clipes por cena: zoompan (do log de cliques) + overlay └─ concat H.264/yuv420p 30fps ├─ transcrição SEMPRE: narracao.srt + roteiro.md @@ -81,13 +81,24 @@ pnpm reels:tts:setup # venv do edge-tts (só para --audio) ## Contrato do shot list `scripts/reels/shot-lists/.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 +verdade. A primeira cena é o `hook`, a última é o `cta` (gráficas); o meio +pode ter mais gráficas (ex. `profile`, a ponte para Instagram/WhatsApp) e as +cenas de captura com `badge` (passo `n/total` ou pílula de rótulo, ex. +`COMECE`), `caption` (o comando do trilho), `setup` (preparo invisível, ex. +`scrollIntoView`) e `steps` (`click`, `fill`, `waitFor`, `download`, `upload`, +`scrollIntoView`, `swipe`). O `upload` clica no alvo visível (ex. “Escolher +foto”) e envia o arquivo de `fixture.photo` (ou do `value` do passo) pelo +seletor de arquivo do navegador; o `swipe` arrasta o track mobile com toque +real (`distance` em px). `fixture.cardName` e `fixture.photo` são fictícios (zero PII de terceiros). Qualquer ajuste de roteiro, texto, ordem, ritmo ou alvo de clique é feito **aqui** — nunca no MP4. +A copy das cenas gráficas e da capa vive no bloco `graphics` do shot list +(`hook`, `cta`, `profile` e `cover`, cada um com `eyebrow`, `headlineLines`, +`accentIndex`, `sub`/`tag`/`actionLines`/`url`/`instruction`/`apps` conforme o +template): o template carrega a estrutura, o reel carrega a mensagem — nenhum +texto temático fica hardcoded no renderer. + A fala de cada cena vive **no shot list**, no campo `narration` (opcional, até 600 caracteres): as cenas de captura caem no texto da própria `caption` quando ele falta (os espaços autorais são preservados), e cenas gráficas sem @@ -148,9 +159,12 @@ sem) e então mostre os artefatos e o resumo (duração, cenas, hash). Depois: ## 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 +A superfície gráfica (hook, cap de marca, trilho de comando, cursor/halo, +capa, cena `profile`, safe zone) vem dos artefatos aprovados +`docs/plans/reels-tutoriais-ui-design.html` (família) e +`docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html` (revisão: trilho no +topo em vez de legenda no centro, copy por reel, ilustrações do kit, ponte +para o perfil); o port é classe-a-classe em `scripts/lib/reelTemplates.mjs`. O `designer` é dono da estrutura visual — não improvise layout. ## Marca oficial (kit 1313) diff --git a/docs/changelog/2026-09-19-reels-tutoriais-evolucao.md b/docs/changelog/2026-09-19-reels-tutoriais-evolucao.md new file mode 100644 index 00000000..8869a15c --- /dev/null +++ b/docs/changelog/2026-09-19-reels-tutoriais-evolucao.md @@ -0,0 +1 @@ +**Skill `reels-tutoriais` — navegação visível, trilho de comando, cena `profile` e copy por reel (2026-09-19):** evolução pedida no gate do reel `foto-de-perfil` (rascunho, **não aprovado para publicação**). O shot list ganha `upload` (envia `fixture.photo` pelo seletor de arquivo clicando no alvo visível, ex. “Escolher foto”), `swipe` (arrasto de toque real no track mobile, com hold antes do `touchEnd` para não voar além do snap) e `scrollIntoView` visível com rolagem suave (o `setup` continua instantâneo e invisível — sem teletransporte na tela); `badge` aceita pílula de rótulo (`COMECE`) além de `n/total`; e todo o texto das cenas gráficas/capa migra para o bloco `graphics` do shot list (`hook`, `cta`, `profile`, `cover` — `eyebrow`, `headlineLines`, `accentIndex`, `sub`/`tag`/`actionLines`/`url`/`instruction`/`apps`), com a normalização fail-closed e cobertura unit correspondente. O capture agora centra o cursor sintético na origem e a superfície gráfica foi re-portada do design revisado (`docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html`): sai a legenda preta central e entram o cap de marca (topo) + trilho de comando único em y88 (`1/4`…`4/4` + ação curta), o hook com pattern/coração oficiais, o CTA com o punho e a cena nova `profile` (~4s, animação CSS) que leva a arte baixada para o avatar do Instagram e do WhatsApp — nenhuma estrela solta como logo de canto e nenhum lockup recriado; o leitor de assets virou `readImageDataUris` (kit + moldura/fixture do reel). O `cards.json` recebeu o mesmo bloco `graphics` (o reel cards adota a superfície nova no próximo build). Verificação: `pnpm lint`, `pnpm typecheck` e `pnpm test:unit` verdes (4.202 unit; novos casos de `graphics`, `badge` de rótulo, `swipe` e cena `profile`). Sem mudança no site público/Payload, sem schema/migration/Consent; a mídia do pacote segue gitignored. diff --git a/docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html b/docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html new file mode 100644 index 00000000..186d02fd --- /dev/null +++ b/docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html @@ -0,0 +1,1544 @@ + + + + + + + Design revisado — Reel foto de perfil + + + + +
+
+
+ REVISÃO DO GATE + foto-de-perfil + 360 × 640 CSS · @3x + 8 cenas · ~25s +
+

+ A tela não salta: ela conduz. +

+

+ Extensão do design aprovado da família em reels-tutoriais-ui-design.html. + Esta revisão troca a abertura genérica por copy de foto de perfil, mostra a chegada à + home, o scroll e o swipe da galeria, cria uma ponte visual até Instagram/WhatsApp e reduz + os comandos a um único trilho no topo. Nenhum lockup foi redesenhado: marca e ilustrações + vêm do kit oficial. +

+ +
+ +
+

01 · SUPERFÍCIE REVISADA

+

+ Quatro frames que definem o reel +

+

+ Hook e fechamento usam ilustrações oficiais em escala grande, nunca uma estrela solta como + selo. A captura mantém o produto visível. A cena profile conclui o trabalho que o site + começou. +

+
+ +
+
+
+

01 · Hook

+

0–2,6s

+
+ GRÁFICA +
+
+ + Ilustração oficial de coração com pulso +
+ TUTORIAL · FOTO DE PERFIL +

+ Sua foto
com a moldura
do Solla. +

+

+ Veja como fazer pelo celular e usar no Instagram ou WhatsApp. +

+
+ Jorge Solla 1313 — Mais Saúde, Mais Futuro +
+

+ Correção: o tema aparece na primeira frase. + Pattern + coração são ilustrações do kit; a única assinatura é a marca completa + oficial. +

+
+ + +
+
+
+

03 · Galeria

+

swipe → tile → drawer

+
+ CAPTURA +
+
+
+ +
+

+ FAÇA PARTE +

+

+ Mostre que você
está com Solla +

+

+ Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. +

+
+ +
+
+ Jorge Solla — Deputado Federal +
+
+ 1/4 +

Deslize e toque em “Moldura quadrada”.

+
+
+

+ Correção: badge e comando viram um só trilho + a y=88. Sem caixa preta no centro; foto, moldura e botão permanecem descobertos. +

+
+ + +
+
+
+

07 · Profile

+

~4s · clique para repetir

+
+ +
+
+ Jorge Solla — Deputado Federal +
+

PASSO FINAL

+

Use a arte
no seu perfil.

+
+
+
+
Instagram
+
+
+ Avatar anterior +
+
+

@seuperfil

+
+
+ +
+ + + ✓ +

Escolha a imagem baixada como nova foto de perfil.

+
+

+ Leitura: a própria arte baixada viaja para os + dois avatares. Mock genérico, sem rede e sem imitar telas proprietárias em detalhe. +

+
+ + +
+
+
+

08 · CTA

+

últimos 3s

+
+ GRÁFICA +
+
+
+ Ilustração oficial de punho erguido +
+
+

AGORA É COM VOCÊ

+

+ Troque sua foto.
Mostre seu apoio. +

+
+
+

FAÇA A SUA

+

Acesse pelo
link na bio

+

Escolha “Moldura quadrada”, coloque sua foto e baixe.

+
+

jorgesolla1313.com.br

+ Jorge Solla 1313 — Mais Saúde, Mais Futuro +
+

+ Correção: o punho oficial é claramente + ilustração, não “logo de canto”. A marca completa positiva assina no fundo claro, sem + sombra ou alteração. +

+
+
+
+ +
+

02 · ROTEIRO VISUAL

+

+ Oito cenas, uma continuidade espacial +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CenaDuraçãoO que apareceMovimentoTrilho de comando
1 · hook2,6sCopy específica + coração + marca completa + eyebrow .15s; título em 2 beats; coração deriva 8px + não se aplica
2 · entrada3,2stopo real da home → seção #cards + pausa 450ms; scroll suave 1,6s; repouso 700ms + + COMECE · “Role até ‘Mostre que você está com Solla’.” +
3 · galeria4,0strack mobile real + Moldura quadrada + drawer + swipe 900ms; pausa; cursor chega; toque; drawer sobe + + 1/4 · “Deslize e toque em ‘Moldura quadrada’.” +
4 · foto3,8sEscolher foto → reveal dentro da moldura + toque; 300ms de espera; crossfade da foto 450ms + 2/4 · “Escolha uma foto sua.”
5 · criar2,4sbotão “Criar meu card” → resultado + zoom 1→1,14; clique; release 500ms + + 3/4 · “Confira e toque em ‘Criar meu card’.” +
6 · baixar2,0sresultado + botão “Baixar meu card”cursor; toque; microcheck 350ms4/4 · “Baixe o card no celular.”
7 · profile4,0smock Instagram + WhatsApp + arte viaja ao Instagram; depois WhatsApp; checks + PASSO FINAL no próprio frame
8 · cta3,0spunho + CTA + marca completa + punho entra pela esquerda; card sobe 18px; segura 1,8s + não se aplica
+
+
+
+ Sem teletransporte: a cena 2 começa no topo carregado da home, com 450ms + de repouso para orientação. O scroll é visível e contínuo até #cards. A cena + 3 começa no mesmo enquadramento final, faz um swipe horizontal humano no track e só então + abre o tile. O corte acontece depois do drawer terminar de subir, nunca entre gesto e + resposta. +
+
+ +
+

03 · CENA NOVA “PROFILE”

+

+ A arte baixada vira avatar — sem rede +

+
+
+

Geometria @ 360 × 640 CSS

+
+
.profile-heading
+
+ x22 · y88 · w316; eyebrow 10px/900; título Brexter 27px/.92. +
+
.profile-phones
+
+ x18 · y176 · w324; 2 colunas de 154px; gap 16px. +
+
.profile-phone
+
+ 154 × 226px; raio 22px; borda preta 12%; sombra azul 13%. +
+
.profile-avatar-shell
+
+ 82 × 82px; top 58px do painel; padding 4px; círculo. +
+
.profile-card
+
+ x145 · y330 · 70²; fixture atrás + photo-square-frame.png na frente. +
+
.profile-footnote
+
x22 · y370 · w316; Inter 11px/800 azul.
+
+
+
+

Timeline CSS em 4,0s

+
    +
  1. + 0–0,8s: heading entra em 420ms; os dois + painéis sobem 20px e estabilizam. +
  2. +
  3. + 0,55–2,70s: + .profile-card--ig aparece no centro, percorre −87/−100px e fecha em + círculo no avatar do Instagram. +
  4. +
  5. + 0,55–3,45s: + .profile-card--wa repete o gesto com +87/−100px até o WhatsApp. +
  6. +
  7. + 3,05–3,70s: checks verdes entram com + overshoot 1,15→1; os dois painéis recebem um único halo amarelo. +
  8. +
  9. + 3,70–4,0s: frame final parado. Nada depende + de áudio. +
  10. +
+

+ Renderer: recebe o mesmo + fontCss já embutido por loadFontCss; o HTML não importa + fontes nem chama rede. O executor adiciona body.play depois de o frame + recorder estar pronto. +

+
+
+
+ +
+

04 · COPY POR SHOT LIST

+

+ O template carrega estrutura; o reel carrega a mensagem +

+
+
+

HOOK

+

eyebrow

+

Tutorial · Foto de perfil

+

headlineLines

+

+ Sua foto
com a moldura
do Solla. +

+

sub

+

+ Veja como fazer pelo celular e usar no Instagram ou WhatsApp. +

+
+
+

CAPA

+

eyebrow

+

Tutorial do site

+

headlineLines

+

+ Use a moldura
na sua foto
de perfil. +

+

sub

+

Instagram e WhatsApp, direto pelo celular.

+
+
+

CTA

+

eyebrow

+

Agora é com você

+

headlineLines

+

+ Troque sua foto.
Mostre seu apoio. +

+

action

+

Acesse pelo link na bio

+

sub

+

+ Escolha “Moldura quadrada”, coloque sua foto e baixe. +

+
+
+

LABELS

+
    +
  • entry.label: COMECE
  • +
  • step1.label: ESCOLHA A MOLDURA
  • +
  • step2.label: COLOQUE SUA FOTO
  • +
  • step3.label: CRIE SEU CARD
  • +
  • step4.label: BAIXE O CARD
  • +
  • profile.eyebrow: PASSO FINAL
  • +
  • cover.tag: #cards
  • +
+
+
+

+ Variáveis mínimas do shot list: + graphics.hook.{eyebrow,headlineLines,accentLine,sub}, + graphics.cover.{eyebrow,headlineLines,accentLine,sub,tag}, + graphics.cta.{eyebrow,headlineLines,accentLine,action,sub,url}, + graphics.profile.{eyebrow,headlineLines,instruction,apps} e, em cada captura, + badge.{number,total,label} + caption.parts. Nenhum texto + temático (“Crie seu card de apoio”) permanece hardcoded no template. +

+
+ +
+

05 · CAPA ESPECÍFICA

+

+ A promessa sobrevive ao recorte central +

+
+
+ +
+ TUTORIAL DO SITE +

+ Use a moldura
na sua foto
de perfil. +

+

Instagram e WhatsApp, direto pelo celular.

+
+ #cards + Jorge Solla 1313 — Mais Saúde, Mais Futuro +
+
+
+

Regra do recorte

+

+ O núcleo central 1080×1350 equivale a + y95–545 @360. Eyebrow, headline inteira, + #cards e marca completa ficam dentro dele. O pattern pode ser cortado + porque é decoração redundante. +

+

+ Brexter: 44px / .86 / tracking −.035em. As + três linhas são explícitas; o renderer não decide quebras. Se a fonte oficial não + carregar, o render falha fechado — não aceita fallback em produção. +

+

+ Sem estrela isolada no canto. A assinatura única é + marca-negativa-completa.png, a 134px CSS (402px físicos), inteira e sem + filtro. +

+
+
+
+ +
+

+ 06 · ESPECIFICAÇÃO CLASSE-A-CLASSE +

+

+ Contrato visual para o port +

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassePosição / tamanhoVisualMotion / regra
.capture-brand-capx0 y0 w360 h38gradiente #E4102F→#184E92; borda inferior 2px #FFEB00 + redundante no topo inseguro; usa jorge-solla-negativo.png 86px, sem + plate branco +
.command-railx16 y88 w328 min-h50 + branco 96%; raio 12; borda 1px; barra esquerda vermelha 4px; sombra 0 8 24 /18% + + entra 10px→0 em 220ms; sai em 160ms antes de qualquer target cruzar y88–138 +
.step-badge34² dentro do rail#FFEB00; círculo; Inter 11px/900 preto + “1/4”, nunca mini-logo, número de campanha ou estrela +
.command-copymax-w258; máx. 2 linhasInter 12.5px/750, line-height 1.2; ação em #A21C1C/900 + copy curta e literal; não repete label; sem caixa separada no centro +
.hook-titlex22 y143 w310Brexter 40px/.88; tracking −.035em; branco/amarelo + linha 1–2 em .55s; linha de acento +.24s +
.hook-illustrationright −30 top58 w154coração.png intacto, sem recolorir + fade + translateX(8→0) .65s; não gira nem pulsa +
.cta-punhox−38 y42 w180 + punho.png intacto e deliberadamente sangrado + + translateX(−16→0) .6s; lê como ilustração, não assinatura +
.cta-cardx22 y328 w316branco; raio 18; padding 20/18; sombra 0 14 36 /14% + rise 18px/.5s; segura ≥1,8s; um único CTA verbal +
.cover-copy h3x24 y181 w316Brexter 44px/.86; tracking −.035emestático; três linhas explícitas
+
+
+
+
+

CORES DO KIT

+

+ #E4102F · vermelho
#184E92 · azul
#FFEB00 · amarelo
#009647 · verde
#FFF8F2 + · creme de superfície +

+
+
+

TIPOGRAFIA

+

Impacto só
onde importa.

+

+ Brexter: hook, capa, títulos de profile e CTA. Inter: comando, labels, app mocks e + URL. +

+
+
+

CÂMERA

+

+ Scroll e swipe sem zoom. Toques usam 108–114% por padrão; 118% só se o alvo continuar + inteiro. Cursor chega 180ms antes. Halo amarelo dura um pulso e some ao abrir o + drawer. +

+
+
+
+ +
+

07 · CRITÉRIO DE ACEITE VISUAL

+

+ O que conferir no MP4 e na capa +

+
    +
  1. +

    1. Entrada compreensível

    +

    + Há um frame orientador no topo da home, scroll contínuo até #cards e swipe real até + “Moldura quadrada”. Nenhum jump cut substitui navegação. +

    +
  2. +
  3. +

    2. Produto não é encoberto

    +

    + O rail fica em y88–138; nunca atravessa foto, moldura, controles ou CTAs. Não existe + legenda preta no centro. Em conflito real, o rail sai antes — não muda de lado em cima + do alvo. +

    +
  4. +
  5. +

    3. Safe zone do Instagram

    +

    + Instrução crítica permanece entre x22–338 e y83–416 CSS (65px laterais, 250px topo e + 672px base no arquivo @3x). Marca no cap/topo e URL no rodapé são redundantes. +

    +
  6. +
  7. +

    4. Legibilidade no mudo

    +

    + Cada rail tem uma ação, no máximo duas linhas, contraste AA e ≥37,5px físicos. Hook, + profile e CTA seguram o frame final pelo tempo indicado. +

    +
  8. +
  9. +

    5. Ponte até o perfil

    +

    + A arte quadrada vista no resultado é visualmente a mesma que entra nos avatares. + Instagram aparece primeiro, WhatsApp depois; os dois terminam com a moldura visível. +

    +
  10. +
  11. +

    6. Marca sem invenção

    +

    + Lockups oficiais intactos, variante negativa sobre vermelho e positiva sobre claro. + Nada de estrela isolada como logo de canto, texto recriando “Jorge Solla”, filtros, + recoloração ou sombra nos PNGs. +

    +
  12. +
  13. +

    7. Capa recortável

    +

    + Eyebrow, headline, #cards e marca estão inteiros em y95–545. Conferir 9:16, recorte + 4:5 e grade 1:1; pattern pode sangrar. +

    +
  14. +
  15. +

    8. Fontes e nitidez

    +

    + Brexter e Inter vêm embutidas; render falha fechado se faltarem. Conferir bordas + nítidas a 1080×1920, sem PNG escalado além da resolução útil e sem artefato de + compressão sobre amarelo/vermelho. +

    +
  16. +
+
+ +
+

+ ATIVOS USADOS — KIT: + public/campaign-kit/pattern-shapes.png, + public/campaign-kit/coração.png, public/campaign-kit/punho.png, + public/campaign-kit/jorge-solla-negativo.png, + public/campaign-kit/marca-negativa-completa.png, + public/campaign-kit/marca-positiva-completa.png. +

+

+ ATIVOS DE PRODUTO/FIXTURE: + public/cards/photo-square-frame.png e + scripts/reels/fixtures/profile-photo.png. São a moldura real e a pessoa + fictícia já usada no reel; não são marca nova. +

+

+ CHAVES A ADICIONAR EM campaignKitAssets.mjs: + patternShapes: 'pattern-shapes.png', heart: 'coração.png' e + fist: 'punho.png'. As chaves nameNegative, + completeNegative e completePositive já existem. Não adicionar + star a este reel. +

+

+ GATE: este arquivo é a revisão material pedida + pelo operador; exige aprovação humana antes do port. A referência da família continua + válida para codec, cursor/halo e matemática geral de safe zone, exceto onde esta extensão + substitui explicitamente hook, capa, CTA, chrome e legenda. +

+
+
+ + + diff --git a/scripts/build-reel.mjs b/scripts/build-reel.mjs index c0bf8ce7..b8fd1da1 100644 --- a/scripts/build-reel.mjs +++ b/scripts/build-reel.mjs @@ -20,7 +20,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { readKitAssets } from './lib/campaignKitAssets.mjs' +import { readImageDataUris, readKitAssets } from './lib/campaignKitAssets.mjs' import { dieWithLabel, loadCliEnv, parseEqualsFlags } from './lib/cli.mjs' import { gotoReelSite, @@ -61,11 +61,12 @@ import { } from './lib/reelScript.mjs' import { captureScenes, graphicScenes, loadShotList } from './lib/reelShotList.mjs' import { - captionOverlayHtml, - chromeOverlayHtml, + captureChromeHtml, + commandRailHtml, coverHtml, ctaHtml, hookHtml, + profileHtml, } from './lib/reelTemplates.mjs' import { captureScenePlan, graphicScenePlan, REEL, totalDurationMs } from './lib/reelTimeline.mjs' import { buildNarrationTrack, DEFAULT_TTS_VOICE, resolveEdgeTts } from './lib/reelTts.mjs' @@ -143,6 +144,18 @@ const main = async () => { await mkdir(workDir, { recursive: true }) await mkdir(outDir, { recursive: true }) const assets = await readKitAssets({ root: ROOT }) + if (graphicScenes(shotList).some((scene) => scene.template === 'profile')) { + Object.assign( + assets, + await readImageDataUris({ + root: ROOT, + files: { + cardFrame: 'public/cards/photo-square-frame.png', + fixturePhoto: shotList.fixture.photo, + }, + }), + ) + } const browser = await launchReelBrowser() const plans = new Map() const graphics = new Map() @@ -167,7 +180,7 @@ const main = async () => { } } const fontFaces = await extractFontFaces(page) - const actions = await runCaptureScenes({ page, shotList, log: [] }) + const actions = await runCaptureScenes({ page, shotList, log: [], root: ROOT }) const stats = await recorder.stop() if (stats.firstFrameWallMs === null) throw new Error('Nenhum frame foi gravado — o site não renderizou?') @@ -208,23 +221,31 @@ const main = async () => { for (const scene of captureScenes(shotList)) { await renderOverlay({ page: renderPage, - html: chromeOverlayHtml({ fontCss, badge: scene.badge, assets }), + html: captureChromeHtml({ 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`), + html: commandRailHtml({ fontCss, badge: scene.badge, caption: scene.caption }), + output: join(workDir, `command-${scene.id}.png`), }) } await renderCover({ page: renderPage, - html: coverHtml({ fontCss, assets }), + html: coverHtml({ fontCss, assets, copy: shotList.graphics.cover }), output: join(outDir, 'capa.png'), }) + const graphicHtmls = { + hook: hookHtml, + cta: ctaHtml, + profile: profileHtml, + } for (const scene of graphicScenes(shotList)) { - const html = - scene.template === 'hook' ? hookHtml({ fontCss, assets }) : ctaHtml({ fontCss, assets }) + const html = graphicHtmls[scene.template]({ + fontCss, + assets, + copy: shotList.graphics[scene.template], + }) const graphic = await recordGraphicScene({ context: renderContext, template: scene.template, @@ -263,20 +284,16 @@ const main = async () => { 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 = + const overlays = scene.kind === 'capture' - ? plan.captions.map((window) => ({ - path: join(workDir, `caption-${scene.id}.png`), - ...window, - })) + ? [ + { path: join(workDir, `chrome-${scene.id}.png`), startMs: 0, endMs: plan.durationMs }, + ...plan.captions.map((window) => ({ + path: join(workDir, `command-${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({ @@ -285,7 +302,7 @@ const main = async () => { startMs: plan.startMs, durationMs: plan.durationMs, zoom: plan.zoom, - captions, + captions: overlays, fadeInMs: scene.id === firstScene ? FADE_IN_MS : 0, fadeOutMs: scene.id === lastScene ? FADE_OUT_MS : 0, fps: REEL.fps, diff --git a/scripts/lib/campaignKitAssets.mjs b/scripts/lib/campaignKitAssets.mjs index b183f8b0..4cb5a848 100644 --- a/scripts/lib/campaignKitAssets.mjs +++ b/scripts/lib/campaignKitAssets.mjs @@ -1,6 +1,6 @@ /** - * Official campaign kit assets (C196/C203): reads the committed PNGs under - * `public/campaign-kit` as data URIs, so every local renderer embeds the brand + * Official campaign kit assets (C196/C203) and the reel's product art: reads the + * committed PNGs as data URIs, so every local renderer embeds the brand * without network access. The default root resolves from this module (never * the cwd): the chart entry runs from temp dirs in the tests and the e2e. */ @@ -15,10 +15,33 @@ const KIT_ASSETS = { completePositive: 'marca-positiva-completa.png', completeNegative: 'marca-negativa-completa.png', star: 'estrela.png', + patternShapes: 'pattern-shapes.png', + heart: 'coração.png', + fist: 'punho.png', } const defaultRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..') +/** + * PNG files (repo-relative or kit-relative) as data URIs. + * + * @param {{ root?: string, files: Record }} options repo root + * (defaults to this module's repo) and one relative path per asset key. + * @returns {Promise>} data URI per key. + */ +export const readImageDataUris = async ({ root = defaultRoot, files }) => { + const entries = await Promise.all( + Object.entries(files).map(async ([key, file]) => { + if (typeof file !== 'string' || file.trim() === '') { + throw new Error(`caminho de imagem inválido para o ativo "${key}".`) + } + const bytes = await readFile(resolve(root, file)) + return [key, `data:image/png;base64,${bytes.toString('base64')}`] + }), + ) + return Object.fromEntries(entries) +} + /** * Official campaign kit marks as data URIs (committed under `public/campaign-kit`). * @@ -30,13 +53,11 @@ export const readKitAssets = async ({ root = defaultRoot, keys = Object.keys(KIT_ASSETS), } = {}) => { - const entries = await Promise.all( - keys.map(async (key) => { - const file = KIT_ASSETS[key] - if (!file) throw new Error(`ativo do kit desconhecido: ${key}`) - const bytes = await readFile(resolve(root, 'public/campaign-kit', file)) - return [key, `data:image/png;base64,${bytes.toString('base64')}`] - }), - ) - return Object.fromEntries(entries) + const files = {} + for (const key of keys) { + const file = KIT_ASSETS[key] + if (!file) throw new Error(`ativo do kit desconhecido: ${key}`) + files[key] = join('public', 'campaign-kit', file) + } + return readImageDataUris({ root, files }) } diff --git a/scripts/lib/reelCapture.mjs b/scripts/lib/reelCapture.mjs index 60bcb10b..f7f09fb3 100644 --- a/scripts/lib/reelCapture.mjs +++ b/scripts/lib/reelCapture.mjs @@ -14,8 +14,8 @@ * cursor) and therefore captured by the screencast. */ -import { mkdir, rm, writeFile } from 'node:fs/promises' -import { join } from 'node:path' +import { access, mkdir, rm, writeFile } from 'node:fs/promises' +import { join, resolve } from 'node:path' import { chromium } from '@playwright/test' @@ -33,7 +33,7 @@ const CURSOR_SVG = '' -const CURSOR_INIT = ` +const cursorInit = (startX, startY) => ` ;(() => { const install = () => { if (!document.body || document.getElementById('${CURSOR_ID}')) return @@ -56,6 +56,7 @@ const CURSOR_INIT = ` const cursor = document.createElement('div') cursor.id = '${CURSOR_ID}' cursor.innerHTML = '${CURSOR_SVG}' + cursor.style.transform = 'translate(${startX}px,${startY}px)' document.body.appendChild(cursor) const ticker = document.createElement('div') ticker.id = '${TICKER_ID}' @@ -87,7 +88,9 @@ export const openReelContext = (browser) => }) export const installCursor = async (page) => { - await page.addInitScript({ content: CURSOR_INIT }) + await page.addInitScript({ + content: cursorInit(REEL_VIEWPORT.width / 2, REEL_VIEWPORT.height / 2), + }) } const cursorPositions = new WeakMap() @@ -157,6 +160,23 @@ const resolveLocator = async (page, selector) => { return locator } +/** Waits until the element's box stops moving (smooth scroll/snap settle). */ +const waitForSettle = async (locator, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs + let previous = await locator.boundingBox().catch(() => null) + for (;;) { + await sleep(120) + const current = await locator.boundingBox().catch(() => null) + if (!current || !previous) return + const stable = + Math.abs(current.x - previous.x) < 1 && + Math.abs(current.y - previous.y) < 1 && + Math.abs(current.width - previous.width) < 1 + if (stable || Date.now() > deadline) return + previous = current + } +} + const waitForEnabled = async (locator, timeoutMs = 12000) => { const deadline = Date.now() + timeoutMs for (;;) { @@ -198,7 +218,7 @@ const clickTarget = async ({ page, selector, log, scene, zoom }) => { await hideTargetRing(page) } -const runStep = async ({ page, scene, step, log, fixture }) => { +const runStep = async ({ page, scene, step, log, fixture, root }) => { if (step.action === 'click') { await clickTarget({ page, selector: step.selector, log, scene: scene.id, zoom: step.zoom }) await sleep(step.pauseMs ?? 650) @@ -225,6 +245,49 @@ const runStep = async ({ page, scene, step, log, fixture }) => { await sleep(step.pauseMs ?? 450) return } + if (step.action === 'scrollIntoView') { + const locator = await resolveLocator(page, step.selector) + await locator.evaluate((element) => + element.scrollIntoView({ behavior: 'smooth', block: 'center' }), + ) + await waitForSettle(locator, 2200) + await sleep(step.pauseMs ?? 700) + return + } + if (step.action === 'swipe') { + const locator = await resolveLocator(page, step.selector) + await locator.scrollIntoViewIfNeeded() + const box = await locator.boundingBox() + if (!box) throw new Error(`Cena "${scene.id}": swipe "${step.selector}" sem bounding box.`) + const distance = step.distance ?? 220 + const y = Math.round(box.y + box.height / 2) + const startX = Math.round(box.x + box.width * 0.82) + const endX = Math.round(Math.max(box.x + 8, startX - distance)) + const client = await page.context().newCDPSession(page) + try { + await client.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x: startX, y }], + }) + const steps = 10 + for (let index = 1; index <= steps; index += 1) { + const x = Math.round(startX + ((endX - startX) * index) / steps) + await client.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y }], + }) + await sleep(35) + } + // Hold before lifting so the gesture does not fling past the next snap. + await sleep(160) + await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + } finally { + await client.detach().catch(() => undefined) + } + await waitForSettle(locator, 1600) + await sleep(step.pauseMs ?? 700) + return + } if (step.action === 'download') { const locator = await resolveLocator(page, step.selector) await waitForEnabled(locator) @@ -258,6 +321,61 @@ const runStep = async ({ page, scene, step, log, fixture }) => { await sleep(step.pauseMs ?? 650) return } + if (step.action === 'upload') { + const value = step.value ?? fixture.photo + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Cena "${scene.id}": upload sem valor e sem fixture.photo.`) + } + const filePath = resolve(root, value) + try { + await access(filePath) + } catch { + throw new Error(`Cena "${scene.id}": arquivo de upload não encontrado em "${value}".`) + } + 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}": upload "${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 chooserPromise = page.waitForEvent('filechooser', { timeout: 15000 }) + await page.mouse.click(center.x, center.y) + await showTargetRing({ page, box: center.box, pulse: true }) + let chooser + try { + chooser = await chooserPromise + } catch { + throw new Error( + `Cena "${scene.id}": o clique em "${step.selector}" não abriu o seletor de arquivo.`, + ) + } + try { + await chooser.setFiles(filePath) + } catch (error) { + throw new Error( + `Cena "${scene.id}": falha ao enviar "${value}" — ${error instanceof Error ? error.message : error}`, + ) + } + await sleep(250) + await hideTargetRing(page) + await sleep(step.pauseMs ?? 900) + return + } throw new Error(`Cena "${scene.id}": ação "${step.action}" não implementada.`) } @@ -276,7 +394,7 @@ const setTicking = (page, on) => * 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 = [] }) => { +export const runCaptureScenes = async ({ page, shotList, log = [], root = process.cwd() }) => { const capture = shotList.scenes.filter((scene) => scene.kind === 'capture') for (const scene of capture) { for (const step of scene.setup) { @@ -288,7 +406,7 @@ export const runCaptureScenes = async ({ page, shotList, log = [] }) => { 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 runStep({ page, scene, step, log, fixture: shotList.fixture, root }) } await sleep(scene.trailOutMs) log.push({ scene: scene.id, kind: 'sceneEnd', at: Date.now() }) diff --git a/scripts/lib/reelShotList.mjs b/scripts/lib/reelShotList.mjs index 10129042..1ed075b6 100644 --- a/scripts/lib/reelShotList.mjs +++ b/scripts/lib/reelShotList.mjs @@ -6,14 +6,19 @@ * 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. + * (paced clicks/typing/file pick that the viewer sees), plus the burned caption + * and the step badge of the design artifact. * * C197 adds the spoken script: an optional `narration` per scene (capture * scenes fall back to the burned caption, graphic scenes are silent without * one) and the top-level `coverAlt` of the cover. Both enter the normalized * object, so the shot list hash identifies them too. * + * The graphic copy lives in the shot list (`graphics.hook/cta/profile` and the + * package `graphics.cover`): the templates carry structure, the reel carries + * its message. The capture overlay is a command rail, not a central caption + * card. + * * Pure module: no browser, no ffmpeg, no I/O beyond reading the JSON file. */ @@ -30,10 +35,15 @@ const NARRATION_MAX_LENGTH = 600 * slug: string, title: string, feature: string, route: string, * coverAlt: string, * fixture: Record, + * graphics: Record, * scenes: Array<{ * id: string, kind: string, * template?: string, durationMs?: number, - * badge?: { number: number, total: number, label: string }, + * badge?: { number?: number, total?: number, label: string }, * caption?: { parts: Array<{ text: string, accent?: boolean }> } | null, * narration?: string | null, * setup: Array<{ action: string, selector: string }>, @@ -44,9 +54,28 @@ const NARRATION_MAX_LENGTH = 600 */ const SCENE_KINDS = new Set(['capture', 'graphic']) -const GRAPHIC_TEMPLATES = new Set(['hook', 'cta']) +const GRAPHIC_TEMPLATES = new Set(['hook', 'cta', 'profile']) +/** Copy keys of each graphic template; `cover` is a package template, not a scene. */ +const GRAPHIC_COPY_KEYS = { + hook: ['eyebrow', 'headlineLines', 'accentIndex', 'sub'], + cta: ['eyebrow', 'headlineLines', 'accentIndex', 'actionLines', 'sub', 'url'], + profile: ['eyebrow', 'headlineLines', 'accentIndex', 'instruction', 'apps'], + cover: ['eyebrow', 'headlineLines', 'accentIndex', 'sub', 'tag'], +} +const COPY_ORDER = ['hook', 'cta', 'profile', 'cover'] +const MAX_HEADLINE_LINES = 3 const SETUP_ACTIONS = new Set(['scrollIntoView']) -const STEP_ACTIONS = new Set(['click', 'fill', 'waitFor', 'download']) +const STEP_ACTIONS = new Set([ + 'click', + 'fill', + 'waitFor', + 'download', + 'upload', + 'scrollIntoView', + 'swipe', +]) +/** Actions whose `value` is meaningful: the inline text (`fill`) or file (`upload`). */ +const VALUE_ACTIONS = new Set(['fill', 'upload']) const fail = (message) => { throw new Error(`Shot list: ${message}`) @@ -101,13 +130,62 @@ const normalizeNarration = (raw, label) => { const normalizeBadge = (raw, label) => { assertObject(raw, label) + const labelText = assertString(raw.label, `${label}.label`) + // The guiding scene carries a label pill; numbered steps carry `n/total`. + if (raw.number === undefined && raw.total === undefined) return { label: labelText } 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 normalizeCopyLines = (raw, label, max) => { + if (!Array.isArray(raw) || raw.length === 0 || raw.length > max) { + fail(`${label} deve ter de 1 a ${max} linhas.`) + } + return raw.map((line, index) => assertString(line, `${label}[${index}]`)) +} + +const normalizeAccentIndex = (raw, lines, label) => { + if (raw === undefined || raw === null) return null + if (!Number.isInteger(raw) || raw < 0 || raw >= lines.length) { + fail(`${label} deve indexar uma linha do título.`) + } + return raw +} + +const normalizeGraphicCopy = (raw, label, keys) => { + assertObject(raw, label) + for (const key of Object.keys(raw)) { + if (!keys.includes(key)) fail(`${label}.${key} não é uma chave válida (${keys}).`) + } + const copy = { eyebrow: assertString(raw.eyebrow, `${label}.eyebrow`) } + copy.headlineLines = normalizeCopyLines( + raw.headlineLines, + `${label}.headlineLines`, + MAX_HEADLINE_LINES, + ) + copy.accentIndex = normalizeAccentIndex( + raw.accentIndex, + copy.headlineLines, + `${label}.accentIndex`, + ) + if (keys.includes('sub')) copy.sub = assertString(raw.sub, `${label}.sub`) + if (keys.includes('tag')) copy.tag = assertString(raw.tag, `${label}.tag`) + if (keys.includes('instruction')) { + copy.instruction = assertString(raw.instruction, `${label}.instruction`) + } + if (keys.includes('apps')) { + if (!Array.isArray(raw.apps) || raw.apps.length !== 2) fail(`${label}.apps deve ter 2 apps.`) + copy.apps = raw.apps.map((app, index) => assertString(app, `${label}.apps[${index}]`)) + } + if (keys.includes('actionLines')) { + copy.actionLines = normalizeCopyLines(raw.actionLines, `${label}.actionLines`, 2) + } + if (keys.includes('url')) copy.url = assertString(raw.url, `${label}.url`) + return copy +} + const normalizeSetup = (raw, label) => { if (raw === undefined) return [] if (!Array.isArray(raw)) fail(`${label} deve ser uma lista.`) @@ -123,7 +201,7 @@ const normalizeSetup = (raw, label) => { 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']) + const allowedKeys = new Set(['action', 'selector', 'value', 'pauseMs', 'zoom', 'distance']) return raw.map((step, index) => { assertObject(step, `${label}[${index}]`) for (const key of Object.keys(step)) { @@ -137,13 +215,22 @@ const normalizeSteps = (raw, label) => { action, selector: assertSelector(step.selector, `${label}[${index}].selector`), } - if (action === 'fill' && step.value !== undefined) { + if (step.value !== undefined && !VALUE_ACTIONS.has(action)) { + fail(`${label}[${index}].value só é válido em ${[...VALUE_ACTIONS]} (ação "${action}").`) + } + if (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.distance !== undefined) { + if (action !== 'swipe') fail(`${label}[${index}].distance só é válido em swipe.`) + normalized.distance = assertPositiveInt(step.distance, `${label}[${index}].distance`, { + max: 1000, + }) + } if (step.pauseMs !== undefined) { normalized.pauseMs = assertPositiveInt(step.pauseMs, `${label}[${index}].pauseMs`, { max: 10000, @@ -234,10 +321,39 @@ export const normalizeShotList = (raw) => { if (usesFixtureName && typeof fixture.cardName !== 'string') { fail('fixture.cardName é obrigatório quando um passo fill não declara value.') } + const usesFixturePhoto = scenes.some( + (scene) => + (scene.kind === 'capture' && + scene.steps.some((step) => step.action === 'upload' && step.value === undefined)) || + (scene.kind === 'graphic' && scene.template === 'profile'), + ) + if (usesFixturePhoto && typeof fixture.photo !== 'string') { + fail('fixture.photo é obrigatório no upload de foto e na cena profile.') + } 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.') + const rawGraphics = raw.graphics ?? {} + assertObject(rawGraphics, 'graphics') + for (const key of Object.keys(rawGraphics)) { + if (!(key in GRAPHIC_COPY_KEYS)) fail(`graphics.${key} não é um template válido.`) + } + const usedTemplates = new Set( + scenes.filter((scene) => scene.kind === 'graphic').map((scene) => scene.template), + ) + usedTemplates.add('cover') + const graphics = {} + for (const template of COPY_ORDER) { + if (!usedTemplates.has(template)) continue + const rawCopy = rawGraphics[template] + if (rawCopy === undefined) fail(`graphics.${template} é obrigatório (copy do template).`) + graphics[template] = normalizeGraphicCopy( + rawCopy, + `graphics.${template}`, + GRAPHIC_COPY_KEYS[template], + ) + } const title = assertString(raw.title ?? slug, 'title') return { slug, @@ -249,6 +365,7 @@ export const normalizeShotList = (raw) => { ? title : assertString(raw.coverAlt, 'coverAlt'), fixture, + graphics, scenes, } } diff --git a/scripts/lib/reelTemplates.mjs b/scripts/lib/reelTemplates.mjs index 6e34a42b..f754f448 100644 --- a/scripts/lib/reelTemplates.mjs +++ b/scripts/lib/reelTemplates.mjs @@ -1,53 +1,129 @@ /** - * 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. + * C196/C197 — the visual templates of the reel, ported class-by-class from the + * design artifacts: the family design `docs/plans/reels-tutoriais-ui-design.html` + * and the revision `docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html` + * (official campaign kit: palette, marks, illustrations, Brexter display face, + * command rail, capture brand cap, profile handoff, cover). Every template is a + * pure string builder rendered at 360×640 with deviceScaleFactor 3, so the + * physical asset is 1080×1920. + * + * The reel's message is data: hook/cover/CTA/profile copy comes from the shot + * list (`graphics.*`); the templates carry structure only. * * 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', + green: '#009647', cream: '#fff8f2', + ink: '#111111', + muted: '#6c615e', + line: 'rgb(0 0 0 / 12%)', } -/** 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} +html,body{width:360px;height:640px;overflow:hidden;background:transparent} +body{font-family:ReelInter,Inter,ui-sans-serif,system-ui,sans-serif;color:${BRAND.ink};-webkit-font-smoothing:antialiased} +.frame{position:relative;width:360px;height:640px;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%)} +.kit-asset{display:block;height:auto;object-fit:contain} +.pattern-wash{position:absolute;inset:0 0 auto;width:360px;height:540px;object-fit:cover;object-position:top center;opacity:.16;pointer-events:none} +.official-mark{position:absolute;display:block;height:auto} + +/* Capture chrome — a quiet top cap and one command rail, never a central card. */ +.capture-brand-cap{position:absolute;z-index:20;inset:0 0 auto;height:38px;background:linear-gradient(90deg,${BRAND.red} 0 78%,${BRAND.blue} 100%);border-bottom:2px solid ${BRAND.yellow};box-shadow:0 4px 12px rgb(0 0 0 / 14%)} +.capture-brand-cap img{position:absolute;right:16px;top:7px;width:86px;height:auto} +.command-rail{position:absolute;z-index:24;left:16px;top:88px;width:328px;min-height:50px;display:grid;grid-template-columns:auto minmax(0,1fr);align-items:center;gap:10px;padding:8px 12px 8px 8px;border:1px solid rgb(0 0 0 / 10%);border-left:4px solid ${BRAND.red};border-radius:12px;background:rgb(255 255 255 / 96%);box-shadow:0 8px 24px rgb(0 0 0 / 18%);color:${BRAND.ink}} +.step-badge{display:grid;width:34px;height:34px;place-items:center;border-radius:50%;background:${BRAND.yellow};color:#000;font-size:11px;font-weight:900;line-height:1;box-shadow:inset 0 0 0 1px rgb(0 0 0 / 8%)} +.step-badge--label{width:auto;min-width:70px;border-radius:999px;padding:0 10px} +.command-copy{margin:0;max-width:258px;font-size:12.5px;font-weight:800;line-height:1.2;letter-spacing:-.01em} +.command-copy strong{color:#a21c1c;font-weight:900} + +/* Graphic scene: hook. */ +.hook-scene{background:linear-gradient(156deg,${BRAND.red} 0 68%,${BRAND.blue} 145%)} +.hook-illustration{position:absolute;right:-30px;top:58px;width:154px;opacity:.98} +.hook-copy{position:absolute;left:22px;right:20px;top:94px;color:#fff} +.hook-eyebrow{display:inline-flex;border-radius:999px;padding:6px 12px;background:${BRAND.yellow};color:#000;font-size:10.5px;font-weight:900;letter-spacing:.06em} +.hook-title{width:310px;margin:24px 0 0;font-size:40px;line-height:.88;letter-spacing:-.035em} +.hook-sub{width:205px;margin:20px 0 0;font-size:14px;line-height:1.3;font-weight:650} + +/* Graphic scene: profile handoff. Class names are the port contract. */ +.profile-scene{background:linear-gradient(180deg,#fff8f2 0%,#fff 72%,#f3f6fb 100%)} +.profile-scene::before{content:'';position:absolute;inset:0 0 auto;height:64px;background:${BRAND.red};border-bottom:4px solid ${BRAND.yellow}} +.profile-brand{position:absolute;z-index:3;right:18px;top:16px;width:95px} +.profile-heading{position:absolute;z-index:3;left:22px;right:22px;top:88px;text-align:center} +.profile-eyebrow{color:${BRAND.red};font-size:10px;font-weight:900;letter-spacing:.12em;text-transform:uppercase} +.profile-title{margin-top:5px;color:${BRAND.blue};font-size:27px;line-height:.92;letter-spacing:-.02em} +.profile-phones{position:absolute;left:18px;right:18px;top:176px;display:grid;grid-template-columns:1fr 1fr;gap:16px} +.profile-phone{position:relative;height:226px;overflow:hidden;border:1px solid rgb(0 0 0 / 12%);border-radius:22px;background:#fff;box-shadow:0 12px 30px rgb(24 78 146 / 13%)} +.profile-phone__top{height:38px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid ${BRAND.line};font-size:11px;font-weight:800} +.profile-phone--ig .profile-phone__top{color:#9d174d} +.profile-phone--wa .profile-phone__top{color:#087a3f} +.profile-avatar-shell{position:absolute;left:50%;top:58px;width:82px;height:82px;transform:translateX(-50%);border-radius:50%;padding:4px;background:linear-gradient(135deg,${BRAND.red},${BRAND.yellow},${BRAND.green})} +.profile-phone--wa .profile-avatar-shell{background:#d9f6e5} +.profile-avatar{position:relative;width:100%;height:100%;overflow:hidden;border:3px solid #fff;border-radius:50%;background:#e7eaee} +.profile-avatar img{width:100%;height:100%;object-fit:cover;filter:saturate(0.72);opacity:.58} +.profile-handle{position:absolute;left:12px;right:12px;top:151px;text-align:center;font-size:10px;font-weight:800} +.profile-lines{position:absolute;left:34px;right:34px;top:172px;display:grid;gap:6px} +.profile-lines span{height:5px;border-radius:999px;background:#e7e7e7} +.profile-lines span:last-child{margin-inline:11px} +.profile-card{position:absolute;z-index:10;left:145px;top:330px;width:70px;height:70px;overflow:hidden;border:3px solid #fff;border-radius:12px;background:#dfe9f0;box-shadow:0 10px 24px rgb(0 0 0 / 24%);opacity:0} +.profile-card__photo{width:100%;height:100%;object-fit:cover} +.profile-card__frame{position:absolute;inset:0;width:100%;height:100%} +.profile-check{position:absolute;z-index:12;top:344px;display:grid;width:28px;height:28px;place-items:center;border:3px solid #fff;border-radius:50%;background:${BRAND.green};color:#fff;font-size:15px;font-weight:900;opacity:0;transform:scale(.65)} +.profile-check--ig{left:118px} +.profile-check--wa{right:34px} +.profile-footnote{position:absolute;left:22px;right:22px;top:370px;text-align:center;color:${BRAND.blue};font-size:11px;font-weight:800} + +@keyframes profile-heading-in{0%{opacity:0;transform:translateY(12px)}100%{opacity:1;transform:translateY(0)}} +@keyframes profile-phones-in{0%{opacity:0;transform:translateY(20px)}100%{opacity:1;transform:translateY(0)}} +@keyframes profile-to-instagram{0%,12%{opacity:0;transform:translate(0,12px) scale(.88);border-radius:12px}24%{opacity:1;transform:translate(0,0) scale(1);border-radius:12px}62%,100%{opacity:1;transform:translate(-87px,-100px) scale(1.06);border-radius:50%}} +@keyframes profile-to-whatsapp{0%,46%{opacity:0;transform:translate(0,12px) scale(.88);border-radius:12px}58%{opacity:1;transform:translate(0,0) scale(1);border-radius:12px}88%,100%{opacity:1;transform:translate(87px,-100px) scale(1.06);border-radius:50%}} +@keyframes profile-check-in{0%,78%{opacity:0;transform:scale(.65)}88%{opacity:1;transform:scale(1.15)}100%{opacity:1;transform:scale(1)}} +@keyframes profile-done-pulse{0%,85%,100%{box-shadow:0 12px 30px rgb(24 78 146 / 13%)}92%{box-shadow:0 0 0 4px rgb(255 235 0 / 65%),0 12px 30px rgb(24 78 146 / 13%)}} +.play .profile-heading{animation:profile-heading-in .42s cubic-bezier(.2,.7,.2,1) both} +.play .profile-phones{animation:profile-phones-in .5s cubic-bezier(.2,.7,.2,1) .3s both} +.play .profile-card--ig{animation:profile-to-instagram 2.15s cubic-bezier(.2,.7,.2,1) .55s both} +.play .profile-card--wa{animation:profile-to-whatsapp 2.9s cubic-bezier(.2,.7,.2,1) .55s both} +.play .profile-check{animation:profile-check-in 3.35s cubic-bezier(.2,.8,.2,1) .35s both} +.play .profile-phone{animation:profile-done-pulse 4s ease-out both} + +/* Graphic scene: CTA. */ +.cta-scene{background:${BRAND.cream}} +.cta-red{position:absolute;inset:0 0 auto;height:306px;overflow:hidden;background:${BRAND.red}} +.cta-punho{position:absolute;left:-38px;top:42px;width:180px;opacity:.98} +.cta-copy{position:absolute;left:132px;right:20px;top:90px;color:#fff;text-align:left} +.cta-copy p{margin:0;color:${BRAND.yellow};font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase} +.cta-copy h3{margin:10px 0 0;font-size:36px;line-height:.88;letter-spacing:-.025em} +.cta-card{position:absolute;left:22px;right:22px;top:328px;padding:20px 18px;border:1px solid ${BRAND.line};border-radius:18px;background:#fff;text-align:center;box-shadow:0 14px 36px rgb(71 19 14 / 14%)} +.cta-card__eyebrow{color:${BRAND.red};font-size:10px;font-weight:900;letter-spacing:.1em;text-transform:uppercase} +.cta-card__action{margin-top:5px;color:${BRAND.blue};font-size:29px;line-height:.95} +.cta-card__sub{margin-top:8px;color:${BRAND.muted};font-size:11px;line-height:1.35} +.cta-url{position:absolute;left:22px;bottom:30px;color:${BRAND.blue};font-size:10px;font-weight:800} + +/* Cover. */ +.cover-scene{background:linear-gradient(156deg,${BRAND.red} 0 72%,${BRAND.blue} 155%)} +.cover-copy{position:absolute;left:24px;right:20px;top:132px;color:#fff} +.cover-copy h3{margin:24px 0 0;font-size:44px;line-height:.86;letter-spacing:-.035em} +.cover-copy p{width:232px;margin:20px 0 0;font-size:13px;line-height:1.3;font-weight:650} +.cover-tag{position:absolute;left:24px;top:500px;border-radius:7px;background:rgb(0 0 0 / 35%);padding:6px 11px;color:#fff;font-size:10px;font-weight:800} + +/* Family motion vocabulary (hook/CTA). */ @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} +@keyframes reel-derive{0%{opacity:0;transform:translateX(8px)}100%{opacity:1;transform:translateX(0)}} +@keyframes reel-rise{0%{opacity:0;transform:translateY(18px)}100%{opacity:1;transform:translateY(0)}} +@keyframes reel-punch{0%{opacity:0;transform:translateX(-16px)}100%{opacity:1;transform:translateX(0)}} +.play .fade-1{animation:reel-fade .45s ease-out .15s both} +.play .beat-1{animation:reel-beat .55s cubic-bezier(.2,.7,.2,1) both} +.play .beat-2{animation:reel-beat .55s cubic-bezier(.2,.7,.2,1) .28s both} +.play .rise-1{animation:reel-rise .5s cubic-bezier(.2,.7,.2,1) .35s both} +.play .derive-1{animation:reel-derive .65s ease-out .1s both} +.play .punch-1{animation:reel-punch .6s ease-out both} ` const htmlDocument = ({ title, fontCss, body }) => ` @@ -63,91 +139,128 @@ ${fontCss ? `` : ''} ` -/** Scene 01 — hook (recorded): official red field, star, Brexter in two beats. */ -export const hookHtml = ({ fontCss, assets }) => +/** Explicit headline breaks; only the declared accent line changes color. */ +const headline = (copy, accentColor = BRAND.yellow) => + copy.headlineLines + .map((line, index) => + index === copy.accentIndex ? `${line}` : line, + ) + .join('
') + +/** Capture chrome — the brand cap (always on for the whole scene). */ +export const captureChromeHtml = ({ assets }) => + htmlDocument({ + title: 'Reel — chrome', + body: ` +
+
Jorge Solla — Deputado Federal
+
`, + }) + +/** Transparent overlay of one command rail (step badge + action in brand red). */ +export const commandRailHtml = ({ fontCss, badge, caption }) => { + const badgeHtml = + typeof badge.number === 'number' + ? `${badge.number}/${badge.total}` + : `${badge.label}` + const parts = caption.parts + .map((part) => (part.accent ? `${part.text}` : part.text)) + .join('') + return htmlDocument({ + title: 'Reel — comando', + fontCss, + body: `
${badgeHtml}

${parts}

`, + }) +} + +/** Scene 01 — hook: red field, official pattern + heart, reel's own headline. */ +export const hookHtml = ({ fontCss, assets, copy }) => htmlDocument({ title: 'Reel — hook', fontCss, body: ` -
-
-
- FAÇA PARTE -

- Seu apoio - vira card - em segundos. -

+
+ + +
+ ${copy.eyebrow} +

${headline(copy)}

+

${copy.sub}

-

Veja como criar o seu pelo celular.

- Jorge Solla 1313 — Mais Saúde, Mais Futuro + Jorge Solla 1313 — Mais Saúde, Mais Futuro
`, }) -/** Scene last — CTA (recorded): official red header, white card with "link na bio". */ -export const ctaHtml = ({ fontCss, assets }) => +/** Scene 07 — profile handoff: the downloaded art becomes the avatar, no network. */ +export const profileHtml = ({ fontCss, assets, copy }) => { + const phone = (variant, appLabel) => ` +
+
${appLabel}
+
+

${variant === 'ig' ? '@seuperfil' : 'Seu perfil'}

+
+
` + return htmlDocument({ + title: 'Reel — profile', + fontCss, + body: ` +
+ Jorge Solla — Deputado Federal +
+

${copy.eyebrow}

+

${headline(copy, BRAND.blue)}

+
+
${phone('ig', copy.apps[0])}${phone('wa', copy.apps[1])}
+ + + ✓ +

${copy.instruction}

+
`, + }) +} + +/** Scene last — CTA: official fist, one verbal CTA, positive mark on the light field. */ +export const ctaHtml = ({ fontCss, assets, copy }) => htmlDocument({ title: 'Reel — CTA', fontCss, body: ` -
-
-
-
-

Seu card, sua voz

-

Agora é
com você.

+
+
+
+

${copy.eyebrow}

+

${headline(copy)}

-
-
- -
-

Acesse o site pelo

-

link na bio

-

Escolha seu modelo, crie e compartilhe.

+
+

FAÇA A SUA

+

${copy.actionLines.join('
')}

+

${copy.sub}

-

jorgesolla1313.com.br

- Jorge Solla 1313 — Mais Saúde, Mais Futuro +

${copy.url}

+ Jorge Solla 1313 — Mais Saúde, Mais Futuro
`, }) -/** 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: ` -
-
${badge.number}${badge.label}
-
Jorge Solla — Deputado Federal
-
`, - }) - -/** Transparent overlay of one burned caption (accent parts in brand yellow). */ -export const captionOverlayHtml = ({ fontCss, caption }) => { - const parts = caption.parts - .map((part) => (part.accent ? `${part.text}` : part.text)) - .join('') - return htmlDocument({ - title: 'Reel — caption', - fontCss, - body: `

${parts}

`, - }) -} - -/** Scene 05 — cover: own composition, title inside the central 1080×1350 crop. */ -export const coverHtml = ({ fontCss, assets }) => +/** Cover: own composition, headline inside the central 1080×1350 crop. */ +export const coverHtml = ({ fontCss, assets, copy }) => htmlDocument({ title: 'Reel — capa', fontCss, body: ` -
-
-
- TUTORIAL DO SITE -

Crie seu
card de
apoio.

-

Seu nome ou sua foto, direto pelo celular.

+
+ +
+ ${copy.eyebrow} +

${headline(copy)}

+

${copy.sub}

-

#cards

- Jorge Solla 1313 — Mais Saúde, Mais Futuro + ${copy.tag} + Jorge Solla 1313 — Mais Saúde, Mais Futuro
`, }) diff --git a/scripts/reels/fixtures/profile-photo.png b/scripts/reels/fixtures/profile-photo.png new file mode 100644 index 00000000..3c316f0f Binary files /dev/null and b/scripts/reels/fixtures/profile-photo.png differ diff --git a/scripts/reels/shot-lists/cards.json b/scripts/reels/shot-lists/cards.json index add012fa..b12906fd 100644 --- a/scripts/reels/shot-lists/cards.json +++ b/scripts/reels/shot-lists/cards.json @@ -7,6 +7,29 @@ "fixture": { "cardName": "Joana" }, + "graphics": { + "hook": { + "eyebrow": "FAÇA PARTE", + "headlineLines": ["Seu apoio", "vira card", "em segundos."], + "accentIndex": 2, + "sub": "Veja como criar o seu pelo celular." + }, + "cta": { + "eyebrow": "Seu card, sua voz", + "headlineLines": ["Agora é", "com você."], + "accentIndex": null, + "actionLines": ["Acesse o site pelo", "link na bio"], + "sub": "Escolha seu modelo, crie e compartilhe.", + "url": "jorgesolla1313.com.br" + }, + "cover": { + "eyebrow": "TUTORIAL DO SITE", + "headlineLines": ["Crie seu", "card de", "apoio."], + "accentIndex": 2, + "sub": "Seu nome ou sua foto, direto pelo celular.", + "tag": "#cards" + } + }, "scenes": [ { "id": "hook", diff --git a/scripts/reels/shot-lists/foto-de-perfil.json b/scripts/reels/shot-lists/foto-de-perfil.json new file mode 100644 index 00000000..dc6b8e3b --- /dev/null +++ b/scripts/reels/shot-lists/foto-de-perfil.json @@ -0,0 +1,179 @@ +{ + "slug": "foto-de-perfil", + "title": "Use a moldura na sua foto de perfil", + "feature": "cards", + "route": "/", + "coverAlt": "Capa do tutorial com o título “Use a moldura na sua foto de perfil” sobre o fundo vermelho da campanha", + "fixture": { + "photo": "scripts/reels/fixtures/profile-photo.png" + }, + "graphics": { + "hook": { + "eyebrow": "TUTORIAL · FOTO DE PERFIL", + "headlineLines": ["Sua foto", "com a moldura", "do Solla."], + "accentIndex": 2, + "sub": "Veja como fazer pelo celular e usar no Instagram ou WhatsApp." + }, + "cta": { + "eyebrow": "AGORA É COM VOCÊ", + "headlineLines": ["Troque sua foto.", "Mostre seu apoio."], + "accentIndex": 1, + "actionLines": ["Acesse pelo", "link na bio"], + "sub": "Escolha “Moldura quadrada”, coloque sua foto e baixe.", + "url": "jorgesolla1313.com.br" + }, + "profile": { + "eyebrow": "PASSO FINAL", + "headlineLines": ["Use a arte", "no seu perfil."], + "accentIndex": null, + "instruction": "Escolha a imagem baixada como nova foto de perfil.", + "apps": ["Instagram", "WhatsApp"] + }, + "cover": { + "eyebrow": "TUTORIAL DO SITE", + "headlineLines": ["Use a moldura", "na sua foto", "de perfil."], + "accentIndex": 2, + "sub": "Instagram e WhatsApp, direto pelo celular.", + "tag": "#cards" + } + }, + "scenes": [ + { + "id": "hook", + "kind": "graphic", + "template": "hook", + "durationMs": 2600, + "narration": "Bora mostrar que você está com Solla?" + }, + { + "id": "step-1", + "kind": "capture", + "badge": { "label": "COMECE" }, + "caption": { + "parts": [ + { "text": "Role até " }, + { "text": "“Mostre que você está com Solla”", "accent": true }, + { "text": "." } + ] + }, + "captionDelayMs": 350, + "captionDurationMs": 2800, + "leadInMs": 450, + "trailOutMs": 600, + "narration": "Acesse o site e desça até os cards.", + "setup": [], + "steps": [{ "action": "scrollIntoView", "selector": "#cards-title", "pauseMs": 800 }] + }, + { + "id": "step-2", + "kind": "capture", + "badge": { "number": 1, "total": 4, "label": "Escolha a moldura" }, + "caption": { + "parts": [ + { "text": "Deslize e toque em " }, + { "text": "“Moldura quadrada”", "accent": true }, + { "text": "." } + ] + }, + "captionDelayMs": 350, + "captionDurationMs": 2400, + "leadInMs": 400, + "trailOutMs": 900, + "narration": "Deslize e escolha a Moldura quadrada!", + "setup": [], + "steps": [ + { + "action": "swipe", + "selector": "[aria-label=\"Modelos de card (mobile)\"]", + "distance": 300, + "pauseMs": 500 + }, + { "action": "click", "selector": "[data-card-model-tile=\"perfil-quadrado\"]" }, + { "action": "waitFor", "selector": "role=heading[name=\"Enquadre sua foto\"]" } + ] + }, + { + "id": "step-3", + "kind": "capture", + "badge": { "number": 2, "total": 4, "label": "Coloque sua foto" }, + "caption": { + "parts": [ + { "text": "Escolha uma " }, + { "text": "foto sua", "accent": true }, + { "text": "." } + ] + }, + "captionDelayMs": 350, + "captionDurationMs": 3400, + "leadInMs": 500, + "trailOutMs": 800, + "narration": "Agora coloque uma foto sua. Vai ficar com a sua cara!", + "setup": [{ "action": "scrollIntoView", "selector": "role=button[name=\"Escolher foto\"]" }], + "steps": [ + { "action": "upload", "selector": "role=button[name=\"Escolher foto\"]", "pauseMs": 1000 }, + { + "action": "scrollIntoView", + "selector": "role=group[name=\"Ajuste da foto\"]", + "pauseMs": 900 + } + ] + }, + { + "id": "step-4", + "kind": "capture", + "badge": { "number": 3, "total": 4, "label": "Crie seu card" }, + "caption": { + "parts": [ + { "text": "Confira e toque em " }, + { "text": "“Criar meu card”", "accent": true }, + { "text": "." } + ] + }, + "captionDelayMs": 350, + "captionDurationMs": 2600, + "leadInMs": 600, + "trailOutMs": 800, + "narration": "Ficou show, né? Toque em Criar meu card.", + "setup": [{ "action": "scrollIntoView", "selector": "role=button[name=\"Criar meu card\"]" }], + "steps": [ + { "action": "click", "selector": "role=button[name=\"Criar meu card\"]" }, + { "action": "waitFor", "selector": "text=Seu card está pronto para compartilhar." } + ] + }, + { + "id": "step-5", + "kind": "capture", + "badge": { "number": 4, "total": 4, "label": "Baixe o card" }, + "caption": { + "parts": [ + { "text": "Baixe o card " }, + { "text": "no celular", "accent": true }, + { "text": "." } + ] + }, + "captionDelayMs": 300, + "captionDurationMs": 2200, + "leadInMs": 500, + "trailOutMs": 700, + "narration": "Baixe agora e use como foto de perfil!", + "setup": [ + { "action": "scrollIntoView", "selector": "role=button[name=\"Baixar meu card\"]" } + ], + "steps": [{ "action": "download", "selector": "role=button[name=\"Baixar meu card\"]" }] + }, + { + "id": "profile", + "kind": "graphic", + "template": "profile", + "durationMs": 4000, + "narration": "Pronto! É só trocar no Instagram e no WhatsApp." + }, + { + "id": "cta", + "kind": "graphic", + "template": "cta", + "durationMs": 3000, + "narration": "Corre no site pelo link na bio!" + } + ] +} diff --git a/tests/unit/reelShotList.unit.spec.ts b/tests/unit/reelShotList.unit.spec.ts index 2f16c653..8b3ed53f 100644 --- a/tests/unit/reelShotList.unit.spec.ts +++ b/tests/unit/reelShotList.unit.spec.ts @@ -10,12 +10,37 @@ import { // 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 rawGraphics = (overrides: Record = {}) => ({ + hook: { + eyebrow: 'FAÇA PARTE', + headlineLines: ['Seu apoio', 'vira card'], + accentIndex: 1, + sub: 'Veja como criar o seu pelo celular.', + }, + cta: { + eyebrow: 'Seu card, sua voz', + headlineLines: ['Agora é', 'com você.'], + actionLines: ['Acesse o site pelo', 'link na bio'], + sub: 'Escolha seu modelo, crie e compartilhe.', + url: 'jorgesolla1313.com.br', + }, + cover: { + eyebrow: 'TUTORIAL DO SITE', + headlineLines: ['Crie seu', 'card de', 'apoio.'], + accentIndex: 2, + sub: 'Seu nome ou sua foto, direto pelo celular.', + tag: '#cards', + }, + ...overrides, +}) + const rawShotList = (overrides: Record = {}) => ({ slug: 'cards', title: 'Crie seu card de apoio', feature: 'cards', route: '/', fixture: { cardName: 'Joana' }, + graphics: rawGraphics(), scenes: [ { id: 'hook', kind: 'graphic', template: 'hook', durationMs: 2400 }, { @@ -119,6 +144,161 @@ describe('reelShotList', () => { expect(() => normalizeShotList({ ...withoutFixture, scenes })).toThrow(/fixture.cardName/) }) + const withUpload = (step: Record, overrides: Record = {}) => { + const base = rawShotList(overrides) + const scenes = (base.scenes as Array>).slice() + scenes[1] = { ...scenes[1], steps: [step] } + return { ...base, scenes } + } + + it('normalizes an upload step with the fixture.photo fallback', () => { + const shotList = normalizeShotList( + withUpload( + { action: 'upload', selector: 'role=button[name="Escolher foto"]' }, + { fixture: { photo: 'scripts/reels/fixtures/profile-photo.png' } }, + ), + ) + const step = captureScenes(shotList)[0].steps[0] + expect(step.action).toBe('upload') + expect(step.value).toBeUndefined() + }) + + it('accepts an inline upload value and hashes the fixture photo', () => { + const inline = normalizeShotList( + withUpload({ + action: 'upload', + selector: '#foto', + value: 'scripts/reels/fixtures/a.png', + }), + ) + expect(captureScenes(inline)[0].steps[0].value).toBe('scripts/reels/fixtures/a.png') + + const photoA = normalizeShotList( + withUpload( + { action: 'upload', selector: '#foto' }, + { fixture: { photo: 'scripts/reels/fixtures/profile-photo.png' } }, + ), + ) + const photoB = normalizeShotList( + withUpload( + { action: 'upload', selector: '#foto' }, + { fixture: { photo: 'scripts/reels/fixtures/outra.png' } }, + ), + ) + expect(shotListHash(photoA)).not.toBe(shotListHash(photoB)) + }) + + it('requires fixture.photo when an upload step has no value', () => { + expect(() => + normalizeShotList(withUpload({ action: 'upload', selector: '#foto' }, { fixture: {} })), + ).toThrow(/fixture.photo/) + }) + + it('requires the copy of every used graphic template', () => { + const graphics = rawGraphics() + delete (graphics as Record).cover + expect(() => normalizeShotList(rawShotList({ graphics }))).toThrow(/graphics.cover/) + const unknown = rawGraphics({ banner: { eyebrow: 'x', headlineLines: ['y'] } }) + expect(() => normalizeShotList(rawShotList({ graphics: unknown }))).toThrow( + /graphics.banner não é um template válido/, + ) + }) + + it('normalizes the graphic copy and validates the accent line', () => { + const shotList = normalizeShotList(rawShotList()) + expect(shotList.graphics.hook).toMatchObject({ + eyebrow: 'FAÇA PARTE', + headlineLines: ['Seu apoio', 'vira card'], + accentIndex: 1, + }) + expect(shotList.graphics.cta.actionLines).toEqual(['Acesse o site pelo', 'link na bio']) + + const graphics = rawGraphics({ + hook: { eyebrow: 'x', headlineLines: ['uma linha'], accentIndex: 2 }, + }) + expect(() => normalizeShotList(rawShotList({ graphics }))).toThrow(/accentIndex/) + + const first = normalizeShotList(rawShotList()) + const changed = normalizeShotList( + rawShotList({ + graphics: rawGraphics({ + hook: { + eyebrow: 'FAÇA PARTE', + headlineLines: ['Seu apoio', 'vira vídeo'], + accentIndex: 1, + sub: 'Veja como criar o seu pelo celular.', + }, + }), + }), + ) + expect(shotListHash(changed)).not.toBe(shotListHash(first)) + }) + + it('accepts a label-only badge and a swipe distance', () => { + const base = rawShotList() + const scenes = (base.scenes as Array>).slice() + scenes[1] = { + ...scenes[1], + badge: { label: 'COMECE' }, + steps: [{ action: 'swipe', selector: '#track', distance: 300 }], + } + const shotList = normalizeShotList({ ...base, scenes }) + expect(captureScenes(shotList)[0].badge).toEqual({ label: 'COMECE' }) + expect(captureScenes(shotList)[0].steps[0].distance).toBe(300) + + const invalid = (base.scenes as Array>).slice() + invalid[1] = { ...invalid[1], steps: [{ action: 'click', selector: '#a', distance: 300 }] } + expect(() => normalizeShotList({ ...base, scenes: invalid })).toThrow(/distance/) + }) + + it('requires fixture.photo for the profile scene and normalizes its copy', () => { + const profileCopy = { + eyebrow: 'PASSO FINAL', + headlineLines: ['Use a arte', 'no seu perfil.'], + instruction: 'Escolha a imagem baixada como nova foto de perfil.', + apps: ['Instagram', 'WhatsApp'], + } + const graphics = rawGraphics({ profile: profileCopy }) + const profileScene = { + id: 'profile', + kind: 'graphic', + template: 'profile', + durationMs: 4000, + } + const withoutPhoto = rawShotList({ graphics, fixture: {} }) + const withoutScenes = (withoutPhoto.scenes as Array>).slice() + withoutScenes.splice(1, 0, profileScene) + expect(() => normalizeShotList({ ...withoutPhoto, scenes: withoutScenes })).toThrow( + /fixture.photo/, + ) + + const withPhoto = rawShotList({ + graphics, + fixture: { photo: 'scripts/reels/fixtures/profile-photo.png' }, + }) + const withScenes = (withPhoto.scenes as Array>).slice() + withScenes.splice(1, 0, profileScene) + const shotList = normalizeShotList({ ...withPhoto, scenes: withScenes }) + expect(graphicScenes(shotList).map((scene: { template: string }) => scene.template)).toEqual([ + 'hook', + 'profile', + 'cta', + ]) + expect(shotList.graphics.profile).toMatchObject({ + accentIndex: null, + apps: ['Instagram', 'WhatsApp'], + }) + }) + + it('rejects value on an action that takes no value', () => { + const scenes = (rawShotList().scenes as Array>).slice() + scenes[1] = { + ...scenes[1], + steps: [{ action: 'click', selector: '#a', value: 'não' }], + } + expect(() => normalizeShotList({ ...rawShotList(), scenes })).toThrow(/value só é válido/) + }) + it('normalizes the narration and preserves the authored caption spacing', () => { const shotList = normalizeShotList( rawShotList({