mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(reels): navegação visível, trilho de comando e cena profile na skill de reels
- captura: swipe de toque real, scrollIntoView suave visível, upload de foto (fixture.photo) e cursor centralizado na origem - shot list: bloco graphics (hook/cta/profile/cover) como fonte da mensagem, badge de rótulo e distance do swipe validados - superfície: port do design revisado (trilho de comando + cap, hook com pattern/coração, CTA com punho, capa) e cena nova profile (avatar IG/WA) - reel foto-de-perfil draft (8 cenas, 30,2s) + fixture sintética sem PII - testes unit do schema e changelog da evolução
This commit is contained in:
parent
8bb3523408
commit
8e431bd876
12 changed files with 2481 additions and 154 deletions
|
|
@ -57,7 +57,7 @@ pnpm reels:build <slug> [--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/<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
|
||||
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)
|
||||
|
|
|
|||
1
docs/changelog/2026-09-19-reels-tutoriais-evolucao.md
Normal file
1
docs/changelog/2026-09-19-reels-tutoriais-evolucao.md
Normal file
|
|
@ -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.
|
||||
1544
docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html
Normal file
1544
docs/plans/reels-tutoriais-foto-de-perfil-ui-design.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<string, string> }} options repo root
|
||||
* (defaults to this module's repo) and one relative path per asset key.
|
||||
* @returns {Promise<Record<string, string>>} 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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
|||
'<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 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() })
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
* graphics: Record<string, {
|
||||
* eyebrow: string, headlineLines: string[], accentIndex: number | null,
|
||||
* sub?: string, tag?: string, instruction?: string, apps?: string[],
|
||||
* actionLines?: string[], url?: string,
|
||||
* }>,
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }) => `<!doctype html>
|
||||
|
|
@ -63,91 +139,128 @@ ${fontCss ? `<style>${fontCss}</style>` : ''}
|
|||
</html>
|
||||
`
|
||||
|
||||
/** 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 ? `<span style="color:${accentColor}">${line}</span>` : line,
|
||||
)
|
||||
.join('<br />')
|
||||
|
||||
/** Capture chrome — the brand cap (always on for the whole scene). */
|
||||
export const captureChromeHtml = ({ assets }) =>
|
||||
htmlDocument({
|
||||
title: 'Reel — chrome',
|
||||
body: `
|
||||
<div class="frame">
|
||||
<div class="capture-brand-cap"><img src="${assets.nameNegative}" alt="Jorge Solla — Deputado Federal" /></div>
|
||||
</div>`,
|
||||
})
|
||||
|
||||
/** Transparent overlay of one command rail (step badge + action in brand red). */
|
||||
export const commandRailHtml = ({ fontCss, badge, caption }) => {
|
||||
const badgeHtml =
|
||||
typeof badge.number === 'number'
|
||||
? `<span class="step-badge">${badge.number}/${badge.total}</span>`
|
||||
: `<span class="step-badge step-badge--label">${badge.label}</span>`
|
||||
const parts = caption.parts
|
||||
.map((part) => (part.accent ? `<strong>${part.text}</strong>` : part.text))
|
||||
.join('')
|
||||
return htmlDocument({
|
||||
title: 'Reel — comando',
|
||||
fontCss,
|
||||
body: `<div class="frame"><div class="command-rail">${badgeHtml}<p class="command-copy">${parts}</p></div></div>`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Scene 01 — hook: red field, official pattern + heart, reel's own headline. */
|
||||
export const hookHtml = ({ fontCss, assets, copy }) =>
|
||||
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 class="frame hook-scene">
|
||||
<img class="pattern-wash" src="${assets.patternShapes}" alt="" />
|
||||
<img class="hook-illustration derive-1" src="${assets.heart}" alt="" />
|
||||
<div class="hook-copy">
|
||||
<span class="hook-eyebrow fade-1">${copy.eyebrow}</span>
|
||||
<h1 class="brexter hook-title">${headline(copy)}</h1>
|
||||
<p class="hook-sub beat-2">${copy.sub}</p>
|
||||
</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" />
|
||||
<img class="official-mark" style="left:204px;top:498px;width:134px" 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 }) =>
|
||||
/** Scene 07 — profile handoff: the downloaded art becomes the avatar, no network. */
|
||||
export const profileHtml = ({ fontCss, assets, copy }) => {
|
||||
const phone = (variant, appLabel) => `
|
||||
<div class="profile-phone profile-phone--${variant}">
|
||||
<div class="profile-phone__top">${appLabel}</div>
|
||||
<div class="profile-avatar-shell"><div class="profile-avatar"><img src="${assets.fixturePhoto}" alt="" /></div></div>
|
||||
<p class="profile-handle">${variant === 'ig' ? '@seuperfil' : 'Seu perfil'}</p>
|
||||
<div class="profile-lines"><span></span><span></span></div>
|
||||
</div>`
|
||||
return htmlDocument({
|
||||
title: 'Reel — profile',
|
||||
fontCss,
|
||||
body: `
|
||||
<div class="frame profile-scene">
|
||||
<img class="kit-asset profile-brand" src="${assets.nameNegative}" alt="Jorge Solla — Deputado Federal" />
|
||||
<div class="profile-heading">
|
||||
<p class="profile-eyebrow">${copy.eyebrow}</p>
|
||||
<h3 class="brexter profile-title">${headline(copy, BRAND.blue)}</h3>
|
||||
</div>
|
||||
<div class="profile-phones">${phone('ig', copy.apps[0])}${phone('wa', copy.apps[1])}</div>
|
||||
<div class="profile-card profile-card--ig" aria-hidden="true">
|
||||
<img class="profile-card__photo" src="${assets.fixturePhoto}" alt="" />
|
||||
<img class="profile-card__frame" src="${assets.cardFrame}" alt="" />
|
||||
</div>
|
||||
<div class="profile-card profile-card--wa" aria-hidden="true">
|
||||
<img class="profile-card__photo" src="${assets.fixturePhoto}" alt="" />
|
||||
<img class="profile-card__frame" src="${assets.cardFrame}" alt="" />
|
||||
</div>
|
||||
<span class="profile-check profile-check--ig">✓</span><span class="profile-check profile-check--wa">✓</span>
|
||||
<p class="profile-footnote">${copy.instruction}</p>
|
||||
</div>`,
|
||||
})
|
||||
}
|
||||
|
||||
/** 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: `
|
||||
<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 class="frame cta-scene">
|
||||
<div class="cta-red"><img class="cta-punho punch-1" src="${assets.fist}" alt="" /></div>
|
||||
<div class="cta-copy">
|
||||
<p class="fade-1">${copy.eyebrow}</p>
|
||||
<h3 class="brexter beat-1">${headline(copy)}</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 class="cta-card rise-1">
|
||||
<p class="cta-card__eyebrow">FAÇA A SUA</p>
|
||||
<p class="brexter cta-card__action">${copy.actionLines.join('<br />')}</p>
|
||||
<p class="cta-card__sub">${copy.sub}</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" />
|
||||
<p class="cta-url">${copy.url}</p>
|
||||
<img class="official-mark" style="right:20px;top:520px;width:132px" 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 }) =>
|
||||
/** Cover: own composition, headline inside the central 1080×1350 crop. */
|
||||
export const coverHtml = ({ fontCss, assets, copy }) =>
|
||||
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 class="frame cover-scene">
|
||||
<img class="pattern-wash" src="${assets.patternShapes}" alt="" />
|
||||
<div class="cover-copy">
|
||||
<span class="hook-eyebrow">${copy.eyebrow}</span>
|
||||
<h3 class="brexter">${headline(copy)}</h3>
|
||||
<p>${copy.sub}</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" />
|
||||
<span class="cover-tag">${copy.tag}</span>
|
||||
<img class="official-mark" style="right:20px;top:455px;width:134px" src="${assets.completeNegative}" alt="Jorge Solla 1313 — Mais Saúde, Mais Futuro" />
|
||||
</div>`,
|
||||
})
|
||||
|
|
|
|||
BIN
scripts/reels/fixtures/profile-photo.png
Normal file
BIN
scripts/reels/fixtures/profile-photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
|
|
@ -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",
|
||||
|
|
|
|||
179
scripts/reels/shot-lists/foto-de-perfil.json
Normal file
179
scripts/reels/shot-lists/foto-de-perfil.json
Normal file
|
|
@ -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!"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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<string, unknown> = {}) => ({
|
||||
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<string, unknown>, overrides: Record<string, unknown> = {}) => {
|
||||
const base = rawShotList(overrides)
|
||||
const scenes = (base.scenes as Array<Record<string, unknown>>).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<string, unknown>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue