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.
|
# normalizes them to HTTP; otherwise this configured origin is the only fallback.
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
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.
|
# 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
|
# 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
|
# 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:
|
env:
|
||||||
NEXT_DIST_DIR: .next-e2e
|
NEXT_DIST_DIR: .next-e2e
|
||||||
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
|
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)
|
- name: E2E tests (blast radius — manifest-selected or curated specs)
|
||||||
if: steps.scope.outputs.e2e_mode == 'selected' || steps.scope.outputs.e2e_mode == 'curated'
|
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
|
# 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:
|
env:
|
||||||
NEXT_DIST_DIR: .next-e2e
|
NEXT_DIST_DIR: .next-e2e
|
||||||
NEXT_PUBLIC_SITE_URL: https://feed.e2e.teqo.test
|
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)
|
- name: E2E tests (full suite, single job, 4 workers)
|
||||||
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
# Explicit CI workers; the local default stays 2 (playwright.config.ts).
|
||||||
# No `--` separator: pnpm would forward it verbatim to playwright and
|
# 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
|
# Proprietary design font files kept only in the local workspace
|
||||||
/public/arial-mt/
|
/public/arial-mt/
|
||||||
|
|
||||||
|
# S15 — MediaPipe wasm runtime, copied from node_modules at dev/build time
|
||||||
|
/public/cards/mediapipe/
|
||||||
|
|
||||||
/data/encode/
|
/data/encode/
|
||||||
/data/poster-candidates/
|
/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 deploy script applies them BEFORE this build (OPS66: static routes read
|
||||||
# the new schema, so the build must see a migrated DB).
|
# 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.
|
# OPS99: Generate importMap with dummy S3_* envs BEFORE next build.
|
||||||
# This ensures the production image always has the S3ClientUploadHandler entry,
|
# This ensures the production image always has the S3ClientUploadHandler entry,
|
||||||
# eliminating the class of bugs where the admin goes blank (OPS69/OPS72/OPS73).
|
# 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/**',
|
'coverage/**',
|
||||||
// Vendored agent material (same carve-out as .prettierignore):
|
// Vendored agent material (same carve-out as .prettierignore):
|
||||||
'.agents/**',
|
'.agents/**',
|
||||||
|
// S15 — MediaPipe wasm glue copied from node_modules (generated, gitignored):
|
||||||
|
'public/cards/mediapipe/**',
|
||||||
]),
|
]),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"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:start": "node scripts/db-start.mjs",
|
||||||
"db:stop": "docker compose -p teqo down",
|
"db:stop": "docker compose -p teqo down",
|
||||||
|
|
@ -101,6 +102,7 @@
|
||||||
"@fullcalendar/react": "7.0.2",
|
"@fullcalendar/react": "7.0.2",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@imgly/background-removal-node": "^1.4.5",
|
"@imgly/background-removal-node": "^1.4.5",
|
||||||
|
"@mediapipe/tasks-vision": "^1.0.1",
|
||||||
"@payloadcms/db-postgres": "3.82.0",
|
"@payloadcms/db-postgres": "3.82.0",
|
||||||
"@payloadcms/email-resend": "3.82.0",
|
"@payloadcms/email-resend": "3.82.0",
|
||||||
"@payloadcms/live-preview-react": "3.82.0",
|
"@payloadcms/live-preview-react": "3.82.0",
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,13 @@ export default defineConfig({
|
||||||
env: {
|
env: {
|
||||||
DATABASE_URL: process.env.DATABASE_URL as string,
|
DATABASE_URL: process.env.DATABASE_URL as string,
|
||||||
PORT: webServerPort,
|
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
|
* C122 — the fake service-account key lets the agenda Google mirror
|
||||||
* derive real states (synced/disabled/paused) in the server process.
|
* derive real states (synced/disabled/paused) in the server process.
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ importers:
|
||||||
'@imgly/background-removal-node':
|
'@imgly/background-removal-node':
|
||||||
specifier: ^1.4.5
|
specifier: ^1.4.5
|
||||||
version: 1.4.5
|
version: 1.4.5
|
||||||
|
'@mediapipe/tasks-vision':
|
||||||
|
specifier: ^1.0.1
|
||||||
|
version: 1.0.1
|
||||||
'@payloadcms/db-postgres':
|
'@payloadcms/db-postgres':
|
||||||
specifier: 3.82.0
|
specifier: 3.82.0
|
||||||
version: 3.82.0(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))
|
version: 3.82.0(payload@3.82.0(@typescript/typescript6@6.0.2)(graphql@16.12.0))
|
||||||
|
|
@ -1789,6 +1792,9 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
yjs: '>=13.5.22'
|
yjs: '>=13.5.22'
|
||||||
|
|
||||||
|
'@mediapipe/tasks-vision@1.0.1':
|
||||||
|
resolution: {integrity: sha512-rvRE2FmAZ6ZxKSw7wq+e+jQDpN3t1B/tD2mJz9SmAzb1msoDkd4dMoE4wAh8Z30Um0PQwLiHr9QtomhmXk3aUQ==}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.27.1':
|
'@modelcontextprotocol/sdk@1.27.1':
|
||||||
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
|
resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -9490,6 +9496,8 @@ snapshots:
|
||||||
lexical: 0.41.0
|
lexical: 0.41.0
|
||||||
yjs: 13.6.29
|
yjs: 13.6.29
|
||||||
|
|
||||||
|
'@mediapipe/tasks-vision@1.0.1': {}
|
||||||
|
|
||||||
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
|
'@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@hono/node-server': 1.19.11(hono@4.12.7)
|
'@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 { spawn } from 'node:child_process'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
import { copyCardVisionAssets } from './copy-card-vision-assets.mjs'
|
||||||
import './guard-dev-db.mjs'
|
import './guard-dev-db.mjs'
|
||||||
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
|
import { dieWithLabel, loadCliEnv, nextDevArgs, resolveDevPort } from './lib/cli.mjs'
|
||||||
|
|
||||||
|
|
@ -22,6 +23,14 @@ const die = dieWithLabel('dev')
|
||||||
|
|
||||||
loadCliEnv()
|
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
|
let port
|
||||||
try {
|
try {
|
||||||
port = resolveDevPort(process.env)
|
port = resolveDevPort(process.env)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { CardsStudio } from '@/components/cards/CardsStudio'
|
||||||
import { isCardModelId } from '@/lib/cardModels'
|
import { isCardModelId } from '@/lib/cardModels'
|
||||||
|
|
||||||
const intro =
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Crie seu card de apoio | Jorge Solla',
|
title: 'Crie seu card de apoio | Jorge Solla',
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,11 @@ body,
|
||||||
--campaign-band: #ebe9e9;
|
--campaign-band: #ebe9e9;
|
||||||
--campaign-line: rgb(0 0 0 / 12%);
|
--campaign-line: rgb(0 0 0 / 12%);
|
||||||
--campaign-content-inset: clamp(20px, calc(25.36vw - 79.64px), 180px);
|
--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
|
/* 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
|
border must be visible against it (the :root default is a white border
|
||||||
for the dark WhatsApp page). */
|
for the dark WhatsApp page). */
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
ArrowUpIcon,
|
ArrowUpIcon,
|
||||||
|
CheckIcon,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
@ -20,20 +21,27 @@ import {
|
||||||
} from '@/components/cards/cardCanvas'
|
} from '@/components/cards/cardCanvas'
|
||||||
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
|
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
|
||||||
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
|
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
|
||||||
|
import { useCardCutout } from '@/components/cards/useCardCutout'
|
||||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
|
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
|
||||||
import type { CardModel } from '@/lib/cardModels'
|
import { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
|
||||||
import type { CardNameFit } from '@/lib/cardNameFit'
|
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
|
||||||
import {
|
import {
|
||||||
CARD_PHOTO_MAX_ZOOM,
|
CARD_PHOTO_MAX_ZOOM,
|
||||||
CARD_PHOTO_MIN_ZOOM,
|
CARD_PHOTO_MIN_ZOOM,
|
||||||
|
cardPhotoTransformsEqual,
|
||||||
centerCardPhotoTransform,
|
centerCardPhotoTransform,
|
||||||
panCardPhotoTransform,
|
panCardPhotoTransform,
|
||||||
zoomCardPhotoTransform,
|
zoomCardPhotoTransform,
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
type CardPhotoTransform,
|
type CardPhotoTransform,
|
||||||
} from '@/lib/cardPhotoTransform'
|
} from '@/lib/cardPhotoTransform'
|
||||||
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
|
import {
|
||||||
|
createCardMeasure,
|
||||||
|
renderNameCard,
|
||||||
|
renderPhotoCard,
|
||||||
|
renderTeamCard,
|
||||||
|
} from '@/lib/cardRender'
|
||||||
|
|
||||||
const primaryButtonClassName =
|
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'
|
'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 =
|
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'
|
'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 = {
|
type CardComposerProps = {
|
||||||
model: CardModel
|
model: CardModel
|
||||||
shell: 'dialog' | 'drawer'
|
shell: 'dialog' | 'drawer'
|
||||||
|
|
@ -58,6 +117,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
const [photoError, setPhotoError] = useState<string | null>(null)
|
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
const [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
|
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 [downloadError, setDownloadError] = useState<string | null>(null)
|
||||||
const [isDownloading, setIsDownloading] = useState(false)
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
|
||||||
|
|
@ -68,10 +129,32 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
const zoomInputId = useId()
|
const zoomInputId = useId()
|
||||||
|
|
||||||
const photoWindow = model.photoWindow
|
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>(
|
const photoSize = useMemo<CardPhotoSize | null>(
|
||||||
() => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
|
() => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
|
||||||
[photo],
|
[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
|
// 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.
|
// 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
|
let cancelled = false
|
||||||
setLoadState('loading')
|
setLoadState('loading')
|
||||||
setBaseImage(null)
|
setBaseImage(null)
|
||||||
|
setOverlayImage(null)
|
||||||
|
setPreviewImage(null)
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
const fontReady = await ensureCardFont(fontFamily)
|
const fontReady = await ensureCardFont(fontFamily)
|
||||||
if (!fontReady) throw new Error('card-font-unavailable')
|
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
|
if (cancelled) return
|
||||||
setBaseImage(image)
|
setBaseImage(image)
|
||||||
|
setOverlayImage(overlay)
|
||||||
|
setPreviewImage(preview)
|
||||||
setLoadState('ready')
|
setLoadState('ready')
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setLoadState('error')
|
if (!cancelled) setLoadState('error')
|
||||||
|
|
@ -97,7 +188,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [model.assetSrc, fontFamily])
|
}, [model.assetSrc, model.overlaySrc, model.previewSrc, fontFamily])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ctx = canvasRef.current?.getContext('2d')
|
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)
|
ctx.clearRect(0, 0, model.width, model.height)
|
||||||
|
|
||||||
if (model.kind === 'name') {
|
if (isNameModel) {
|
||||||
setNameFit(
|
setNameFit(
|
||||||
renderNameCard(ctx, model, {
|
renderNameCard(ctx, model, {
|
||||||
image: baseImage,
|
image: baseImage,
|
||||||
|
|
@ -117,20 +208,44 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
return
|
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, {
|
const clamped = renderPhotoCard(ctx, {
|
||||||
photo,
|
photo,
|
||||||
frame: baseImage,
|
frame: baseImage,
|
||||||
photoSize,
|
photoSize,
|
||||||
frameSize: { width: model.width, height: model.height },
|
frameSize: { width: model.width, height: model.height },
|
||||||
window: photoWindow,
|
window: photoWindow,
|
||||||
transform: photoTransform,
|
transform: effectiveTransform,
|
||||||
})
|
})
|
||||||
if (
|
if (!cardPhotoTransformsEqual(clamped, effectiveTransform)) {
|
||||||
clamped.zoom !== photoTransform.zoom ||
|
|
||||||
clamped.offsetX !== photoTransform.offsetX ||
|
|
||||||
clamped.offsetY !== photoTransform.offsetY
|
|
||||||
) {
|
|
||||||
setPhotoTransform(clamped)
|
setPhotoTransform(clamped)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
@ -141,7 +256,23 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
ctx.fillRect(0, 0, model.width, model.height)
|
ctx.fillRect(0, 0, model.width, model.height)
|
||||||
ctx.drawImage(baseImage, 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) => {
|
const handlePhotoFile = async (file: File | undefined) => {
|
||||||
if (!file || !photoWindow) return
|
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>) => {
|
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
if (!photoSize || !photoWindow) return
|
if (!transformSourceSize || !photoWindow) return
|
||||||
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
|
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
|
|
@ -174,14 +328,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||||
const drag = dragRef.current
|
const drag = dragRef.current
|
||||||
const canvas = canvasRef.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 scale = model.width / Math.max(1, canvas.getBoundingClientRect().width)
|
||||||
const dx = (event.clientX - drag.x) * scale
|
const dx = (event.clientX - drag.x) * scale
|
||||||
const dy = (event.clientY - drag.y) * scale
|
const dy = (event.clientY - drag.y) * scale
|
||||||
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
||||||
setPhotoTransform((current) =>
|
withTransform((transform, size, window) =>
|
||||||
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
|
panCardPhotoTransform(transform, size, window, dx, dy),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -190,16 +344,14 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
}
|
}
|
||||||
|
|
||||||
const panBy = (dx: number, dy: number) => {
|
const panBy = (dx: number, dy: number) => {
|
||||||
if (!photoSize || !photoWindow) return
|
withTransform((transform, size, window) =>
|
||||||
setPhotoTransform((current) =>
|
panCardPhotoTransform(transform, size, window, dx, dy),
|
||||||
current ? panCardPhotoTransform(current, photoSize, photoWindow, dx, dy) : current,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomTo = (zoom: number) => {
|
const zoomTo = (zoom: number) => {
|
||||||
if (!photoSize || !photoWindow) return
|
withTransform((transform, size, window) =>
|
||||||
setPhotoTransform((current) =>
|
zoomCardPhotoTransform(transform, size, window, zoom),
|
||||||
current ? zoomCardPhotoTransform(current, photoSize, photoWindow, zoom) : current,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,31 +371,64 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNameModel = model.kind === 'name'
|
|
||||||
const nameCanAdvance = nameFit?.ok === true
|
const nameCanAdvance = nameFit?.ok === true
|
||||||
const canAdvance = isNameModel ? nameCanAdvance : photo !== null
|
const canAdvance = isNameModel
|
||||||
const nameError = isNameModel && name.trim().length > 0 && nameFit !== null && !nameFit.ok
|
? nameCanAdvance
|
||||||
const title =
|
: isTeamModel
|
||||||
step === 'result'
|
? teamReady !== null && nameCanAdvance
|
||||||
? 'Seu card está pronto para compartilhar.'
|
: photo !== null
|
||||||
: isNameModel
|
const nameError =
|
||||||
? 'Personalize com seu nome'
|
(isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok
|
||||||
: 'Enquadre sua foto'
|
const adjustable = isTeamModel ? teamReady !== null : photo !== null
|
||||||
const description =
|
const title = (() => {
|
||||||
step === 'result' || isNameModel
|
if (step === 'result') return 'Seu card está pronto para compartilhar.'
|
||||||
? null
|
if (isNameModel) return 'Personalize com seu nome'
|
||||||
: 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
|
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 =
|
const header =
|
||||||
shell === 'dialog' ? (
|
shell === 'dialog' ? (
|
||||||
<DialogHeader className="min-w-0 text-left">
|
<DialogHeader className="min-w-0 text-left">
|
||||||
|
{eyebrowNode}
|
||||||
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
|
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||||
</DialogHeader>
|
</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">
|
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
|
||||||
{title}
|
{title}
|
||||||
</DrawerTitle>
|
</DrawerTitle>
|
||||||
|
|
@ -251,6 +436,90 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
</DrawerHeader>
|
</DrawerHeader>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const teamPreviewStage: TeamPreviewStage =
|
||||||
|
step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle'
|
||||||
|
const previewWidthClassName = isTeamModel ? TEAM_PREVIEW_WIDTH[teamPreviewStage] : 'max-w-[22rem]'
|
||||||
|
|
||||||
|
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)'
|
||||||
|
}`
|
||||||
|
|
||||||
|
const zoomAndPanControls = effectiveTransform ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
|
||||||
|
Zoom
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Diminuir zoom"
|
||||||
|
onClick={() => zoomTo(effectiveTransform.zoom - 0.1)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<MinusIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
id={zoomInputId}
|
||||||
|
type="range"
|
||||||
|
min={CARD_PHOTO_MIN_ZOOM}
|
||||||
|
max={CARD_PHOTO_MAX_ZOOM}
|
||||||
|
step={0.05}
|
||||||
|
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(effectiveTransform.zoom + 0.1)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p>
|
||||||
|
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mover foto para a esquerda"
|
||||||
|
onClick={() => panBy(-12, 0)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mover foto para cima"
|
||||||
|
onClick={() => panBy(0, -12)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<ArrowUpIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mover foto para baixo"
|
||||||
|
onClick={() => panBy(0, 12)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Mover foto para a direita"
|
||||||
|
onClick={() => panBy(12, 0)}
|
||||||
|
className={controlButtonClassName}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
|
<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">
|
<div className="flex items-start gap-3 px-5 pt-5">
|
||||||
|
|
@ -271,12 +540,12 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative mx-auto w-full max-w-[22rem] ${photo ? 'touch-none' : ''}`}
|
className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}`}
|
||||||
tabIndex={photo ? 0 : undefined}
|
tabIndex={adjustable ? 0 : undefined}
|
||||||
role={photo ? 'group' : undefined}
|
role={adjustable ? 'group' : undefined}
|
||||||
aria-label={photo ? 'Ajuste da foto' : undefined}
|
aria-label={adjustable ? 'Ajuste da foto' : undefined}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
photo
|
adjustable
|
||||||
? (event) => {
|
? (event) => {
|
||||||
const step = event.shiftKey ? 48 : 12
|
const step = event.shiftKey ? 48 : 12
|
||||||
const deltas: Record<string, [number, number]> = {
|
const deltas: Record<string, [number, number]> = {
|
||||||
|
|
@ -299,11 +568,16 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
className={`mx-auto block h-auto max-h-[38dvh] w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${
|
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'
|
loadState === 'loading' ? 'opacity-60' : 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
onPointerDown={photo ? handlePointerDown : undefined}
|
onPointerDown={adjustable ? handlePointerDown : undefined}
|
||||||
onPointerMove={photo ? handlePointerMove : undefined}
|
onPointerMove={adjustable ? handlePointerMove : undefined}
|
||||||
onPointerUp={photo ? handlePointerEnd : undefined}
|
onPointerUp={adjustable ? handlePointerEnd : undefined}
|
||||||
onPointerCancel={photo ? 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>
|
</div>
|
||||||
|
|
||||||
{loadState === 'error' ? (
|
{loadState === 'error' ? (
|
||||||
|
|
@ -331,104 +605,159 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
</p>
|
</p>
|
||||||
{nameError ? (
|
{nameError ? (
|
||||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
<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ê é
|
{NAME_TOO_LONG_MESSAGE}
|
||||||
chamado.
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{step === 'compose' && !isNameModel && photoWindow ? (
|
{step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
{photo && photoTransform ? (
|
<TeamNameField
|
||||||
<>
|
id={nameInputId}
|
||||||
<div className="flex items-center gap-3">
|
value={name}
|
||||||
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
|
onChange={setName}
|
||||||
Zoom
|
error={nameError}
|
||||||
</label>
|
inputClassName={nameInputClassName}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<div className="mt-5 rounded-lg border border-(--campaign-line) bg-(--campaign-surface) p-4">
|
||||||
aria-label="Diminuir zoom"
|
<p className="text-sm font-bold text-(--campaign-ink)">Sua foto de busto</p>
|
||||||
onClick={() => zoomTo(photoTransform.zoom - 0.1)}
|
<p className="mt-1 text-sm leading-5 text-(--campaign-muted)">
|
||||||
className={controlButtonClassName}
|
Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples.
|
||||||
>
|
</p>
|
||||||
<MinusIcon className="size-4" aria-hidden="true" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<input
|
onClick={() => fileInputRef.current?.click()}
|
||||||
id={zoomInputId}
|
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"
|
||||||
type="range"
|
>
|
||||||
min={CARD_PHOTO_MIN_ZOOM}
|
Escolher foto
|
||||||
max={CARD_PHOTO_MAX_ZOOM}
|
</button>
|
||||||
step={0.05}
|
</div>
|
||||||
value={photoTransform.zoom}
|
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
||||||
onChange={(event) => zoomTo(Number(event.target.value))}
|
</div>
|
||||||
className="h-11 w-full flex-1 accent-(--pt-red)"
|
) : null}
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Aumentar zoom"
|
|
||||||
onClick={() => zoomTo(photoTransform.zoom + 0.1)}
|
|
||||||
className={controlButtonClassName}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
{step === 'compose' && teamProcessing ? (
|
||||||
<p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p>
|
<div className="mt-5">
|
||||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<button
|
<p
|
||||||
type="button"
|
role="status"
|
||||||
aria-label="Mover foto para a esquerda"
|
aria-live="polite"
|
||||||
onClick={() => panBy(-12, 0)}
|
className="text-sm font-bold text-(--campaign-ink)"
|
||||||
className={controlButtonClassName}
|
>
|
||||||
>
|
{teamProcessing.phase === 'download'
|
||||||
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
? 'Baixando o modelo de recorte…'
|
||||||
</button>
|
: 'Removendo o fundo da foto…'}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
<p aria-hidden="true" className="text-sm font-black text-(--pt-red)">
|
||||||
aria-label="Mover foto para cima"
|
{Math.round(teamProcessing.ratio * 100)}%
|
||||||
onClick={() => panBy(0, -12)}
|
</p>
|
||||||
className={controlButtonClassName}
|
</div>
|
||||||
>
|
<div
|
||||||
<ArrowUpIcon className="size-4" aria-hidden="true" />
|
role="progressbar"
|
||||||
</button>
|
aria-label={`Progresso do recorte: ${Math.round(teamProcessing.ratio * 100)}%`}
|
||||||
<button
|
aria-valuemin={0}
|
||||||
type="button"
|
aria-valuemax={100}
|
||||||
aria-label="Mover foto para baixo"
|
aria-valuenow={Math.round(teamProcessing.ratio * 100)}
|
||||||
onClick={() => panBy(0, 12)}
|
className="mt-2 h-2 overflow-hidden rounded-full bg-(--campaign-band)"
|
||||||
className={controlButtonClassName}
|
>
|
||||||
>
|
<div
|
||||||
<ArrowDownIcon className="size-4" aria-hidden="true" />
|
className="h-full rounded-full bg-(--pt-red) transition-all"
|
||||||
</button>
|
style={{ width: `${Math.round(teamProcessing.ratio * 100)}%` }}
|
||||||
<button
|
/>
|
||||||
type="button"
|
</div>
|
||||||
aria-label="Mover foto para a direita"
|
<p className="mt-3 text-sm leading-5 text-(--campaign-muted)">
|
||||||
onClick={() => panBy(12, 0)}
|
Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto
|
||||||
className={controlButtonClassName}
|
no seu aparelho. Isso pode levar um pouco.
|
||||||
>
|
</p>
|
||||||
<ArrowRightIcon className="size-4" aria-hidden="true" />
|
<div className="mt-4 flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
|
||||||
</button>
|
<span
|
||||||
</div>
|
aria-hidden="true"
|
||||||
</div>
|
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)">
|
<p className="text-sm text-(--campaign-muted)">
|
||||||
Escolha uma foto vertical ou horizontal para começar.
|
Escolha uma foto vertical ou horizontal para começar.
|
||||||
</p>
|
</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 ? (
|
{photoError ? (
|
||||||
<div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3">
|
<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>
|
<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>
|
</div>
|
||||||
) : null}
|
) : 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' ? (
|
{step === 'result' ? (
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="text-sm text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
|
{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 ? (
|
{downloadError ? (
|
||||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||||
{downloadError}
|
{downloadError}
|
||||||
|
|
@ -476,7 +831,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
onClick={() => setStep('result')}
|
onClick={() => setStep('result')}
|
||||||
className={primaryButtonClassName}
|
className={primaryButtonClassName}
|
||||||
>
|
>
|
||||||
Criar meu card
|
{teamProcessing ? 'Processando…' : 'Criar meu card'}
|
||||||
</button>
|
</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)'
|
'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
|
* S14/S15 — the four models side by side on desktop and a one-item-with-peek
|
||||||
* track on mobile, as buttons for the studio island: selecting a tile opens the
|
* snap track on mobile, as buttons for the studio island: selecting a tile
|
||||||
* single composer in place (home or `/cards`). Both tracks come from the same
|
* opens the single composer in place (home or `/cards`). Both tracks come from
|
||||||
* catalog (S13).
|
* the same catalog (S13).
|
||||||
*/
|
*/
|
||||||
export const CardModelGallery = ({
|
export const CardModelGallery = ({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
|
|
@ -47,7 +47,7 @@ export const CardModelGallery = ({
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<ul
|
<ul
|
||||||
aria-label={`${ariaLabel} (desktop)`}
|
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) => (
|
{CARD_MODELS.map((model) => (
|
||||||
<li key={model.id} className="m-0">
|
<li key={model.id} className="m-0">
|
||||||
|
|
@ -68,7 +68,7 @@ export const CardModelGallery = ({
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import type { CardModel } from '@/lib/cardModels'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S13 — artwork preview of one card model, shared by the home section and the
|
* S13/S15 — artwork preview of one card model, shared by the home section and
|
||||||
* `/cards` gallery. The name tile reuses the empty master with a `SEU NOME`
|
* 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
|
* 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 = ({
|
export const CardModelTile = ({
|
||||||
model,
|
model,
|
||||||
|
|
@ -50,10 +51,10 @@ export const CardModelTile = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={model.assetSrc}
|
src={model.previewSrc ?? model.assetSrc}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 1024px) 340px, 78vw"
|
sizes="(min-width: 1024px) 260px, 78vw"
|
||||||
className="object-cover"
|
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">
|
<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" />
|
<CheckIcon className="size-4" aria-hidden="true" />
|
||||||
</span>
|
</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}
|
) : null}
|
||||||
</span>
|
</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
|
* 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
|
* 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
|
* real output size in pixels and, for photo/team models, the transparent window
|
||||||
* visitor's photo must cover.
|
* the visitor's photo must cover.
|
||||||
*
|
*
|
||||||
* Geometry comes from the master files (pixel-measured, keep in sync with
|
* Geometry comes from the master files (pixel-measured, keep in sync with
|
||||||
* `docs/plans/cards-personalizados-campanha-impl.md`): the photo frames are
|
* `docs/plans/cards-personalizados-campanha-impl.md` and
|
||||||
* 1000×1000 and 1000×1440 despite the `1080x` in the original file names.
|
* `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]
|
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
|
||||||
type CardModelKind = 'name' | 'photo'
|
type CardModelKind = 'name' | 'photo' | 'team'
|
||||||
|
|
||||||
export type CardRect = {
|
export type CardRect = {
|
||||||
x: number
|
x: number
|
||||||
|
|
@ -29,8 +35,14 @@ export type CardModel = {
|
||||||
assetSrc: string
|
assetSrc: string
|
||||||
width: number
|
width: number
|
||||||
height: 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
|
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[] = [
|
export const CARD_MODELS: readonly CardModel[] = [
|
||||||
|
|
@ -60,8 +72,56 @@ export const CARD_MODELS: readonly CardModel[] = [
|
||||||
height: 1440,
|
height: 1440,
|
||||||
photoWindow: { x: 0, y: 0, width: 1000, height: 1044 },
|
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):
|
* 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
|
* 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.
|
* over the base; the fixed lines already live in the art.
|
||||||
*/
|
*/
|
||||||
export const NAME_CARD_SLOT = {
|
export const NAME_CARD_SLOT = {
|
||||||
|
align: 'left',
|
||||||
leftX: 213,
|
leftX: 213,
|
||||||
capTop: 430,
|
capTop: 430,
|
||||||
capHeight: 106,
|
capHeight: 106,
|
||||||
|
|
@ -79,8 +140,47 @@ export const NAME_CARD_SLOT = {
|
||||||
minCapHeight: 48,
|
minCapHeight: 48,
|
||||||
fill: '#ffec01',
|
fill: '#ffec01',
|
||||||
maxLines: 2,
|
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
|
} 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 BRAND_FALLBACK_FONT = 'sans-serif'
|
||||||
|
|
||||||
export const isCardModelId = (value: unknown): value is CardModelId =>
|
export const isCardModelId = (value: unknown): value is CardModelId =>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
* DOM and unit-testable with a deterministic fake.
|
* 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 = {
|
export type CardTextMetrics = {
|
||||||
width: number
|
width: number
|
||||||
|
|
@ -29,8 +29,6 @@ export type CardNameFit =
|
||||||
}
|
}
|
||||||
| { ok: false; reason: 'empty' | 'too-long' }
|
| { ok: false; reason: 'empty' | 'too-long' }
|
||||||
|
|
||||||
export type CardNameSlot = typeof NAME_CARD_SLOT
|
|
||||||
|
|
||||||
const CAP_PROBE = 'X'
|
const CAP_PROBE = 'X'
|
||||||
const LINE_HEIGHT_RATIO = 1.25
|
const LINE_HEIGHT_RATIO = 1.25
|
||||||
const MEASURE_PROBE_SIZE = 100
|
const MEASURE_PROBE_SIZE = 100
|
||||||
|
|
@ -73,7 +71,7 @@ const fitSingleLine = (
|
||||||
const fitTwoLines = (
|
const fitTwoLines = (
|
||||||
words: string[],
|
words: string[],
|
||||||
measure: CardMeasureText,
|
measure: CardMeasureText,
|
||||||
slot: CardNameSlot,
|
slot: CardNameBlockSlot,
|
||||||
): { fontSize: number; lines: [string, string] } | null => {
|
): { fontSize: number; lines: [string, string] } | null => {
|
||||||
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
|
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
|
||||||
const maxBlockHeight = slot.maxBlockBottom - slot.capTop
|
const maxBlockHeight = slot.maxBlockBottom - slot.capTop
|
||||||
|
|
@ -134,7 +132,7 @@ export const fitCardName = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const words = normalized.split(' ')
|
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)
|
const twoLines = fitTwoLines(words, measure, slot)
|
||||||
if (twoLines !== null && twoLines.fontSize >= minFontSize) {
|
if (twoLines !== null && twoLines.fontSize >= minFontSize) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ import type { CardRect } from './cardModels'
|
||||||
export const CARD_PHOTO_MIN_ZOOM = 1
|
export const CARD_PHOTO_MIN_ZOOM = 1
|
||||||
export const CARD_PHOTO_MAX_ZOOM = 4
|
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 = {
|
export type CardPhotoSize = {
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
|
|
@ -24,6 +31,17 @@ export type CardPhotoTransform = {
|
||||||
offsetY: number
|
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 =>
|
const clamp = (value: number, min: number, max: number): number =>
|
||||||
Math.min(Math.max(value, min), max)
|
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. */
|
/** Nudges the offset by a step in card pixels, clamped to the window. */
|
||||||
export const panCardPhotoTransform = (
|
export const panCardPhotoTransform = (
|
||||||
transform: CardPhotoTransform,
|
transform: CardPhotoTransform,
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,22 @@
|
||||||
* recorder instead of jsdom canvas.
|
* 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 {
|
import {
|
||||||
fitCardName,
|
fitCardName,
|
||||||
|
resolveFontSizeForCapHeight,
|
||||||
type CardMeasureText,
|
type CardMeasureText,
|
||||||
type CardNameFit,
|
type CardNameFit,
|
||||||
type CardNameSlot,
|
|
||||||
type CardTextMetrics,
|
type CardTextMetrics,
|
||||||
} from './cardNameFit'
|
} from './cardNameFit'
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,8 +38,13 @@ export type CardDrawContext = {
|
||||||
textAlign: CanvasTextAlign
|
textAlign: CanvasTextAlign
|
||||||
textBaseline: CanvasTextBaseline
|
textBaseline: CanvasTextBaseline
|
||||||
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
|
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
|
fillText(text: string, x: number, y: number): void
|
||||||
measureText(text: string): CardTextMetrics
|
measureText(text: string): CardTextMetrics
|
||||||
|
save(): void
|
||||||
|
restore(): void
|
||||||
|
translate(x: number, y: number): void
|
||||||
|
rotate(angle: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createCardMeasure = (
|
export const createCardMeasure = (
|
||||||
|
|
@ -62,15 +77,58 @@ export type CardNameDrawArgs = {
|
||||||
slot?: CardNameSlot
|
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
|
* 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 => {
|
export const drawCardName = (ctx: CardDrawContext, args: CardNameDrawArgs): void => {
|
||||||
const slot = args.slot ?? NAME_CARD_SLOT
|
const slot = args.slot ?? NAME_CARD_SLOT
|
||||||
const { fit } = args
|
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.font = `700 ${fit.fontSize}px ${args.fontFamily}`
|
||||||
ctx.fillStyle = slot.fill
|
ctx.fillStyle = slot.fill
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
|
|
@ -125,3 +183,58 @@ export const renderPhotoCard = (
|
||||||
|
|
||||||
return clampCardPhotoTransform(args.transform, args.photoSize, args.window)
|
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(
|
await expect(
|
||||||
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
|
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
|
||||||
).toBeVisible()
|
).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)
|
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="${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
|
// 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).
|
// 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)
|
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 { 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)', () => {
|
describe('card model catalog (S13/S15)', () => {
|
||||||
it('exposes the three shipped models with unique ids', () => {
|
it('exposes the four shipped models with unique ids', () => {
|
||||||
expect(CARD_MODELS.map((model) => model.id)).toEqual([
|
expect(CARD_MODELS.map((model) => model.id)).toEqual([
|
||||||
'eu-sou-solla',
|
'eu-sou-solla',
|
||||||
'perfil-quadrado',
|
'perfil-quadrado',
|
||||||
'perfil-retangular',
|
'perfil-retangular',
|
||||||
|
'time-de-voce',
|
||||||
])
|
])
|
||||||
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
|
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', () => {
|
it('guards the name slot geometry measured from the filled master', () => {
|
||||||
expect(NAME_CARD_SLOT).toMatchObject({
|
expect(NAME_CARD_SLOT).toMatchObject({
|
||||||
|
align: 'left',
|
||||||
leftX: 213,
|
leftX: 213,
|
||||||
capTop: 430,
|
capTop: 430,
|
||||||
capHeight: 106,
|
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', () => {
|
it('sanitizes query-param values through isCardModelId', () => {
|
||||||
expect(isCardModelId('eu-sou-solla')).toBe(true)
|
expect(isCardModelId('eu-sou-solla')).toBe(true)
|
||||||
expect(isCardModelId('perfil-quadrado')).toBe(true)
|
expect(isCardModelId('perfil-quadrado')).toBe(true)
|
||||||
expect(isCardModelId('perfil-retangular')).toBe(true)
|
expect(isCardModelId('perfil-retangular')).toBe(true)
|
||||||
|
expect(isCardModelId('time-de-voce')).toBe(true)
|
||||||
expect(isCardModelId('modelo-inventado')).toBe(false)
|
expect(isCardModelId('modelo-inventado')).toBe(false)
|
||||||
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
|
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
|
||||||
expect(isCardModelId(undefined)).toBe(false)
|
expect(isCardModelId(undefined)).toBe(false)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
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 {
|
import {
|
||||||
fitCardName,
|
fitCardName,
|
||||||
normalizeCardName,
|
normalizeCardName,
|
||||||
|
|
@ -116,4 +116,44 @@ describe('fitCardName', () => {
|
||||||
expect(fit.ok).toBe(true)
|
expect(fit.ok).toBe(true)
|
||||||
if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO')
|
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,
|
centerCardPhotoTransform,
|
||||||
clampCardPhotoTransform,
|
clampCardPhotoTransform,
|
||||||
coverScale,
|
coverScale,
|
||||||
|
frameCardPhotoOnBbox,
|
||||||
panCardPhotoTransform,
|
panCardPhotoTransform,
|
||||||
zoomCardPhotoTransform,
|
zoomCardPhotoTransform,
|
||||||
|
type CardAlphaBbox,
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
} from '@/lib/cardPhotoTransform'
|
} 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', () => {
|
describe('clampCardPhotoTransform', () => {
|
||||||
it('clamps the zoom to [1, 4]', () => {
|
it('clamps the zoom to [1, 4]', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
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 { fitCardName } from '@/lib/cardNameFit'
|
||||||
import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform'
|
import {
|
||||||
|
centerCardPhotoTransform,
|
||||||
|
frameCardPhotoOnBbox,
|
||||||
|
type CardPhotoSize,
|
||||||
|
} from '@/lib/cardPhotoTransform'
|
||||||
import {
|
import {
|
||||||
createCardMeasure,
|
createCardMeasure,
|
||||||
drawCardName,
|
drawCardName,
|
||||||
renderNameCard,
|
renderNameCard,
|
||||||
renderPhotoCard,
|
renderPhotoCard,
|
||||||
|
renderTeamCard,
|
||||||
type CardDrawContext,
|
type CardDrawContext,
|
||||||
} from '@/lib/cardRender'
|
} from '@/lib/cardRender'
|
||||||
|
|
||||||
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
|
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
|
||||||
type TextCall = { text: string; x: number; y: 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 fontSizeFrom = (font: string): number => {
|
||||||
const match = /(\d+(?:\.\d+)?)px/.exec(font)
|
const match = /(\d+(?:\.\d+)?)px/.exec(font)
|
||||||
|
|
@ -22,6 +36,8 @@ const fontSizeFrom = (font: string): number => {
|
||||||
const createFakeContext = () => {
|
const createFakeContext = () => {
|
||||||
const drawCalls: DrawCall[] = []
|
const drawCalls: DrawCall[] = []
|
||||||
const textCalls: TextCall[] = []
|
const textCalls: TextCall[] = []
|
||||||
|
const rectCalls: RectCall[] = []
|
||||||
|
const ops: OpCall[] = []
|
||||||
|
|
||||||
const ctx: CardDrawContext = {
|
const ctx: CardDrawContext = {
|
||||||
font: '',
|
font: '',
|
||||||
|
|
@ -31,6 +47,9 @@ const createFakeContext = () => {
|
||||||
drawImage: (image, dx, dy, dw, dh) => {
|
drawImage: (image, dx, dy, dw, dh) => {
|
||||||
drawCalls.push({ 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) => {
|
fillText: (text, x, y) => {
|
||||||
textCalls.push({ text, x, y })
|
textCalls.push({ text, x, y })
|
||||||
},
|
},
|
||||||
|
|
@ -42,13 +61,26 @@ const createFakeContext = () => {
|
||||||
actualBoundingBoxDescent: size * 0.2,
|
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 nameModel = getCardModel('eu-sou-solla')!
|
||||||
const squareModel = getCardModel('perfil-quadrado')!
|
const squareModel = getCardModel('perfil-quadrado')!
|
||||||
|
const teamModel = getCardModel('time-de-voce')!
|
||||||
|
|
||||||
describe('renderNameCard', () => {
|
describe('renderNameCard', () => {
|
||||||
it('draws the master base first and then the name on the left border', () => {
|
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', () => {
|
describe('renderPhotoCard', () => {
|
||||||
it('draws the photo full-bleed under the official overlay', () => {
|
it('draws the photo full-bleed under the official overlay', () => {
|
||||||
const { ctx, drawCalls } = createFakeContext()
|
const { ctx, drawCalls } = createFakeContext()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue