mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
43fbb16d8a
commit
48e08bf7f5
16 changed files with 320 additions and 843 deletions
|
|
@ -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).
|
||||
104
docs/postmortems/2026-09-20-card-time-recorte-e-enquadramento.md
Normal file
104
docs/postmortems/2026-09-20-card-time-recorte-e-enquadramento.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
Binary file not shown.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
60
tests/unit/cardCutout.unit.spec.ts
Normal file
60
tests/unit/cardCutout.unit.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue