diff --git a/docs/changelog/2026-09-19-s18-card-cabeca-proporcional.md b/docs/changelog/2026-09-19-s18-card-cabeca-proporcional.md new file mode 100644 index 00000000..d079fdd1 --- /dev/null +++ b/docs/changelog/2026-09-19-s18-card-cabeca-proporcional.md @@ -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. diff --git a/docs/plans/cards-time-de-voce-escala-cabeca-impl.md b/docs/plans/cards-time-de-voce-escala-cabeca-impl.md new file mode 100644 index 00000000..0afce941 --- /dev/null +++ b/docs/plans/cards-time-de-voce-escala-cabeca-impl.md @@ -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 `` é 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). diff --git a/public/cards/blaze_face_full_range.LICENSE.txt b/public/cards/blaze_face_full_range.LICENSE.txt new file mode 100644 index 00000000..5dd42a3a --- /dev/null +++ b/public/cards/blaze_face_full_range.LICENSE.txt @@ -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. diff --git a/public/cards/blaze_face_full_range.tflite b/public/cards/blaze_face_full_range.tflite new file mode 100644 index 00000000..7128856e Binary files /dev/null and b/public/cards/blaze_face_full_range.tflite differ diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx index a569401c..11cadad8 100644 --- a/src/components/cards/CardComposer.tsx +++ b/src/components/cards/CardComposer.tsx @@ -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 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 | 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 => { + 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, + }, + } } diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts index 46a4e430..83e2ea61 100644 --- a/src/components/cards/useCardCutout.ts +++ b/src/components/cards/useCardCutout.ts @@ -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) { diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index f89aa251..f4bbc504 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -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 => diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts index 7c9c50f8..412dd5c4 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -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, ) } diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index 9fe7e9a4..1818e83c 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -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, + ), } } diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index d72a1fb9..2b3b1a09 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -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() + }) +}) diff --git a/tests/unit/cardFaceDetection.unit.spec.ts b/tests/unit/cardFaceDetection.unit.spec.ts new file mode 100644 index 00000000..ad29472e --- /dev/null +++ b/tests/unit/cardFaceDetection.unit.spec.ts @@ -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 }) + }) +}) diff --git a/tests/unit/cardModels.unit.spec.ts b/tests/unit/cardModels.unit.spec.ts index 458dd83d..7b571968 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -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) diff --git a/tests/unit/cardPhotoTransform.unit.spec.ts b/tests/unit/cardPhotoTransform.unit.spec.ts index 02981eb0..925ac7e8 100644 --- a/tests/unit/cardPhotoTransform.unit.spec.ts +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -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) + }) })