mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(S15): card "Time de você" — quarto modelo auto-serviço no funil público (Closes #1161)
This commit is contained in:
parent
7e164b637d
commit
560f2e494a
31 changed files with 1963 additions and 170 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/ci-pr.yml
vendored
3
.github/workflows/ci-pr.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/deploy.yml
vendored
3
.github/workflows/deploy.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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/
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
1
docs/changelog/2026-09-18-s15-card-time-de-voce.md
Normal file
1
docs/changelog/2026-09-18-s15-card-time-de-voce.md
Normal file
|
|
@ -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 <NOME>` 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.
|
||||
196
docs/plans/cards-time-de-voce-impl.md
Normal file
196
docs/plans/cards-time-de-voce-impl.md
Normal file
|
|
@ -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 <NOME>` 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 `<button>`; progresso em `role="status"`/`aria-live="polite"`; erro em `role="alert"`; o preview pronto reusa `tabIndex`/setas/zoom do bloco de foto; `Cancelar`/fechar funcionam em qualquer fase (não aborta o promise; run id ignora resultado tardio).
|
||||
Rejeitadas: B (espalha o ciclo e a guarda de corrida no componente grande), C (não há segundo consumidor).
|
||||
|
||||
6. **Testes — unit puro obrigatório; e2e com seam do motor; int não se aplica.**
|
||||
Opções: A) estender os specs de unit e o describe do `frontend.e2e.spec.ts` com stub do motor via flag de build | B) spec e2e novo com engine real no CI | C) testar pixels/jsdom.
|
||||
Recomendação: A — o stub (flag `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, nunca em produção; comportamento por `window.__cardsCutoutStub = 'ok' | 'slow' | 'error'` via `addInitScript`) evita baixar/rodar o wasm no CI e torna os estados (processing/ready/error/retry) determinísticos; o engine real é verificado no craft/UAT. Envs do stub: webServer do `playwright.config.ts` (dev) + step "Build for e2e" do `ci-pr.yml` e do `deploy.yml` (prod mode); documentar em `.env.example`.
|
||||
Rejeitadas: B porque o CI baixaria ~12MB de wasm e a inferência sobre fixture sintético é não-determinística; C porque jsdom não tem canvas e fidelidade se prova no craft + download real.
|
||||
Manifest: nenhuma entrada nova — `src/components/cards` e `src/lib/card` já mapeiam para `frontend` (`scripts/lib/e2e-affected-manifest.mjs:71-76`); `cardCutout`/`useCardCutout`/specs caem nesses prefixos, e `public/cards/*.tflite` não entra no classificador de e2e.
|
||||
|
||||
7. **Copy — “três” → “quatro” e literais das cenas.**
|
||||
Opções: A) editar os dois pontos canônicos (`cards/page.tsx:11` intro + `CardModelGallery.tsx:71` dica) e portar os literais das cenas 3–7 do design | B) centralizar tudo num arquivo de copy novo | C) reescrever a copy.
|
||||
Recomendação: A — a intro é metadata (`metadata.description = intro`) e a dica é a única outra menção; o compositor do time usa os literais do design: títulos "Entre para o time"/"Preparando sua foto"/"Confira seu card"; ajuda do nome "Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido."; bloco "Sua foto de busto" + "Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples." + "Escolher foto"; processamento "Baixando o modelo de recorte…" + "Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto no seu aparelho. Isso pode levar um pouco."; pronto "O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles." + "Arraste para ajustar" + "Trocar foto"; erro "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." + "Tentar de novo"/"Escolher outra foto"; nome longo reusa a mensagem exata existente ("Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.") + "A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você corrige o nome."; privacidade reusa `CARD_PRIVACY_NOTE`/`CARD_PHOTO_PRIVACY_NOTE`; eyebrow/título/corpo da home não mudam.
|
||||
Rejeitadas: B (1–2 call sites cada, sem compartilhamento), C (o gate é o design).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/cardModels.ts`** (editar): `kind` vira `'name' | 'photo' | 'team'`; 4º modelo `time-de-voce` (`assetSrc` base, `overlaySrc` front, `previewSrc` example, 1080×1440, `photoWindow {286,439,592,577}`, `badge 'NOVO'`); `TEAM_CARD_NAME_SLOT` (align center, centerX 545, capTop 248, capHeight 130, maxInkWidth 470, minCapHeight 56, maxLines 1, fill `#FFFFFF`, rotationDeg −4.1), `TEAM_CARD_NAME_BANNER` (545,313, 509×176, `#0061A5`, −4.1°) e `TEAM_CARD_LABEL` (693×118 centro (545,162) `#E50E2F`, texto `TIME DE`, cap 93, `#FFEC01`, −3.5°); `CardModel` ganha `overlaySrc?/previewSrc?/badge?`; `isCardModelId` cobre o novo id de graça.
|
||||
- **`src/lib/cardNameFit.ts`** (editar): `CardNameSlot` vira união discriminada por `align` (`left`+`leftX` | `center`+`centerX`+`rotationDeg`); matemática de fit intocada.
|
||||
- **`src/lib/cardPhotoTransform.ts`** (editar): `CardAlphaBbox` + `frameCardPhotoOnBbox(source, window, bbox): CardPhotoTransform | null`.
|
||||
- **`src/lib/cardRender.ts`** (editar): `CardDrawContext` ganha `save/restore/translate/rotate/fillRect`; `drawCardName` ramifica `align`; `drawCardBanner`; `renderTeamCard` (base → foto pelo transform → front → banner `TIME DE` + nome fitado; devolve fit/clamp; fit falho desenha os banners fixos sem o nome — nunca corta).
|
||||
- **`src/components/cards/cardCutout.ts`** (novo, adapter client-safe): dynamic import do tasks-vision; prefetch streaming do wasm; modelo como buffer; `segment()` após yield; confiança→alpha (`destination-in` bilinear), bbox (alpha>32, canvas ≤1600px), higiene de memória (`close()` de máscaras/segmenter); branch do stub.
|
||||
- **`src/components/cards/useCardCutout.ts`** (novo, client): máquina de estados, progresso por fase, `process(file)`, `retry()` (mesmo File), `reset()`, run id, dispose.
|
||||
- **`src/components/cards/CardComposer.tsx`** (editar): ramo `isTeamModel` com as cenas (idle exemplo + nome + foto; processing base + status/barra; ready canvas + controles + `Trocar foto`; erro de recorte; nome longo inline com CTA off); carrega `overlaySrc` junto do `assetSrc`; `canAdvance`/download condicionados ao cutout ready.
|
||||
- **`src/components/cards/CardModelTile.tsx`** (editar): `src={model.previewSrc ?? model.assetSrc}`, `sizes` para 4 colunas (~260px no lg), selo `NOVO`.
|
||||
- **`src/components/cards/CardModelGallery.tsx`** (editar): grid desktop `lg:grid-cols-4`; dica "Deslize para ver os quatro modelos".
|
||||
- **`src/app/(frontend)/(home)/cards/page.tsx`** (editar): intro/metadata "Escolha um dos quatro modelos…".
|
||||
- **`src/app/(frontend)/styles.css`** (editar): tokens `--team-red/--team-yellow/--team-blue` no tema `campaign-site` (port do artefato; o canvas mantém os literais no lib).
|
||||
- **`scripts/copy-card-vision-assets.mjs`** (novo; idempotente, importável) + **`scripts/dev.mjs`** (preflight) + **`package.json`** (`prebuild`) + **`Dockerfile`** (`RUN` no builder antes do `next build`) + **`.gitignore`** (`/public/cards/mediapipe/`) + **`.env.example`** (flag do stub).
|
||||
- **`public/cards/selfie_segmenter.tflite`** (novo, ~244KB, Apache-2.0, origem documentada).
|
||||
- **`playwright.config.ts`**, **`.github/workflows/ci-pr.yml`**, **`.github/workflows/deploy.yml`** (editar): `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1` no webServer dev e nos builds de e2e (nunca em produção).
|
||||
- **Testes** (editar): `tests/unit/cardModels.unit.spec.ts`, `cardNameFit.unit.spec.ts`, `cardPhotoTransform.unit.spec.ts`, `cardRender.unit.spec.ts`; `tests/e2e/frontend.e2e.spec.ts` (describe novo + loop de tiles do S14).
|
||||
- **`docs/changelog/2026-09-18-s15-card-time-de-voce.md`** (novo).
|
||||
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
|
||||
- **Access / Consent:** não se aplica — nenhuma superfície Payload; nome/foto nunca saem do aparelho; sem chave nova de Consent e sem persistência.
|
||||
- **UI:** Impeccable C — shape (artefato aprovado, cenas 1–7) → craft (port class-by-class; grid 4×1; engine real em fotos de busto; progresso/erro) → critique (a11y, mobile 390, copy) → polish. Shells reusados: `CardsStudio`, `ui/dialog`, `ui/Drawer`, `CardPreviewCanvas`; tokens `--pt-red`/`--campaign-*` + `--team-*`; alvos ≥44px, contraste ≥4,5:1, `role=status`/`role=alert`, foco preso e retorno ao tile.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única "forma" é a geometria do próprio card (literais medidos) e o progresso do processamento. Nome e foto são insumos locais da imagem — nunca dados apresentados, coletados ou enviados.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer da geometria + catálogo** — quota ~0,75 dia. Catálogo com o 4º modelo/slots; `CardNameSlot` com `align`; `CardDrawContext` com ops; `drawCardBanner` + `renderTeamCard`; `frameCardPhotoOnBbox`; units atualizados. O composer abre o time e desenha base+banners+nome (sem recorte) para calibrar contra `team-card-example.jpg` (posição/rotação/corpo/cores).
|
||||
Prova: `pnpm gate:fast` verde; `/cards?model=time-de-voce` mostra `TIME DE MARIA` nas posições medidas; pin dos 4 ids passa.
|
||||
2. **Motor + auto-enquadramento + estados** — quota ~1–1,5 dia. Dependência + script de cópia + tflite; `cardCutout` + `useCardCutout`; auto-frame; controles reusados; processing/ready/error/retry; stub seam + envs de e2e.
|
||||
Prova: `pnpm gate:fast` verde; no browser, fotos de busto reais recortam e enquadram (topo encostado/centrado), progresso aparece, foto ruim cai no erro com retry, sem COOP/COEP; wasm servido same-origin.
|
||||
3. **UI/copy/galeria + e2e + gates** — quota ~0,5–0,75 dia. Grid 4×1 + selo NOVO + `sizes`; dica/intro "quatro"; copy das cenas; craft/critique/polish; e2e do describe novo; changelog; `pnpm gate:fast`; e2e local (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Time de você"`); `pnpm push`.
|
||||
Prova: e2e verde com stub (galeria 4 tiles + selo; `/cards?model=time-de-voce` idle → processing → ready → resultado → download `card-jorge-solla-time-de-voce.png` com assinatura PNG; falha do recorte → `role=alert` + `Tentar de novo`/`Escolher outra foto` e retry vira ready; nome longo → mensagem de nome curto com CTA desabilitado; drawer mobile); unit dos 4 specs; fixture de console-error intacto.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Worker/OffscreenCanvas/COOP+COEP/multithread sem jank medido — o engine roda single-thread same-origin; gatilho: bloqueio perceptível de main thread no craft.
|
||||
- Máscara manual (pincel/borracha), detecção de rosto, múltiplas pessoas, "recorte perfeito", correção automática de foto.
|
||||
- Segundo editor/rota/fluxo; abrir o motor para os 3 modelos existentes; 5º modelo; CMS de modelos/métricas.
|
||||
- Refatorar `cardRender` para pipeline genérico de camadas.
|
||||
- Compartilhamento social, persistência de nome/foto/arte, upload, conta, analytics de PII.
|
||||
- Substituir o `@imgly/background-removal-node` dos scripts (gatilho: se o tooling deixar de ser interno); nenhum pacote AGPL no bundle do navegador.
|
||||
- Testes de pixel no jsdom/snapshot visual/e2e com o engine real (fidelidade se prova no craft + download real).
|
||||
- Headers globais no `next.config.mjs` (COOP/COEP quebraria o embed do YouTube — c167).
|
||||
|
||||
## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora
|
||||
|
||||
**Resolvido no simplify (não reabrir):** janela do banner embutida no slot (`slot.banner`,
|
||||
`maxLines: 1` literal) em vez de constante global; transform do recorte derivado
|
||||
(`photoTransform ?? ready.transform`) sem efeito-espelho (fim do frame de base puro); helper
|
||||
`cardPhotoTransformsEqual` compartilhado; `try/finally` fechando `MPMask`/resultado/bitmap no
|
||||
adapter; throttle de progresso dono único (adapter, 100 ms) com o hook sem dedupe duplicado;
|
||||
`bbox` removido do estado pronto do hook; campo do nome extraído (`TeamNameField` + mensagem
|
||||
única) e renderizado também no estado pronto com nome longo (não é mais beco sem saída);
|
||||
título/descrição/larguras por IIFE/mapa em vez de ternários aninhados; `role=status` só no
|
||||
rótulo da fase (leitor de tela não anuncia o percentual a cada tick); um único botão primário
|
||||
(`Processando…` via label); `handleTeamPhotoFile` não bloqueia o input; `void buffer` morto;
|
||||
NOTICE Apache-2.0 do `selfie_segmenter.tflite`; CardNameSlot importado só de `cardModels`;
|
||||
tipo do slot centralizado (`align` + banner); eyebrow/erro sem duplicação.
|
||||
|
||||
**Adiados com gatilho (engenharia):**
|
||||
|
||||
- **Fatiar o `CardComposer` em subcomponentes de estado** (processing/error/ready) — adjudicado
|
||||
durante a revisão como polish de legibilidade com risco de plumbing; hoje o arquivo tem
|
||||
~830 linhas e 3 ramos de time. Gatilho: um 5º modelo OU folga de ~0,5 dia no próximo
|
||||
item do funil de cards.
|
||||
- **`disposeCardCutout()` no unmount** — o singleton do segmenter é cache de vida da página
|
||||
(recriar a sessão wasm por abertura custaria segundos); máscaras/bitmaps são liberados por
|
||||
resultado. Gatilho: memória do tab virar problema medido (heap profiler) em uso prolongado.
|
||||
- **Stub de e2e em módulo lazy** — o stub é pequeno e o flag é build-time (`NEXT_PUBLIC_*`);
|
||||
mover para import dinâmico só se o bundle inicial do cards crescer além do appetite.
|
||||
- **`slot?` opcional do `renderTeamCard`/`slot` do adaptador de e2e pinado por import** —
|
||||
flexibilidade sem call site; documentado, sem Issue (score ≤2).
|
||||
|
||||
**Explicitamente fora (não reabrir):** detalhe do erro do motor na UI (engine/empty usam a
|
||||
mesma copy recuperável por decisão de produto); `CardModel` como união discriminada por kind
|
||||
(Decisão 2 registrou campos aditivos); união/dispose do segmenter (acima); preview sem
|
||||
`text-balance` no drawer (herdado do shell, acessível); trigger duplicado do wasm em dev/build
|
||||
(Dockerfile não roda `prebuild` — comentado nos três pontos).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Qualidade de borda do selfie segmenter (cabelo) abaixo do ISNet:** o aceite é honesto (busto/fundo simples, sem recorte perfeito); craft com fotos reais; alpha da máscara de confiança + escala bilinear suaviza a borda. Gatilho de revisão: se o UAT reprovar, o adapter é o único ponto a trocar (decisão de motor reaberta com o custo de licença explícito).
|
||||
- **Licença AGPL do pacote já existente:** `@imgly/background-removal-node` continua restrito a scripts/dev (não distribuído ao usuário).
|
||||
- **Build standalone sem os wasm:** o script de cópia roda no builder e no `prebuild`; o aceite verifica fetch 200 same-origin das URLs do wasm no deploy.
|
||||
- **Wasm grande no primeiro uso (~12MB):** lazy (nada no bundle inicial), cache HTTP, progresso explícito; sem headers globais.
|
||||
- **Bbox degenerado/segmentação vazia:** fail-closed com erro recuperável (nunca card com foto invisível); unit cobre o frame e e2e cobre o estado de erro.
|
||||
- **Corrida entre tentativas/troca de foto:** run id no hook; resultado antigo ignorado; desmontar não seta estado e faz dispose seguro.
|
||||
- **e2e instável:** stub determinístico evita wasm/inferência/console logs no CI; o describe S14 existente é ajustado (loop com 4 ids) no mesmo PR.
|
||||
- **`prebuild` novo no fluxo de build:** script idempotente/rápido, roda em CI/dev/Docker; falha de cópia falha o build (não silencia).
|
||||
- **iOS/Safari:** wasm SIMD suportado nas versões atuais; `destination-in`/`getImageData` ok; validar no craft (iPhone físico, como no S13).
|
||||
- **Fonte no canvas:** banners só desenham depois de `ensureCardFont` — sem fallback medido errado.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: 4º modelo `Time de você` na home e `/cards` (mesmo editor/shells); nome + foto de busto; `TIME DE <NOME>` nos banners com a foto recortada no centro sobre o fundo oficial; recorte no aparelho com progresso e retry; auto-enquadrado com pan/zoom como escape; nome em 1 linha com erro acionável (mesma mensagem); `/cards?model=time-de-voce`; toque e teclado; sem conta/analytics de PII; expectativa honesta de recorte.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem migration/collection/Consent; `src/lib` puro e sem DOM (engine no adapter em `src/components/cards`); copy pt-BR/identificadores em inglês; sem `as never`; knip sem órfãos; artes-mestre intocadas; `pnpm gate:fast` + e2e da superfície + `pnpm push` verdes.
|
||||
- [ ] Testes de domínio previstos: unit dos módulos puros (catálogo 4 ids, `frameCardPhotoOnBbox`, fit do slot do time, ordem/ops do `renderTeamCard`) obrigatório; e2e no spec `frontend` com stub do motor (processing/ready/download real/erro/retry/nome longo/mobile); **int não se aplica** — não há Payload/DB/access nem escrita multi-collection neste fluxo (100% client-side + assets estáticos).
|
||||
|
||||
## Self-score decision-quality: 5/5
|
||||
|
||||
1. **Decisões caras com rejeitadas:** motor (licença/hosting/progresso), 4º kind, auto-frame, fit/rotação, estado/hook, harness de e2e e copy — todas com Opções/Recomendação/Rejeitadas.
|
||||
2. **Cabe no appetite:** 3 fases ≤3 dias, sem migration/infra nova; tracer de geometria antes do motor pesado.
|
||||
3. **Rabbit holes nomeados:** worker/COOP, máscara manual, segundo editor, pipeline genérico, engine real no CI, CDN de terceiros.
|
||||
4. **Depth check:** reusa `CardsStudio`/shells, `fitCardName`, `cardPhotoTransform`/clamp, `cardRender`, `loadCardImage`/`loadCardPhoto`/`ensureCardFont`, prewarm/manifest/specs; nada de abstração especulativa.
|
||||
5. **Intenção preservada:** aceite de produto intacto (outcome, geometria literal, privacidade, expectativa honesta); a engenharia só resolveu a escolha de motor que a intenção deixou explicitamente aberta.
|
||||
|
|
@ -253,6 +253,8 @@ const eslintConfig = [
|
|||
'coverage/**',
|
||||
// Vendored agent material (same carve-out as .prettierignore):
|
||||
'.agents/**',
|
||||
// S15 — MediaPipe wasm glue copied from node_modules (generated, gitignored):
|
||||
'public/cards/mediapipe/**',
|
||||
]),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"license": "UNLICENSED",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prebuild": "node scripts/copy-card-vision-assets.mjs",
|
||||
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --conditions=react-server\" payload migrate && cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
|
||||
"db:start": "node scripts/db-start.mjs",
|
||||
"db:stop": "docker compose -p teqo down",
|
||||
|
|
@ -101,6 +102,7 @@
|
|||
"@fullcalendar/react": "7.0.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@imgly/background-removal-node": "^1.4.5",
|
||||
"@mediapipe/tasks-vision": "^1.0.1",
|
||||
"@payloadcms/db-postgres": "3.82.0",
|
||||
"@payloadcms/email-resend": "3.82.0",
|
||||
"@payloadcms/live-preview-react": "3.82.0",
|
||||
|
|
|
|||
|
|
@ -220,6 +220,13 @@ export default defineConfig({
|
|||
env: {
|
||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||
PORT: webServerPort,
|
||||
/*
|
||||
* S15 — the card-cutout engine is replaced by a deterministic stub in
|
||||
* tests: no 12 MB wasm download, no inference, no console noise. The
|
||||
* real engine is exercised manually (craft/UAT). CI's prod build passes
|
||||
* the same flag in its "Build for e2e" step.
|
||||
*/
|
||||
NEXT_PUBLIC_CARDS_CUTOUT_STUB: process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB ?? '1',
|
||||
/*
|
||||
* C122 — the fake service-account key lets the agenda Google mirror
|
||||
* derive real states (synced/disabled/paused) in the server process.
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ importers:
|
|||
'@imgly/background-removal-node':
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@payloadcms/db-postgres':
|
||||
specifier: 3.82.0
|
||||
version: 3.82.0(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))
|
||||
|
|
@ -1789,6 +1792,9 @@ packages:
|
|||
peerDependencies:
|
||||
yjs: '>=13.5.22'
|
||||
|
||||
'@mediapipe/tasks-vision@1.0.1':
|
||||
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1':
|
||||
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -9490,6 +9496,8 @@ snapshots:
|
|||
lexical: 0.41.0
|
||||
yjs: 13.6.29
|
||||
|
||||
'@mediapipe/tasks-vision@1.0.1': {}
|
||||
|
||||
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.11(hono@4.12.7)
|
||||
|
|
|
|||
25
public/cards/selfie_segmenter.LICENSE.txt
Normal file
25
public/cards/selfie_segmenter.LICENSE.txt
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
selfie_segmenter.tflite
|
||||
=======================
|
||||
|
||||
Model: MediaPipe Selfie Segmenter (float16, 256x256)
|
||||
Source: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/1/selfie_segmenter.tflite
|
||||
SHA-256: 191ac9529ae506ee0beefa6b2c945a172dab9d07d1e802a290a4e4038226658b
|
||||
|
||||
Copyright 2023 The MediaPipe Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
The MediaPipe Vision wasm runtime is not vendored here: the published npm
|
||||
package `@mediapipe/tasks-vision` (also Apache-2.0) is copied into
|
||||
public/cards/mediapipe/wasm/ at dev/build time by
|
||||
scripts/copy-card-vision-assets.mjs.
|
||||
BIN
public/cards/selfie_segmenter.tflite
Normal file
BIN
public/cards/selfie_segmenter.tflite
Normal file
Binary file not shown.
53
scripts/copy-card-vision-assets.mjs
Normal file
53
scripts/copy-card-vision-assets.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* S15 — copies the MediaPipe Vision wasm runtime into `public/cards/mediapipe/`
|
||||
* so the card cutout runs 100% same-origin (no CDN executing JS in the
|
||||
* visitor's browser; the e2e suite never touches the network).
|
||||
*
|
||||
* The wasm is a gitignored build artifact: this script runs in `pnpm dev`
|
||||
* (preflight), in `pnpm build` (npm `prebuild`) and in the Docker builder
|
||||
* before `next build`, so both the dev server and the standalone image serve
|
||||
* the files. Idempotent and cheap: a file already present with the same byte
|
||||
* size is skipped.
|
||||
*/
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
const WASM_FILES = [
|
||||
'vision_wasm_internal.js',
|
||||
'vision_wasm_internal.wasm',
|
||||
'vision_wasm_nosimd_internal.js',
|
||||
'vision_wasm_nosimd_internal.wasm',
|
||||
]
|
||||
|
||||
export const copyCardVisionAssets = async (repoRoot) => {
|
||||
const sourceDir = path.join(repoRoot, 'node_modules/@mediapipe/tasks-vision/wasm')
|
||||
const targetDir = path.join(repoRoot, 'public/cards/mediapipe/wasm')
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
|
||||
for (const name of WASM_FILES) {
|
||||
const source = path.join(sourceDir, name)
|
||||
const target = path.join(targetDir, name)
|
||||
|
||||
try {
|
||||
const [sourceStat, targetStat] = await Promise.all([
|
||||
stat(source),
|
||||
stat(target).catch(() => null),
|
||||
])
|
||||
if (targetStat && targetStat.size === sourceStat.size) continue
|
||||
await writeFile(target, await readFile(source))
|
||||
console.log(`[copy-card-vision-assets] ${name}`)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`[copy-card-vision-assets] failed to copy ${name}: ${error instanceof Error ? error.message : error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (isDirectRun) {
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
await copyCardVisionAssets(repoRoot)
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { copyCardVisionAssets } from './copy-card-vision-assets.mjs'
|
||||
import './guard-dev-db.mjs'
|
||||
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
|
||||
|
||||
|
|
@ -22,6 +23,14 @@ const die = dieWithLabel('dev')
|
|||
|
||||
loadCliEnv()
|
||||
|
||||
// S15 — the card-cutout wasm runtime is a gitignored artifact: make sure the dev
|
||||
// server serves it same-origin before Next boots.
|
||||
try {
|
||||
await copyCardVisionAssets(process.cwd())
|
||||
} catch (error) {
|
||||
die(error.message)
|
||||
}
|
||||
|
||||
let port
|
||||
try {
|
||||
port = resolveDevPort(process.env)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { CardsStudio } from '@/components/cards/CardsStudio'
|
|||
import { isCardModelId } from '@/lib/cardModels'
|
||||
|
||||
const intro =
|
||||
'Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
|
||||
'Escolha um dos quatro modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Crie seu card de apoio | Jorge Solla',
|
||||
|
|
|
|||
|
|
@ -184,6 +184,11 @@ body,
|
|||
--campaign-band: #ebe9e9;
|
||||
--campaign-line: rgb(0 0 0 / 12%);
|
||||
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
|
||||
/* S15 — literal colors of the "Time de você" card banners, measured from the
|
||||
reference card. The canvas renderer keeps the same literals in `src/lib`. */
|
||||
--team-red: #e50e2f;
|
||||
--team-yellow: #ffec01;
|
||||
--team-blue: #0061a5;
|
||||
/* S9 — the "novidades" capture form sits on the light band, so the field
|
||||
border must be visible against it (the :root default is a white border
|
||||
for the dark WhatsApp page). */
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
CheckIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
|
|
@ -20,20 +21,27 @@ import {
|
|||
} from '@/components/cards/cardCanvas'
|
||||
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
|
||||
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
|
||||
import { useCardCutout } from '@/components/cards/useCardCutout'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
|
||||
import type { CardModel } from '@/lib/cardModels'
|
||||
import type { CardNameFit } from '@/lib/cardNameFit'
|
||||
import { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
|
||||
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
|
||||
import {
|
||||
CARD_PHOTO_MAX_ZOOM,
|
||||
CARD_PHOTO_MIN_ZOOM,
|
||||
cardPhotoTransformsEqual,
|
||||
centerCardPhotoTransform,
|
||||
panCardPhotoTransform,
|
||||
zoomCardPhotoTransform,
|
||||
type CardPhotoSize,
|
||||
type CardPhotoTransform,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
|
||||
import {
|
||||
createCardMeasure,
|
||||
renderNameCard,
|
||||
renderPhotoCard,
|
||||
renderTeamCard,
|
||||
} from '@/lib/cardRender'
|
||||
|
||||
const primaryButtonClassName =
|
||||
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
|
@ -42,6 +50,57 @@ const secondaryButtonClassName =
|
|||
const controlButtonClassName =
|
||||
'inline-flex size-11 items-center justify-center rounded-lg border border-(--campaign-line) bg-white text-(--campaign-ink) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none'
|
||||
|
||||
const NAME_TOO_LONG_MESSAGE =
|
||||
'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.'
|
||||
|
||||
/** S15 design gate: the team preview width per state. */
|
||||
type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result'
|
||||
const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = {
|
||||
idle: 'max-w-[10.75rem]',
|
||||
processing: 'max-w-[9rem]',
|
||||
ready: 'max-w-[15rem]',
|
||||
result: 'max-w-[11.875rem]',
|
||||
}
|
||||
|
||||
const TeamNameField = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
inputClassName,
|
||||
}: {
|
||||
id: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
error: boolean
|
||||
inputClassName: string
|
||||
}) => (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-bold text-(--campaign-ink)">
|
||||
Seu nome
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
autoComplete="off"
|
||||
maxLength={60}
|
||||
placeholder="Como você é chamado"
|
||||
aria-invalid={error}
|
||||
className={inputClassName}
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-(--campaign-muted)">
|
||||
Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido.
|
||||
</p>
|
||||
{error ? (
|
||||
<p role="alert" className="mt-2 text-sm leading-5 font-semibold text-(--pt-red)">
|
||||
{NAME_TOO_LONG_MESSAGE}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
type CardComposerProps = {
|
||||
model: CardModel
|
||||
shell: 'dialog' | 'drawer'
|
||||
|
|
@ -58,6 +117,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
|
||||
const [overlayImage, setOverlayImage] = useState<HTMLImageElement | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
|
|
@ -68,10 +129,32 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const zoomInputId = useId()
|
||||
|
||||
const photoWindow = model.photoWindow
|
||||
const isNameModel = model.kind === 'name'
|
||||
const isTeamModel = model.kind === 'team'
|
||||
const cutout = useCardCutout(photoWindow)
|
||||
const cutoutState = cutout.state
|
||||
|
||||
const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null
|
||||
const teamError = cutoutState.status === 'error' ? cutoutState : null
|
||||
const teamReady = cutoutState.status === 'ready' ? cutoutState : null
|
||||
|
||||
const photoSize = useMemo<CardPhotoSize | null>(
|
||||
() => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
|
||||
[photo],
|
||||
)
|
||||
const transformSourceSize = useMemo<CardPhotoSize | null>(() => {
|
||||
if (!isTeamModel) return photoSize
|
||||
return teamReady ? { width: teamReady.width, height: teamReady.height } : null
|
||||
}, [isTeamModel, photoSize, teamReady])
|
||||
|
||||
/**
|
||||
* The cutout carries the initial framing; local state takes over after the
|
||||
* first pan/zoom. Deriving (instead of mirroring through an effect) keeps the
|
||||
* first ready paint composed — no bare-base frame.
|
||||
*/
|
||||
const effectiveTransform = isTeamModel
|
||||
? (photoTransform ?? teamReady?.transform ?? null)
|
||||
: photoTransform
|
||||
|
||||
// Assets + font load once per model: typing/panning must not flip the preview
|
||||
// back into the loading state (flicker) nor re-announce the live region.
|
||||
|
|
@ -79,14 +162,22 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
let cancelled = false
|
||||
setLoadState('loading')
|
||||
setBaseImage(null)
|
||||
setOverlayImage(null)
|
||||
setPreviewImage(null)
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const fontReady = await ensureCardFont(fontFamily)
|
||||
if (!fontReady) throw new Error('card-font-unavailable')
|
||||
const image = await loadCardImage(model.assetSrc)
|
||||
const [image, overlay, preview] = await Promise.all([
|
||||
loadCardImage(model.assetSrc),
|
||||
model.overlaySrc ? loadCardImage(model.overlaySrc) : Promise.resolve(null),
|
||||
model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null),
|
||||
])
|
||||
if (cancelled) return
|
||||
setBaseImage(image)
|
||||
setOverlayImage(overlay)
|
||||
setPreviewImage(preview)
|
||||
setLoadState('ready')
|
||||
} catch {
|
||||
if (!cancelled) setLoadState('error')
|
||||
|
|
@ -97,7 +188,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [model.assetSrc, fontFamily])
|
||||
}, [model.assetSrc, model.overlaySrc, model.previewSrc, fontFamily])
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
|
|
@ -105,7 +196,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
|
||||
ctx.clearRect(0, 0, model.width, model.height)
|
||||
|
||||
if (model.kind === 'name') {
|
||||
if (isNameModel) {
|
||||
setNameFit(
|
||||
renderNameCard(ctx, model, {
|
||||
image: baseImage,
|
||||
|
|
@ -117,20 +208,44 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
return
|
||||
}
|
||||
|
||||
if (photo && photoTransform && photoWindow && photoSize) {
|
||||
if (isTeamModel) {
|
||||
const measure = createCardMeasure(ctx, fontFamily)
|
||||
|
||||
if (teamReady && overlayImage && effectiveTransform && photoWindow) {
|
||||
const result = renderTeamCard(ctx, model, {
|
||||
base: baseImage,
|
||||
overlay: overlayImage,
|
||||
photo: teamReady.canvas,
|
||||
photoSize: { width: teamReady.width, height: teamReady.height },
|
||||
transform: effectiveTransform,
|
||||
window: photoWindow,
|
||||
name,
|
||||
fontFamily,
|
||||
measure,
|
||||
})
|
||||
setNameFit(result.fit)
|
||||
if (!cardPhotoTransformsEqual(result.transform, effectiveTransform)) {
|
||||
setPhotoTransform(result.transform)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT))
|
||||
const idleImage = cutoutState.status === 'idle' && previewImage ? previewImage : baseImage
|
||||
ctx.drawImage(idleImage, 0, 0, model.width, model.height)
|
||||
return
|
||||
}
|
||||
|
||||
if (photo && effectiveTransform && photoWindow && photoSize) {
|
||||
const clamped = renderPhotoCard(ctx, {
|
||||
photo,
|
||||
frame: baseImage,
|
||||
photoSize,
|
||||
frameSize: { width: model.width, height: model.height },
|
||||
window: photoWindow,
|
||||
transform: photoTransform,
|
||||
transform: effectiveTransform,
|
||||
})
|
||||
if (
|
||||
clamped.zoom !== photoTransform.zoom ||
|
||||
clamped.offsetX !== photoTransform.offsetX ||
|
||||
clamped.offsetY !== photoTransform.offsetY
|
||||
) {
|
||||
if (!cardPhotoTransformsEqual(clamped, effectiveTransform)) {
|
||||
setPhotoTransform(clamped)
|
||||
}
|
||||
return
|
||||
|
|
@ -141,7 +256,23 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
ctx.fillRect(0, 0, model.width, model.height)
|
||||
ctx.drawImage(baseImage, 0, 0, model.width, model.height)
|
||||
}
|
||||
}, [baseImage, loadState, model, name, fontFamily, photo, photoSize, photoTransform, photoWindow])
|
||||
}, [
|
||||
baseImage,
|
||||
overlayImage,
|
||||
previewImage,
|
||||
loadState,
|
||||
isNameModel,
|
||||
isTeamModel,
|
||||
teamReady,
|
||||
cutoutState.status,
|
||||
model,
|
||||
name,
|
||||
fontFamily,
|
||||
photo,
|
||||
photoSize,
|
||||
effectiveTransform,
|
||||
photoWindow,
|
||||
])
|
||||
|
||||
const handlePhotoFile = async (file: File | undefined) => {
|
||||
if (!file || !photoWindow) return
|
||||
|
|
@ -165,8 +296,31 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
}
|
||||
}
|
||||
|
||||
const handleTeamPhotoFile = (file: File | undefined) => {
|
||||
if (!file) return
|
||||
setPhotoError(null)
|
||||
setPhotoTransform(null)
|
||||
void cutout.start(file)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
/** Applies a pan/zoom update over the derived or user-owned transform. */
|
||||
const withTransform = (
|
||||
update: (
|
||||
transform: CardPhotoTransform,
|
||||
size: CardPhotoSize,
|
||||
window: CardRect,
|
||||
) => CardPhotoTransform,
|
||||
) => {
|
||||
if (!transformSourceSize || !photoWindow) return
|
||||
setPhotoTransform((current) => {
|
||||
const base = current ?? (isTeamModel ? (teamReady?.transform ?? null) : null)
|
||||
return base ? update(base, transformSourceSize, photoWindow) : base
|
||||
})
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!photoSize || !photoWindow) return
|
||||
if (!transformSourceSize || !photoWindow) return
|
||||
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
|
@ -174,14 +328,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
const drag = dragRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId || !photoSize || !photoWindow || !canvas) return
|
||||
if (!drag || drag.pointerId !== event.pointerId || !canvas) return
|
||||
|
||||
const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width)
|
||||
const dx = (event.clientX - drag.x) * scale
|
||||
const dy = (event.clientY - drag.y) * scale
|
||||
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
||||
setPhotoTransform((current) =>
|
||||
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
|
||||
withTransform((transform, size, window) =>
|
||||
panCardPhotoTransform(transform, size, window, dx, dy),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -190,16 +344,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
}
|
||||
|
||||
const panBy = (dx: number, dy: number) => {
|
||||
if (!photoSize || !photoWindow) return
|
||||
setPhotoTransform((current) =>
|
||||
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
|
||||
withTransform((transform, size, window) =>
|
||||
panCardPhotoTransform(transform, size, window, dx, dy),
|
||||
)
|
||||
}
|
||||
|
||||
const zoomTo = (zoom: number) => {
|
||||
if (!photoSize || !photoWindow) return
|
||||
setPhotoTransform((current) =>
|
||||
current ? zoomCardPhotoTransform(current, photoSize, photoWindow, zoom) : current,
|
||||
withTransform((transform, size, window) =>
|
||||
zoomCardPhotoTransform(transform, size, window, zoom),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -219,31 +371,64 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
}
|
||||
}
|
||||
|
||||
const isNameModel = model.kind === 'name'
|
||||
const nameCanAdvance = nameFit?.ok === true
|
||||
const canAdvance = isNameModel ? nameCanAdvance : photo !== null
|
||||
const nameError = isNameModel && name.trim().length > 0 && nameFit !== null && !nameFit.ok
|
||||
const title =
|
||||
step === 'result'
|
||||
? 'Seu card está pronto para compartilhar.'
|
||||
: isNameModel
|
||||
? 'Personalize com seu nome'
|
||||
: 'Enquadre sua foto'
|
||||
const description =
|
||||
step === 'result' || isNameModel
|
||||
? null
|
||||
: 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
|
||||
const canAdvance = isNameModel
|
||||
? nameCanAdvance
|
||||
: isTeamModel
|
||||
? teamReady !== null && nameCanAdvance
|
||||
: photo !== null
|
||||
const nameError =
|
||||
(isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok
|
||||
const adjustable = isTeamModel ? teamReady !== null : photo !== null
|
||||
const title = (() => {
|
||||
if (step === 'result') return 'Seu card está pronto para compartilhar.'
|
||||
if (isNameModel) return 'Personalize com seu nome'
|
||||
if (!isTeamModel) return 'Enquadre sua foto'
|
||||
if (teamProcessing) return 'Preparando sua foto'
|
||||
if (teamError) return 'Vamos tentar outra vez'
|
||||
if (nameError) return 'Encurte o nome'
|
||||
if (teamReady) return 'Confira seu card'
|
||||
return 'Entre para o time'
|
||||
})()
|
||||
const description = (() => {
|
||||
if (step === 'result' || isNameModel) return null
|
||||
if (isTeamModel) {
|
||||
return teamReady && !nameError
|
||||
? 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.'
|
||||
: null
|
||||
}
|
||||
return 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
|
||||
})()
|
||||
const eyebrow = isTeamModel
|
||||
? step === 'result'
|
||||
? 'Tudo certo'
|
||||
: teamError
|
||||
? 'Falha no recorte'
|
||||
: nameError
|
||||
? 'Nome muito longo'
|
||||
: 'Time de você'
|
||||
: null
|
||||
const eyebrowNode = eyebrow ? (
|
||||
<p className="text-[10px] font-black tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null
|
||||
|
||||
// The drawer header defaults to centered + `shrink-0`, so a long description
|
||||
// would overflow the popup; `text-left!` + `flex-1` port the design gate
|
||||
// (left-aligned, wrapped header) for every model in the shared composer.
|
||||
const header =
|
||||
shell === 'dialog' ? (
|
||||
<DialogHeader className="min-w-0 text-left">
|
||||
{eyebrowNode}
|
||||
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DrawerHeader className="min-w-0 text-left">
|
||||
<DrawerHeader className="min-w-0 flex-1 text-left!">
|
||||
{eyebrowNode}
|
||||
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
|
||||
{title}
|
||||
</DrawerTitle>
|
||||
|
|
@ -251,97 +436,17 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
</DrawerHeader>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
|
||||
<div className="flex items-start gap-3 px-5 pt-5">
|
||||
{header}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Fechar"
|
||||
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
const teamPreviewStage: TeamPreviewStage =
|
||||
step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle'
|
||||
const previewWidthClassName = isTeamModel ? TEAM_PREVIEW_WIDTH[teamPreviewStage] : 'max-w-[22rem]'
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 pb-5">
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{loadState === 'loading' ? 'Carregando prévia…' : ''}
|
||||
</p>
|
||||
const nameInputClassName = `mt-2 h-11 w-full rounded-lg bg-white px-3 text-base text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
|
||||
nameError
|
||||
? 'border-2 border-(--pt-red)'
|
||||
: 'border border-(--field-border) focus-visible:border-(--pt-red)'
|
||||
}`
|
||||
|
||||
<div
|
||||
className={`relative mx-auto w-full max-w-[22rem] ${photo ? 'touch-none' : ''}`}
|
||||
tabIndex={photo ? 0 : undefined}
|
||||
role={photo ? 'group' : undefined}
|
||||
aria-label={photo ? 'Ajuste da foto' : undefined}
|
||||
onKeyDown={
|
||||
photo
|
||||
? (event) => {
|
||||
const step = event.shiftKey ? 48 : 12
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
}
|
||||
const delta = deltas[event.key]
|
||||
if (!delta) return
|
||||
event.preventDefault()
|
||||
panBy(delta[0], delta[1])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardPreviewCanvas
|
||||
model={model}
|
||||
canvasRef={canvasRef}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadState === 'error' ? (
|
||||
<p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)">
|
||||
Não foi possível carregar o modelo. Recarregue a página e tente de novo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && isNameModel ? (
|
||||
<div className="mt-5">
|
||||
<label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)">
|
||||
Seu nome
|
||||
</label>
|
||||
<input
|
||||
id={nameInputId}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
maxLength={60}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">
|
||||
Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você.
|
||||
</p>
|
||||
{nameError ? (
|
||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||
Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é
|
||||
chamado.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && !isNameModel && photoWindow ? (
|
||||
<div className="mt-5">
|
||||
{photo && photoTransform ? (
|
||||
const zoomAndPanControls = effectiveTransform ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
|
||||
|
|
@ -350,7 +455,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
<button
|
||||
type="button"
|
||||
aria-label="Diminuir zoom"
|
||||
onClick={() => zoomTo(photoTransform.zoom - 0.1)}
|
||||
onClick={() => zoomTo(effectiveTransform.zoom - 0.1)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<MinusIcon className="size-4" aria-hidden="true" />
|
||||
|
|
@ -361,14 +466,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
min={CARD_PHOTO_MIN_ZOOM}
|
||||
max={CARD_PHOTO_MAX_ZOOM}
|
||||
step={0.05}
|
||||
value={photoTransform.zoom}
|
||||
value={effectiveTransform.zoom}
|
||||
onChange={(event) => zoomTo(Number(event.target.value))}
|
||||
className="h-11 w-full flex-1 accent-(--pt-red)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Aumentar zoom"
|
||||
onClick={() => zoomTo(photoTransform.zoom + 0.1)}
|
||||
onClick={() => zoomTo(effectiveTransform.zoom + 0.1)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
|
|
@ -413,22 +518,246 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
|
||||
<div className="flex items-start gap-3 px-5 pt-5">
|
||||
{header}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Fechar"
|
||||
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 pb-5">
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{loadState === 'loading' ? 'Carregando prévia…' : ''}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}`}
|
||||
tabIndex={adjustable ? 0 : undefined}
|
||||
role={adjustable ? 'group' : undefined}
|
||||
aria-label={adjustable ? 'Ajuste da foto' : undefined}
|
||||
onKeyDown={
|
||||
adjustable
|
||||
? (event) => {
|
||||
const step = event.shiftKey ? 48 : 12
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
}
|
||||
const delta = deltas[event.key]
|
||||
if (!delta) return
|
||||
event.preventDefault()
|
||||
panBy(delta[0], delta[1])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardPreviewCanvas
|
||||
model={model}
|
||||
canvasRef={canvasRef}
|
||||
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={adjustable ? handlePointerDown : undefined}
|
||||
onPointerMove={adjustable ? handlePointerMove : undefined}
|
||||
onPointerUp={adjustable ? handlePointerEnd : undefined}
|
||||
onPointerCancel={adjustable ? handlePointerEnd : undefined}
|
||||
/>
|
||||
{teamReady && step === 'compose' && !nameError ? (
|
||||
<span className="pointer-events-none absolute right-2 bottom-2 rounded bg-black/70 px-2 py-1 text-[10px] font-bold text-white">
|
||||
Arraste para ajustar
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loadState === 'error' ? (
|
||||
<p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)">
|
||||
Não foi possível carregar o modelo. Recarregue a página e tente de novo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && isNameModel ? (
|
||||
<div className="mt-5">
|
||||
<label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)">
|
||||
Seu nome
|
||||
</label>
|
||||
<input
|
||||
id={nameInputId}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
maxLength={60}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">
|
||||
Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você.
|
||||
</p>
|
||||
{nameError ? (
|
||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||
{NAME_TOO_LONG_MESSAGE}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? (
|
||||
<div className="mt-5">
|
||||
<TeamNameField
|
||||
id={nameInputId}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
error={nameError}
|
||||
inputClassName={nameInputClassName}
|
||||
/>
|
||||
<div className="mt-5 rounded-lg border border-(--campaign-line) bg-(--campaign-surface) p-4">
|
||||
<p className="text-sm font-bold text-(--campaign-ink)">Sua foto de busto</p>
|
||||
<p className="mt-1 text-sm leading-5 text-(--campaign-muted)">
|
||||
Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-3 inline-flex min-h-11 w-full items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
>
|
||||
Escolher foto
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && teamProcessing ? (
|
||||
<div className="mt-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-sm font-bold text-(--campaign-ink)"
|
||||
>
|
||||
{teamProcessing.phase === 'download'
|
||||
? 'Baixando o modelo de recorte…'
|
||||
: 'Removendo o fundo da foto…'}
|
||||
</p>
|
||||
<p aria-hidden="true" className="text-sm font-black text-(--pt-red)">
|
||||
{Math.round(teamProcessing.ratio * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`Progresso do recorte: ${Math.round(teamProcessing.ratio * 100)}%`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(teamProcessing.ratio * 100)}
|
||||
className="mt-2 h-2 overflow-hidden rounded-full bg-(--campaign-band)"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-(--pt-red) transition-all"
|
||||
style={{ width: `${Math.round(teamProcessing.ratio * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-5 text-(--campaign-muted)">
|
||||
Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto
|
||||
no seu aparelho. Isso pode levar um pouco.
|
||||
</p>
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-(--team-blue) text-white"
|
||||
>
|
||||
<CheckIcon className="size-3" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && teamError ? (
|
||||
<div className="mt-5">
|
||||
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
|
||||
<p className="text-sm font-black text-(--pt-red)">
|
||||
Não foi possível remover o fundo desta foto.
|
||||
</p>
|
||||
<p className="mt-1 text-sm leading-5 text-(--campaign-ink)">
|
||||
Tente de novo ou escolha outra foto.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
|
||||
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
|
||||
</p>
|
||||
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
||||
{CARD_PHOTO_PRIVACY_NOTE}
|
||||
</p>
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={secondaryButtonClassName}
|
||||
>
|
||||
Escolher outra foto
|
||||
</button>
|
||||
<button type="button" onClick={cutout.retry} className={primaryButtonClassName}>
|
||||
Tentar de novo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && teamReady && photoWindow && transformSourceSize ? (
|
||||
<div className="mt-5">
|
||||
{nameError ? (
|
||||
<>
|
||||
<TeamNameField
|
||||
id={nameInputId}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
error={nameError}
|
||||
inputClassName={nameInputClassName}
|
||||
/>
|
||||
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">
|
||||
A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você
|
||||
corrige o nome.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{zoomAndPanControls}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`${secondaryButtonClassName} mt-4 w-full`}
|
||||
>
|
||||
Trocar foto
|
||||
</button>
|
||||
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
||||
{CARD_PHOTO_PRIVACY_NOTE}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && !isNameModel && !isTeamModel && photoWindow ? (
|
||||
<div className="mt-5">
|
||||
{photo && effectiveTransform ? (
|
||||
zoomAndPanControls
|
||||
) : (
|
||||
<p className="text-sm text-(--campaign-muted)">
|
||||
Escolha uma foto vertical ou horizontal para começar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
onChange={(event) => void handlePhotoFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{photoError ? (
|
||||
<div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3">
|
||||
<p className="text-sm font-bold text-(--pt-red)">Não foi possível usar esta foto</p>
|
||||
|
|
@ -452,9 +781,35 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
onChange={(event) =>
|
||||
isTeamModel
|
||||
? handleTeamPhotoFile(event.target.files?.[0])
|
||||
: void handlePhotoFile(event.target.files?.[0])
|
||||
}
|
||||
/>
|
||||
|
||||
{step === 'result' ? (
|
||||
<div className="mt-5">
|
||||
{isTeamModel ? (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-green-700 text-white"
|
||||
>
|
||||
<CheckIcon className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||
)}
|
||||
{downloadError ? (
|
||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||
{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'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
<div className="mt-8">
|
||||
<ul
|
||||
aria-label={`${ariaLabel} (desktop)`}
|
||||
className="m-0 hidden list-none grid-cols-3 gap-5 p-0 sm:gap-6 lg:grid"
|
||||
className="m-0 hidden list-none grid-cols-4 gap-5 p-0 sm:gap-6 lg:grid"
|
||||
>
|
||||
{CARD_MODELS.map((model) => (
|
||||
<li key={model.id} className="m-0">
|
||||
|
|
@ -68,7 +68,7 @@ export const CardModelGallery = ({
|
|||
</ul>
|
||||
|
||||
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
|
||||
Deslize para ver os três modelos
|
||||
Deslize para ver os quatro modelos
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
<Image
|
||||
src={model.assetSrc}
|
||||
src={model.previewSrc ?? model.assetSrc}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(min-width: 1024px) 340px, 78vw"
|
||||
sizes="(min-width: 1024px) 260px, 78vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
|
|
@ -63,6 +64,10 @@ export const CardModelTile = ({
|
|||
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : model.badge ? (
|
||||
<span className="absolute top-3 right-3 rounded-full bg-white/95 px-2.5 py-1 text-[10px] font-black text-(--team-blue) shadow">
|
||||
{model.badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
|
|
|
|||
324
src/components/cards/cardCutout.ts
Normal file
324
src/components/cards/cardCutout.ts
Normal file
|
|
@ -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<CardCutoutResult> => {
|
||||
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<ImageSegmenter> | 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<CardCutoutResult> => {
|
||||
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<void>((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<CardCutoutResult> => {
|
||||
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 } }
|
||||
}
|
||||
88
src/components/cards/useCardCutout.ts
Normal file
88
src/components/cards/useCardCutout.ts
Normal file
|
|
@ -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<CardCutoutState>({ status: 'idle' })
|
||||
const fileRef = useRef<File | null>(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 }
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue