mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
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:
parent
ca19b2c86f
commit
cde96b26d0
14 changed files with 721 additions and 40 deletions
|
|
@ -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.
|
||||
176
docs/plans/cards-time-de-voce-escala-cabeca-impl.md
Normal file
176
docs/plans/cards-time-de-voce-escala-cabeca-impl.md
Normal 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).
|
||||
25
public/cards/blaze_face_full_range.LICENSE.txt
Normal file
25
public/cards/blaze_face_full_range.LICENSE.txt
Normal 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.
|
||||
BIN
public/cards/blaze_face_full_range.tflite
Normal file
BIN
public/cards/blaze_face_full_range.tflite
Normal file
Binary file not shown.
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
28
tests/unit/cardFaceDetection.unit.spec.ts
Normal file
28
tests/unit/cardFaceDetection.unit.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue