From 560f2e494a433c8a82b06d8373e0378f05f181d9 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Fri, 18 Sep 2026 21:29:35 -0300 Subject: [PATCH] =?UTF-8?q?feat(S15):=20card=20"Time=20de=20voc=C3=AA"=20?= =?UTF-8?q?=E2=80=94=20quarto=20modelo=20auto-servi=C3=A7o=20no=20funil=20?= =?UTF-8?q?p=C3=BAblico=20(Closes=20#1161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + .github/workflows/ci-pr.yml | 3 + .github/workflows/deploy.yml | 3 + .gitignore | 3 + Dockerfile | 4 + .../2026-09-18-s15-card-time-de-voce.md | 1 + docs/plans/cards-time-de-voce-impl.md | 196 ++++++ eslint.config.mjs | 2 + package.json | 2 + playwright.config.ts | 7 + pnpm-lock.yaml | 8 + public/cards/selfie_segmenter.LICENSE.txt | 25 + public/cards/selfie_segmenter.tflite | Bin 0 -> 249537 bytes scripts/copy-card-vision-assets.mjs | 53 ++ scripts/dev.mjs | 9 + src/app/(frontend)/(home)/cards/page.tsx | 2 +- src/app/(frontend)/styles.css | 5 + src/components/cards/CardComposer.tsx | 619 ++++++++++++++---- src/components/cards/CardModelGallery.tsx | 12 +- src/components/cards/CardModelTile.tsx | 15 +- src/components/cards/cardCutout.ts | 324 +++++++++ src/components/cards/useCardCutout.ts | 88 +++ src/lib/cardModels.ts | 116 +++- src/lib/cardNameFit.ts | 8 +- src/lib/cardPhotoTransform.ts | 62 ++ src/lib/cardRender.ts | 121 +++- tests/e2e/frontend.e2e.spec.ts | 131 +++- tests/unit/cardModels.unit.spec.ts | 64 +- tests/unit/cardNameFit.unit.spec.ts | 42 +- tests/unit/cardPhotoTransform.unit.spec.ts | 60 ++ tests/unit/cardRender.unit.spec.ts | 142 +++- 31 files changed, 1963 insertions(+), 170 deletions(-) create mode 100644 docs/changelog/2026-09-18-s15-card-time-de-voce.md create mode 100644 docs/plans/cards-time-de-voce-impl.md create mode 100644 public/cards/selfie_segmenter.LICENSE.txt create mode 100644 public/cards/selfie_segmenter.tflite create mode 100644 scripts/copy-card-vision-assets.mjs create mode 100644 src/components/cards/cardCutout.ts create mode 100644 src/components/cards/useCardCutout.ts diff --git a/.env.example b/.env.example index 1199a445..f2b0d4c0 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,12 @@ S3_SECRET_ACCESS_KEY= # normalizes them to HTTP; otherwise this configured origin is the only fallback. NEXT_PUBLIC_SITE_URL=http://localhost:3000 +# S15 — test seam for the on-device card cutout. `1` swaps the MediaPipe engine +# for a deterministic stub (e2e builds and the Playwright webServer only; the +# real engine must run in dev/production). Local E2E_PROD=1 builds set it too: +# NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm build +NEXT_PUBLIC_CARDS_CUTOUT_STUB= + # Shared secret guarding POST /api/revalidate, which busts ISR cache tags on demand. # Needed after direct-DB writes (e.g. `pnpm db:seed:posts`, Onda 0 migrations) since those # bypass the deployed Payload hooks. Default tag is `posts`; allowlisted tags also include diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index f2bdb787..d08822fc 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -221,6 +221,9 @@ jobs: env: NEXT_DIST_DIR: .next-e2e NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test + # S15 — deterministic cutout stub: the e2e build never downloads the + # MediaPipe wasm nor runs inference. + NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1' - name: E2E tests (blast radius — manifest-selected or curated specs) if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated' # OPS72 + OPS86: the PR never runs e2e full — a high-risk diff runs diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f2efebc6..82e2b973 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -195,6 +195,9 @@ jobs: env: NEXT_DIST_DIR: .next-e2e NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test + # S15 — deterministic cutout stub: the e2e build never downloads the + # MediaPipe wasm nor runs inference. + NEXT_PUBLIC_CARDS_CUTOUT_STUB: '1' - name: E2E tests (full suite, single job, 4 workers) # Explicit CI workers; the local default stays 2 (playwright.config.ts). # No `--` separator: pnpm would forward it verbatim to playwright and diff --git a/.gitignore b/.gitignore index 44ce0c79..ad8b2978 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,9 @@ next-env.d.ts # Proprietary design font files kept only in the local workspace /public/arial-mt/ +# S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time +/public/cards/mediapipe/ + /data/encode/ /data/poster-candidates/ diff --git a/Dockerfile b/Dockerfile index 6890250e..821e24a8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,6 +43,10 @@ ENV NEXT_TELEMETRY_DISABLED=1 # the deploy script applies them BEFORE this build (OPS66: static routes read # the new schema, so the build must see a migrated DB). # +# S15 — copy the MediaPipe wasm runtime into public/ so the card cutout runs +# same-origin (the files are gitignored build artifacts, never committed). +RUN node scripts/copy-card-vision-assets.mjs + # OPS99: Generate importMap with dummy S3_* envs BEFORE next build. # This ensures the production image always has the S3ClientUploadHandler entry, # eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73). diff --git a/docs/changelog/2026-09-18-s15-card-time-de-voce.md b/docs/changelog/2026-09-18-s15-card-time-de-voce.md new file mode 100644 index 00000000..32b106ca --- /dev/null +++ b/docs/changelog/2026-09-18-s15-card-time-de-voce.md @@ -0,0 +1 @@ +**S15 — Card "Time de você": quarto modelo auto-serviço no funil público (2026-09-18):** o funil de cards (`#cards` da home + `/cards`) ganha o quarto modelo: nome + foto de busto, remoção de fundo 100% no aparelho (nenhum byte sai), auto-enquadramento pelo bbox alfa (topo no slot, centrado, com pan/zoom existentes como escape) e o card `TIME DE ` composto na ordem base → recorte → overlay → banners (geometria medida: vermelho `#E50E2F`/`#FFEC01` −3,5°, azul `#0061A5`/branco −4,1°; nome em 1 linha, `too-long` com a mesma mensagem do modelo de nome). Motor MediaPipe `selfie_segmenter` (Apache-2.0, float16): wasm ~12MB lazy e same-origin copiado de `node_modules` em dev/build (`scripts/copy-card-vision-assets.mjs`; gitignored), modelo de 244KB commitado com NOTICE, sem COOP/COEP (precedente c167) e sem CDN de terceiros; `@imgly` (AGPL) rejeitado para o bundle. Catálogo puro ganha `kind: 'team'` + `overlaySrc`/`previewSrc`/`badge`, `renderTeamCard`/`drawCardBanner` com ops de rotação no ctx estrutural, `frameCardPhotoOnBbox` e o hook `useCardCutout` (progresso, retry com o mesmo arquivo, run-id, fail-closed em bbox degenerado); e2e determinístico via stub (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, só dev/e2e). Sem migration, sem collection/Consent novo, sem persistência — `Contact`/Payload intocados. Design gate certificado pelo `designer` (cenas 1–7). Verificação: unit dos 4 specs puros, `pnpm gate:fast`, e2e do `frontend` (29 verdes), motor real exercitado no browser em 390/1280 com download de PNG. diff --git a/docs/plans/cards-time-de-voce-impl.md b/docs/plans/cards-time-de-voce-impl.md new file mode 100644 index 00000000..75dc9002 --- /dev/null +++ b/docs/plans/cards-time-de-voce-impl.md @@ -0,0 +1,196 @@ +# Impl: Card "Time de você": quarto modelo auto-serviço no funil público + +Status: aprovado +Atualizado em: 2026-09-18 +Issue: #1161 +Intenção: docs/plans/cards-time-de-voce.md +Appetite restante: herdado — ~2–3 dias eng (3 fases somam ≤3 dias; sem migration) + +## Leitura da intenção + +- **Outcome:** o funil de cards (home `#cards` + `/cards`, S13/S14) ganha o quarto modelo — `Time de você`: nome + foto de busto, fundo removido no aparelho, card `TIME DE ` com a foto recortada no centro sobre o fundo oficial e os banners no topo, baixável em PNG — sem conta e sem nenhum byte da foto saindo do aparelho. +- **O que NÃO negociar:** + - 100% client-side: sem upload, sem persistência, sem analytics de PII; nome/foto nunca saem do aparelho. + - Um único editor/fluxo: quarto item do catálogo no MESMO `CardsStudio`/`CardComposer`; `/cards` e `?model=time-de-voce` continuam. + - Artes-mestre intocadas (compor por cima, nunca redesenhar); geometria literal medida: janela da foto `{286,439,592,577}`; banner `TIME DE` centro (545,162) ≈693×118, −3,5°, `#E50E2F`, texto `#FFEC01` cap ≈93; banner do nome centro (545,313) ≈509×176, −4,1°, `#0061A5`, nome `#FFFFFF` cap ≈130, tinta ≤470, 1 linha, cap mín ≈56. + - Nome nunca cortado em silêncio: se não couber no cap mínimo → mesma mensagem de nome curto do modelo de nome. + - Recorte local com progresso; falha → erro claro com retry, sem beco sem saída. + - Sem migration/collection/CMS/Consent novo; sem headers globais que quebrem o embed (precedente c167). +- **O que reavaliar (hipóteses da intenção):** + - **Motor recomendado (`@imgly/background-removal`)**: reavaliado e rejeitado — o `LICENSE` do repo é proprietário ("All Rights Reserved") e o pacote é AGPL-3.0; detalhe na Decisão 1. A escolha final era explicitamente do plano de implementação. + - **“O composer carrega UMA imagem base e ramifica por kind”**: o time precisa de DOIS assets (base + overlay frontal) e de um estado de processamento — é um ramo de compositor/tempo, não um `photoWindow` a mais. + - **“3 ids pinados”**: os pins de unit (`tests/unit/cardModels.unit.spec.ts:7`) e o loop de tiles do e2e (:1712) quebram com o 4º modelo; atualizá-los faz parte da entrega. + +## Abordagem recomendada + +```mermaid +flowchart LR + H["home: seção cards / rota /cards"] --> S["CardsStudio — ilha única (S13/S14)"] + S --> G["CardModelGallery — 4 tiles, grid 4x1 no lg"] + G -->|"tile Time de você"| C["CardComposer — editor único"] + C --> I["nome + foto de busto"] + I --> HK["useCardCutout — idle → processing → ready | error"] + HK --> AD["cardCutout — adapter DOM (src/components/cards)"] + AD --> MP["MediaPipe selfie segmenter — wasm + tflite same-origin"] + MP -->|"máscara de confiança"| AD + AD -->|"cutout canvas + bbox alfa"| HK + HK --> FR["frameCardPhotoOnBbox — puro (cardPhotoTransform)"] + FR --> RT["renderTeamCard — puro (base → foto → overlay → banners)"] + RT --> PV["prévia canvas + controles existentes"] + C -->|"Criar meu card"| R["resultado"] + R --> DL["Baixar PNG no aparelho"] + CM["cardModels — catálogo único"] -.-> G + CM -.-> RT +``` + +**Opções consideradas:** A) estender o funil S13/S14 (catálogo + composer + render puros + adapter DOM novo) | B) rota/editor novo só para o time | C) recorte como serviço/API/iframe. +**Recomendação:** A — o quarto modelo é um item do catálogo num funil que já tem editor único, shells e núcleo puro; reusar mantém o anti-goal "segundo editor" cortado e cabe no appetite. +**Rejeitadas:** B porque cria dois fluxos (o rabbit hole de produto) e duplica shells/estado; C porque viola o aceite "nenhum byte sai do aparelho". + +### Decisões de engenharia + +1. **Motor de remoção de fundo — MediaPipe selfie segmenter (Apache-2.0), 100% self-hosted.** + Opções: A) `@imgly/background-removal` no navegador (CDN padrão ou self-host do pacote de dados; ~40MB no 1º uso; progresso nativo; AGPL-3.0) | B) `@mediapipe/tasks-vision` `ImageSegmenter` + `selfie_segmenter.tflite` float16 256×256 (Apache-2.0; wasm SIMD ~11,7MB + modelo ~244KB; sem COOP/COEP) | C) recorte no servidor/API | D) ONNX Runtime Web + modelo próprio. + Recomendação: B — Apache-2.0 compatível com o `LICENSE` proprietário do repo (imgly é AGPL-3.0; o repo é público mas "All Rights Reserved": visibilidade do código ≠ concessão de direitos AGPL, então a §13 não está satisfeita; o `@imgly/background-removal-node` de `scripts/resize-images.mjs` é ferramenta local de script, não distribuída ao usuário, e não vale como precedente de bundle); primeiro uso de ~12MB de wasm (cacheado) + 244KB em vez de ~40MB no celular; roda sem COOP/COEP (wasm single-thread, sem SharedArrayBuffer — respeita o c167); assets same-origin sob `public/cards/` (sem CDN em runtime; e2e hermético); a expectativa honesta de busto/fundo simples já está no aceite. + Rejeitadas: A por licença + peso no celular + CDN de terceiro executando JS no browser (o self-host do pacote de dados imgly é grande demais para repo/imagem); C por PII/serviço; D por reimplementar runtime/wasm/hosting/modelo (fora do appetite) sem ganho sobre B. + Hospedagem/carregamento: `selfie_segmenter.tflite` commitado em `public/cards/` (origem e Apache-2.0 documentados no código); wasm copiado de `node_modules/@mediapipe/tasks-vision/wasm/` para `public/cards/mediapipe/wasm/` (gitignored) por `scripts/copy-card-vision-assets.mjs`, disparado no preflight do `scripts/dev.mjs`, no `prebuild` (CI) e num `RUN` do estágio builder do Dockerfile (o runner copia `public/`). `await import('@mediapipe/tasks-vision')` dentro do adapter (code-split; só o modelo time paga); `FilesetResolver.forVisionTasks('/cards/mediapipe/wasm')` + `createFromOptions({ baseOptions: { modelAssetBuffer }, outputConfidenceMasks: true, runningMode: 'IMAGE' })`; main thread com yield antes do `segment()` (bloqueio de poucas centenas de ms); worker só com jank medido (gatilho). + Progresso/falha: pré-aquecimento do wasm com fetch streaming por bytes + modelo passado como buffer → ratio real; adapter devolve `{ ok:true; canvas; size; bbox } | { ok:false; reason:'engine'|'empty' }`; um único estado de erro recuperável (`Tentar de novo` com o mesmo File guardado / `Escolher outra foto`); nenhum log em console (fixture e2e). + +2. **Quarto modelo — `kind: 'team'` explícito + campos aditivos, sem pipeline genérico.** + Opções: A) terceiro valor de `kind` + `overlaySrc`/`previewSrc`/`badge` opcionais + `renderTeamCard` reusando a math existente | B) manter `kind:'photo'` e detectar o time por flags (`photoWindow`+`nameSlot`) | C) refatorar para um pipeline de camadas (`layers[]`) para os 4 modelos | D) render/editor dedicado por id. + Recomendação: A — o modelo tem foto + nome + overlay + processamento, então não é nenhum dos dois kinds atuais; o discriminante explícito mantém os 3 modelos entregues no mesmo caminho (mesmos unit pins de render) e deixa o composer ramificar uma vez. `renderTeamCard` (base → foto → overlay → banners) reusa `cardPhotoDrawRect`/`clampCardPhotoTransform`, `fitCardName` e um helper novo de banner; `overlaySrc`/`previewSrc` aditivos não mudam os modelos existentes. Fato verificado nos assets: `team-card-base.png` é opaco, `team-card-front.png` só cobre y≥971 e nenhum asset contém os banners — os dois banners (retângulo + texto) são desenhados pelo render com os literais medidos. + Rejeitadas: B porque esconde um fluxo novo (processar/recortar) atrás de flags de um tipo antigo; C porque refatora o render dos 3 modelos por generalidade especulativa (risco + reescreve pins); D porque duplica editor/render (anti-goal). + +3. **Auto-enquadramento — bbox alfa no adapter + math pura reusando o transform existente.** + Opções: A) math nova em `cardPhotoTransform.ts` (`frameCardPhotoOnBbox`) convertendo para o `CardPhotoTransform` atual e passando pelo `clampCardPhotoTransform`; bbox no adapter via canvas `getImageData` | B) calcular um draw rect direto, sem o transform (perde pan/zoom reusados) | C) enquadrar "no olho" no adapter, sem função pura. + Recomendação: A — `zoom = clamp(coverNecessário(bbox→janela) / coverScale(fonte,janela), 1, CARD_PHOTO_MAX_ZOOM)`; topo do bbox encosta no topo da janela e o centro x do bbox alinha ao centro da janela; clamp final garante cobertura, e os controles de arrastar/zoom/setas do S13/S14 seguem funcionando com a transform resultante. Bbox: canvas de trabalho limitado a 1600px na maior aresta (memória/scan), varredura única de alpha>32; degenerado (vazio ou < ~24px) OU zoom necessário > 4 → `null` → erro recuperável (`empty-cutout`) — nunca card com foto invisível/minúscula. + Rejeitadas: B porque joga fora o escape de ajuste e a garantia de cobertura; C porque não é testável puro e o adapter é DOM. + +4. **Fit do nome — reusar `fitCardName` com slot novo; rotação via ops no ctx estrutural.** + Opções: A) `TEAM_CARD_NAME_SLOT` (1 linha, centrado, cap 130, tinta 470, cap mín 56, rotação −4,1°) + união `CardNameSlot` discriminada por `align` (`'left'` | `'center'`) + `CardDrawContext` ganhando `save/restore/translate/rotate/fillRect` | B) `fitTeamName` nova | C) desenhar o nome sem centralizar/rotacionar. + Recomendação: A — a máquina de fit (probe de cap, shrink, mínimo, `too-long`) é a mesma; `NAME_CARD_SLOT` ganha `align:'left'` sem mudar comportamento e os pins atuais continuam passando; `drawCardName` ramifica por `align`; um helper `drawCardBanner` desenha retângulo e texto centrados/rotacionados (label fixo `TIME DE` cap 93/−3,5° e nome com o cap do fit); o fake ctx dos units registra a sequência `save → translate → rotate → fillRect → fillText → restore`, mantendo os testes existentes. + Rejeitadas: B (twin da mesma matemática), C (viola a fidelidade medida do exemplo). + +5. **Estado do composer — hook `useCardCutout` + estado de UI no composer.** + Opções: A) hook `src/components/cards/useCardCutout.ts` dono do ciclo do motor (`idle → processing → ready | error`, retry, run id, desmontagem) consumido pelo `CardComposer` | B) `useState` no `CardComposer` | C) store/contexto global. + Recomendação: A — o ciclo do engine (import dinâmico, fases, progresso, guarda de corrida/desmontagem, File do retry, dispose do segmenter) é uma preocupação coesa vizinha do adapter; o composer já tem ~500 linhas e ganharia mais 6 estados. CTA `Criar meu card` só com `cutout.status==='ready'` + `nameFit.ok` + assets prontos; passo `result` inalcançável antes disso; `Baixar meu card` só no result (nunca baixa exemplo/estado intermediário). A11y: picker/retry são ` + zoomTo(Number(event.target.value))} + className="h-11 w-full flex-1 accent-(--pt-red)" + /> + + + +
+

Ajuste fino

+
+ + + + +
+
+ + ) : null + return (
@@ -271,12 +540,12 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer

{ const step = event.shiftKey ? 48 : 12 const deltas: Record = { @@ -299,11 +568,16 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer className={`mx-auto block h-auto max-h-[38dvh] w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${ loadState === 'loading' ? 'opacity-60' : 'opacity-100' }`} - onPointerDown={photo ? handlePointerDown : undefined} - onPointerMove={photo ? handlePointerMove : undefined} - onPointerUp={photo ? handlePointerEnd : undefined} - onPointerCancel={photo ? handlePointerEnd : undefined} + onPointerDown={adjustable ? handlePointerDown : undefined} + onPointerMove={adjustable ? handlePointerMove : undefined} + onPointerUp={adjustable ? handlePointerEnd : undefined} + onPointerCancel={adjustable ? handlePointerEnd : undefined} /> + {teamReady && step === 'compose' && !nameError ? ( + + Arraste para ajustar + + ) : null}
{loadState === 'error' ? ( @@ -331,104 +605,159 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer

{nameError ? (

- Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é - chamado. + {NAME_TOO_LONG_MESSAGE}

) : null}

{CARD_PRIVACY_NOTE}

) : null} - {step === 'compose' && !isNameModel && photoWindow ? ( + {step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? (
- {photo && photoTransform ? ( - <> -
- - - zoomTo(Number(event.target.value))} - className="h-11 w-full flex-1 accent-(--pt-red)" - /> - -
+ +
+

Sua foto de busto

+

+ Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples. +

+ +
+

{CARD_PRIVACY_NOTE}

+
+ ) : null} -
-

Ajuste fino

-
- - - - -
-
+ {step === 'compose' && teamProcessing ? ( +
+
+

+ {teamProcessing.phase === 'download' + ? 'Baixando o modelo de recorte…' + : 'Removendo o fundo da foto…'} +

+ +
+
+
+
+

+ Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto + no seu aparelho. Isso pode levar um pouco. +

+
+ +

{CARD_PHOTO_PRIVACY_NOTE}

+
+
+ ) : null} + + {step === 'compose' && teamError ? ( +
+
+

+ Não foi possível remover o fundo desta foto. +

+

+ Tente de novo ou escolha outra foto. +

+
+

+ Fotos de busto, nítidas e com fundo simples costumam funcionar melhor. +

+

+ {CARD_PHOTO_PRIVACY_NOTE} +

+
+ + +
+
+ ) : null} + + {step === 'compose' && teamReady && photoWindow && transformSourceSize ? ( +
+ {nameError ? ( + <> + +

+ A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você + corrige o nome. +

+ ) : ( + <> + {zoomAndPanControls} + +

+ {CARD_PHOTO_PRIVACY_NOTE} +

+ + )} +
+ ) : null} + + {step === 'compose' && !isNameModel && !isTeamModel && photoWindow ? ( +
+ {photo && effectiveTransform ? ( + zoomAndPanControls ) : (

Escolha uma foto vertical ou horizontal para começar.

)} - void handlePhotoFile(event.target.files?.[0])} - /> - {photoError ? (

Não foi possível usar esta foto

@@ -452,9 +781,35 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
) : null} + + isTeamModel + ? handleTeamPhotoFile(event.target.files?.[0]) + : void handlePhotoFile(event.target.files?.[0]) + } + /> + {step === 'result' ? (
-

{CARD_PRIVACY_NOTE}

+ {isTeamModel ? ( +
+ +

{CARD_PRIVACY_NOTE}

+
+ ) : ( +

{CARD_PRIVACY_NOTE}

+ )} {downloadError ? (

{downloadError} @@ -476,7 +831,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer onClick={() => setStep('result')} className={primaryButtonClassName} > - Criar meu card + {teamProcessing ? 'Processando…' : 'Criar meu card'} ) : ( diff --git a/src/components/cards/CardModelGallery.tsx b/src/components/cards/CardModelGallery.tsx index b85f4611..1141449b 100644 --- a/src/components/cards/CardModelGallery.tsx +++ b/src/components/cards/CardModelGallery.tsx @@ -12,10 +12,10 @@ const tileClassName = 'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)' /** - * S14 — the three models side by side on desktop and a one-item-with-peek snap - * track on mobile, as buttons for the studio island: selecting a tile opens the - * single composer in place (home or `/cards`). Both tracks come from the same - * catalog (S13). + * S14/S15 — the four models side by side on desktop and a one-item-with-peek + * snap track on mobile, as buttons for the studio island: selecting a tile + * opens the single composer in place (home or `/cards`). Both tracks come from + * the same catalog (S13). */ export const CardModelGallery = ({ ariaLabel, @@ -47,7 +47,7 @@ export const CardModelGallery = ({

    {CARD_MODELS.map((model) => (
  • @@ -68,7 +68,7 @@ export const CardModelGallery = ({

- Deslize para ver os três modelos + Deslize para ver os quatro modelos

) diff --git a/src/components/cards/CardModelTile.tsx b/src/components/cards/CardModelTile.tsx index 0d7ee502..c0bbbbab 100644 --- a/src/components/cards/CardModelTile.tsx +++ b/src/components/cards/CardModelTile.tsx @@ -6,10 +6,11 @@ import type { CardModel } from '@/lib/cardModels' import { cn } from '@/lib/utils' /** - * S13 — artwork preview of one card model, shared by the home section and the - * `/cards` gallery. The name tile reuses the empty master with a `SEU NOME` + * S13/S15 — artwork preview of one card model, shared by the home section and + * the `/cards` gallery. The name tile reuses the empty master with a `SEU NOME` * preview drawn by the real composer pipeline (S14); the photo tiles show the - * official transparent overlay above a neutral photo slot. + * official transparent overlay above a neutral photo slot; the team tile shows + * the filled example (`previewSrc`) with its `NOVO` badge. */ export const CardModelTile = ({ model, @@ -50,10 +51,10 @@ export const CardModelTile = ({ ) : null} @@ -63,6 +64,10 @@ export const CardModelTile = ({ + ) : model.badge ? ( + + {model.badge} + ) : null} ) diff --git a/src/components/cards/cardCutout.ts b/src/components/cards/cardCutout.ts new file mode 100644 index 00000000..44ed988c --- /dev/null +++ b/src/components/cards/cardCutout.ts @@ -0,0 +1,324 @@ +/** + * S15 — on-device background removal for the `Time de você` card. The visitor's + * photo never leaves the browser: the model + wasm are fetched from our own + * `/cards` static assets and inference runs locally. Kept out of `src/lib` + * because it touches DOM/worker APIs; the pure framing math stays in + * `cardPhotoTransform`. + * + * Engine: MediaPipe `@mediapipe/tasks-vision` ImageSegmenter with the Apache-2.0 + * `selfie_segmenter.tflite` (float16, 256×256) — no COOP/COEP, single-threaded + * wasm, lazily imported only when the visitor picks the team model. The + * `@imgly` packages stay out of the bundle (AGPL + ~40 MB on mobile). + */ + +import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision' + +import type { CardAlphaBbox } from '@/lib/cardPhotoTransform' + +declare global { + interface Window { + /** Test seam: the e2e build stubs the engine through this flag. */ + __cardsCutoutStub?: 'ok' | 'slow' | 'error' + } +} + +const WASM_DIR = '/cards/mediapipe/wasm' +const MODEL_URL = '/cards/selfie_segmenter.tflite' +const CUTOUT_MAX_EDGE = 1600 +const ALPHA_THRESHOLD = 32 +const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1' +const STUB_DELAY_MS = 1500 +const STUB_PROGRESS = 0.36 +/** Upscale target so the synthetic bbox passes the real cutout checks. */ +const STUB_MIN_EDGE = 400 + +export type CardCutoutProgress = { + phase: 'download' | 'process' + /** 0..1 — real bytes while downloading, a coarse step while processing. */ + ratio: number +} + +type CardCutoutFailureReason = 'engine' | 'empty' + +export type CardCutoutResult = + | { + ok: true + canvas: HTMLCanvasElement + width: number + height: number + bbox: CardAlphaBbox + } + | { ok: false; reason: CardCutoutFailureReason } + +/** + * Removes the background of `file` on this device. Resolves with the cutout + * canvas + its alpha bbox, or fails with `engine` (decode/inference/asset + * failure) or `empty` (blank segmentation) — both recoverable in the composer. + */ +export const removeCardPhotoBackground = async ( + file: File, + onProgress: (progress: CardCutoutProgress) => void, +): Promise => { + const report = createProgressReporter(onProgress) + + try { + return STUB_ENABLED ? await stubCutout(file, report) : await engineCutout(file, report) + } catch { + return { ok: false, reason: 'engine' } + } +} + +const PROGRESS_MIN_INTERVAL_MS = 100 + +/** + * Streaming a ~12 MB wasm fires a progress event per chunk; without throttle + * that is hundreds of React updates in one microtask chain (the dev build + * reports "Maximum update depth exceeded"). One report per 100 ms — always + * emitting phase changes and completion — keeps the bar honest and the UI calm. + */ +const createProgressReporter = (onProgress: (progress: CardCutoutProgress) => void) => { + let lastAt = 0 + let lastPhase: CardCutoutProgress['phase'] | null = null + + return (progress: CardCutoutProgress) => { + const now = Date.now() + const phaseChanged = progress.phase !== lastPhase + const finished = progress.ratio >= 1 + if (!phaseChanged && !finished && now - lastAt < PROGRESS_MIN_INTERVAL_MS) return + + lastAt = now + lastPhase = progress.phase + onProgress(progress) + } +} + +let segmenterPromise: Promise | null = null + +const loadSegmenter = (onProgress: (progress: CardCutoutProgress) => void) => { + if (!segmenterPromise) { + segmenterPromise = createSegmenter(onProgress).catch((error: unknown) => { + segmenterPromise = null + throw error + }) + } + + return segmenterPromise +} + +const createSegmenter = async (onProgress: (progress: CardCutoutProgress) => void) => { + const wasmFiles = supportsWasmSimd() + ? ['vision_wasm_internal.js', 'vision_wasm_internal.wasm'] + : ['vision_wasm_nosimd_internal.js', 'vision_wasm_nosimd_internal.wasm'] + await prefetchAssets([...wasmFiles.map((name) => `${WASM_DIR}/${name}`), MODEL_URL], onProgress) + + const vision = await import('@mediapipe/tasks-vision') + const fileset = await vision.FilesetResolver.forVisionTasks(WASM_DIR) + const modelResponse = await fetch(MODEL_URL) + if (!modelResponse.ok) throw new Error(`card-cutout-model:${modelResponse.status}`) + const modelAssetBuffer = new Uint8Array(await modelResponse.arrayBuffer()) + + return vision.ImageSegmenter.createFromOptions(fileset, { + baseOptions: { modelAssetBuffer }, + runningMode: 'IMAGE', + outputConfidenceMasks: true, + outputCategoryMask: false, + }) +} + +/** + * Streams each asset once so the progress bar reflects real bytes — and warms + * the HTTP cache for the runtimes that later fetch the same URLs themselves. + */ +const prefetchAssets = async ( + urls: string[], + onProgress: (progress: CardCutoutProgress) => void, +) => { + let loadedFiles = 0 + + for (const url of urls) { + const response = await fetch(url) + if (!response.ok) throw new Error(`card-cutout-asset:${response.status}`) + const size = Number(response.headers.get('content-length')) || 0 + + if (!response.body) { + await response.arrayBuffer() + loadedFiles += 1 + onProgress({ phase: 'download', ratio: Math.min(1, loadedFiles / urls.length) }) + continue + } + + const reader = response.body.getReader() + let received = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + received += value?.length ?? 0 + const fileRatio = size > 0 ? Math.min(1, received / size) : 0.5 + onProgress({ phase: 'download', ratio: Math.min(1, (loadedFiles + fileRatio) / urls.length) }) + } + loadedFiles += 1 + onProgress({ phase: 'download', ratio: Math.min(1, loadedFiles / urls.length) }) + } +} + +const engineCutout = async ( + file: File, + onProgress: (progress: CardCutoutProgress) => void, +): Promise => { + const segmenter = await loadSegmenter(onProgress) + onProgress({ phase: 'process', ratio: 0.5 }) + + const bitmap = await createImageBitmap(file) + const { width, height } = cappedSize(bitmap.width, bitmap.height) + + // Let the processing state paint before the synchronous inference. + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + + const result = segmenter.segment(bitmap) + let mask: MPMask | null = null + try { + mask = result.confidenceMasks?.[0] ?? null + if (!mask) return { ok: false, reason: 'engine' } + + const canvas = composeCutout(bitmap, width, height, mask) + const bbox = readAlphaBbox(canvas) + if (!bbox) return { ok: false, reason: 'empty' } + + onProgress({ phase: 'process', ratio: 1 }) + return { ok: true, canvas, width, height, bbox } + } finally { + mask?.close() + result.close() + bitmap.close() + } +} + +const composeCutout = ( + image: ImageBitmap, + width: number, + height: number, + mask: MPMask, +): HTMLCanvasElement => { + const maskCanvas = document.createElement('canvas') + maskCanvas.width = mask.width + maskCanvas.height = mask.height + const maskCtx = maskCanvas.getContext('2d') + if (!maskCtx) throw new Error('card-cutout-mask-context') + + const confidence = mask.getAsFloat32Array() + const maskImage = maskCtx.createImageData(mask.width, mask.height) + for (let index = 0, alpha = 3; index < confidence.length; index += 1, alpha += 4) { + const value = confidence[index] ?? 0 + maskImage.data[alpha] = value <= 0 ? 0 : value >= 1 ? 255 : Math.round(value * 255) + } + maskCtx.putImageData(maskImage, 0, 0) + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('card-cutout-context') + + ctx.drawImage(image, 0, 0, width, height) + ctx.globalCompositeOperation = 'destination-in' + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(maskCanvas, 0, 0, width, height) + ctx.globalCompositeOperation = 'source-over' + + return canvas +} + +const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => { + const ctx = canvas.getContext('2d') + if (!ctx) return null + + const { width, height } = canvas + const pixels = ctx.getImageData(0, 0, width, height).data + let minX = width + let minY = height + let maxX = -1 + let maxY = -1 + + for (let y = 0; y < height; y += 1) { + const row = y * width * 4 + for (let x = 0; x < width; x += 1) { + if (pixels[row + x * 4 + 3]! > ALPHA_THRESHOLD) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + } + + if (maxX < minX || maxY < minY) return null + return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } +} + +const cappedSize = (width: number, height: number) => { + const longest = Math.max(width, height) + const scale = longest > CUTOUT_MAX_EDGE ? CUTOUT_MAX_EDGE / longest : 1 + + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +const supportsWasmSimd = (): boolean => { + // Minimal wasm module using `v128.const` — validates only where SIMD exists. + const probe = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7b, 0x03, + 0x02, 0x01, 0x00, 0x0a, 0x0a, 0x01, 0x08, 0x00, 0x41, 0x00, 0xfd, 0x0f, 0x1a, 0x0b, + ]) + + try { + return WebAssembly.validate(probe) + } catch { + return false + } +} + +const readStubMode = (): 'ok' | 'slow' | 'error' => { + if (typeof window === 'undefined') return 'ok' + const mode = window.__cardsCutoutStub + return mode === 'slow' || mode === 'error' ? mode : 'ok' +} + +/** + * Test seam (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, e2e builds only): a + * deterministic cutout with the same shape as the engine result. `slow` keeps + * the processing state visible; `error` exercises the retry path. + */ +const stubCutout = async ( + file: File, + onProgress: (progress: CardCutoutProgress) => void, +): Promise => { + const mode = readStubMode() + onProgress({ phase: 'download', ratio: mode === 'slow' ? STUB_PROGRESS : 1 }) + await new Promise((resolve) => setTimeout(resolve, mode === 'slow' ? STUB_DELAY_MS : 20)) + if (mode === 'error') return { ok: false, reason: 'engine' } + onProgress({ phase: 'process', ratio: 0.8 }) + + const bitmap = await createImageBitmap(file) + const capped = cappedSize(bitmap.width, bitmap.height) + const stubScale = Math.max(1, STUB_MIN_EDGE / Math.max(capped.width, capped.height)) + const width = Math.round(capped.width * stubScale) + const height = Math.round(capped.height * stubScale) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('card-cutout-context') + + ctx.drawImage(bitmap, 0, 0, width, height) + ctx.globalCompositeOperation = 'destination-in' + ctx.beginPath() + ctx.ellipse(width / 2, height / 2, width * 0.45, height * 0.45, 0, 0, Math.PI * 2) + ctx.fill() + ctx.globalCompositeOperation = 'source-over' + bitmap.close() + + return { ok: true, canvas, width, height, bbox: { x: 0, y: 0, width, height } } +} diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts new file mode 100644 index 00000000..f0b52352 --- /dev/null +++ b/src/components/cards/useCardCutout.ts @@ -0,0 +1,88 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' + +import { removeCardPhotoBackground } from '@/components/cards/cardCutout' +import type { CardRect } from '@/lib/cardModels' +import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform' + +export type CardCutoutState = + | { status: 'idle' } + | { status: 'processing'; phase: 'download' | 'process'; ratio: number } + | { + status: 'ready' + canvas: HTMLCanvasElement + width: number + height: number + /** Initial framing: cutout top on the slot top, centered, covering it. */ + transform: CardPhotoTransform + } + | { status: 'error'; reason: 'engine' | 'empty' } + +/** + * S15 — owns the cutout lifecycle for the team model: engine import, progress, + * degenerate framing (fail-closed), retry with the same file and run-id guards + * so a stale inference or an unmount never writes state. The engine itself is a + * page-lifetime singleton in the adapter (re-creating the wasm session per open + * would cost seconds); each result's masks/bitmap are released there. + */ +export const useCardCutout = (photoWindow: CardRect | undefined) => { + const [state, setState] = useState({ status: 'idle' }) + const fileRef = useRef(null) + const runRef = useRef(0) + + useEffect( + () => () => { + runRef.current += 1 + }, + [], + ) + + const start = useCallback( + async (file: File) => { + runRef.current += 1 + const run = runRef.current + fileRef.current = file + setState({ status: 'processing', phase: 'download', ratio: 0 }) + + const result = await removeCardPhotoBackground(file, (progress) => { + if (runRef.current !== run) return + setState({ status: 'processing', phase: progress.phase, ratio: progress.ratio }) + }) + if (runRef.current !== run) return + + if (!result.ok) { + setState({ status: 'error', reason: result.reason }) + return + } + + const transform = photoWindow + ? frameCardPhotoOnBbox( + { width: result.width, height: result.height }, + photoWindow, + result.bbox, + ) + : null + if (!transform) { + setState({ status: 'error', reason: 'empty' }) + return + } + + setState({ + status: 'ready', + canvas: result.canvas, + width: result.width, + height: result.height, + transform, + }) + }, + [photoWindow], + ) + + const retry = useCallback(() => { + const file = fileRef.current + if (file) void start(file) + }, [start]) + + return { state, start, retry } +} diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index 52ebf3db..9012efd7 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -1,19 +1,25 @@ /** - * S13 — the three personalized-card models of the campaign site. Pure data so + * S13/S15 — the personalized-card models of the campaign site. Pure data so * the home section, the `/cards` gallery and the canvas renderer share one * catalog (no second editor, no CMS): each entry carries the master asset, the - * real output size in pixels and, for photo models, the transparent window the - * visitor's photo must cover. + * real output size in pixels and, for photo/team models, the transparent window + * the visitor's photo must cover. * * Geometry comes from the master files (pixel-measured, keep in sync with - * `docs/plans/cards-personalizados-campanha-impl.md`): the photo frames are - * 1000×1000 and 1000×1440 despite the `1080x` in the original file names. + * `docs/plans/cards-personalizados-campanha-impl.md` and + * `docs/plans/cards-time-de-voce-impl.md`): the photo frames are 1000×1000 and + * 1000×1440 despite the `1080x` in the original file names. */ -const CARD_MODEL_IDS = ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular'] as const +const CARD_MODEL_IDS = [ + 'eu-sou-solla', + 'perfil-quadrado', + 'perfil-retangular', + 'time-de-voce', +] as const export type CardModelId = (typeof CARD_MODEL_IDS)[number] -type CardModelKind = 'name' | 'photo' +type CardModelKind = 'name' | 'photo' | 'team' export type CardRect = { x: number @@ -29,8 +35,14 @@ export type CardModel = { assetSrc: string width: number height: number - /** Photo models only: the transparent window the photo must cover. */ + /** Photo and team models: the transparent window the photo must cover. */ photoWindow?: CardRect + /** Team model: the master front overlay drawn above the cutout photo. */ + overlaySrc?: string + /** Team model: the filled example shown before a cutout is ready. */ + previewSrc?: string + /** Optional gallery badge (e.g. `NOVO`). */ + badge?: string } export const CARD_MODELS: readonly CardModel[] = [ @@ -60,8 +72,56 @@ export const CARD_MODELS: readonly CardModel[] = [ height: 1440, photoWindow: { x: 0, y: 0, width: 1000, height: 1044 }, }, + { + id: 'time-de-voce', + kind: 'team', + label: 'Time de você', + assetSrc: '/cards/team-card-base.png', + overlaySrc: '/cards/team-card-front.png', + previewSrc: '/cards/team-card-example.jpg', + badge: 'NOVO', + width: 1080, + height: 1440, + photoWindow: { x: 286, y: 439, width: 592, height: 577 }, + }, ] +/** Fields every name slot shares, whatever the drawing alignment. */ +type CardNameSlotBase = { + capHeight: number + minCapHeight: number + maxInkWidth: number + maxLines: number + fill: string +} + +/** Left-aligned slot with a two-line band (S13 name model). */ +export type CardNameBlockSlot = CardNameSlotBase & { + align: 'left' + leftX: number + capTop: number + maxBlockBottom: number +} + +/** Rotated rectangle + background the renderer draws for a banner. */ +export type CardBanner = { + centerX: number + centerY: number + width: number + height: number + rotationDeg: number + background: string +} + +/** Centered single-line slot drawn on a rotated banner (S15 team model). */ +export type CardNameBannerSlot = CardNameSlotBase & { + align: 'center' + maxLines: 1 + banner: CardBanner +} + +export type CardNameSlot = CardNameBlockSlot | CardNameBannerSlot + /** * Name model slot, pixel-measured from the filled master (`FULANO` example): * the `#SOU` line and the visitor's name share the same left border at x=213 @@ -71,6 +131,7 @@ export const CARD_MODELS: readonly CardModel[] = [ * over the base; the fixed lines already live in the art. */ export const NAME_CARD_SLOT = { + align: 'left', leftX: 213, capTop: 430, capHeight: 106, @@ -79,8 +140,47 @@ export const NAME_CARD_SLOT = { minCapHeight: 48, fill: '#ffec01', maxLines: 2, +} as const satisfies CardNameBlockSlot + +/** + * S15 — geometry pixel-measured from `team-card-example.jpg` (the reference + * filled card). Neither master carries the top banners, so the renderer draws + * both rectangles + text over the composed art: the red `TIME DE` label and the + * blue name banner. `rotationDeg` is counter-clockwise, matching the masters' + * built-in tilt (the photo window sits inside the sky area at x=286 y=439). + */ +export const TEAM_CARD_LABEL = { + text: 'TIME DE', + centerX: 545, + centerY: 162, + width: 693, + height: 118, + rotationDeg: -3.5, + background: '#e50e2f', + fill: '#ffec01', + capHeight: 93, } as const +export const TEAM_CARD_NAME_BANNER = { + centerX: 545, + centerY: 313, + width: 509, + height: 176, + rotationDeg: -4.1, + background: '#0061a5', + fill: '#ffffff', +} as const + +export const TEAM_CARD_NAME_SLOT = { + align: 'center', + banner: TEAM_CARD_NAME_BANNER, + capHeight: 130, + minCapHeight: 56, + maxInkWidth: 470, + fill: TEAM_CARD_NAME_BANNER.fill, + maxLines: 1, +} as const satisfies CardNameBannerSlot + export const BRAND_FALLBACK_FONT = 'sans-serif' export const isCardModelId = (value: unknown): value is CardModelId => diff --git a/src/lib/cardNameFit.ts b/src/lib/cardNameFit.ts index e03d42e6..53d9a27e 100644 --- a/src/lib/cardNameFit.ts +++ b/src/lib/cardNameFit.ts @@ -9,7 +9,7 @@ * DOM and unit-testable with a deterministic fake. */ -import { NAME_CARD_SLOT } from './cardModels' +import { NAME_CARD_SLOT, type CardNameBlockSlot, type CardNameSlot } from './cardModels' export type CardTextMetrics = { width: number @@ -29,8 +29,6 @@ export type CardNameFit = } | { ok: false; reason: 'empty' | 'too-long' } -export type CardNameSlot = typeof NAME_CARD_SLOT - const CAP_PROBE = 'X' const LINE_HEIGHT_RATIO = 1.25 const MEASURE_PROBE_SIZE = 100 @@ -73,7 +71,7 @@ const fitSingleLine = ( const fitTwoLines = ( words: string[], measure: CardMeasureText, - slot: CardNameSlot, + slot: CardNameBlockSlot, ): { fontSize: number; lines: [string, string] } | null => { const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight) const maxBlockHeight = slot.maxBlockBottom - slot.capTop @@ -134,7 +132,7 @@ export const fitCardName = ( } const words = normalized.split(' ') - if (slot.maxLines >= 2 && words.length > 1) { + if (slot.align === 'left' && slot.maxLines >= 2 && words.length > 1) { const twoLines = fitTwoLines(words, measure, slot) if (twoLines !== null && twoLines.fontSize >= minFontSize) { return { diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts index 7bd03eae..3e0c7ebd 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -13,6 +13,13 @@ import type { CardRect } from './cardModels' export const CARD_PHOTO_MIN_ZOOM = 1 export const CARD_PHOTO_MAX_ZOOM = 4 +/** + * S15 — a cutout smaller than this (in source pixels) is treated as an empty + * segmentation: the card would show a speck, so the composer fails closed and + * offers a retry instead. + */ +const CARD_CUTOUT_MIN_BBOX = 24 + export type CardPhotoSize = { width: number height: number @@ -24,6 +31,17 @@ export type CardPhotoTransform = { offsetY: number } +/** Alpha bounding box of a cutout photo, in source pixels. */ +export type CardAlphaBbox = { + x: number + y: number + width: number + height: number +} + +export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean => + a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY + const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) @@ -81,6 +99,50 @@ export const centerCardPhotoTransform = ( } } +/** + * S15 — initial framing for a cutout photo: the alpha bbox is scaled to cover + * the window, anchored at the bbox top (head against the top of the slot) and + * centered horizontally on it. Returns `null` when the bbox is degenerate + * (empty segmentation) or would demand more than the max zoom — the composer + * fails closed instead of drawing an invisible or speck-sized photo. + */ +export const frameCardPhotoOnBbox = ( + source: CardPhotoSize, + window: CardRect, + bbox: CardAlphaBbox, +): CardPhotoTransform | null => { + if ( + source.width <= 0 || + source.height <= 0 || + window.width <= 0 || + window.height <= 0 || + bbox.width < CARD_CUTOUT_MIN_BBOX || + bbox.height < CARD_CUTOUT_MIN_BBOX + ) { + return null + } + + const scale = coverScale(source, window) + const requiredZoom = Math.max( + window.width / bbox.width / scale, + window.height / bbox.height / scale, + ) + if (!Number.isFinite(requiredZoom) || requiredZoom > CARD_PHOTO_MAX_ZOOM) return null + + const zoom = Math.max(CARD_PHOTO_MIN_ZOOM, requiredZoom) + const applied = scale * zoom + + return clampCardPhotoTransform( + { + zoom, + offsetX: window.x + window.width / 2 - (bbox.x + bbox.width / 2) * applied, + offsetY: window.y - bbox.y * applied, + }, + source, + window, + ) +} + /** Nudges the offset by a step in card pixels, clamped to the window. */ export const panCardPhotoTransform = ( transform: CardPhotoTransform, diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index b571af68..d802a4dc 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -7,12 +7,22 @@ * recorder instead of jsdom canvas. */ -import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, type CardModel, type CardRect } from './cardModels' +import { + BRAND_FALLBACK_FONT, + NAME_CARD_SLOT, + TEAM_CARD_LABEL, + TEAM_CARD_NAME_SLOT, + type CardBanner, + type CardModel, + type CardNameBannerSlot, + type CardNameSlot, + type CardRect, +} from './cardModels' import { fitCardName, + resolveFontSizeForCapHeight, type CardMeasureText, type CardNameFit, - type CardNameSlot, type CardTextMetrics, } from './cardNameFit' import { @@ -28,8 +38,13 @@ export type CardDrawContext = { textAlign: CanvasTextAlign textBaseline: CanvasTextBaseline drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void + fillRect(x: number, y: number, width: number, height: number): void fillText(text: string, x: number, y: number): void measureText(text: string): CardTextMetrics + save(): void + restore(): void + translate(x: number, y: number): void + rotate(angle: number): void } export const createCardMeasure = ( @@ -62,15 +77,58 @@ export type CardNameDrawArgs = { slot?: CardNameSlot } +type CardBannerDrawArgs = { + banner: CardBanner + text: string + fill: string + fontSize: number + capHeight: number + fontFamily: string +} + /** - * S14 — draws only the fitted name (no base image) so the home tile preview + * S15 — draws one official top banner: the rotated rectangle plus its centered + * cap-height text. Both team banners (the fixed `TIME DE` label and the fitted + * name) share this so the measured geometry lives once. + */ +const drawCardBanner = (ctx: CardDrawContext, args: CardBannerDrawArgs): void => { + const { banner } = args + + ctx.save() + ctx.translate(banner.centerX, banner.centerY) + ctx.rotate((banner.rotationDeg * Math.PI) / 180) + ctx.fillStyle = banner.background + ctx.fillRect(-banner.width / 2, -banner.height / 2, banner.width, banner.height) + ctx.font = `700 ${args.fontSize}px ${args.fontFamily}` + ctx.fillStyle = args.fill + ctx.textAlign = 'center' + ctx.textBaseline = 'alphabetic' + ctx.fillText(args.text, 0, args.capHeight / 2) + ctx.restore() +} + +/** + * S14/S15 — draws only the fitted name (no base image) so the home tile preview * reuses the exact composer geometry. `fit.ok` is the caller's contract: a - * failed fit never draws (names are never cut silently). + * failed fit never draws (names are never cut silently). Left slots draw on the + * master's border; the team slot draws the blue banner + centered name. */ export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void => { const slot = args.slot ?? NAME_CARD_SLOT const { fit } = args + if (slot.align === 'center') { + drawCardBanner(ctx, { + banner: slot.banner, + text: fit.lines[0], + fill: slot.fill, + fontSize: fit.fontSize, + capHeight: fit.capHeight, + fontFamily: args.fontFamily, + }) + return + } + ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}` ctx.fillStyle = slot.fill ctx.textAlign = 'left' @@ -125,3 +183,58 @@ export const renderPhotoCard = ( return clampCardPhotoTransform(args.transform, args.photoSize, args.window) } + +export type TeamCardRenderArgs = { + base: CanvasImageSource + overlay: CanvasImageSource + photo: CanvasImageSource + photoSize: CardPhotoSize + transform: CardPhotoTransform + window: CardRect + name: string + fontFamily: string + measure: CardMeasureText + slot?: CardNameBannerSlot +} + +export type TeamCardRenderResult = { + fit: CardNameFit + transform: CardPhotoTransform +} + +/** + * S15 — the team card composition, in the measured order: master base → cutout + * photo (framed by the transform) → master front overlay → `TIME DE` banner → + * blue name banner (only when the name fits; never cut silently). The two + * banners are drawn by the renderer because neither master carries them. + */ +export const renderTeamCard = ( + ctx: CardDrawContext, + model: CardModel, + args: TeamCardRenderArgs, +): TeamCardRenderResult => { + ctx.drawImage(args.base, 0, 0, model.width, model.height) + + const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) + ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) + + ctx.drawImage(args.overlay, 0, 0, model.width, model.height) + + drawCardBanner(ctx, { + banner: TEAM_CARD_LABEL, + text: TEAM_CARD_LABEL.text, + fill: TEAM_CARD_LABEL.fill, + fontSize: resolveFontSizeForCapHeight(args.measure, TEAM_CARD_LABEL.capHeight), + capHeight: TEAM_CARD_LABEL.capHeight, + fontFamily: args.fontFamily, + }) + + const slot = args.slot ?? TEAM_CARD_NAME_SLOT + const fit = fitCardName(args.name, args.measure, slot) + if (fit.ok) drawCardName(ctx, { fit, fontFamily: args.fontFamily, slot }) + + return { + fit, + transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window), + } +} diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 6a53a22f..62c389a1 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1707,11 +1707,14 @@ test.describe('Cards personalizados (S14)', () => { await expect( section.getByRole('heading', { name: 'Mostre que você está com Solla' }), ).toBeVisible() - // S14 — the CTA is gone: the three models are the only trigger. + // S14/S15 — the CTA is gone: the four models are the only trigger. await expect(section.getByRole('link', { name: 'Criar meu card' })).toHaveCount(0) - for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) { + for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', 'time-de-voce']) { await expect(section.locator(`[data-card-model-tile="${model}"]`).first()).toBeAttached() } + await expect(section.locator('[data-card-model-tile="time-de-voce"]').first()).toContainText( + 'NOVO', + ) // S14 — the name tile reproduces the real result: `SEU NOME` drawn by the // composer pipeline, left-aligned with the `SOU` border (x≈213 at 1080). @@ -1820,3 +1823,127 @@ test.describe('Cards personalizados (S14)', () => { await expect(drawer).toHaveCount(0) }) }) + +/** + * S15 — the team model runs its engine in an e2e build with the deterministic + * stub (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`): no wasm download, no inference. The + * real engine is exercised manually (craft/UAT); the stub keeps the states + * (progress, ready, error/retry) reproducible. + */ +/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */ +const STUB_CUTOUT_PERCENT = 36 + +const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error') => + page.addInitScript((value: string) => { + ;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value + }, mode) + +const uploadBustPhoto = (dialog: Locator) => + dialog.locator('input[type="file"]').setInputFiles({ + name: 'busto-e2e.png', + mimeType: 'image/png', + buffer: TEST_PHOTO_PNG, + }) + +test.describe('Cards personalizados (S15 — Time de você)', () => { + test('cuts the photo on device, reaches the result and downloads a real PNG', async ({ + page, + }) => { + await setCutoutStub(page, 'slow') + await page.goto('/cards?model=time-de-voce') + + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('heading', { name: 'Entre para o time' })).toBeVisible() + + const canvas = dialog.locator('canvas') + await expect(canvas).toHaveAttribute('width', '1080') + await expect(canvas).toHaveAttribute('height', '1440') + + const primary = dialog.getByRole('button', { name: 'Criar meu card' }) + await expect(primary).toBeDisabled() + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await uploadBustPhoto(dialog) + + // Processing is observable (first run: model download + local cutout). + await expect(dialog.getByText('Baixando o modelo de recorte…')).toBeVisible() + await expect(dialog.getByText(`${STUB_CUTOUT_PERCENT}%`)).toBeVisible() + await expect(dialog.getByRole('progressbar')).toHaveAttribute( + 'aria-valuenow', + String(STUB_CUTOUT_PERCENT), + ) + + // The cutout lands framed: CTA enabled, pan/zoom escape offered. + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + await expect(primary).toBeEnabled() + await expect(dialog.getByText('Arraste para ajustar')).toBeVisible() + + await primary.click() + await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible() + + const [download] = await Promise.all([ + page.waitForEvent('download'), + dialog.getByRole('button', { name: 'Baixar meu card' }).click(), + ]) + expect(download.suggestedFilename()).toBe('card-jorge-solla-time-de-voce.png') + const downloadPath = await download.path() + expect(downloadPath).toBeTruthy() + expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true) + }) + + test('a failed cutout is recoverable: retry with the same photo reaches the result', async ({ + page, + }) => { + await setCutoutStub(page, 'error') + await page.goto('/cards?model=time-de-voce') + + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await uploadBustPhoto(dialog) + + await expect(dialog.getByRole('heading', { name: 'Vamos tentar outra vez' })).toBeVisible() + await expect(dialog.getByRole('alert')).toContainText( + 'Não foi possível remover o fundo desta foto.', + ) + await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled() + + // The retry re-runs the SAME file; no re-upload, no lost state. + await page.evaluate(() => { + ;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = 'ok' + }) + await dialog.getByRole('button', { name: 'Tentar de novo' }).click() + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled() + }) + + test('a name that cannot fit fails closed and keeps the cutout', async ({ page }) => { + await setCutoutStub(page, 'ok') + await page.goto('/cards?model=time-de-voce') + + const dialog = page.getByRole('dialog') + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente') + await uploadBustPhoto(dialog) + + await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible() + await expect(dialog.getByRole('alert')).toContainText('nome mais curto') + const primary = dialog.getByRole('button', { name: 'Criar meu card' }) + await expect(primary).toBeDisabled() + await expect(dialog.getByText('permanecem salvos neste aparelho')).toBeVisible() + + // The cutout is kept and the name stays editable: fixing it re-enables the card. + await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria') + await expect(dialog.getByRole('alert')).toHaveCount(0) + await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible() + await expect(primary).toBeEnabled() + }) + + test('mobile opens the team composer in the bottom drawer', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/cards?model=time-de-voce') + + const drawer = page.locator('[data-slot="drawer-popup"]') + await expect(drawer).toBeVisible() + await expect(drawer.getByRole('heading', { name: 'Entre para o time' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(drawer).toHaveCount(0) + }) +}) diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts index 079c6b59..369d8b00 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -1,13 +1,22 @@ import { describe, expect, it } from 'vitest' -import { CARD_MODELS, NAME_CARD_SLOT, getCardModel, isCardModelId } from '@/lib/cardModels' +import { + CARD_MODELS, + NAME_CARD_SLOT, + TEAM_CARD_LABEL, + TEAM_CARD_NAME_BANNER, + TEAM_CARD_NAME_SLOT, + getCardModel, + isCardModelId, +} from '@/lib/cardModels' -describe('card model catalog (S13)', () => { - it('exposes the three shipped models with unique ids', () => { +describe('card model catalog (S13/S15)', () => { + it('exposes the four shipped models with unique ids', () => { expect(CARD_MODELS.map((model) => model.id)).toEqual([ 'eu-sou-solla', 'perfil-quadrado', 'perfil-retangular', + 'time-de-voce', ]) expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length) }) @@ -50,6 +59,7 @@ describe('card model catalog (S13)', () => { it('guards the name slot geometry measured from the filled master', () => { expect(NAME_CARD_SLOT).toMatchObject({ + align: 'left', leftX: 213, capTop: 430, capHeight: 106, @@ -57,10 +67,58 @@ describe('card model catalog (S13)', () => { }) }) + it('pins the team model, its overlay assets and the measured photo window', () => { + expect(getCardModel('time-de-voce')).toMatchObject({ + kind: 'team', + label: 'Time de você', + assetSrc: '/cards/team-card-base.png', + overlaySrc: '/cards/team-card-front.png', + previewSrc: '/cards/team-card-example.jpg', + badge: 'NOVO', + width: 1080, + height: 1440, + photoWindow: { x: 286, y: 439, width: 592, height: 577 }, + }) + expect(getCardModel('eu-sou-solla')?.overlaySrc).toBeUndefined() + }) + + it('guards the team banners and name slot measured from the example card', () => { + expect(TEAM_CARD_LABEL).toMatchObject({ + text: 'TIME DE', + centerX: 545, + centerY: 162, + width: 693, + height: 118, + rotationDeg: -3.5, + background: '#e50e2f', + fill: '#ffec01', + capHeight: 93, + }) + expect(TEAM_CARD_NAME_BANNER).toMatchObject({ + centerX: 545, + centerY: 313, + width: 509, + height: 176, + rotationDeg: -4.1, + background: '#0061a5', + fill: '#ffffff', + }) + expect(TEAM_CARD_NAME_SLOT).toMatchObject({ + align: 'center', + banner: TEAM_CARD_NAME_BANNER, + capHeight: 130, + minCapHeight: 56, + maxInkWidth: 470, + maxLines: 1, + fill: '#ffffff', + }) + }) + it('sanitizes query-param values through isCardModelId', () => { expect(isCardModelId('eu-sou-solla')).toBe(true) expect(isCardModelId('perfil-quadrado')).toBe(true) expect(isCardModelId('perfil-retangular')).toBe(true) + expect(isCardModelId('time-de-voce')).toBe(true) expect(isCardModelId('modelo-inventado')).toBe(false) expect(isCardModelId(['perfil-quadrado'])).toBe(false) expect(isCardModelId(undefined)).toBe(false) diff --git a/tests/unit/cardNameFit.unit.spec.ts b/tests/unit/cardNameFit.unit.spec.ts index 214707bb..3e42aee0 100644 --- a/tests/unit/cardNameFit.unit.spec.ts +++ b/tests/unit/cardNameFit.unit.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { NAME_CARD_SLOT } from '@/lib/cardModels' +import { NAME_CARD_SLOT, TEAM_CARD_NAME_SLOT } from '@/lib/cardModels' import { fitCardName, normalizeCardName, @@ -116,4 +116,44 @@ describe('fitCardName', () => { expect(fit.ok).toBe(true) if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO') }) + + describe('team slot (single line, centered)', () => { + it('keeps the measured cap for a short name and never wraps', () => { + const fit = fitCardName('Ana', measure, TEAM_CARD_NAME_SLOT) + + expect(fit).toMatchObject({ ok: true, lines: ['ANA'] }) + if (fit.ok) { + expect(fit.capHeight).toBeCloseTo(TEAM_CARD_NAME_SLOT.capHeight, 5) + expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual( + TEAM_CARD_NAME_SLOT.maxInkWidth, + ) + } + }) + + it('shrinks a name that overflows the ink width down to the readable minimum', () => { + const fit = fitCardName('Maria', measure, TEAM_CARD_NAME_SLOT) + + expect(fit).toMatchObject({ ok: true, lines: ['MARIA'] }) + if (fit.ok) { + expect(fit.capHeight).toBeLessThan(TEAM_CARD_NAME_SLOT.capHeight) + expect(fit.fontSize).toBeGreaterThanOrEqual( + resolveFontSizeForCapHeight(measure, TEAM_CARD_NAME_SLOT.minCapHeight), + ) + expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual( + TEAM_CARD_NAME_SLOT.maxInkWidth, + ) + } + }) + + it('fails with too-long instead of wrapping below the readable minimum', () => { + expect(fitCardName('Maria da Conceição', measure, TEAM_CARD_NAME_SLOT)).toEqual({ + ok: false, + reason: 'too-long', + }) + expect(fitCardName('Bartolomeucostajunior', measure, TEAM_CARD_NAME_SLOT)).toEqual({ + ok: false, + reason: 'too-long', + }) + }) + }) }) diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts index c29d50cc..02981eb0 100644 --- a/tests/unit/cardPhotoTransform.unit.spec.ts +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -8,8 +8,10 @@ import { centerCardPhotoTransform, clampCardPhotoTransform, coverScale, + frameCardPhotoOnBbox, panCardPhotoTransform, zoomCardPhotoTransform, + type CardAlphaBbox, type CardPhotoSize, } from '@/lib/cardPhotoTransform' @@ -53,6 +55,64 @@ describe('centerCardPhotoTransform', () => { }) }) +describe('frameCardPhotoOnBbox', () => { + const TEAM_WINDOW: CardRect = { x: 286, y: 439, width: 592, height: 577 } + + it('anchors the bbox top on the window top and centers it horizontally', () => { + const source: CardPhotoSize = { width: 900, height: 1200 } + const bbox: CardAlphaBbox = { x: 260, y: 140, width: 380, height: 520 } + + const transform = frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox) + expect(transform).not.toBeNull() + if (!transform) return + + const rect = cardPhotoDrawRect(transform, source, TEAM_WINDOW) + const scale = rect.width / source.width + // bbox top lands exactly on the window top, bbox center on the window center + expect(rect.y + bbox.y * scale).toBeCloseTo(TEAM_WINDOW.y, 5) + expect(rect.x + (bbox.x + bbox.width / 2) * scale).toBeCloseTo( + TEAM_WINDOW.x + TEAM_WINDOW.width / 2, + 5, + ) + // the cutout itself covers the whole slot + expect(bbox.width * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.width - 1e-6) + expect(bbox.height * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.height - 1e-6) + expect(coversWindow(rect, TEAM_WINDOW)).toBe(true) + }) + + it('keeps the bottom of a full-height bbox visible when the zoom allows it', () => { + const source: CardPhotoSize = { width: 800, height: 1200 } + const bbox: CardAlphaBbox = { x: 200, y: 0, width: 400, height: 1000 } + + const transform = frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox) + expect(transform).not.toBeNull() + if (!transform) return + + const rect = cardPhotoDrawRect(transform, source, TEAM_WINDOW) + const scale = rect.width / source.width + expect(rect.y + (bbox.y + bbox.height) * scale).toBeGreaterThanOrEqual( + TEAM_WINDOW.y + TEAM_WINDOW.height, + ) + }) + + it('fails closed on a degenerate bbox (empty segmentation)', () => { + const source: CardPhotoSize = { width: 900, height: 1200 } + expect( + frameCardPhotoOnBbox(source, TEAM_WINDOW, { x: 0, y: 0, width: 0, height: 0 }), + ).toBeNull() + expect( + frameCardPhotoOnBbox(source, TEAM_WINDOW, { x: 10, y: 10, width: 10, height: 30 }), + ).toBeNull() + }) + + it('fails closed when the bbox is so small it would need more than the max zoom', () => { + const source: CardPhotoSize = { width: 4000, height: 4000 } + const bbox: CardAlphaBbox = { x: 100, y: 100, width: 80, height: 80 } + + expect(frameCardPhotoOnBbox(source, TEAM_WINDOW, bbox)).toBeNull() + }) +}) + describe('clampCardPhotoTransform', () => { it('clamps the zoom to [1, 4]', () => { expect( diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 908d1acd..30a28965 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -1,18 +1,32 @@ import { describe, expect, it } from 'vitest' -import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels' +import { + NAME_CARD_SLOT, + TEAM_CARD_LABEL, + TEAM_CARD_NAME_BANNER, + TEAM_CARD_NAME_SLOT, + getCardModel, + type CardRect, +} from '@/lib/cardModels' import { fitCardName } from '@/lib/cardNameFit' -import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform' +import { + centerCardPhotoTransform, + frameCardPhotoOnBbox, + type CardPhotoSize, +} from '@/lib/cardPhotoTransform' import { createCardMeasure, drawCardName, renderNameCard, renderPhotoCard, + renderTeamCard, type CardDrawContext, } from '@/lib/cardRender' type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number } type TextCall = { text: string; x: number; y: number } +type RectCall = { x: number; y: number; width: number; height: number } +type OpCall = { op: string; args: number[] } const fontSizeFrom = (font: string): number => { const match = /(\d+(?:\.\d+)?)px/.exec(font) @@ -22,6 +36,8 @@ const fontSizeFrom = (font: string): number => { const createFakeContext = () => { const drawCalls: DrawCall[] = [] const textCalls: TextCall[] = [] + const rectCalls: RectCall[] = [] + const ops: OpCall[] = [] const ctx: CardDrawContext = { font: '', @@ -31,6 +47,9 @@ const createFakeContext = () => { drawImage: (image, dx, dy, dw, dh) => { drawCalls.push({ image, dx, dy, dw, dh }) }, + fillRect: (x, y, width, height) => { + rectCalls.push({ x, y, width, height }) + }, fillText: (text, x, y) => { textCalls.push({ text, x, y }) }, @@ -42,13 +61,26 @@ const createFakeContext = () => { actualBoundingBoxDescent: size * 0.2, } }, + save: () => { + ops.push({ op: 'save', args: [] }) + }, + restore: () => { + ops.push({ op: 'restore', args: [] }) + }, + translate: (x, y) => { + ops.push({ op: 'translate', args: [x, y] }) + }, + rotate: (angle) => { + ops.push({ op: 'rotate', args: [angle] }) + }, } - return { ctx, drawCalls, textCalls } + return { ctx, drawCalls, textCalls, rectCalls, ops } } const nameModel = getCardModel('eu-sou-solla')! const squareModel = getCardModel('perfil-quadrado')! +const teamModel = getCardModel('time-de-voce')! describe('renderNameCard', () => { it('draws the master base first and then the name on the left border', () => { @@ -133,6 +165,110 @@ describe('drawCardName', () => { }) }) +describe('renderTeamCard', () => { + const window = teamModel.photoWindow! + const photoSize: CardPhotoSize = { width: 400, height: 600 } + const transform = frameCardPhotoOnBbox(photoSize, window, { + x: 80, + y: 40, + width: 240, + height: 420, + })! + + const render = (name: string) => { + const fake = createFakeContext() + const base = { id: 'base' } as unknown as CanvasImageSource + const overlay = { id: 'overlay' } as unknown as CanvasImageSource + const photo = { id: 'photo' } as unknown as CanvasImageSource + const result = renderTeamCard(fake.ctx, teamModel, { + base, + overlay, + photo, + photoSize, + transform, + window, + name, + fontFamily: 'Brexter', + measure: createCardMeasure(fake.ctx, 'Brexter'), + }) + + return { ...fake, result, base, overlay, photo } + } + + it('draws base → framed photo → overlay, then the label and the name banner', () => { + const { drawCalls, textCalls, rectCalls, ops, result, base, overlay, photo } = render('Maria') + + expect(result.fit).toMatchObject({ ok: true, lines: ['MARIA'] }) + expect(drawCalls.map((call) => call.image)).toEqual([base, photo, overlay]) + expect(drawCalls[0]).toEqual({ image: base, dx: 0, dy: 0, dw: 1080, dh: 1440 }) + expect(drawCalls[2]).toEqual({ image: overlay, dx: 0, dy: 0, dw: 1080, dh: 1440 }) + + // Both banners are drawn as rotated rectangles: two save/restore pairs. + expect(rectCalls).toHaveLength(2) + expect(rectCalls[0]).toMatchObject({ + width: TEAM_CARD_LABEL.width, + height: TEAM_CARD_LABEL.height, + }) + expect(rectCalls[1]).toMatchObject({ + width: TEAM_CARD_NAME_BANNER.width, + height: TEAM_CARD_NAME_BANNER.height, + }) + expect(ops.filter((op) => op.op === 'save')).toHaveLength(2) + expect(ops.filter((op) => op.op === 'restore')).toHaveLength(2) + expect(ops.map((op) => op.op)).toEqual([ + 'save', + 'translate', + 'rotate', + 'restore', + 'save', + 'translate', + 'rotate', + 'restore', + ]) + expect(ops[1]!.args).toEqual([TEAM_CARD_LABEL.centerX, TEAM_CARD_LABEL.centerY]) + expect(ops[5]!.args).toEqual([TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY]) + + expect(textCalls.map((call) => call.text)).toEqual(['TIME DE', 'MARIA']) + expect(textCalls.every((call) => call.x === 0)).toBe(true) + }) + + it('draws the label but no name banner when the name cannot fit (never cuts silently)', () => { + const { textCalls, rectCalls, result } = render('Bartolomeucostajunior') + + expect(result.fit).toEqual({ ok: false, reason: 'too-long' }) + expect(rectCalls).toHaveLength(1) + expect(textCalls.map((call) => call.text)).toEqual(['TIME DE']) + }) +}) + +describe('drawCardName (centered team slot)', () => { + it('draws the blue banner with the centered name through the same banner helper', () => { + const { ctx, textCalls, rectCalls, ops } = createFakeContext() + const fit = fitCardName('Maria', createCardMeasure(ctx, 'Brexter'), TEAM_CARD_NAME_SLOT) + expect(fit.ok).toBe(true) + if (!fit.ok) return + + drawCardName(ctx, { fit, fontFamily: 'Brexter', slot: TEAM_CARD_NAME_SLOT }) + + expect(rectCalls).toEqual([ + { + x: -TEAM_CARD_NAME_BANNER.width / 2, + y: -TEAM_CARD_NAME_BANNER.height / 2, + width: TEAM_CARD_NAME_BANNER.width, + height: TEAM_CARD_NAME_BANNER.height, + }, + ]) + expect(ops).toEqual([ + { op: 'save', args: [] }, + { op: 'translate', args: [TEAM_CARD_NAME_BANNER.centerX, TEAM_CARD_NAME_BANNER.centerY] }, + { op: 'rotate', args: [(TEAM_CARD_NAME_BANNER.rotationDeg * Math.PI) / 180] }, + { op: 'restore', args: [] }, + ]) + expect(textCalls).toEqual([{ text: 'MARIA', x: 0, y: fit.capHeight / 2 }]) + expect(ctx.fillStyle).toBe('#ffffff') + }) +}) + describe('renderPhotoCard', () => { it('draws the photo full-bleed under the official overlay', () => { const { ctx, drawCalls } = createFakeContext()