feat(S18): cabeça do visitante proporcional à dos candidatos no card Time de você

- FaceDetector (blaze_face_full_range, Apache-2.0, lazy/on-device) mede o maior rosto no canvas do recorte; math pura frameCardPhotoOnFace escala a face à referência TEAM_CARD_FACE_REFERENCE_SIZE=103 (mediana das 5 caixas da arte-mestre).
- Piso do time CARD_TEAM_PHOTO_MIN_ZOOM=0.2 com clamp contain/cover por eixo (minZoom opcional preserva os 3 modelos S13/S14); fallback silencioso ao enquadramento S15 sem rosto/falha.
- Stub e2e estendido ('noface'); unit novo da math e da seleção do rosto; craft com retrato real 86-98px vs 103 (±20%); crítica Tier 1 certificada.

Closes #1197
This commit is contained in:
Francisco Solla 2026-09-19 12:23:22 -03:00
parent dbd9ecdfd4
commit e08b9c497c
14 changed files with 721 additions and 40 deletions

View file

@ -0,0 +1 @@
**S18 — Card "Time de você": cabeça do visitante proporcional à dos candidatos (2026-09-19):** o auto-enquadramento do card time passa a mirar a proporção da cabeça: MediaPipe `FaceDetector` + `blaze_face_full_range.tflite` (Apache-2.0, 1,0MB lazy e same-origin, commitado com NOTICE; `minDetectionConfidence: 0.5`, maior rosto vence) mede o rosto no canvas do recorte — a mesma régua do lado dos candidatos, cuja referência é a mediana literal `TEAM_CARD_FACE_REFERENCE_SIZE = 103` medida uma vez sobre `public/cards/team-card-base.png` (5 caixas, SHA do modelo no comentário). A math pura nova `frameCardPhotoOnFace` escala a face à referência (±20%) e mantém o rosto sempre dentro da janela (com a referência 103px a face desenhada nunca passa de ~118px), com fallback silencioso ao enquadramento S15 (`frameCardPhotoOnBbox`) em qualquer falha/ausência de rosto — o único erro recuperável continua sendo o do recorte. **Decisão de execução (humano, 2026-09-19):** o piso de cobertura do S13 (`zoom ≥ 1`) deixava selfies fechadas em 2,0–2,6× a cabeça dos candidatos (craft: 207px/270px vs. 103px); o enquadramento do time pode descer até `CARD_TEAM_PHOTO_MIN_ZOOM = 0.2`, o que mantém a face alcançada em ≤ +15% da referência por construção (a foto menor fica _dentro_ da janela — contain/cover por eixo no `clampCardPhotoTransform`, default dos 3 modelos S13/S14 intacto) e deixa o ajuste fino do visitante como escape; a arte-mestre aparece ao redor, como na Cena 1 do design. Detector com catch local sem log; e2e determinístico pelo stub estendido (`'noface'` → fallback; rosto → slider < 0.5) e unit novo da math/pins. Integra o S17 (harmonizar cores) e o S16 (banner azul dinâmico), mergeados no mesmo pipeline: as features convivem (a variante harmonizada e o banner dinâmico seguem intactos) e o e2e do S17 teve o ponto de amostra deslocado para dentro da foto desenhada pelo enquadramento novo. Sem migration, sem Consent/access, sem copy/controle novos; 100% no aparelho. Verificação: unit dos specs puros (4083), `pnpm gate:fast`, e2e do `frontend` (32 verdes), craft com engine real (retrato real: 96/86/98px vs. 103px, fallback sem alerta). Design artefato `DEGRADED` (frontier sem quota no plan-issue) com crítica final Tier 1 certificada (`openai/gpt-5.6-sol`): sign-off humano registrado no PR #1210.

View file

@ -0,0 +1,176 @@
# Impl: Card "Time de você": cabeça do visitante proporcional à dos candidatos
Status: aprovado
Atualizado em: 2026-09-19
Issue: #1197
Intenção: docs/plans/cards-time-de-voce-escala-cabeca.md
Appetite restante: herdado — ~1–1,5 dia eng (3 fases somam ≤1,5 dia; sem migration)
## Leitura da intenção
- **Outcome:** o auto-enquadramento do card `Time de você` passa a mirar a proporção da cabeça: o rosto do visitante é medido com a mesma régua dos candidatos (caixa do `FaceDetector` sobre o recorte e sobre a arte-mestre) e o zoom enquadra a cabeça na mesma ordem de grandeza (±20%) sempre que houver rosto; a cobertura da janela prevalece, o rosto nunca é cortado pela proporcionalidade e qualquer impossibilidade cai no enquadramento S15, em silêncio. Nada sai do aparelho; nenhum controle novo.
- **O que NÃO negociar:**
- 100% no aparelho: foto e medida nunca saem; nada além dos assets same-origin; sem analytics.
- Fallback obrigatório e silencioso: sem rosto, detecção indisponível ou qualquer falha → enquadramento S15 (`frameCardPhotoOnBbox`); **nunca** erro novo no funil — o único erro recuperável continua sendo o do recorte de fundo.
- Cobertura da janela prevalece sobre a proporção; o rosto nunca é cortado pelo ajuste proporcional (quando a janela permite); pan/zoom seguem como escape.
- Métrica única dos dois lados (lado da caixa do rosto), maior rosto vence, referência literal dos candidatos, sem re-detecção em pan/zoom.
- Os 3 modelos S13/S14, a galeria, a copy e a privacidade ficam intocados; sem controle/toggle novo; sem migration/Consent/access.
- **O que reavaliar (hipóteses da intenção):**
- **"Detecção no mesmo pipeline do recorte"**: confirmado, mas como passo tolerante do **mesmo run** — a caixa do rosto viaja no `CardCutoutResult` (`face`), não como máquina de estado paralela; falha vira `null` sem log.
- **"Ajuste no adapter/hook"**: confirmado, porém a régua é 100% pura em `cardPhotoTransform.ts`; o adapter só entrega a caixa no espaço de coords do recorte.
- **"±20% sempre"**: **reaberto na execução com evidência de craft** — no piso de cobertura (`zoom ≥ 1`) as selfies fechadas ficavam 2,0–2,6× acima dos candidatos, deixando o caso "fechado fica gigante" (o principal do pedido) sem correção. Decisão do humano em 2026-09-19: o enquadramento do time pode descer abaixo do piso (`CARD_TEAM_PHOTO_MIN_ZOOM=0.2`), com o clamp mantendo a foto menor _dentro_ da janela (a arte-mestre aparece ao redor, como na Cena 1 do design); o ajuste fino do visitante continua sendo o escape.
- **"Qualquer foto serve"**: o detector escolhido tem limite medido — rosto abaixo de ~10% da largura vira ruído e a 15% o score é ~0.58; `minDetectionConfidence: 0.5` e a mediana da arte são os pins; reavaliar só com medição nova.
## Abordagem recomendada
```mermaid
flowchart LR
P["foto escolhida"] --> CUT["cardCutout — engine no aparelho (S15)"]
CUT --> SEG["ImageSegmenter — recorte + bbox alfa"]
SEG --> FACE["detectCardFace — FaceDetector + blaze_face_full_range, lazy, catch local"]
FACE --> RES["CardCutoutResult — canvas + bbox + face"]
RES --> HOOK["useCardCutout — run id + estado"]
HOOK --> MATH["frameCardPhotoOnFace — math pura"]
REF["TEAM_CARD_FACE_REFERENCE_SIZE = 103"] -.-> MATH
MATH --> TR["transform proporcional (piso do time 0.2 + teto de rosto)"]
MATH --> FB["sem rosto ou falha: frameCardPhotoOnBbox (S15)"]
TR --> PV["prévia renderTeamCard — pan/zoom como escape"]
FB --> PV
PV --> DL["Baixar PNG no aparelho"]
STUB["stub e2e — ok / slow / error / noface"] -.-> CUT
```
**Opções consideradas:** A) `FaceDetector` no canvas do recorte + math pura nova com piso de cobertura e fallback S15 | B) enquadrar no adapter (DOM), "no olho", sem função pura | C) mudar a `frameCardPhotoOnBbox` existente para aceitar rosto.
**Recomendação:** A — reaproveita o runtime/wasm/stub que o S15 já paga, mantém a régua pura/testável em `src/lib` e deixa o adapter só medir; o fallback é o próprio caminho S15 intacto, então nada do funil regride.
**Rejeitadas:** B porque enquadramento sem função pura não tem pin de unit e reabre o rabbit hole "medir no olho" (adapter é DOM); C porque mexe na função pinada e nos 3 modelos para um comportamento que é só do time.
### Decisões de engenharia
1. **Detector — `FaceDetector` + `blaze_face_full_range` (Apache-2.0), commitado e lazy.**
Opções: A) `FaceDetector` do `@mediapipe/tasks-vision` com `blaze_face_full_range.tflite` (float16, 1.083.786 B) | B) `blaze_face_short_range.tflite` (229.746 B) | C) `FaceLandmarker` (malha facial) | D) detector próprio/ONNX.
Recomendação: A — é o único medido que enxerga a arte-mestre e a foto do visitante na mesma régua. Com `minDetectionConfidence: 0.5` (explícito, embora o default da lib também seja 0.5 — vira pin de contrato) o `full_range` detecta os 5 rostos de `public/cards/team-card-base.png` (1080×1440) de forma estável em 1×/2×/3× (scores 0.62–0.90; caixas em coords da arte: c1 `(45,609,108,108)`, c2 `(202,580,103,103)`, centro/Solla `(336,540,149,149)`, c4 `(762,583,103,103)`, c5 `(926,608,99,99)`); em canvas sintético com a Lenna a 40%/30%/20%/15% da largura detecta com scores 0.89/0.74/0.70/0.58 (caixas 278/186/119/75 px), degradando para ruído a 10% (scores ~0.2). O `FaceDetector` já vive no runtime que o S15 carrega — entra só um modelo novo, same-origin e lazy (só o time paga); a caixa do rosto é exatamente a métrica da intenção. Modelo commitado em `public/cards/blaze_face_full_range.tflite` (origem `https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_full_range/float16/1/blaze_face_full_range.tflite`, SHA-256 `3698b18f063835bc609069ef052228fbe86d9c9a6dc8dcb7c7c2d69aed2b181b`) com `public/cards/blaze_face_full_range.LICENSE.txt` no padrão do `selfie_segmenter.LICENSE.txt` (origem + SHA + Apache-2.0 + nota do wasm). `scripts/copy-card-vision-assets.mjs` não muda (copia só wasm).
Rejeitadas: B porque não detectou **nenhum** rosto da arte-mestre (nem cropada, nem upscalada 2×/3×) nem um rosto sintético a 40% da largura — um detector que não mede a arte não tem como dar proporção; C porque a malha facial adiciona peso/superfície de API sem mudar a medida (a caixa do rosto já é o lado que importa) e não tem ganho medido; D porque reimplementar runtime/hosting/modelo está fora do appetite e repetiria o custo do S15 sem evidência.
2. **Onde detectar — no canvas do recorte, uma vez, maior rosto vence.**
Opções: A) no canvas do cutout (mesmo espaço de coords do `bbox`, fundo já removido) | B) no bitmap original | C) no arquivo da foto antes do recorte.
Recomendação: A — o canvas do recorte é o mesmo sistema de coordenadas do `bbox` que a math já usa (zero conversão de escala), o fundo removido reduz rostos de terceiros/objetos (menos falso positivo) e o custo roda uma única vez por foto, dentro do run do recorte; "maior rosto vence" (maior área) alinha com "em foto com mais de um rosto, vale o do visitante". Detecção uma vez, no recorte; **nunca** re-detecta em pan/zoom (a medida é do momento do recorte).
Rejeitadas: B porque exigiria escalar as caixas do bitmap original para o canvas capado (≤1600 px) e detectaria rostos de fundo que o recorte acabou de remover; C porque duplicaria decode/custo antes do cutout e poderia medir um rosto que a segmentação nem manteve.
3. **Math pura — `frameCardPhotoOnFace` com piso do time, delegando ao S15 sem rosto.**
Opções: A) função nova em `src/lib/cardPhotoTransform.ts`: `frameCardPhotoOnFace(source, window, bbox, face, referenceSize): CardPhotoTransform | null` | B) mudar assinatura/comportamento de `frameCardPhotoOnBbox` | C) calcular o enquadramento no adapter (DOM) | D) manter o piso de cobertura `zoom ≥ 1` (plano original).
Recomendação: A — a régua fica inteira pura e pinável:
```text
s = coverScale(source, window)
faceSize = Math.max(face.width, face.height)
zoom = clamp(referenceSize / (faceSize * s), CARD_TEAM_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
offX = window.x + window.width/2 − (face.x + face.width/2) * s * zoom
offY = window.y − bbox.y * s * zoom
→ clampCardPhotoTransform({ zoom, offX, offY }, source, window, CARD_TEAM_PHOTO_MIN_ZOOM)
```
**Piso do time (`CARD_TEAM_PHOTO_MIN_ZOOM = 0.2`).** O craft provou que o piso de cobertura do S13 (`zoom ≥ 1`) deixava as selfies fechadas em 2,0–2,6× a cabeça dos candidatos (207px e 270px vs. a referência de 103px): o caso "fechado fica gigante" — o principal do pedido — seguia sem correção. Decisão do humano (2026-09-19): permitir o zoom abaixo de 1. O clamp ganha um `minZoom` opcional (default = `CARD_PHOTO_MIN_ZOOM`, preservando os 3 modelos S13/S14) e, quando a foto desenhada é menor que a janela num eixo, ela é mantida **dentro** da janela (contain) em vez de pinada na borda — a foto nunca sai da prévia e o ajuste fino do visitante segue livre. Com o piso 0.2, a face alcançada nunca passa de +15% da referência (a caixa do rosto cobre o canvas no máximo; `0.2 × 592 = 118px`), ou seja, o aceite de ±20% fica garantido por construção; rostos minúsculos ficam no teto de 4 (e podem não ser detectados → fallback).
**Rosto nunca cortado:** com a referência 103px, a face desenhada nunca passa de ~118px, muito dentro da janela 592×577; o posicionamento mantém o centro-x do rosto no centro-x da janela e o topo do bbox no topo (contain/cover por eixo do clamp). Face ausente/inválida/falha → delega a `frameCardPhotoOnBbox`; se o bbox também falhar → `null` → erro recuperável `empty` de hoje.
Rejeitadas: B porque a assinatura/comportamento são pinados (`tests/unit/cardPhotoTransform.unit.spec.ts:58-114`) e usados pelos 3 modelos; C porque o adapter é DOM e o enquadramento viraria opinião não testável; **D (piso de cobertura) por não cumprir o aceite primário nos casos fechados** — craft medido: cabeça 207px/270px contra referência de 103px, enquanto a Cena 1 do design mostra o alvo proporcional (busto menor que a janela).
4. **Referência — constante literal medida uma vez sobre a arte-mestre.**
Opções: A) `TEAM_CARD_FACE_REFERENCE_SIZE = 103` em `src/lib/cardModels.ts` | B) medir a arte em runtime | C) usar máximo/média das 5 caixas.
Recomendação: A — mediana das 5 caixas (99, 103, 103, 108, 149 → **103 px**) é robusta ao outlier do rosto central/Solla (149) e ao par menor (99/103); o comentário da constante registra o método (modelo `blaze_face_full_range`, `minDetectionConfidence: 0.5`, SHA) e as 5 caixas; fica ao lado dos outros literais do time (`TEAM_CARD_LABEL`/`TEAM_CARD_NAME_BANNER`), auditável e com custo zero no aparelho. A mediana ainda é conservadora: puxa a cabeça do visitante para o tamanho típico, não para o maior.
Rejeitadas: B porque paga inferência e movimento por um valor que não muda (a arte-mestre é fixa/versionada; a intenção já assumiu constante); C porque o máximo puxa a proporção para o rosto do Solla e a média fica sensível a um rosto fora de escala (média 112,4 / máxima 149).
5. **Integração — `face` viaja no `CardCutoutResult`; detector com catch local; prefetch intocado.**
Opções: A) `CardCutoutResult` ganha `face: CardFaceBox | null`; `detectCardFace(canvas)` singleton próprio (mesmo `WASM_DIR`) com catch local que nunca propaga; `useCardCutout.start` passa `result.face` + `TEAM_CARD_FACE_REFERENCE_SIZE` à math | B) segunda máquina de estado no hook para a detecção | C) incluir o modelo do detector no pré-aquecimento/progresso do cutout.
Recomendação: A — a detecção é parte do resultado do mesmo run (mesma foto/canvas; sem corrida nova); falha de download/criação/detect vira `face: null` **sem log** (guard de console/5xx do e2e intacto) e **nunca** vira `error` do hook; o `try/finally` do engine continua fechando mask/bitmap. O detector cria lazily **depois** do cutout, antes de reportar `ratio: 1` (a barra não congela em 100% esperando os ~1 MB): o prefetch atual (wasm + segmenter) fica intocado, o modelo entra no primeiro uso do time e a falha é tolerada — se a rede/modelo falhar, vale o enquadramento de hoje. `CardComposer`/`cardRender` não mudam (`photoTransform ?? teamReady?.transform` já consome o resultado do hook; o pan/zoom continua sendo o escape).
Rejeitadas: B porque não há transições próprias (é `face | null` dentro de um resultado existente) e um segundo ciclo pediria mais guardas de corrida; C porque acoplaria a falha do detector ao progresso/erro do recorte (um 404 no modelo viraria erro de recorte, violando o aceite).
6. **Stub de e2e — estender o seam existente com `'noface'`.**
Opções: A) `window.__cardsCutoutStub` ganha o modo `'noface'` (`ok`/`slow` devolvem um rosto sintético determinístico; `noface` devolve `face: null`) | B) seam separado `window.__cardsFaceStub` (`'ok' | 'none'`).
Recomendação: A — um único seam, um único `addInitScript`; o modo descreve o resultado do stub inteiro (recorte ok + sem rosto), que é exatamente o cenário de fallback a testar, e nenhum teste precisa da matriz "slow + noface". No modo stub o detector real **nunca** é chamado (nenhum fetch do `.tflite`, nenhuma inferência) — o e2e segue hermético e o guard não quebra. Rosto sintético: caixa determinística ancorada na elipse do stub (literais documentados no adapter junto de `STUB_MIN_EDGE`). Testes novos no describe S18: `ok` → card pronto/baixável pelo caminho proporcional; `noface` → mesmo fluxo silencioso, sem `role=alert` novo, card pronto (fallback).
Rejeitadas: B porque dobra o contrato de teste (dois init scripts, dois tipos) sem nenhum caso que precise combinar os eixos; a face não é um engine separado, é parte do mesmo resultado.
7. **Sem migration, sem Consent/access, sem int, sem copy/controle novos.**
Opções: A) só `src/lib` puro + adapter/hook + assets + testes | B) global/collection para a configuração | C) controle/toggle novo.
Recomendação: A — 100% client-side: nenhuma superfície Payload, schema intocado (`push:false`), nenhuma chave de Consent, nenhum dado sai do aparelho; copy/estados do funil intactos (o texto novo é só comentário de código); `E2E_AFFECTED_MANIFEST` sem entrada nova (`src/components/cards` + `src/lib/card` já mapeiam para `frontend` — `scripts/lib/e2e-affected-manifest.mjs:78`).
Rejeitadas: B (config de produto que não varia por ambiente; a constante vive no catálogo puro), C (anti-goal explícito da intenção).
### Componentes / mudanças
- **`CardFaceBox` + `frameCardPhotoOnFace` + `CARD_TEAM_PHOTO_MIN_ZOOM`** (`src/lib/cardPhotoTransform.ts`): tipo da caixa do rosto em pixels da fonte (ao lado de `CardAlphaBbox`) e a math pura nova — proporção pelo lado, piso do time (0.2), teto de rosto, fallback por delegação a `frameCardPhotoOnBbox`; `clampCardPhotoTransform`/`cardPhotoDrawRect`/`pan`/`zoom` ganham `minZoom` opcional (default = `CARD_PHOTO_MIN_ZOOM`) e o clamp mantém a foto **dentro** da janela quando ela é menor (contain) e cobrindo quando é maior (cover), por eixo; reusa `coverScale`, `CARD_PHOTO_MIN_ZOOM/MAX_ZOOM`.
- **`TEAM_CARD_FACE_REFERENCE_SIZE = 103`** (`src/lib/cardModels.ts`): comentário com método, modelo+SHA, as 5 caixas e a mediana; ao lado dos literais do time.
- **`detectCardFace` + `CardCutoutResult.face`** (`src/components/cards/cardCutout.ts`): `FACE_MODEL_URL='/cards/blaze_face_full_range.tflite'`, `FACE_MIN_CONFIDENCE=0.5`, `faceDetectorPromise` singleton com reset em falha (padrão do `segmenterPromise`), mapeamento `BoundingBox {originX,originY,width,height}` → `CardFaceBox`, maior rosto por área, catch local devolvendo `null` (sem log); stub `'noface'` e rosto sintético nos modos `ok`/`slow`; reusa `WASM_DIR`, `supportsWasmSimd` e o `try/finally` do engine.
- **`useCardCutout`** (`src/components/cards/useCardCutout.ts`): troca `frameCardPhotoOnBbox` por `frameCardPhotoOnFace(..., result.face, TEAM_CARD_FACE_REFERENCE_SIZE)`; run id/retry/progresso/estado intactos; `null` continua virando `error: 'empty'` só quando o bbox também falha.
- **`CardComposer`** (`src/components/cards/CardComposer.tsx`) e **`renderTeamCard`** (`src/lib/cardRender.ts`): o slider de zoom usa o piso do time (0.2) e pan/zoom/arrasto passam o mesmo piso; o render do time desenha/clampeia com o piso do time. Nenhuma mudança visual fora do enquadramento inicial.
- **`public/cards/blaze_face_full_range.tflite`** + **`public/cards/blaze_face_full_range.LICENSE.txt`** (novos): modelo Apache-2.0 (1.083.786 B, SHA-256 `3698b18f…181b`) e licença no padrão S15. `scripts/copy-card-vision-assets.mjs` não muda.
- **Testes** (editar): `tests/unit/cardPhotoTransform.unit.spec.ts` (describe novo de `frameCardPhotoOnFace`: proporção exata, proporção abaixo do piso S13, piso do time com +20% garantido, teto de rosto, contain/cover do clamp com `minZoom`, fallback/delegação, entradas inválidas); `tests/unit/cardModels.unit.spec.ts` (pin da constante); `tests/e2e/frontend.e2e.spec.ts` (describe `Cards personalizados (S18 — cabeça proporcional)` + união `'noface'` no `setCutoutStub` + asserção do slider: `noface` → zoom 1, rosto → zoom < 1). `cardRender.unit.spec.ts` intocado (pins passam com o piso do time).
- **`docs/changelog/2026-09-19-s18-card-cabeca-proporcional.md`** (novo).
- **Migration:** sem migration — nenhuma collection/global/field; `push:false` intocado.
- **Access / Consent:** não se aplica — nenhuma superfície Payload; foto/medida nunca saem do aparelho; sem chave nova de Consent.
- **UI:** Impeccable C — sem cena/controle novo; a prévia continua a mesma (muda o enquadramento inicial). O artefato `cards-time-de-voce-escala-cabeca-ui-design.html` (`Design tier: DEGRADED`) serve de antes/depois e **não certifica**: exige sign-off humano no gate/PR. Craft no browser com fotos reais (perto/longe/sem rosto/multirrosto) + iPhone físico; sem shape novo.
### Dados → forma (se aplicável)
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única "forma" é a geometria do card (literais medidos) e o enquadramento. A caixa do rosto é geometria efêmera no aparelho — nunca dado apresentado, coletado ou enviado.
## Fases verificáveis
1. **Tracer da math + referência** — quota ~0,5 dia. `CardFaceBox`; `frameCardPhotoOnFace` (proporção, piso de cobertura, teto de rosto, delegação); `TEAM_CARD_FACE_REFERENCE_SIZE = 103` com o comentário do método; units novos.
Prova: `pnpm gate:fast` verde; unit pinando proporção (rosto pequeno amplia; rosto grande cai no piso), teto de rosto, fallback idêntico à `frameCardPhotoOnBbox` (deep equal) e `null` quando o bbox falha.
2. **Detector no adapter + hook** — quota ~0,5 dia. Modelo + licença; `detectCardFace` lazy/singleton/catch local; `CardCutoutResult.face`; stub `'noface'` + rosto sintético; hook passa face+constante.
Prova: `pnpm gate:fast` verde; no browser, foto de perto e de longe chegam à prévia com a cabeça na mesma ordem de grandeza; foto sem rosto cai no enquadramento S15 sem alerta; bloquear o `.tflite` no devtools não muda o fluxo nem loga nada; modo stub não baixa o modelo; iPhone físico no craft.
3. **e2e + gates + changelog** — quota ~0,25–0,5 dia. Testes do describe S18; changelog; `pnpm gate:fast`; e2e local `pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "S18"`; `pnpm push`.
Prova: e2e verde com stub — `ok` → `Confira seu card` + download PNG; `noface` → mesmo fluxo, `role=alert` count 0, card pronto; os 4 testes S15 seguem verdes; fixture de console-error intacto.
## Craft (engine real, 2026-09-19)
Medição com o engine real (sem stub) neste worktree, cards baixados e medidos com o mesmo `FaceDetector` da referência (103px):
- **Retrato real nítido** (retrato oficial em domínio público, 2687×3356) em três enquadramentos: rosto do visitante = **96px** (busto normal), **86px** (fechado) e **98px** (pessoa pequena sobre fundo liso) — todos dentro de ±20% da referência e com a detecção funcionando nos três.
- Com o piso de cobertura (implantação anterior do mesmo craft, fotos sintéticas): fechado/normal saíam 270px e 207px (2,0–2,6× a referência) → motivou a decisão do piso do time.
- **Sem rosto (rosto desfocado)**: recorte ok + detecção falha → enquadramento S15, card pronto, zero `role=alert`; o cenário "pessoa muito pequena" também cai no fallback silencioso (limite do segmentador/detector, registrado em Riscos).
- **Logs**: o engine real loga `INFO: … XNNPACK delegate` via `console.error` (caminho real; o e2e usa o stub e mantém o guard de console intacto).
- **Crítica final (trigger c)**: certificada Tier 1 (`openai/gpt-5.6-sol`), sem ajustes bloqueantes ("86–98 px para referência de 103 px, dentro de ±20%, sem cortar o rosto"; fallback e mobile OK). O artefato de referência nasceu `DEGRADED` (frontier sem quota no plan-issue) e por isso exige sign-off humano no PR, registrado no body.
## Rabbit holes / Não escopo (engenharia)
- Re-detectar/rastrear rosto em pan/zoom; rastreamento em vídeo; escolher entre vários rostos; identificação/reconhecimento facial.
- Mudar a semântica default do `clampCardPhotoTransform` (piso 1/cobertura) ou o range do slider dos 3 modelos S13/S14 — o S18 usa o `minZoom` opcional e o piso só no time; o default fica intocado.
- `FaceLandmarker`, detector próprio/ONNX, segunda lib de visão; medir a arte em runtime; auto-calibrar a referência.
- Alterar `frameCardPhotoOnBbox`, `renderTeamCard`, `CardComposer`, a arte-mestre, banners ou a janela (`{286,439,592,577}`).
- Novo controle/toggle, copy nova, novo estado de erro (detecção nunca vira erro), persistir a medida, analytics da detecção.
- Worker/OffscreenCanvas/segundo wasm; CDN do modelo (assets seguem same-origin); engine real no CI.
- Migration/Consent/access/int; tocar o segmentador ou os 3 modelos antigos.
## Riscos e mitigação
- **Detector não detecta rosto (perfil, sombra, recorte difícil)** → `detectCardFace` devolve `null` sem log e a math delega à `frameCardPhotoOnBbox`: o fluxo fica exatamente o de hoje; unit do fallback + e2e `noface`.
- **Ruído em rosto muito pequeno** (abaixo de ~10–15% da largura o score cai a ~0.2–0.58) → `minDetectionConfidence: 0.5` + "maior rosto vence" (um falso positivo pequeno dificilmente é o maior) + teto de zoom 4; foto de longe demais segue limitada e o pan/zoom é o escape; gatilho de revisão: taxa de falso negativo/positivo medida no craft com fotos reais.
- **Modelo de ~1 MB no primeiro uso do time** → lazy depois do cutout (fora do bundle inicial), cache HTTP, sem entrada no denominador do progresso; falha tolerada (fallback); stub impede rede no e2e.
- **iOS/Safari** → mesmo runtime wasm do S15 (probe SIMD com caminho nosimd); modelo float16; `detect` em `<canvas>` é API estável; validar em iPhone físico no craft, como no S15.
- **Guard de console/5xx do e2e** → caminho de detecção sem logs; stub não chama o detector; nenhuma URL externa no runtime.
- **Corrida/troca de foto** → a detecção roda dentro do mesmo run do recorte e viaja no resultado; o run id do hook já ignora resultado velho; nenhum estado novo para sincronizar.
- **Cobertura vs proporção** → piso `CARD_PHOTO_MIN_ZOOM=1` + `clampCardPhotoTransform` garantem a janela sempre coberta; limite explícito: selfie muito fechada pode passar de +20% e rosto maior que a janela em zoom 1 respeita o limite da janela (cobertura manda) — registrado no aceite.
- **Ajuste percebido como "errado" pelo visitante** → o enquadramento é só o inicial; arrastar/zoom continuam o escape e o CTA não trava.
- **Colisão com S17 no mesmo pipeline** → os dois tocam `cardCutout`/`CardComposer`; se rodarem em paralelo, serializar (dependência registrada na intenção).
## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora
**Resolvido no simplify (não reabrir):** `format:check` (prettier/organize-imports); `readLargestFaceBox` exportada + `tests/unit/cardFaceDetection.unit.spec.ts`; memoização falsa do fileset revertida (cada creator chama `forVisionTasks` direto); teto de rosto inalcançável removido (a garantia ficou estrutural: face desenhada ≤ ~118px) e o teste virou "+15% no piso" com o valor exato; `zoomCardPhotoTransform` com options object `{ anchor?, minZoom? }` (fim do `undefined` posicional); doc do `useCardCutout`, JSDoc do `removeCardPhotoBackground` (face) e guards `!(x > 0)` (NaN) na math/filtro; describe `(S13/S15/S18)`; testes novos de pan/zoom com o piso do time. **Absorvido:** o fix do `codebaseConventions.unit.spec.ts` (`git ls-files -z` para paths não-ASCII) — `main` estava vermelho no verify do deploy `4d376402` e o PR não passaria sem ele.
**Adiados com gatilho (engenharia):** piso do time selecionado no composer e reusado no `renderTeamCard` (hoje ambos importam a mesma constante; gatilho: um dos lados deixar de importá-la ou o piso virar por-modelo); config do time dividida entre `cardModels` (referência) e `cardPhotoTransform` (piso) (gatilho: recalibrar arte/janela/referência em conjunto ou 2º consumidor); união do modo do stub em 3 pontos (gatilho: 4º ponto ou 5º modo → extrair `CardCutoutStubMode`); detecção de rosto pequeno/distante (gatilho já em Riscos: medir falso negativo/positivo com fotos reais na UAT).
**Explicitamente fora (não reabrir):** e2e do ramo com rosto não baixa o PNG — o download do caminho proporcional é exercido pelo teste S15 `slow`, que agora roda com o rosto sintético do stub e asserta filename + assinatura PNG. Nenhum débito virou Issue nesta sessão (scores 2–3, sem `expensive_lock`).
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: cabeça do visitante na ordem de grandeza dos candidatos (±20%) quando medível; automático, sem controle novo; rosto nunca cortado pela proporcionalidade; cobertura da janela prevalece; fallback silencioso ao enquadramento S15 sem erro novo; 100% no aparelho; 3 modelos/galeria/copy/privacidade intocados.
- [ ] Invariantes AGENTS/engineering-standards: math pura e client-safe em `src/lib` (sem DOM); detecção no adapter `src/components/cards`; sem migration/collection/Consent/access; sem `as never`; knip/cycles verdes; arte-mestre intocada; `pnpm gate:fast` + e2e da superfície + `pnpm push`.
- [ ] Testes de domínio previstos: unit de `frameCardPhotoOnFace` (proporção, piso, teto, fallback/delegação) e pin de `TEAM_CARD_FACE_REFERENCE_SIZE` obrigatórios; e2e no spec `frontend` com stub (`ok`/`noface`); `E2E_AFFECTED_MANIFEST` sem entrada nova; **int não se aplica** — 100% client-side, sem Payload/DB/access nem escrita multi-collection.
- [ ] Limites registrados: (a) o piso do time (zoom ≥ 0.2) mantém a face alcançada em no máximo +15% da referência, então o aceite de ±20% fica garantido por construção; o limite real é o teto de 4 (rosto minúsculo/distante) e a foto do visitante pode ficar menor que a janela, com o ajuste fino como escape; (b) rosto maior que a janela → o teto de rosto respeita a janela; (c) detecção indisponível ou abaixo de 0.5 → enquadramento S15 (sem aviso); (d) falso positivo só age se for o maior rosto.
## Self-score decision-quality: 5/5
1. **Decisões caras com rejeitadas:** detector (A–D), onde detectar, math/API, constante, integração/prefetch e seam de e2e — todas com Opções/Recomendação/Rejeitadas; o barato (nomes, literais do rosto sintético) fica como fill-in.
2. **Cabe no appetite:** 3 fases ≤1,5 dia, sem migration/infra; a math pura vem primeiro (tracer) e o detector depois, com fallback garantido.
3. **Rabbit holes nomeados:** re-detecção em pan/zoom, zoom < 1, FaceLandmarker/detector próprio, medir arte em runtime, segundo seam, engine real no CI.
4. **Depth check:** reusa `frameCardPhotoOnBbox`/`clampCardPhotoTransform`, o singleton/prefetch/stub do S15, `CardCutoutResult`/hook e o padrão de licença — nada de abstração nova além da função pura pedida.
5. **Intenção preservada:** aceite de produto intacto (outcome, ±20%, cobertura, rosto não cortado, fallback, privacidade); a engenharia só resolveu as aberturas que a intenção deixou (detector, referência, cobertura vs proporção).

View file

@ -0,0 +1,25 @@
blaze_face_full_range.tflite
============================
Model: MediaPipe Face Detector (BlazeFace full-range, float16)
Source: https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_full_range/float16/1/blaze_face_full_range.tflite
SHA-256: 3698b18f063835bc609069ef052228fbe86d9c9a6dc8dcb7c7c2d69aed2b181b
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.

Binary file not shown.

View file

@ -34,6 +34,7 @@ import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
CARD_TEAM_PHOTO_MIN_ZOOM,
cardPhotoTransformsEqual,
centerCardPhotoTransform,
panCardPhotoTransform,
@ -177,6 +178,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const photoWindow = model.photoWindow
const isNameModel = model.kind === 'name'
const isTeamModel = model.kind === 'team'
/** S18 — the team framing may sit below the S13 cover floor. */
const photoMinZoom = isTeamModel ? CARD_TEAM_PHOTO_MIN_ZOOM : CARD_PHOTO_MIN_ZOOM
const cutout = useCardCutout(photoWindow, isTeamModel ? model.assetSrc : undefined)
const cutoutState = cutout.state
@ -382,7 +385,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const dy = (event.clientY - drag.y) * scale
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy),
panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom),
)
}
@ -392,13 +395,13 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
const panBy = (dx: number, dy: number) => {
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy),
panCardPhotoTransform(transform, size, window, dx, dy, photoMinZoom),
)
}
const zoomTo = (zoom: number) => {
withTransform((transform, size, window) =>
zoomCardPhotoTransform(transform, size, window, zoom),
zoomCardPhotoTransform(transform, size, window, zoom, { minZoom: photoMinZoom }),
)
}
@ -510,7 +513,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
<input
id={zoomInputId}
type="range"
min={CARD_PHOTO_MIN_ZOOM}
min={photoMinZoom}
max={CARD_PHOTO_MAX_ZOOM}
step={0.05}
value={effectiveTransform.zoom}

View file

@ -9,28 +9,42 @@
* `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).
*
* S18 — the same runtime also runs the Apache-2.0 `blaze_face_full_range.tflite`
* FaceDetector (see `blaze_face_full_range.LICENSE.txt`) on the cutout canvas:
* the largest face feeds the proportional framing. The detector is a second
* lazy singleton and every detection failure is swallowed into `face: null` so
* the card never fails or surfaces an error because of it.
*/
import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision'
import type { Detection, FaceDetector, ImageSegmenter, MPMask } from '@mediapipe/tasks-vision'
import type { CardAlphaBbox } from '@/lib/cardPhotoTransform'
import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform'
import {
CARD_CUTOUT_ALPHA_THRESHOLD,
type CardAlphaBbox,
type CardFaceBox,
} from '@/lib/cardPhotoTransform'
declare global {
interface Window {
/** Test seam: the e2e build stubs the engine through this flag. */
__cardsCutoutStub?: 'ok' | 'slow' | 'error'
__cardsCutoutStub?: 'ok' | 'slow' | 'error' | 'noface'
}
}
const WASM_DIR = '/cards/mediapipe/wasm'
const MODEL_URL = '/cards/selfie_segmenter.tflite'
const FACE_MODEL_URL = '/cards/blaze_face_full_range.tflite'
const FACE_MIN_CONFIDENCE = 0.5
const CUTOUT_MAX_EDGE = 1600
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
/** Synthetic head box of the stub ellipse, as a fraction of its canvas (S18). */
const STUB_FACE_WIDTH_RATIO = 0.3
const STUB_FACE_TOP_RATIO = 0.18
export type CardCutoutProgress = {
phase: 'download' | 'process'
@ -47,13 +61,17 @@ export type CardCutoutResult =
width: number
height: number
bbox: CardAlphaBbox
/** S18 — largest detected face in canvas pixels; null falls back to S15. */
face: CardFaceBox | null
}
| { 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.
* canvas + its alpha bbox + the largest detected face (`face: null` when the
* detector is unavailable or finds none — the composer falls back to the S15
* framing in silence), or fails with `engine` (decode/inference/asset failure)
* or `empty` (blank segmentation) — both recoverable in the composer.
*/
export const removeCardPhotoBackground = async (
file: File,
@ -113,9 +131,7 @@ const createSegmenter = async (onProgress: (progress: CardCutoutProgress) => voi
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())
const modelAssetBuffer = await fetchModelBuffer(MODEL_URL, 'card-cutout-model')
return vision.ImageSegmenter.createFromOptions(fileset, {
baseOptions: { modelAssetBuffer },
@ -125,6 +141,72 @@ const createSegmenter = async (onProgress: (progress: CardCutoutProgress) => voi
})
}
const fetchModelBuffer = async (url: string, errorPrefix: string) => {
const response = await fetch(url)
if (!response.ok) throw new Error(`${errorPrefix}:${response.status}`)
return new Uint8Array(await response.arrayBuffer())
}
let faceDetectorPromise: Promise<FaceDetector> | null = null
const loadFaceDetector = () => {
if (!faceDetectorPromise) {
faceDetectorPromise = createFaceDetector().catch((error: unknown) => {
faceDetectorPromise = null
throw error
})
}
return faceDetectorPromise
}
const createFaceDetector = async () => {
const vision = await import('@mediapipe/tasks-vision')
const fileset = await vision.FilesetResolver.forVisionTasks(WASM_DIR)
const modelAssetBuffer = await fetchModelBuffer(FACE_MODEL_URL, 'card-face-model')
return vision.FaceDetector.createFromOptions(fileset, {
baseOptions: { modelAssetBuffer },
runningMode: 'IMAGE',
minDetectionConfidence: FACE_MIN_CONFIDENCE,
})
}
/**
* S18 — largest face of the cutout canvas, or `null`. Every failure (model
* fetch, detector creation, inference) is swallowed here on purpose: the
* proportional framing is an enhancement, so the card must never fail nor show
* an error because of it — the composer falls back to the S15 framing.
*/
const detectCardFace = async (canvas: HTMLCanvasElement): Promise<CardFaceBox | null> => {
try {
const detector = await loadFaceDetector()
return readLargestFaceBox(detector.detect(canvas).detections)
} catch {
return null
}
}
/**
* S18 — the visitor's face: the largest detected box, or `null`. Exported for
* unit tests (the adapter itself is DOM-bound); MediaPipe may omit the box of a
* degenerate detection, so those are dropped.
*/
export const readLargestFaceBox = (detections: Detection[]): CardFaceBox | null => {
let largest: CardFaceBox | null = null
for (const detection of detections) {
const box = detection.boundingBox
if (!box || !(box.width > 0) || !(box.height > 0)) continue
if (!largest || box.width * box.height > largest.width * largest.height) {
largest = { x: box.originX, y: box.originY, width: box.width, height: box.height }
}
}
return largest
}
/**
* 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.
@ -184,8 +266,9 @@ const engineCutout = async (
const bbox = readAlphaBbox(canvas)
if (!bbox) return { ok: false, reason: 'empty' }
const face = await detectCardFace(canvas)
onProgress({ phase: 'process', ratio: 1 })
return { ok: true, canvas, width, height, bbox }
return { ok: true, canvas, width, height, bbox, face }
} finally {
mask?.close()
result.close()
@ -280,16 +363,17 @@ const supportsWasmSimd = (): boolean => {
}
}
const readStubMode = (): 'ok' | 'slow' | 'error' => {
const readStubMode = (): 'ok' | 'slow' | 'error' | 'noface' => {
if (typeof window === 'undefined') return 'ok'
const mode = window.__cardsCutoutStub
return mode === 'slow' || mode === 'error' ? mode : 'ok'
return mode === 'slow' || mode === 'error' || mode === 'noface' ? 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.
* the processing state visible; `error` exercises the retry path; `noface`
* returns no detected face so the S15 fallback framing runs.
*/
const stubCutout = async (
file: File,
@ -320,5 +404,22 @@ const stubCutout = async (
ctx.globalCompositeOperation = 'source-over'
bitmap.close()
return { ok: true, canvas, width, height, bbox: { x: 0, y: 0, width, height } }
const stubFaceSize = Math.round(width * STUB_FACE_WIDTH_RATIO)
return {
ok: true,
canvas,
width,
height,
bbox: { x: 0, y: 0, width, height },
face:
mode === 'noface'
? null
: {
x: Math.round((width - stubFaceSize) / 2),
y: Math.round(height * STUB_FACE_TOP_RATIO),
width: stubFaceSize,
height: stubFaceSize,
},
}
}

View file

@ -4,8 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
import type { CardRect } from '@/lib/cardModels'
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
import { TEAM_CARD_FACE_REFERENCE_SIZE, type CardRect } from '@/lib/cardModels'
import { frameCardPhotoOnFace, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
export type CardCutoutState =
| { status: 'idle' }
@ -21,7 +21,7 @@ export type CardCutoutState =
harmonized: HTMLCanvasElement | null
width: number
height: number
/** Initial framing: cutout top on the slot top, centered, covering it. */
/** Initial framing: cutout top on the slot top, face centered (S18). */
transform: CardPhotoTransform
}
| { status: 'error'; reason: 'engine' | 'empty' }
@ -67,10 +67,12 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc
}
const transform = photoWindow
? frameCardPhotoOnBbox(
? frameCardPhotoOnFace(
{ width: result.width, height: result.height },
photoWindow,
result.bbox,
result.face,
TEAM_CARD_FACE_REFERENCE_SIZE,
)
: null
if (!transform) {

View file

@ -200,6 +200,19 @@ export const TEAM_CARD_NAME_SLOT = {
maxLines: 1,
} as const satisfies CardNameBannerSlot
/**
* S18 — the candidates' face box measured once over the master art
* (`public/cards/team-card-base.png`, 1080×1440) with the same detector the
* composer runs on the visitor's cutout: MediaPipe `FaceDetector` +
* `blaze_face_full_range.tflite` (float16/1, SHA-256
* 3698b18f063835bc609069ef052228fbe86d9c9a6dc8dcb7c7c2d69aed2b181b) at
* `minDetectionConfidence: 0.5`. Detected boxes (x,y,w,h): (45,609,108,108),
* (202,580,103,103), (336,540,149,149), (762,583,103,103), (926,608,99,99).
* The median (103) is the reference so the visitor's head lands on the team's
* typical scale instead of the central candidate's larger box (149).
*/
export const TEAM_CARD_FACE_REFERENCE_SIZE = 103
export const BRAND_FALLBACK_FONT = 'sans-serif'
export const isCardModelId = (value: unknown): value is CardModelId =>

View file

@ -4,6 +4,10 @@
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
* [1, 4] and pins the offsets so no gap can open at the window edges.
*
* S18 — the team model may frame the head below the cover floor
* (`CARD_TEAM_PHOTO_MIN_ZOOM`), so its clamp keeps the drawn photo *inside* the
* window when it is smaller than it instead of pinning it to the edge.
*
* `centerCardPhotoTransform` is the initial framing (cover on the window, not
* on the whole card) so the face lands on the visible area of the frame.
*/
@ -13,6 +17,15 @@ import type { CardRect } from './cardModels'
export const CARD_PHOTO_MIN_ZOOM = 1
export const CARD_PHOTO_MAX_ZOOM = 4
/**
* S18 — floor for the team model's proportional framing. At 0.2 the drawn photo
* can sit inside the window, which is what the design's "proporcional" scene
* asks for; the floor also bounds a runaway zoom-out (a face box can span the
* whole source at most, so the achieved face never exceeds the reference by
* more than the ~15% the window's tighter axis implies).
*/
export const CARD_TEAM_PHOTO_MIN_ZOOM = 0.2
/**
* 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
@ -45,6 +58,14 @@ export type CardAlphaBbox = {
height: number
}
/** Detected face box of a source photo, in the same source pixels (S18). */
export type CardFaceBox = {
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
@ -61,8 +82,9 @@ export const cardPhotoDrawRect = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
minZoom: number = CARD_PHOTO_MIN_ZOOM,
): CardRect => {
const clamped = clampCardPhotoTransform(transform, source, window)
const clamped = clampCardPhotoTransform(transform, source, window, minZoom)
const scale = coverScale(source, window) * clamped.zoom
return {
@ -73,20 +95,31 @@ export const cardPhotoDrawRect = (
}
}
/**
* Keeps the drawn photo covering the window when it is at least as big as it
* (the S13 rule) and fully inside it when it is smaller (the S18 team framing,
* where the visitor no longer fills the slot).
*/
const clampPhotoOffset = (value: number, start: number, size: number, drawn: number): number =>
drawn >= size
? clamp(value, start + size - drawn, start)
: clamp(value, start, start + size - drawn)
export const clampCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
minZoom: number = CARD_PHOTO_MIN_ZOOM,
): CardPhotoTransform => {
const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
const zoom = clamp(transform.zoom, minZoom, CARD_PHOTO_MAX_ZOOM)
const scale = coverScale(source, window) * zoom
const width = source.width * scale
const height = source.height * scale
return {
zoom,
offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x),
offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y),
offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width),
offsetY: clampPhotoOffset(transform.offsetY, window.y, window.height, height),
}
}
@ -149,6 +182,51 @@ export const frameCardPhotoOnBbox = (
)
}
/**
* S18 — initial framing for the team cutout oriented by the visitor's face: the
* detected face box is scaled to `referenceSize` (the candidates' face box
* measured once over the master art) so the visitor's head lands on the team's
* scale. The zoom may sit below the S13 cover floor (down to
* `CARD_TEAM_PHOTO_MIN_ZOOM`), which is the design's proportional framing: the
* visitor keeps the team's scale and the master art shows around them. With the
* pinned reference (103px) the framed face never exceeds ~118px (the floor
* times the window's tighter axis), so it fits well inside the 592×577 window;
* the placement centers it horizontally and keeps the bbox top on the window
* top. Without a usable face (or with a degenerate bbox) the S15 framing
* (`frameCardPhotoOnBbox`) applies unchanged.
*/
export const frameCardPhotoOnFace = (
source: CardPhotoSize,
window: CardRect,
bbox: CardAlphaBbox,
face: CardFaceBox | null,
referenceSize: number,
): CardPhotoTransform | null => {
const baseFraming = frameCardPhotoOnBbox(source, window, bbox)
if (!baseFraming) return null
if (!face || !(face.width > 0) || !(face.height > 0) || !(referenceSize > 0)) return baseFraming
const scale = coverScale(source, window)
const faceSize = Math.max(face.width, face.height)
const zoom = clamp(
referenceSize / (faceSize * scale),
CARD_TEAM_PHOTO_MIN_ZOOM,
CARD_PHOTO_MAX_ZOOM,
)
const applied = scale * zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: window.x + window.width / 2 - (face.x + face.width / 2) * applied,
offsetY: window.y - bbox.y * applied,
},
source,
window,
CARD_TEAM_PHOTO_MIN_ZOOM,
)
}
/** Nudges the offset by a step in card pixels, clamped to the window. */
export const panCardPhotoTransform = (
transform: CardPhotoTransform,
@ -156,39 +234,47 @@ export const panCardPhotoTransform = (
window: CardRect,
dx: number,
dy: number,
minZoom: number = CARD_PHOTO_MIN_ZOOM,
): CardPhotoTransform =>
clampCardPhotoTransform(
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
source,
window,
minZoom,
)
/**
* Rescales around a window anchor (used by the zoom control): keeps the point
* under `anchor` stable from old to new zoom, then clamps.
* under `anchor` stable from old to new zoom, then clamps. The team model
* passes its own `minZoom` so the visitor can fine-tune below the cover floor.
*/
export const zoomCardPhotoTransform = (
transform: CardPhotoTransform,
source: CardPhotoSize,
window: CardRect,
nextZoom: number,
anchor: { x: number; y: number } = {
{
anchor,
minZoom = CARD_PHOTO_MIN_ZOOM,
}: { anchor?: { x: number; y: number }; minZoom?: number } = {},
): CardPhotoTransform => {
const resolvedAnchor = anchor ?? {
x: window.x + window.width / 2,
y: window.y + window.height / 2,
},
): CardPhotoTransform => {
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window)
}
const zoom = clamp(nextZoom, minZoom, CARD_PHOTO_MAX_ZOOM)
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window, minZoom)
const ratio = zoom / transform.zoom
return clampCardPhotoTransform(
{
zoom,
offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio,
offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio,
offsetX: resolvedAnchor.x - (resolvedAnchor.x - transform.offsetX) * ratio,
offsetY: resolvedAnchor.y - (resolvedAnchor.y - transform.offsetY) * ratio,
},
source,
window,
minZoom,
)
}

View file

@ -26,6 +26,7 @@ import {
type CardTextMetrics,
} from './cardNameFit'
import {
CARD_TEAM_PHOTO_MIN_ZOOM,
cardPhotoDrawRect,
clampCardPhotoTransform,
type CardPhotoSize,
@ -236,7 +237,12 @@ export const renderTeamCard = (
): TeamCardRenderResult => {
ctx.drawImage(args.base, 0, 0, model.width, model.height)
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window)
const rect = cardPhotoDrawRect(
args.transform,
args.photoSize,
args.window,
CARD_TEAM_PHOTO_MIN_ZOOM,
)
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
ctx.drawImage(args.overlay, 0, 0, model.width, model.height)
@ -257,6 +263,11 @@ export const renderTeamCard = (
return {
fit,
transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window),
transform: clampCardPhotoTransform(
args.transform,
args.photoSize,
args.window,
CARD_TEAM_PHOTO_MIN_ZOOM,
),
}
}

View file

@ -1837,7 +1837,7 @@ test.describe('Cards personalizados (S14)', () => {
/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */
const STUB_CUTOUT_PERCENT = 36
const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error') =>
const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error' | 'noface') =>
page.addInitScript((value: string) => {
;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value
}, mode)
@ -1995,7 +1995,10 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
const readSample = () =>
canvas.evaluate((element) => {
const node = element as HTMLCanvasElement
return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data]
// S18 — the proportional framing draws the stub photo smaller than the
// slot, so the sample sits inside the drawn ellipse (window center)
// instead of the S15 cover point (582, 727).
return [...node.getContext('2d')!.getImageData(582, 550, 1, 1).data]
})
// Default ON: the preview pixel is the harmonized variant, not the raw photo.
@ -2050,3 +2053,57 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
await expect(drawer).toHaveCount(0)
})
})
/**
* S18 — the proportional face framing. The stub carries a synthetic face box
* (`ok`/`slow`) or none (`noface`), so both branches run through the composer
* without touching the real detector; the unit specs pin the framing math.
*/
test.describe('Cards personalizados (S18 — cabeça proporcional)', () => {
test('a cutout without a detected face falls back to the S15 framing in silence', async ({
page,
}) => {
await setCutoutStub(page, 'noface')
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)
// The fallback is invisible: no detection alert, no error state, S15 zoom.
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
await expect(dialog.getByRole('alert')).toHaveCount(0)
expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBe(1)
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
await expect(primary).toBeEnabled()
await primary.click()
const [download] = await Promise.all([
page.waitForEvent('download'),
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
])
expect(
readFileSync((await download.path())!)
.subarray(0, 8)
.equals(PNG_SIGNATURE),
).toBe(true)
})
test('a detected face reaches the proportional framing without surfacing errors', 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('Maria')
await uploadBustPhoto(dialog)
await expect(dialog.getByRole('heading', { name: 'Confira seu card' })).toBeVisible()
await expect(dialog.getByRole('alert')).toHaveCount(0)
// The proportional framing sits well below the S13 cover floor (team min).
expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBeLessThan(0.5)
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled()
await expect(dialog.getByText('Arraste para ajustar')).toBeVisible()
})
})

View file

@ -0,0 +1,28 @@
import type { Detection } from '@mediapipe/tasks-vision'
import { describe, expect, it } from 'vitest'
import { readLargestFaceBox } from '@/components/cards/cardCutout'
const detection = (originX: number, originY: number, width: number, height: number): Detection => ({
categories: [],
keypoints: [],
boundingBox: { originX, originY, width, height, angle: 0 },
})
describe('readLargestFaceBox (S18)', () => {
it('returns null when there is no usable box', () => {
expect(readLargestFaceBox([])).toBeNull()
expect(readLargestFaceBox([{ categories: [], keypoints: [] }])).toBeNull()
expect(readLargestFaceBox([detection(0, 0, 0, 40), detection(0, 0, 40, 0)])).toBeNull()
})
it('maps the largest detection to the CardFaceBox shape', () => {
expect(
readLargestFaceBox([
detection(10, 20, 30, 40),
detection(50, 60, 80, 90),
detection(0, 0, 70, 70),
]),
).toEqual({ x: 50, y: 60, width: 80, height: 90 })
})
})

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
CARD_MODELS,
NAME_CARD_SLOT,
TEAM_CARD_FACE_REFERENCE_SIZE,
TEAM_CARD_LABEL,
TEAM_CARD_NAME_BANNER,
TEAM_CARD_NAME_SLOT,
@ -10,7 +11,7 @@ import {
isCardModelId,
} from '@/lib/cardModels'
describe('card model catalog (S13/S15)', () => {
describe('card model catalog (S13/S15/S18)', () => {
it('exposes the four shipped models with unique ids', () => {
expect(CARD_MODELS.map((model) => model.id)).toEqual([
'eu-sou-solla',
@ -132,6 +133,10 @@ describe('card model catalog (S13/S15)', () => {
expect(lowestCorner).toBeLessThan(model.photoWindow!.y)
})
it('pins the measured candidates face reference (S18)', () => {
expect(TEAM_CARD_FACE_REFERENCE_SIZE).toBe(103)
})
it('sanitizes query-param values through isCardModelId', () => {
expect(isCardModelId('eu-sou-solla')).toBe(true)
expect(isCardModelId('perfil-quadrado')).toBe(true)

View file

@ -4,14 +4,17 @@ import type { CardRect } from '@/lib/cardModels'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
CARD_TEAM_PHOTO_MIN_ZOOM,
cardPhotoDrawRect,
centerCardPhotoTransform,
clampCardPhotoTransform,
coverScale,
frameCardPhotoOnBbox,
frameCardPhotoOnFace,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardAlphaBbox,
type CardFaceBox,
type CardPhotoSize,
} from '@/lib/cardPhotoTransform'
@ -113,6 +116,132 @@ describe('frameCardPhotoOnBbox', () => {
})
})
describe('frameCardPhotoOnFace', () => {
const TEAM_WINDOW: CardRect = { x: 286, y: 439, width: 592, height: 577 }
const SOURCE: CardPhotoSize = { width: 1000, height: 1250 }
const BBOX: CardAlphaBbox = { x: 200, y: 100, width: 600, height: 900 }
const REFERENCE = 103
it('scales the detected face to the reference, centered and on the window top', () => {
const face: CardFaceBox = { x: 400, y: 200, width: 60, height: 60 }
const transform = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, face, REFERENCE)
expect(transform).not.toBeNull()
if (!transform) return
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW)
const scale = rect.width / SOURCE.width
expect(transform.zoom).toBeCloseTo(
REFERENCE / (face.width * coverScale(SOURCE, TEAM_WINDOW)),
5,
)
// the face lands exactly on the candidates' reference size
expect(face.width * scale).toBeCloseTo(REFERENCE, 5)
// face center on the window center, bbox top on the window top
expect(rect.x + (face.x + face.width / 2) * scale).toBeCloseTo(
TEAM_WINDOW.x + TEAM_WINDOW.width / 2,
5,
)
expect(rect.y + BBOX.y * scale).toBeCloseTo(TEAM_WINDOW.y, 5)
// the face itself stays inside the window
expect(rect.x + face.x * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.x - 1e-6)
expect(rect.x + (face.x + face.width) * scale).toBeLessThanOrEqual(
TEAM_WINDOW.x + TEAM_WINDOW.width + 1e-6,
)
expect(rect.y + face.y * scale).toBeGreaterThanOrEqual(TEAM_WINDOW.y - 1e-6)
expect(rect.y + (face.y + face.height) * scale).toBeLessThanOrEqual(
TEAM_WINDOW.y + TEAM_WINDOW.height + 1e-6,
)
})
it('scales a large face down below the S13 cover floor onto the reference size', () => {
const face: CardFaceBox = { x: 380, y: 180, width: 220, height: 220 }
const transform = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, face, REFERENCE)
expect(transform).not.toBeNull()
if (!transform) return
// the proportional framing wins over the S13 cover floor...
expect(transform.zoom).toBeLessThan(CARD_PHOTO_MIN_ZOOM)
expect(transform.zoom).toBeGreaterThanOrEqual(CARD_TEAM_PHOTO_MIN_ZOOM)
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
const scale = rect.width / SOURCE.width
// ...and still lands the face exactly on the reference size
expect(face.width * scale).toBeCloseTo(REFERENCE, 5)
// per axis: contained where the photo is smaller, covering where it is bigger
expect(rect.x).toBeGreaterThanOrEqual(TEAM_WINDOW.x - 1e-6)
expect(rect.x + rect.width).toBeLessThanOrEqual(TEAM_WINDOW.x + TEAM_WINDOW.width + 1e-6)
expect(rect.y).toBeLessThanOrEqual(TEAM_WINDOW.y + 1e-6)
expect(rect.y + rect.height).toBeGreaterThanOrEqual(TEAM_WINDOW.y + TEAM_WINDOW.height - 1e-6)
// the face itself stays inside the window
expect(rect.y + (face.y + face.height) * scale).toBeLessThanOrEqual(
TEAM_WINDOW.y + TEAM_WINDOW.height + 1e-6,
)
})
it('clamps an extreme face at the team floor and keeps it within +15%', () => {
const face: CardFaceBox = { x: 40, y: 100, width: 900, height: 900 }
const transform = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, face, REFERENCE)
expect(transform).not.toBeNull()
if (!transform) return
expect(transform.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM)
const rect = cardPhotoDrawRect(transform, SOURCE, TEAM_WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
const scale = rect.width / SOURCE.width
const achieved = face.width * scale
expect(achieved).toBeCloseTo(face.width * coverScale(SOURCE, TEAM_WINDOW) * 0.2, 5)
expect(achieved).toBeLessThanOrEqual(REFERENCE * 1.15)
})
it('delegates to the S15 framing on non-finite face or reference values', () => {
const expected = frameCardPhotoOnBbox(SOURCE, TEAM_WINDOW, BBOX)
expect(
frameCardPhotoOnFace(
SOURCE,
TEAM_WINDOW,
BBOX,
{ x: 0, y: 0, width: Number.NaN, height: 40 },
REFERENCE,
),
).toEqual(expected)
expect(frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, null, Number.POSITIVE_INFINITY)).toEqual(
expected,
)
})
it('delegates to the S15 framing when there is no usable face', () => {
const expected = frameCardPhotoOnBbox(SOURCE, TEAM_WINDOW, BBOX)
expect(frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, null, REFERENCE)).toEqual(expected)
expect(
frameCardPhotoOnFace(
SOURCE,
TEAM_WINDOW,
BBOX,
{ x: 0, y: 0, width: 0, height: 0 },
REFERENCE,
),
).toEqual(expected)
expect(
frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, { x: 400, y: 200, width: 60, height: 60 }, 0),
).toEqual(expected)
})
it('fails closed on a degenerate bbox, with or without a face', () => {
const emptyBbox: CardAlphaBbox = { x: 0, y: 0, width: 0, height: 0 }
const face: CardFaceBox = { x: 10, y: 10, width: 50, height: 50 }
expect(frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, emptyBbox, face, REFERENCE)).toBeNull()
expect(frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, emptyBbox, null, REFERENCE)).toBeNull()
})
})
describe('clampCardPhotoTransform', () => {
it('clamps the zoom to [1, 4]', () => {
expect(
@ -139,6 +268,22 @@ describe('clampCardPhotoTransform', () => {
expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 })
expect(coversWindow(cardPhotoDrawRect(tooFarLeft, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
})
it('accepts the team floor and keeps a smaller photo contained by the window', () => {
const shrunk = clampCardPhotoTransform(
{ zoom: 0.1, offsetX: 9999, offsetY: -9999 },
LANDSCAPE,
WINDOW,
CARD_TEAM_PHOTO_MIN_ZOOM,
)
expect(shrunk.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM)
const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, CARD_TEAM_PHOTO_MIN_ZOOM)
expect(rect.x).toBeGreaterThanOrEqual(WINDOW.x)
expect(rect.y).toBeGreaterThanOrEqual(WINDOW.y)
expect(rect.x + rect.width).toBeLessThanOrEqual(WINDOW.x + WINDOW.width)
expect(rect.y + rect.height).toBeLessThanOrEqual(WINDOW.y + WINDOW.height)
})
})
describe('panCardPhotoTransform', () => {
@ -152,12 +297,27 @@ describe('panCardPhotoTransform', () => {
const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999)
expect(overPanned).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
})
it('keeps a sub-1 team zoom while panning the contained photo', () => {
const panned = panCardPhotoTransform(
{ zoom: 0.3, offsetX: 0, offsetY: 0 },
LANDSCAPE,
WINDOW,
40,
30,
CARD_TEAM_PHOTO_MIN_ZOOM,
)
expect(panned).toEqual({ zoom: 0.3, offsetX: 40, offsetY: 30 })
})
})
describe('zoomCardPhotoTransform', () => {
it('keeps the anchor point stable while zooming', () => {
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 })
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, {
anchor: { x: 500, y: 370 },
})
const scale = coverScale(LANDSCAPE, WINDOW) * 2
expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5)
@ -182,4 +342,17 @@ describe('zoomCardPhotoTransform', () => {
expect(same).toEqual(start)
})
it('honors the team floor and preserves a sub-1 escape zoom', () => {
const start = { zoom: 0.3, offsetX: 0, offsetY: 0 }
const floored = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 0.05, {
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
})
const adjusted = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 0.5, {
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
})
expect(floored.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM)
expect(adjusted.zoom).toBe(0.5)
})
})