# Impl: Card "Time de você": quarto modelo auto-serviço no funil público Status: aprovado Atualizado em: 2026-09-18 Issue: #1161 Intenção: docs/plans/cards-time-de-voce.md Appetite restante: herdado — ~2–3 dias eng (3 fases somam ≤3 dias; sem migration) ## Leitura da intenção - **Outcome:** o funil de cards (home `#cards` + `/cards`, S13/S14) ganha o quarto modelo — `Time de você`: nome + foto de busto, fundo removido no aparelho, card `TIME DE ` com a foto recortada no centro sobre o fundo oficial e os banners no topo, baixável em PNG — sem conta e sem nenhum byte da foto saindo do aparelho. - **O que NÃO negociar:** - 100% client-side: sem upload, sem persistência, sem analytics de PII; nome/foto nunca saem do aparelho. - Um único editor/fluxo: quarto item do catálogo no MESMO `CardsStudio`/`CardComposer`; `/cards` e `?model=time-de-voce` continuam. - Artes-mestre intocadas (compor por cima, nunca redesenhar); geometria literal medida: janela da foto `{286,439,592,577}`; banner `TIME DE` centro (545,162) ≈693×118, −3,5°, `#E50E2F`, texto `#FFEC01` cap ≈93; banner do nome centro (545,313) ≈509×176, −4,1°, `#0061A5`, nome `#FFFFFF` cap ≈130, tinta ≤470, 1 linha, cap mín ≈56. - Nome nunca cortado em silêncio: se não couber no cap mínimo → mesma mensagem de nome curto do modelo de nome. - Recorte local com progresso; falha → erro claro com retry, sem beco sem saída. - Sem migration/collection/CMS/Consent novo; sem headers globais que quebrem o embed (precedente c167). - **O que reavaliar (hipóteses da intenção):** - **Motor recomendado (`@imgly/background-removal`)**: reavaliado e rejeitado — o `LICENSE` do repo é proprietário ("All Rights Reserved") e o pacote é AGPL-3.0; detalhe na Decisão 1. A escolha final era explicitamente do plano de implementação. - **“O composer carrega UMA imagem base e ramifica por kind”**: o time precisa de DOIS assets (base + overlay frontal) e de um estado de processamento — é um ramo de compositor/tempo, não um `photoWindow` a mais. - **“3 ids pinados”**: os pins de unit (`tests/unit/cardModels.unit.spec.ts:7`) e o loop de tiles do e2e (:1712) quebram com o 4º modelo; atualizá-los faz parte da entrega. ## Abordagem recomendada ```mermaid flowchart LR H["home: seção cards / rota /cards"] --> S["CardsStudio — ilha única (S13/S14)"] S --> G["CardModelGallery — 4 tiles, grid 4x1 no lg"] G -->|"tile Time de você"| C["CardComposer — editor único"] C --> I["nome + foto de busto"] I --> HK["useCardCutout — idle → processing → ready | error"] HK --> AD["cardCutout — adapter DOM (src/components/cards)"] AD --> MP["MediaPipe selfie segmenter — wasm + tflite same-origin"] MP -->|"máscara de confiança"| AD AD -->|"cutout canvas + bbox alfa"| HK HK --> FR["frameCardPhotoOnBbox — puro (cardPhotoTransform)"] FR --> RT["renderTeamCard — puro (base → foto → overlay → banners)"] RT --> PV["prévia canvas + controles existentes"] C -->|"Criar meu card"| R["resultado"] R --> DL["Baixar PNG no aparelho"] CM["cardModels — catálogo único"] -.-> G CM -.-> RT ``` **Opções consideradas:** A) estender o funil S13/S14 (catálogo + composer + render puros + adapter DOM novo) | B) rota/editor novo só para o time | C) recorte como serviço/API/iframe. **Recomendação:** A — o quarto modelo é um item do catálogo num funil que já tem editor único, shells e núcleo puro; reusar mantém o anti-goal "segundo editor" cortado e cabe no appetite. **Rejeitadas:** B porque cria dois fluxos (o rabbit hole de produto) e duplica shells/estado; C porque viola o aceite "nenhum byte sai do aparelho". ### Decisões de engenharia 1. **Motor de remoção de fundo — MediaPipe selfie segmenter (Apache-2.0), 100% self-hosted.** Opções: A) `@imgly/background-removal` no navegador (CDN padrão ou self-host do pacote de dados; ~40MB no 1º uso; progresso nativo; AGPL-3.0) | B) `@mediapipe/tasks-vision` `ImageSegmenter` + `selfie_segmenter.tflite` float16 256×256 (Apache-2.0; wasm SIMD ~11,7MB + modelo ~244KB; sem COOP/COEP) | C) recorte no servidor/API | D) ONNX Runtime Web + modelo próprio. Recomendação: B — Apache-2.0 compatível com o `LICENSE` proprietário do repo (imgly é AGPL-3.0; o repo é público mas "All Rights Reserved": visibilidade do código ≠ concessão de direitos AGPL, então a §13 não está satisfeita; o `@imgly/background-removal-node` de `scripts/resize-images.mjs` é ferramenta local de script, não distribuída ao usuário, e não vale como precedente de bundle); primeiro uso de ~12MB de wasm (cacheado) + 244KB em vez de ~40MB no celular; roda sem COOP/COEP (wasm single-thread, sem SharedArrayBuffer — respeita o c167); assets same-origin sob `public/cards/` (sem CDN em runtime; e2e hermético); a expectativa honesta de busto/fundo simples já está no aceite. Rejeitadas: A por licença + peso no celular + CDN de terceiro executando JS no browser (o self-host do pacote de dados imgly é grande demais para repo/imagem); C por PII/serviço; D por reimplementar runtime/wasm/hosting/modelo (fora do appetite) sem ganho sobre B. Hospedagem/carregamento: `selfie_segmenter.tflite` commitado em `public/cards/` (origem e Apache-2.0 documentados no código); wasm copiado de `node_modules/@mediapipe/tasks-vision/wasm/` para `public/cards/mediapipe/wasm/` (gitignored) por `scripts/copy-card-vision-assets.mjs`, disparado no preflight do `scripts/dev.mjs`, no `prebuild` (CI) e num `RUN` do estágio builder do Dockerfile (o runner copia `public/`). `await import('@mediapipe/tasks-vision')` dentro do adapter (code-split; só o modelo time paga); `FilesetResolver.forVisionTasks('/cards/mediapipe/wasm')` + `createFromOptions({ baseOptions: { modelAssetBuffer }, outputConfidenceMasks: true, runningMode: 'IMAGE' })`; main thread com yield antes do `segment()` (bloqueio de poucas centenas de ms); worker só com jank medido (gatilho). Progresso/falha: pré-aquecimento do wasm com fetch streaming por bytes + modelo passado como buffer → ratio real; adapter devolve `{ ok:true; canvas; size; bbox } | { ok:false; reason:'engine'|'empty' }`; um único estado de erro recuperável (`Tentar de novo` com o mesmo File guardado / `Escolher outra foto`); nenhum log em console (fixture e2e). 2. **Quarto modelo — `kind: 'team'` explícito + campos aditivos, sem pipeline genérico.** Opções: A) terceiro valor de `kind` + `overlaySrc`/`previewSrc`/`badge` opcionais + `renderTeamCard` reusando a math existente | B) manter `kind:'photo'` e detectar o time por flags (`photoWindow`+`nameSlot`) | C) refatorar para um pipeline de camadas (`layers[]`) para os 4 modelos | D) render/editor dedicado por id. Recomendação: A — o modelo tem foto + nome + overlay + processamento, então não é nenhum dos dois kinds atuais; o discriminante explícito mantém os 3 modelos entregues no mesmo caminho (mesmos unit pins de render) e deixa o composer ramificar uma vez. `renderTeamCard` (base → foto → overlay → banners) reusa `cardPhotoDrawRect`/`clampCardPhotoTransform`, `fitCardName` e um helper novo de banner; `overlaySrc`/`previewSrc` aditivos não mudam os modelos existentes. Fato verificado nos assets: `team-card-base.png` é opaco, `team-card-front.png` só cobre y≥971 e nenhum asset contém os banners — os dois banners (retângulo + texto) são desenhados pelo render com os literais medidos. Rejeitadas: B porque esconde um fluxo novo (processar/recortar) atrás de flags de um tipo antigo; C porque refatora o render dos 3 modelos por generalidade especulativa (risco + reescreve pins); D porque duplica editor/render (anti-goal). 3. **Auto-enquadramento — bbox alfa no adapter + math pura reusando o transform existente.** Opções: A) math nova em `cardPhotoTransform.ts` (`frameCardPhotoOnBbox`) convertendo para o `CardPhotoTransform` atual e passando pelo `clampCardPhotoTransform`; bbox no adapter via canvas `getImageData` | B) calcular um draw rect direto, sem o transform (perde pan/zoom reusados) | C) enquadrar "no olho" no adapter, sem função pura. Recomendação: A — `zoom = clamp(coverNecessário(bbox→janela) / coverScale(fonte,janela), 1, CARD_PHOTO_MAX_ZOOM)`; topo do bbox encosta no topo da janela e o centro x do bbox alinha ao centro da janela; clamp final garante cobertura, e os controles de arrastar/zoom/setas do S13/S14 seguem funcionando com a transform resultante. Bbox: canvas de trabalho limitado a 1600px na maior aresta (memória/scan), varredura única de alpha>32; degenerado (vazio ou < ~24px) OU zoom necessário > 4 → `null` → erro recuperável (`empty-cutout`) — nunca card com foto invisível/minúscula. Rejeitadas: B porque joga fora o escape de ajuste e a garantia de cobertura; C porque não é testável puro e o adapter é DOM. 4. **Fit do nome — reusar `fitCardName` com slot novo; rotação via ops no ctx estrutural.** Opções: A) `TEAM_CARD_NAME_SLOT` (1 linha, centrado, cap 130, tinta 470, cap mín 56, rotação −4,1°) + união `CardNameSlot` discriminada por `align` (`'left'` | `'center'`) + `CardDrawContext` ganhando `save/restore/translate/rotate/fillRect` | B) `fitTeamName` nova | C) desenhar o nome sem centralizar/rotacionar. Recomendação: A — a máquina de fit (probe de cap, shrink, mínimo, `too-long`) é a mesma; `NAME_CARD_SLOT` ganha `align:'left'` sem mudar comportamento e os pins atuais continuam passando; `drawCardName` ramifica por `align`; um helper `drawCardBanner` desenha retângulo e texto centrados/rotacionados (label fixo `TIME DE` cap 93/−3,5° e nome com o cap do fit); o fake ctx dos units registra a sequência `save → translate → rotate → fillRect → fillText → restore`, mantendo os testes existentes. Rejeitadas: B (twin da mesma matemática), C (viola a fidelidade medida do exemplo). 5. **Estado do composer — hook `useCardCutout` + estado de UI no composer.** Opções: A) hook `src/components/cards/useCardCutout.ts` dono do ciclo do motor (`idle → processing → ready | error`, retry, run id, desmontagem) consumido pelo `CardComposer` | B) `useState` no `CardComposer` | C) store/contexto global. Recomendação: A — o ciclo do engine (import dinâmico, fases, progresso, guarda de corrida/desmontagem, File do retry, dispose do segmenter) é uma preocupação coesa vizinha do adapter; o composer já tem ~500 linhas e ganharia mais 6 estados. CTA `Criar meu card` só com `cutout.status==='ready'` + `nameFit.ok` + assets prontos; passo `result` inalcançável antes disso; `Baixar meu card` só no result (nunca baixa exemplo/estado intermediário). A11y: picker/retry são `