fix(cards): recorte no celular (WASM/WebGL) e volta do enquadramento S15

O recorte do card 'Time de você' falhava em navegadores endurecidos (IronFox/Tor
desligam WebAssembly e WebGL por padrão) com a copy errada de 'tente outra foto';
o motor MediaPipe exige ambos (o grafo pede kGpuService mesmo com delegate CPU).
Agora o adapter sonda WASM/WebGL antes do download e devolve 'unsupported' com
mensagem acionável; a entrada de inferência é capada em 1600 e a foto passa pelo
loadCardPhoto (foto de 61MP que falhava agora conclui).

O enquadramento automático volta ao S15 (bbox cobre a janela): o S18 mirava a
caixa do rosto em 103px e deixava o busto inteiro pequeno na janela. Saem o
detector de rosto (asset removido), frameCardPhotoOnFace, a referência 103px, o
piso 0.2 e a âncora S20 (inerte sob o cover). S16/S17 intactos.
This commit is contained in:
Francisco Solla 2026-09-20 17:07:01 -03:00
parent 43fbb16d8a
commit 48e08bf7f5
16 changed files with 320 additions and 843 deletions

View file

@ -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).

View file

@ -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.

View file

@ -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.

View file

@ -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<CardPhotoClamp>(
() => ({
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
<input
id={zoomInputId}
type="range"
min={photoMinZoom}
min={CARD_PHOTO_MIN_ZOOM}
max={CARD_PHOTO_MAX_ZOOM}
step={0.05}
value={effectiveTransform.zoom}
@ -753,15 +734,21 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
<div className="mt-5">
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
<p className="text-sm font-black text-(--pt-red)">
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.'}
</p>
<p className="mt-1 text-sm leading-5 text-(--campaign-ink)">
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.'}
</p>
</div>
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
</p>
{teamError.reason === 'unsupported' ? null : (
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
</p>
)}
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_PHOTO_PRIVACY_NOTE}
</p>

View file

@ -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<CardCutoutResult> => {
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<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.
@ -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 (`<img>`, 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<void>((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,
},
}
}

View file

@ -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],

View file

@ -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 =>

View file

@ -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 },
)
}

View file

@ -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),
}
}

View file

@ -1837,7 +1837,7 @@ test.describe('Cards personalizados (S14)', () => {
/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */
const STUB_CUTOUT_PERCENT = 36
const setCutoutStub = (page: Page, mode: 'ok' | 'slow' | 'error' | '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()
})
})

View file

@ -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 })
})
})

View file

@ -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 })
})
})

View file

@ -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)

View file

@ -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)
})
})

View file

@ -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)', () => {