diff --git a/docs/changelog/2026-09-20-card-time-recorte-e-enquadramento.md b/docs/changelog/2026-09-20-card-time-recorte-e-enquadramento.md new file mode 100644 index 00000000..75e9dc46 --- /dev/null +++ b/docs/changelog/2026-09-20-card-time-recorte-e-enquadramento.md @@ -0,0 +1 @@ +**Card "Time de você" — recorte no celular e enquadramento automático (2026-09-20):** dois bugs do funil público. (1) O recorte falhava no celular com "não conseguiu remover o background" e culpava a foto: navegadores endurecidos (IronFox, Tor-like) desligam WebAssembly e WebGL por padrão, e o grafo do MediaPipe `tasks-vision` **exige os dois** — mesmo com delegate CPU ele tenta criar o contexto WebGL (`kGpuService`), então não há fallback por software. `removeCardPhotoBackground` agora sonda `supportsWebAssembly()`/`supportsWebgl()` **antes** dos ~12 MB de download e devolve o reason novo `unsupported`; o composer mostra uma mensagem acionável (ativar WebAssembly/WebGL para o site nas configurações do navegador — ou abrir em outro navegador — e tentar de novo) em vez do "tente outra foto", e o download do engine deixa de acontecer quando ele não pode rodar. A entrada de inferência passa a ser capada em 1600px (`cappedSize`) e a foto é decodificada pelo loader compartilhado `loadCardPhoto` (EXIF aplicado, decode subsampleado no iOS): uma foto de 61MP que antes retornava segmentação vazia (`empty` → mesmo erro) agora conclui o card. (2) O enquadramento automático encolhia a pessoa: o S18 (cde96b26) passou a mirar a caixa do rosto na referência de 103px (mediana dos candidatos), o que deixa o rosto no tamanho certo mas transforma o busto inteiro num adesivo pequeno na janela 592×577, longe do card de exemplo oficial e do resultado S15. Decisão do humano (2026-09-20): voltar ao enquadramento S15 — `useCardCutout` volta a `frameCardPhotoOnBbox` (bbox cobre a janela, topo no topo do slot, centrado) e o zoom do time volta à faixa [1,4]. Saem o detector de rosto (`cardCutout.ts`, asset `blaze_face_full_range.tflite` + LICENSE removidos), `frameCardPhotoOnFace`, `TEAM_CARD_FACE_REFERENCE_SIZE`, o piso `CARD_TEAM_PHOTO_MIN_ZOOM = 0.2` e a âncora do S20 — que só ampliava o alcance lateral quando a foto desenhada era menor que a janela, impossível sob o enquadramento cover (por construção inerte). S16 (banner dinâmico) e S17 (harmonizar cores) ficam intactos; sem migration, sem Consent/access, sem URL/shape público, 100% no aparelho. Testes: unit 4203 verdes com o spec novo `cardCutout.unit.spec.ts` (WASM/WebGL off → `unsupported`; cap 1600) e a math do S15 restaurada; e2e `frontend` 31/31 com o teste novo "cobre a janela no piso de cobertura" (zoom 1 + sonda de pixel que o adesivo do S18 não alcançava); engine real no browser (Chromium e Firefox, viewport mobile, WebGL off, WASM off, foto 61MP). diff --git a/docs/postmortems/2026-09-20-card-time-recorte-e-enquadramento.md b/docs/postmortems/2026-09-20-card-time-recorte-e-enquadramento.md new file mode 100644 index 00000000..8a80766c --- /dev/null +++ b/docs/postmortems/2026-09-20-card-time-recorte-e-enquadramento.md @@ -0,0 +1,104 @@ +# Post-mortem: card "Time de você" — recorte falha em navegador endurecido e enquadramento automático encolhe a pessoa + +> Template do `/bug-fix`. Preencha com fatos apurados; o que não for apurado fica "não apurado" — nunca invente. + +## Registro + +| Campo | Valor | +| ------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Data do post-mortem | 2026-09-20 | +| Severidade | alta (funil público quebrado para o relator; enquadramento errado para todos os visitantes que usam o modelo time) | +| Ambiente | prod (jorgessolla1313.com.br) | +| Issue(s) | sem Issue — relato do humano na sessão do worktree `fix/8` (o post-mortem é o registro, conforme a skill `bug-fix`) | +| PR do fix | este PR | +| Detectado por | humano (relato na sessão) | + +## Timeline + +| Momento | Data/hora | Evento | +| ------------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Início provável | 2026-09-18 (S15) / 2026-09-19 (S18) | Bug A (recorte): o motor S15 nunca suportou navegador sem WASM/WebGL; o caminho de erro já culpava a foto. Bug B (enquadramento): o S18 (`cde96b26`, merge 2026-09-19 ~16:52) trocou o alvo do enquadramento para a caixa do rosto (103px) | +| Detecção | 2026-09-20 (sessão `fix/8`) | Humano relata os dois sintomas: "mesma foto funciona no computador mas no celular falha, diz que não conseguiu remover o background" e "agora posiciona sempre muito pequeno em relação aos candidatos" | +| Diagnóstico | 2026-09-20 | Reproduções locais: Firefox com `webgl.disabled=true` e com `javascript.options.wasm=false` → mesmo erro de recorte; card real em prod com foto de teste → zoom 0.30–0.65 (adesivo pequeno). Fotos grandes (61MP) → `empty`. O humano confirma aparelho/navegador (Android/IronFox) e que a foto é uma versão exportada (não é o arquivo pesado) | +| Correção mergeada | a definir (este PR) | — | +| Deploy | a definir | deploy começa no merge; staging automático; produção só após approve humano no environment `production` | +| Verificado em prod | pendente (aguarda o humano) | — | + +## O bug + +Dois sintomas no card `Time de você`, relatados juntos pelo mesmo humano: + +1. **Recorte no celular:** com a mesma foto que funciona no computador, o celular (Android, navegador **IronFox**) falhava com "Não foi possível remover o fundo desta foto. Tente de novo ou escolha outra foto." — o app tratava uma limitação do navegador como se a foto fosse o problema. +2. **Enquadramento automático:** "antes do último deploy a imagem da pessoa estava sendo ajustada automaticamente em relação aos candidatos (tamanho e posicionamento) melhor do que agora, agora está posicionando sempre muito pequeno em relação aos candidatos." Reproduzido em prod: o visitante virava um adesivo pequeno dentro da janela de 592×577, enquanto os candidatos seguem grandes (o exemplo oficial `team-card-example.jpg` tem a cabeça do visitante em ~230px). + +## Causa-raiz + +**Bug A — recorte (5-whys).** + +1. O recorte falhava com o erro genérico do composer. +2. `removeCardPhotoBackground` engolia qualquer exceção do motor em `reason: 'engine'` e o composer mostrava a mesma copy de "foto ruim" — a mensagem não distinguia foto de ambiente. +3. O motor `@mediapipe/tasks-vision` exige **WASM** e um contexto **WebGL** para o grafo de visão; com o delegate CPU ele ainda pede o `kGpuService` (processamento de imagem), então não há caminho por software. Evidência: `[card-cutout] Failed to create WebGL context` → `StartGraph failed: Service "kGpuService" ... was not created` (reproduzido em Firefox com `webgl.disabled=true`), e `ReferenceError: WebAssembly is not defined` (com `javascript.options.wasm=false`). Confirmado como comportamento upstream conhecido (mediapipe issues #5348, #5609, #5970: CPU delegate também falha sem WebGL). +4. O IronFox desliga **WebAssembly e WebGL por padrão** (README/IronFox: "IronFox has WebAssembly disabled by default", "IronFox has WebGL disabled by default"; há toggles em Configurações → Privacidade e segurança → Configurações do site → Conteúdo). O relator usa esse navegador no Android. +5. Nada no funil detectava essas capacidades antes de baixar ~12 MB de wasm nem dizia ao visitante o que fazer — o usuário só podia tentar de novo com outra foto, o que nunca resolveria. + +**Causa secundária (prevenção):** a decodificação (`createImageBitmap(file)`) e a segmentação (`segmenter.segment(bitmap)`) rodavam na resolução total da foto; uma imagem 61MP reproduzidamente retornava segmentação vazia (`empty`) e o mesmo erro, e o aparelho pagava textura/alocação no tamanho original — exatamente a classe de falha que atinge celulares. + +**Bug B — enquadramento (5-whys).** + +1. A pessoa saía pequena na prévia. +2. O S18 trocou o enquadramento automático de "bbox cobre a janela" (S15) para "caixa do rosto escalada à referência de 103px" (`TEAM_CARD_FACE_REFERENCE_SIZE`, mediana das caixas dos candidatos medida na arte-mestre). +3. O aceite do S18 (±20% da cabeça dos candidatos, medido pela **mesma caixa do detector nos dois lados**) foi cumprido literalmente — o rosto desenhado bate ~103px — mas a régua escolhida não era a que o humano percebe: o busto inteiro é escalado junto com o rosto, então a foto vira um adesivo dentro da janela, distante do card de exemplo (cabeça ~230px) e do resultado S15. +4. O design gate do S18 era DEGRADED (não certificado, artefato ilustrativo) e a craft mediu apenas a própria régua (86–98px vs 103px), não a comparação com o exemplo oficial; o sign-off humano no PR aprovou a régua, não a prévia real de um busto típico. +5. O humano viu o resultado em produção e pediu para voltar ao comportamento anterior (S15) — decisão de produto registrada nesta sessão (2026-09-20). + +**Observação sobre "antes do último deploy":** o S20 (`e652f0ae`, deploy seguinte) não altera o enquadramento automático; a mudança de tamanho/posição veio do S18. A âncora lateral do S20 só ampliava o alcance quando a foto desenhada era **menor** que a janela (regime do zoom < 1 do S18); com o enquadramento cover restaurado ela é inerte por construção, e por isso saiu junto (código morto). + +## Correção + +**Bug A — `src/components/cards/cardCutout.ts` + `src/components/cards/CardComposer.tsx`:** + +- Probes `supportsWebAssembly()` (valida um módulo mínimo) e `supportsWebgl()` (cria um contexto e o libera com `WEBGL_lose_context`) rodam **antes** do download; qualquer bloqueio devolve `{ ok: false, reason: 'unsupported' }` (reason novo no `CardCutoutResult`/`CardCutoutState`). +- O composer mostra, para `unsupported`, a mensagem acionável: "Seu navegador está bloqueando o recorte de fundo. O recorte roda no seu aparelho e precisa de WebAssembly e WebGL, que navegadores com proteções avançadas (como IronFox e Tor) desligam por padrão. Ative essas opções para este site nas configurações do navegador — ou abra a página em outro navegador — e toque em 'Tentar de novo'." (a dica de "foto de busto nítida" some nesse ramo). +- Tentativa de fallback para o delegate CPU foi implementada e **descartada com prova**: o grafo pede `kGpuService` de qualquer forma, então não entrou no fix final. +- `engineCutout` passa a decodificar via `loadCardPhoto` (loader compartilhado dos outros cards) e a desenhar num canvas capado (`cappedSize`, maior aresta 1600) que é a entrada de `segmenter.segment`; `readAlphaBbox`/`applyMask` operam nesse canvas. Uma foto 61MP que falhava agora conclui. + +**Bug B — revert do S18 (e do S20 dependente dele):** + +- `src/lib/cardPhotoTransform.ts` e `src/lib/cardRender.ts` restaurados ao estado pré-S18 via `git show cde96b26~1 -- …` (mantendo S16/S17, que vivem em outros trechos): saem `frameCardPhotoOnFace`, `CardFaceBox`, `isUsableCardFace`, `resolveCardPhotoAnchor`, `CARD_TEAM_PHOTO_MIN_ZOOM`, `CardPhotoClamp`/`anchorBox`. +- `useCardCutout` volta a `frameCardPhotoOnBbox` e o state `ready` não carrega mais `bbox`/`face`; `CardComposer` usa o piso padrão `CARD_PHOTO_MIN_ZOOM` no slider e nos gestos. +- `src/lib/cardModels.ts` perde `TEAM_CARD_FACE_REFERENCE_SIZE`; `public/cards/blaze_face_full_range.tflite` e `.LICENSE.txt` são removidos (o detector de rosto não existe mais). + +Sem migration, sem Consent/access, sem contrato de URL/shape público; tudo 100% no aparelho. + +## Verificação + +- Teste de regressão (A): `tests/unit/cardCutout.unit.spec.ts` — `supportsWebAssembly` false com o global escondido; `removeCardPhotoBackground` devolve `unsupported` com WASM off e com WebGL bloqueado; `cappedSize` 7000×8750 → 1280×1600. +- Teste de regressão (B): e2e `Cards personalizados (S15 — enquadramento automático)` — com o stub, o card conclui sem alerta, o slider fica em **1** (o S18 dava < 0.5) e a sonda de pixel em (300, 727) é a cor crua da foto (o adesivo do S18 não alcançava esse ponto); specs da math do S15 restaurados (`frameCardPhotoOnBbox` cobre a janela, falha fechado em bbox degenerado, etc.). +- Suíte: `pnpm gate:fast` verde (lint 0, `tsc --noEmit` 0, unit 4203), `pnpm format:check`, `pnpm knip`, `pnpm check:cycles`, `node scripts/check-test-locations.mjs`. +- e2e: `tests/e2e/frontend.e2e.spec.ts` 31/31 (inclui os 5 do time e o novo). +- Engine real (craft local, sem stub): Chromium — selfie 1600×1600 → pronto com zoom 1 e card visualmente igual ao exemplo oficial; foto 61MP → pronto (antes: erro); viewport 390×844 → pronto. Firefox — normal, viewport mobile e RFP → pronto com zoom 1; `webgl.disabled=true` → mensagem acionável (`unsupported`); `javascript.options.wasm=false` → mensagem acionável. +- CI: a confirmar no PR. +- Prod: pendente — aguarda o approve humano do environment `production` e a confirmação do relator, inclusive no aparelho dele (IronFox exige ativar WebAssembly/WebGL para o site, ou usar outro navegador). + +## Prevenção + +| Estratégia | Custo | Estado | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------ | +| Probes de capacidade (WASM/WebGL) antes do download + reason `unsupported` com copy acionável + testes unit de regressão | barata | implementada agora (este PR) | +| Cap da entrada de inferência (1600) + decode pelo loader compartilhado `loadCardPhoto` (evita textura/alocação em resolução total) | barata | implementada agora (este PR) | +| Matriz de craft com navegadores endurecidos (IronFox/Tor) e viewport mobile como passo explícito em entregas que tocam o engine | barata | documentada — não implementada | +| Bbox derivado da máscara de segmentação (não do `getImageData` do canvas): mais barato (256² vs 1600²) e imune à randomização de canvas do RFP | barata | documentada — não implementada | +| Fallback de recorte para navegador sem WebGL (outro motor ou serviço no servidor) — esbarraria no contrato "nenhum byte da foto sai do aparelho", exigiria decisão de produto | cara | documentada — não implementada | +| Port do provisionador de worktree que evite portas IANA reservadas (o slot 559 gerou `PORT=3659` = apple-sasl e derrubou `pnpm dev`/e2e local; contornado com 3900/3950) | barata | documentada — não implementada | + +**Estratégia implementada:** detecção de capacidade antes do trabalho pesado com mensagem verdadeira e acionável (testada em unit), e o cap de inferência que fecha o caso das fotos gigantes. + +**Estratégia documentada (cara):** fallback de recorte sem WebGL dependeria de outro motor/serviço — conflita com a promessa de privacidade (100% no aparelho) e não entra sem decisão de produto; a matriz de navegadores endurecidos no craft e o bbox pela máscara ficam como candidatos baratos de follow-up. + +## Lições + +- **Navegadores endurecidos são usuários reais.** IronFox (fork do Mull) desliga WebAssembly e WebGL por padrão; o motor de visão no browser do MediaPipe exige os dois e não tem caminho CPU. Antes de baixar um wasm de 12 MB, valeu detectar e dizer a verdade — a copy antiga ("escolha outra foto") mandava o visitante lutar contra a própria foto. +- **A régua do aceite pode esconder a regressão visual.** O S18 cumpriu "cabeça ±20% da caixa dos candidatos" medindo os dois lados com o mesmo detector, mas o resultado real ficou pequeno contra o exemplo oficial do card. Para features visuais, comparar com a **referência do produto** (o card de exemplo) na prévia, não só com a métrica interna. +- **Uma feature pode morrer quando a premissa cai.** A âncora lateral do S20 existia só porque o S18 deixava a foto menor que a janela; ao voltar o enquadramento cover, ela virou código inerte — saiu junto em vez de ficar de enfeite. +- **Fotos de celular são grandes.** A inferência em resolução total era uma bomba de memória/textura (61MP reproduzido); capar a entrada é barato e não muda a qualidade (a máscara já é 256²). +- **Achado de infraestrutura:** o worktree `fix/8` foi provisionado com `PORT=3659`, reservado pela IANA (`apple-sasl`) — o Next recusa subir e o webServer do e2e falha. Contornado localmente (dev em 3900; `.env.test.local` com `PLAYWRIGHT_BASE_URL` em 3950, arquivo gitignored). O provisionador deveria pular portas reservadas. diff --git a/public/cards/blaze_face_full_range.LICENSE.txt b/public/cards/blaze_face_full_range.LICENSE.txt deleted file mode 100644 index 5dd42a3a..00000000 --- a/public/cards/blaze_face_full_range.LICENSE.txt +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 7128856e..00000000 Binary files a/public/cards/blaze_face_full_range.tflite and /dev/null differ diff --git a/src/components/cards/CardComposer.tsx b/src/components/cards/CardComposer.tsx index a03ea43f..e8af19fd 100644 --- a/src/components/cards/CardComposer.tsx +++ b/src/components/cards/CardComposer.tsx @@ -34,13 +34,10 @@ import { fitCardName, type CardNameFit } from '@/lib/cardNameFit' import { CARD_PHOTO_MAX_ZOOM, CARD_PHOTO_MIN_ZOOM, - CARD_TEAM_PHOTO_MIN_ZOOM, cardPhotoTransformsEqual, centerCardPhotoTransform, panCardPhotoTransform, - resolveCardPhotoAnchor, zoomCardPhotoTransform, - type CardPhotoClamp, type CardPhotoSize, type CardPhotoTransform, } from '@/lib/cardPhotoTransform' @@ -180,8 +177,6 @@ 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 @@ -207,18 +202,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer ? (photoTransform ?? teamReady?.transform ?? null) : photoTransform - /** - * S20 — the fine-tuning clamp context: the team's anchor is the detected face - * when usable, else the silhouette; the photo models keep the S13 defaults. - */ - const photoClamp = useMemo( - () => ({ - minZoom: photoMinZoom, - anchorBox: teamReady ? resolveCardPhotoAnchor(teamReady.bbox, teamReady.face) : null, - }), - [photoMinZoom, teamReady], - ) - // Assets + font load once per model: typing/panning must not flip the preview // back into the loading state (flicker) nor re-announce the live region. useEffect(() => { @@ -282,7 +265,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer photoSize: { width: teamReady.width, height: teamReady.height }, transform: effectiveTransform, window: photoWindow, - anchorBox: photoClamp.anchorBox, name, fontFamily, measure, @@ -337,7 +319,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer photoSize, effectiveTransform, photoWindow, - photoClamp, ]) const handlePhotoFile = async (file: File | undefined) => { @@ -401,7 +382,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, photoClamp), + panCardPhotoTransform(transform, size, window, dx, dy), ) } @@ -411,13 +392,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, photoClamp), + panCardPhotoTransform(transform, size, window, dx, dy), ) } const zoomTo = (zoom: number) => { withTransform((transform, size, window) => - zoomCardPhotoTransform(transform, size, window, zoom, photoClamp), + zoomCardPhotoTransform(transform, size, window, zoom), ) } @@ -529,7 +510,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer

- Não foi possível remover o fundo desta foto. + {teamError.reason === 'unsupported' + ? 'Seu navegador está bloqueando o recorte de fundo.' + : 'Não foi possível remover o fundo desta foto.'}

- Tente de novo ou escolha outra foto. + {teamError.reason === 'unsupported' + ? 'O recorte roda no seu aparelho e precisa de WebAssembly e WebGL, que navegadores com proteções avançadas (como IronFox e Tor) desligam por padrão. Ative essas opções para este site nas configurações do navegador — ou abra a página em outro navegador — e toque em “Tentar de novo”.' + : 'Tente de novo ou escolha outra foto.'}

-

- Fotos de busto, nítidas e com fundo simples costumam funcionar melhor. -

+ {teamError.reason === 'unsupported' ? null : ( +

+ Fotos de busto, nítidas e com fundo simples costumam funcionar melhor. +

+ )}

{CARD_PHOTO_PRIVACY_NOTE}

diff --git a/src/components/cards/cardCutout.ts b/src/components/cards/cardCutout.ts index 65879916..d91413ff 100644 --- a/src/components/cards/cardCutout.ts +++ b/src/components/cards/cardCutout.ts @@ -10,41 +10,35 @@ * 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. + * Hardened browsers (IronFox, Tor-like builds) disable WebGL and/or WebAssembly + * by default, which breaks the engine before any photo is processed. The engine + * now probes both up front (a truthful retry message instead of a misleading + * "try another photo") — MediaPipe's vision graph needs a WebGL context even + * with the CPU delegate, so there is no software fallback. The inference input + * is capped before it reaches the runtime so a full-resolution phone photo + * never becomes a giant GPU texture/allocation. */ -import type { Detection, FaceDetector, ImageSegmenter, MPMask } from '@mediapipe/tasks-vision' +import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision' -import { - CARD_CUTOUT_ALPHA_THRESHOLD, - type CardAlphaBbox, - type CardFaceBox, -} from '@/lib/cardPhotoTransform' +import { loadCardPhoto } from '@/components/cards/cardCanvas' +import { CARD_CUTOUT_ALPHA_THRESHOLD, type CardAlphaBbox } from '@/lib/cardPhotoTransform' declare global { interface Window { /** Test seam: the e2e build stubs the engine through this flag. */ - __cardsCutoutStub?: 'ok' | 'slow' | 'error' | 'noface' + __cardsCutoutStub?: 'ok' | 'slow' | 'error' } } 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' @@ -52,7 +46,7 @@ export type CardCutoutProgress = { ratio: number } -type CardCutoutFailureReason = 'engine' | 'empty' +type CardCutoutFailureReason = 'engine' | 'empty' | 'unsupported' export type CardCutoutResult = | { @@ -61,17 +55,15 @@ 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 + 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. + * canvas + its alpha bbox, or fails with `engine` (decode/inference/asset + * failure), `empty` (blank segmentation) or `unsupported` (the browser blocks + * WebAssembly/WebGL — the local engine cannot run at all) — all recoverable in + * the composer. */ export const removeCardPhotoBackground = async ( file: File, @@ -79,13 +71,61 @@ export const removeCardPhotoBackground = async ( ): Promise => { const report = createProgressReporter(onProgress) + if (STUB_ENABLED) { + try { + return await stubCutout(file, report) + } catch { + return { ok: false, reason: 'engine' } + } + } + + if (!supportsWebAssembly() || !supportsWebgl()) return { ok: false, reason: 'unsupported' } + try { - return STUB_ENABLED ? await stubCutout(file, report) : await engineCutout(file, report) + return await engineCutout(file, report) } catch { return { ok: false, reason: 'engine' } } } +/** + * WebAssembly availability probe: hardened browsers can hide the global + * entirely (`javascript.options.wasm` off) — the engine cannot run without it, + * so the composer must say so up front instead of blaming the photo. Exported + * for unit tests. + */ +export const supportsWebAssembly = (): boolean => { + try { + // Minimal valid module header — cheap and only true where compiling works. + return WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0])) + } catch { + return false + } +} + +/** + * WebGL availability probe: MediaPipe's vision graph requests a WebGL2 context + * (falling back to WebGL1) for its image pipeline even when the inference + * delegate is CPU, so without it the engine always fails. Probed before the + * ~12 MB download. Exported for unit tests. + */ +export const supportsWebgl = (): boolean => { + if (typeof document === 'undefined') return false + + try { + const canvas = document.createElement('canvas') + const context = canvas.getContext('webgl2') ?? canvas.getContext('webgl') + if (!context) return false + + // Release the probe context right away: repeated retries must never + // accumulate toward the browser's live-context cap. + context.getExtension('WEBGL_lose_context')?.loseContext() + return true + } catch { + return false + } +} + const PROGRESS_MIN_INTERVAL_MS = 100 /** @@ -148,65 +188,6 @@ const fetchModelBuffer = async (url: string, errorPrefix: string) => { 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. @@ -250,38 +231,43 @@ const engineCutout = async ( const segmenter = await loadSegmenter(onProgress) onProgress({ phase: 'process', ratio: 0.5 }) - const bitmap = await createImageBitmap(file) - const { width, height } = cappedSize(bitmap.width, bitmap.height) + // The photo is decoded through the shared card-photo loader (``, EXIF + // orientation applied, iOS-subsampled decodes) and downscaled to the cap + // *before* it reaches the runtime: full-resolution phone photos otherwise + // become giant GPU textures/allocs and can blank the segmentation. + const photo = await loadCardPhoto(file) + const { width, height } = cappedSize(photo.naturalWidth, photo.naturalHeight) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('card-cutout-context') + ctx.imageSmoothingEnabled = true + ctx.imageSmoothingQuality = 'high' + ctx.drawImage(photo, 0, 0, width, height) // Let the processing state paint before the synchronous inference. await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const result = segmenter.segment(bitmap) + const result = segmenter.segment(canvas) let mask: MPMask | null = null try { mask = result.confidenceMasks?.[0] ?? null if (!mask) return { ok: false, reason: 'engine' } - const canvas = composeCutout(bitmap, width, height, mask) + applyMask(canvas, mask) 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, face } + return { ok: true, canvas, width, height, bbox } } finally { mask?.close() result.close() - bitmap.close() } } -const composeCutout = ( - image: ImageBitmap, - width: number, - height: number, - mask: MPMask, -): HTMLCanvasElement => { +const applyMask = (canvas: HTMLCanvasElement, mask: MPMask): void => { const maskCanvas = document.createElement('canvas') maskCanvas.width = mask.width maskCanvas.height = mask.height @@ -296,20 +282,14 @@ const composeCutout = ( } maskCtx.putImageData(maskImage, 0, 0) - const canvas = document.createElement('canvas') - canvas.width = width - canvas.height = height const ctx = canvas.getContext('2d') if (!ctx) throw new Error('card-cutout-context') - ctx.drawImage(image, 0, 0, width, height) ctx.globalCompositeOperation = 'destination-in' ctx.imageSmoothingEnabled = true ctx.imageSmoothingQuality = 'high' - ctx.drawImage(maskCanvas, 0, 0, width, height) + ctx.drawImage(maskCanvas, 0, 0, canvas.width, canvas.height) ctx.globalCompositeOperation = 'source-over' - - return canvas } const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => { @@ -339,7 +319,8 @@ const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => { return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 } } -const cappedSize = (width: number, height: number) => { +/** Exported for unit tests: the longest edge cap of the inference canvas. */ +export const cappedSize = (width: number, height: number) => { const longest = Math.max(width, height) const scale = longest > CUTOUT_MAX_EDGE ? CUTOUT_MAX_EDGE / longest : 1 @@ -363,17 +344,16 @@ const supportsWasmSimd = (): boolean => { } } -const readStubMode = (): 'ok' | 'slow' | 'error' | 'noface' => { +const readStubMode = (): 'ok' | 'slow' | 'error' => { if (typeof window === 'undefined') return 'ok' const mode = window.__cardsCutoutStub - return mode === 'slow' || mode === 'error' || mode === 'noface' ? mode : 'ok' + return mode === 'slow' || mode === 'error' ? mode : 'ok' } /** * Test seam (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, e2e builds only): a * deterministic cutout with the same shape as the engine result. `slow` keeps - * the processing state visible; `error` exercises the retry path; `noface` - * returns no detected face so the S15 fallback framing runs. + * the processing state visible; `error` exercises the retry path. */ const stubCutout = async ( file: File, @@ -404,22 +384,11 @@ const stubCutout = async ( ctx.globalCompositeOperation = 'source-over' bitmap.close() - 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 4c467119..3e8edc57 100644 --- a/src/components/cards/useCardCutout.ts +++ b/src/components/cards/useCardCutout.ts @@ -4,13 +4,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { removeCardPhotoBackground } from '@/components/cards/cardCutout' import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas' -import { TEAM_CARD_FACE_REFERENCE_SIZE, type CardRect } from '@/lib/cardModels' -import { - frameCardPhotoOnFace, - type CardAlphaBbox, - type CardFaceBox, - type CardPhotoTransform, -} from '@/lib/cardPhotoTransform' +import type { CardRect } from '@/lib/cardModels' +import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform' export type CardCutoutState = | { status: 'idle' } @@ -26,13 +21,10 @@ export type CardCutoutState = harmonized: HTMLCanvasElement | null width: number height: number - /** Initial framing: cutout top on the slot top, face centered (S18). */ + /** Initial framing: cutout top on the slot top, centered (S15). */ transform: CardPhotoTransform - /** S20 — silhouette and detected face, kept for the fine-tuning anchor. */ - bbox: CardAlphaBbox - face: CardFaceBox | null } - | { status: 'error'; reason: 'engine' | 'empty' } + | { status: 'error'; reason: 'engine' | 'empty' | 'unsupported' } /** * S15 — owns the cutout lifecycle for the team model: engine import, progress, @@ -75,12 +67,10 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc } const transform = photoWindow - ? frameCardPhotoOnFace( + ? frameCardPhotoOnBbox( { width: result.width, height: result.height }, photoWindow, result.bbox, - result.face, - TEAM_CARD_FACE_REFERENCE_SIZE, ) : null if (!transform) { @@ -100,8 +90,6 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc width: result.width, height: result.height, transform, - bbox: result.bbox, - face: result.face, }) }, [photoWindow, harmonyReferenceSrc], diff --git a/src/lib/cardModels.ts b/src/lib/cardModels.ts index f4bbc504..f89aa251 100644 --- a/src/lib/cardModels.ts +++ b/src/lib/cardModels.ts @@ -200,19 +200,6 @@ 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 9b12a906..7c9c50f8 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -4,16 +4,6 @@ * 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. - * - * S20 — the team fine-tuning may also anchor on the measured box (the face, - * else the silhouette): the horizontal range becomes the union of the S13/S18 - * rule with the range that lets the anchor box touch both window edges, so the - * visitor is never limited by the transparent canvas around the person. The - * vertical axis keeps the S13/S18 rule. - * * `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. */ @@ -23,15 +13,6 @@ 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 @@ -64,48 +45,12 @@ 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 -} - -/** - * S20 — fine-tuning context of the clamp: `minZoom` widens the zoom floor - * (S18) and `anchorBox` allows the pan/zoom to keep the measured box (face, - * else silhouette) reaching the window edges. Omitted = the S13/S18 rule. - */ -export type CardPhotoClamp = { - minZoom?: number - anchorBox?: CardAlphaBbox | null -} - export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean => a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY const clamp = (value: number, min: number, max: number): number => Math.min(Math.max(value, min), max) -const isUsableCardFace = (face: CardFaceBox | null): face is CardFaceBox => - face !== null && - Number.isFinite(face.x) && - Number.isFinite(face.y) && - Number.isFinite(face.width) && - face.width > 0 && - Number.isFinite(face.height) && - face.height > 0 - -/** - * S20 — the box the fine-tuning anchors on: the detected face when it is - * usable, else the visible silhouette (alpha bbox). Source-pixel coords. - */ -export const resolveCardPhotoAnchor = ( - bbox: CardAlphaBbox, - face: CardFaceBox | null, -): CardAlphaBbox => (isUsableCardFace(face) ? face : bbox) - export const coverScale = (source: CardPhotoSize, window: CardRect): number => { if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1 @@ -116,9 +61,8 @@ export const cardPhotoDrawRect = ( transform: CardPhotoTransform, source: CardPhotoSize, window: CardRect, - clampOptions?: CardPhotoClamp, ): CardRect => { - const clamped = clampCardPhotoTransform(transform, source, window, clampOptions) + const clamped = clampCardPhotoTransform(transform, source, window) const scale = coverScale(source, window) * clamped.zoom return { @@ -129,68 +73,20 @@ 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 photoOffsetRange = (start: number, size: number, drawn: number): [number, number] => - drawn >= size ? [start + size - drawn, start] : [start, start + size - drawn] - -/** - * S20 — the horizontal offset range that lets the drawn anchor box touch both - * window edges; `null` when the drawn box is wider than the window (no - * placement). - */ -const anchorXOffsetRange = ( - box: CardAlphaBbox, - scale: number, - start: number, - size: number, -): [number, number] | null => - box.width * scale > size - ? null - : [start - box.x * scale, start + size - (box.x + box.width) * scale] - -/** - * S20 — the S13/S18 range, widened by the anchor range when one exists. For a - * box inside the source the two always intersect, so the union is a single - * interval; a detected face may sit at the image edge, so a disjoint anchor - * (outside the photo) falls back to the S13/S18 range instead of hulling a gap. - */ -const clampPhotoOffset = ( - value: number, - start: number, - size: number, - drawn: number, - anchorRange?: [number, number] | null, -): number => { - const [min, max] = photoOffsetRange(start, size, drawn) - if (!anchorRange || anchorRange[1] < min || anchorRange[0] > max) { - return clamp(value, min, max) - } - - return clamp(value, Math.min(min, anchorRange[0]), Math.max(max, anchorRange[1])) -} - export const clampCardPhotoTransform = ( transform: CardPhotoTransform, source: CardPhotoSize, window: CardRect, - { minZoom = CARD_PHOTO_MIN_ZOOM, anchorBox }: CardPhotoClamp = {}, ): CardPhotoTransform => { - const zoom = clamp(transform.zoom, minZoom, CARD_PHOTO_MAX_ZOOM) + const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM) const scale = coverScale(source, window) * zoom const width = source.width * scale const height = source.height * scale - const anchorRange = anchorBox - ? anchorXOffsetRange(anchorBox, scale, window.x, window.width) - : null return { zoom, - offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width, anchorRange), - offsetY: clampPhotoOffset(transform.offsetY, window.y, window.height, height), + offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x), + offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y), } } @@ -253,51 +149,6 @@ 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 (!isUsableCardFace(face) || !(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, - { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, - ) -} - /** Nudges the offset by a step in card pixels, clamped to the window. */ export const panCardPhotoTransform = ( transform: CardPhotoTransform, @@ -305,51 +156,39 @@ export const panCardPhotoTransform = ( window: CardRect, dx: number, dy: number, - clampOptions?: CardPhotoClamp, ): CardPhotoTransform => clampCardPhotoTransform( { ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy }, source, window, - clampOptions, ) /** * Rescales around a window anchor (used by the zoom control): keeps the point - * under `anchor` stable from old to new zoom, then clamps. The team model - * passes its own `minZoom` (and, on S20, the `anchorBox`) so the visitor can - * fine-tune below the cover floor. + * under `anchor` stable from old to new zoom, then clamps. */ export const zoomCardPhotoTransform = ( transform: CardPhotoTransform, source: CardPhotoSize, window: CardRect, nextZoom: number, - { - anchor, - minZoom = CARD_PHOTO_MIN_ZOOM, - anchorBox, - }: { anchor?: { x: number; y: number } } & CardPhotoClamp = {}, -): CardPhotoTransform => { - const resolvedAnchor = anchor ?? { + anchor: { x: number; y: number } = { x: window.x + window.width / 2, y: window.y + window.height / 2, - } - const zoom = clamp(nextZoom, minZoom, CARD_PHOTO_MAX_ZOOM) - if (zoom === transform.zoom) { - return clampCardPhotoTransform(transform, source, window, { minZoom, anchorBox }) - } + }, +): CardPhotoTransform => { + const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM) + if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window) const ratio = zoom / transform.zoom return clampCardPhotoTransform( { zoom, - offsetX: resolvedAnchor.x - (resolvedAnchor.x - transform.offsetX) * ratio, - offsetY: resolvedAnchor.y - (resolvedAnchor.y - transform.offsetY) * ratio, + offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio, + offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio, }, source, window, - { minZoom, anchorBox }, ) } diff --git a/src/lib/cardRender.ts b/src/lib/cardRender.ts index 33beffff..9fe7e9a4 100644 --- a/src/lib/cardRender.ts +++ b/src/lib/cardRender.ts @@ -26,11 +26,8 @@ import { type CardTextMetrics, } from './cardNameFit' import { - CARD_TEAM_PHOTO_MIN_ZOOM, cardPhotoDrawRect, clampCardPhotoTransform, - type CardAlphaBbox, - type CardPhotoClamp, type CardPhotoSize, type CardPhotoTransform, } from './cardPhotoTransform' @@ -219,8 +216,6 @@ export type TeamCardRenderArgs = { fontFamily: string measure: CardMeasureText slot?: CardNameBannerSlot - /** S20 — measured anchor (face, else silhouette) of the fine-tuning clamp. */ - anchorBox?: CardAlphaBbox | null } export type TeamCardRenderResult = { @@ -241,11 +236,7 @@ export const renderTeamCard = ( ): TeamCardRenderResult => { ctx.drawImage(args.base, 0, 0, model.width, model.height) - const clampOptions: CardPhotoClamp = { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: args.anchorBox, - } - const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window, clampOptions) + const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window) ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height) ctx.drawImage(args.overlay, 0, 0, model.width, model.height) @@ -266,6 +257,6 @@ export const renderTeamCard = ( return { fit, - transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window, clampOptions), + transform: clampCardPhotoTransform(args.transform, args.photoSize, args.window), } } diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 2b3b1a09..7ba9b5c6 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' | 'noface') => +const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error') => page.addInitScript((value: string) => { ;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value }, mode) @@ -1995,10 +1995,9 @@ test.describe('Cards personalizados (S15 — Time de você)', () => { const readSample = () => canvas.evaluate((element) => { const node = element as HTMLCanvasElement - // 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] + // S15 — the auto-framing covers the slot, so the window center is the + // stub ellipse center. + return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data] }) // Default ON: the preview pixel is the harmonized variant, not the raw photo. @@ -2055,29 +2054,45 @@ test.describe('Cards personalizados (S15 — Time de você)', () => { }) /** - * 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. + * S15 — the auto-framing after the S18 proportional framing was reverted: the + * stub cutout (bbox = whole canvas) must cover the photo window at the cover + * floor (zoom 1), the person filling the slot instead of a small sticker. The + * engine failure paths (WebGL/WASM blocked) are covered by the adapter unit + * specs; the real engine is exercised in craft. */ -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') +test.describe('Cards personalizados (S15 — enquadramento automático)', () => { + test('covers the photo window at the cover floor and downloads the PNG', 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) - // 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) + // The S18 proportional framing sat well below the cover floor; the reverted + // auto-framing always covers (zoom 1 for the stub's full-canvas bbox). expect(Number(await dialog.locator('input[type="range"]').inputValue())).toBe(1) + + // Scale proof: the stub ellipse (rx 180 on a 400px canvas) reaches x=300 in + // card pixels only under the cover framing — the small S18 sticker did not. + // Harmony is ON by default; turn it off to sample the raw fixture. + await dialog.getByRole('switch', { name: 'Harmonizar cores' }).click() + const canvas = dialog.locator('canvas') + await expect + .poll(() => + canvas.evaluate((element) => { + const node = element as HTMLCanvasElement + return [...node.getContext('2d')!.getImageData(300, 727, 1, 1).data] + }), + ) + .toEqual([30, 120, 200, 255]) + 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(), @@ -2088,22 +2103,4 @@ test.describe('Cards personalizados (S18 — cabeça proporcional)', () => { .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/cardCutout.unit.spec.ts b/tests/unit/cardCutout.unit.spec.ts new file mode 100644 index 00000000..0ad517c5 --- /dev/null +++ b/tests/unit/cardCutout.unit.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + cappedSize, + removeCardPhotoBackground, + supportsWebAssembly, + supportsWebgl, +} from '@/components/cards/cardCutout' + +const photo = {} as File +const report = () => {} + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('supportsWebAssembly', () => { + it('is true where wasm compiles', () => { + expect(supportsWebAssembly()).toBe(true) + }) + + it('fails closed when the browser hides WebAssembly (IronFox default)', () => { + vi.stubGlobal('WebAssembly', undefined) + + expect(supportsWebAssembly()).toBe(false) + }) +}) + +describe('removeCardPhotoBackground — unsupported browser', () => { + it('reports `unsupported` before downloading anything when WebAssembly is off', async () => { + vi.stubGlobal('WebAssembly', undefined) + + await expect(removeCardPhotoBackground(photo, report)).resolves.toEqual({ + ok: false, + reason: 'unsupported', + }) + }) + + it('reports `unsupported` when WebGL is blocked (MediaPipe graph needs it even on CPU)', async () => { + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null) + + expect(supportsWebgl()).toBe(false) + await expect(removeCardPhotoBackground(photo, report)).resolves.toEqual({ + ok: false, + reason: 'unsupported', + }) + }) +}) + +describe('cappedSize (inference input cap)', () => { + it('caps the longest edge at 1600 regardless of orientation', () => { + expect(cappedSize(7000, 8750)).toEqual({ width: 1280, height: 1600 }) + expect(cappedSize(4032, 3024)).toEqual({ width: 1600, height: 1200 }) + }) + + it('keeps photos already inside the cap untouched', () => { + expect(cappedSize(800, 600)).toEqual({ width: 800, height: 600 }) + }) +}) diff --git a/tests/unit/cardFaceDetection.unit.spec.ts b/tests/unit/cardFaceDetection.unit.spec.ts deleted file mode 100644 index ad29472e..00000000 --- a/tests/unit/cardFaceDetection.unit.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 7b571968..458dd83d 100644 --- a/tests/unit/cardModels.unit.spec.ts +++ b/tests/unit/cardModels.unit.spec.ts @@ -3,7 +3,6 @@ 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, @@ -11,7 +10,7 @@ import { isCardModelId, } from '@/lib/cardModels' -describe('card model catalog (S13/S15/S18)', () => { +describe('card model catalog (S13/S15)', () => { it('exposes the four shipped models with unique ids', () => { expect(CARD_MODELS.map((model) => model.id)).toEqual([ 'eu-sou-solla', @@ -133,10 +132,6 @@ describe('card model catalog (S13/S15/S18)', () => { 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 851711dc..02981eb0 100644 --- a/tests/unit/cardPhotoTransform.unit.spec.ts +++ b/tests/unit/cardPhotoTransform.unit.spec.ts @@ -4,19 +4,14 @@ 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, - resolveCardPhotoAnchor, zoomCardPhotoTransform, type CardAlphaBbox, - type CardFaceBox, - type CardPhotoClamp, type CardPhotoSize, } from '@/lib/cardPhotoTransform' @@ -118,136 +113,6 @@ 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, { - minZoom: 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, { - minZoom: 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( @@ -274,24 +139,6 @@ 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, - { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, - ) - - expect(shrunk.zoom).toBe(CARD_TEAM_PHOTO_MIN_ZOOM) - const rect = cardPhotoDrawRect(shrunk, LANDSCAPE, WINDOW, { - minZoom: 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', () => { @@ -305,202 +152,12 @@ 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, - { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, - ) - - expect(panned).toEqual({ zoom: 0.3, offsetX: 40, offsetY: 30 }) - }) -}) - -describe('resolveCardPhotoAnchor', () => { - const BBOX: CardAlphaBbox = { x: 10, y: 20, width: 300, height: 400 } - - it('prefers a usable face over the silhouette', () => { - const face: CardFaceBox = { x: 40, y: 50, width: 60, height: 60 } - - expect(resolveCardPhotoAnchor(BBOX, face)).toEqual(face) - expect(resolveCardPhotoAnchor(BBOX, { ...face, width: Number.NaN })).toEqual(BBOX) - expect(resolveCardPhotoAnchor(BBOX, { ...face, width: 0 })).toEqual(BBOX) - expect(resolveCardPhotoAnchor(BBOX, { ...face, height: -1 })).toEqual(BBOX) - expect(resolveCardPhotoAnchor(BBOX, null)).toEqual(BBOX) - }) -}) - -describe('S20 anchor clamp', () => { - 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 - // Big enough that the face lands on the reference below the cover floor: - // the drawn photo is smaller than the window horizontally (contain regime). - const FACE: CardFaceBox = { x: 400, y: 200, width: 200, height: 200 } - const anchor = resolveCardPhotoAnchor(BBOX, FACE) - - it('lets the anchor box reach both window edges from the initial framing', () => { - const initial = frameCardPhotoOnFace(SOURCE, TEAM_WINDOW, BBOX, FACE, REFERENCE) - expect(initial).not.toBeNull() - if (!initial) return - - const clampOptions: CardPhotoClamp = { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: anchor, - } - const scale = coverScale(SOURCE, TEAM_WINDOW) * initial.zoom - const toTheLeft = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, -9999, 0, clampOptions) - const toTheRight = panCardPhotoTransform(initial, SOURCE, TEAM_WINDOW, 9999, 0, clampOptions) - // course = window − face drawn on the reference size, split evenly around - // the initial framing: the design's ≈±244px per side - const halfCourse = (TEAM_WINDOW.width - REFERENCE) / 2 - - expect(initial.offsetX - toTheLeft.offsetX).toBeCloseTo(halfCourse, 5) - expect(toTheRight.offsetX - initial.offsetX).toBeCloseTo(halfCourse, 5) - // the anchor box touches each edge and never leaves the window - expect(toTheLeft.offsetX + anchor.x * scale).toBeCloseTo(TEAM_WINDOW.x, 5) - expect(toTheRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo( - TEAM_WINDOW.x + TEAM_WINDOW.width, - 5, - ) - }) - - it('accepts on the anchor side what the S18 clamp rejects, without touching Y', () => { - const zoom = REFERENCE / (FACE.width * coverScale(SOURCE, TEAM_WINDOW)) - const scale = coverScale(SOURCE, TEAM_WINDOW) * zoom - const clampOptions: CardPhotoClamp = { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: anchor, - } - - const todayRight = panCardPhotoTransform( - { zoom, offsetX: 0, offsetY: 0 }, - SOURCE, - TEAM_WINDOW, - 9999, - 9999, - { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, - ) - const anchoredRight = panCardPhotoTransform( - { zoom, offsetX: 0, offsetY: 0 }, - SOURCE, - TEAM_WINDOW, - 9999, - 9999, - clampOptions, - ) - - expect(anchoredRight.offsetX).toBeGreaterThan(todayRight.offsetX) - expect(anchoredRight.offsetY).toBe(todayRight.offsetY) - expect(anchoredRight.offsetX + (anchor.x + anchor.width) * scale).toBeCloseTo( - TEAM_WINDOW.x + TEAM_WINDOW.width, - 5, - ) - }) - - it('never rejects a transform that the S18 clamp accepts (grid of zooms and anchors)', () => { - const anchors: CardAlphaBbox[] = [ - anchor, - { x: 40, y: 60, width: 300, height: 300 }, - { x: 0, y: 0, width: 900, height: 900 }, - { x: -400, y: 0, width: 500, height: 500 }, - ] - - for (const zoom of [0.3, 0.87, 1, 1.6, 2.5]) { - for (const anchorBox of anchors) { - for (const offset of [-2000, -500, 0, 300, 900, 2000]) { - const today = clampCardPhotoTransform( - { zoom, offsetX: offset, offsetY: offset }, - SOURCE, - TEAM_WINDOW, - { minZoom: CARD_TEAM_PHOTO_MIN_ZOOM }, - ) - const anchored = clampCardPhotoTransform(today, SOURCE, TEAM_WINDOW, { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox, - }) - - expect(anchored).toEqual(today) - } - } - } - }) - - it('falls back to the S18 range when the anchor lies outside the photo', () => { - // zoom 1 draws the source exactly on the window width: today's X is pinned, - // while a face box at x = -100 would anchor entirely to its right - const clampOptions: CardPhotoClamp = { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: { x: -100, y: 0, width: 100, height: 100 }, - } - const today = clampCardPhotoTransform( - { zoom: 1, offsetX: 9999, offsetY: 9999 }, - SOURCE, - TEAM_WINDOW, - { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - }, - ) - - expect( - clampCardPhotoTransform( - { zoom: 1, offsetX: 9999, offsetY: 9999 }, - SOURCE, - TEAM_WINDOW, - clampOptions, - ), - ).toEqual(today) - }) - - it('falls back to the S18 rule when the drawn anchor is wider than the window', () => { - const wideAnchor: CardAlphaBbox = { x: 0, y: 0, width: 600, height: 600 } - // zoom 2: the 600px anchor draws 710px wide, wider than the 592px window - const transform = { zoom: 2, offsetX: 9999, offsetY: 9999 } - const clampOptions: CardPhotoClamp = { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: wideAnchor, - } - - expect(clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, clampOptions)).toEqual( - clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - }), - ) - }) - - it('is idempotent with an anchor in both regimes', () => { - const transforms = [ - { zoom: 0.5, offsetX: -9999, offsetY: -9999 }, - { zoom: 0.87, offsetX: 9999, offsetY: 9999 }, - { zoom: 2.5, offsetX: -400, offsetY: 120 }, - ] - - for (const transform of transforms) { - const once = clampCardPhotoTransform(transform, SOURCE, TEAM_WINDOW, { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: anchor, - }) - const twice = clampCardPhotoTransform(once, SOURCE, TEAM_WINDOW, { - minZoom: CARD_TEAM_PHOTO_MIN_ZOOM, - anchorBox: anchor, - }) - - expect(twice).toEqual(once) - } - }) }) describe('zoomCardPhotoTransform', () => { it('keeps the anchor point stable while zooming', () => { const start = centerCardPhotoTransform(LANDSCAPE, WINDOW) - const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { - anchor: { x: 500, y: 370 }, - }) + const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 }) const scale = coverScale(LANDSCAPE, WINDOW) * 2 expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5) @@ -525,17 +182,4 @@ 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) - }) }) diff --git a/tests/unit/cardRender.unit.spec.ts b/tests/unit/cardRender.unit.spec.ts index 29a0c4ad..803f9c14 100644 --- a/tests/unit/cardRender.unit.spec.ts +++ b/tests/unit/cardRender.unit.spec.ts @@ -12,7 +12,6 @@ import { fitCardName } from '@/lib/cardNameFit' import { centerCardPhotoTransform, frameCardPhotoOnBbox, - type CardAlphaBbox, type CardPhotoSize, } from '@/lib/cardPhotoTransform' import { @@ -245,37 +244,6 @@ describe('renderTeamCard', () => { expect(rectCalls).toHaveLength(1) expect(textCalls.map((call) => call.text)).toEqual(['TIME DE']) }) - - it('S20 — draws and returns the anchored reach instead of the S18 bound', () => { - const face: CardAlphaBbox = { x: 80, y: 100, width: 60, height: 60 } - const pushed = { ...transform, offsetX: 9999 } - const draw = (anchorBox?: CardAlphaBbox | null) => { - const fake = createFakeContext() - const result = renderTeamCard(fake.ctx, teamModel, { - base: { id: 'base' } as unknown as CanvasImageSource, - overlay: { id: 'overlay' } as unknown as CanvasImageSource, - photo: { id: 'photo' } as unknown as CanvasImageSource, - photoSize, - transform: pushed, - window, - anchorBox, - name: 'Maria', - fontFamily: 'Brexter', - measure: createCardMeasure(fake.ctx, 'Brexter'), - }) - - return { dx: fake.drawCalls[1]!.dx, result } - } - - const s18 = draw() - const anchored = draw(face) - - // the face box reaches further right than the S18 cover bound, in the draw - // and in the write-back contract, with Y untouched - expect(anchored.dx).toBeGreaterThan(s18.dx) - expect(anchored.result.transform.offsetX).toBeGreaterThan(s18.result.transform.offsetX) - expect(anchored.result.transform.offsetY).toBe(s18.result.transform.offsetY) - }) }) describe('resolveCardBannerWidth (S16)', () => {