mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-15 02:23:07 +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 {
|
import {
|
||||||
CARD_PHOTO_MAX_ZOOM,
|
CARD_PHOTO_MAX_ZOOM,
|
||||||
CARD_PHOTO_MIN_ZOOM,
|
CARD_PHOTO_MIN_ZOOM,
|
||||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
|
||||||
cardPhotoTransformsEqual,
|
cardPhotoTransformsEqual,
|
||||||
centerCardPhotoTransform,
|
centerCardPhotoTransform,
|
||||||
panCardPhotoTransform,
|
panCardPhotoTransform,
|
||||||
resolveCardPhotoAnchor,
|
|
||||||
zoomCardPhotoTransform,
|
zoomCardPhotoTransform,
|
||||||
type CardPhotoClamp,
|
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
type CardPhotoTransform,
|
type CardPhotoTransform,
|
||||||
} from '@/lib/cardPhotoTransform'
|
} from '@/lib/cardPhotoTransform'
|
||||||
|
|
@ -180,8 +177,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
const photoWindow = model.photoWindow
|
const photoWindow = model.photoWindow
|
||||||
const isNameModel = model.kind === 'name'
|
const isNameModel = model.kind === 'name'
|
||||||
const isTeamModel = model.kind === 'team'
|
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 cutout = useCardCutout(photoWindow, isTeamModel ? model.assetSrc : undefined)
|
||||||
const cutoutState = cutout.state
|
const cutoutState = cutout.state
|
||||||
|
|
||||||
|
|
@ -207,18 +202,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
? (photoTransform ?? teamReady?.transform ?? null)
|
? (photoTransform ?? teamReady?.transform ?? null)
|
||||||
: photoTransform
|
: 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
|
// 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.
|
// back into the loading state (flicker) nor re-announce the live region.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -282,7 +265,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
photoSize: { width: teamReady.width, height: teamReady.height },
|
photoSize: { width: teamReady.width, height: teamReady.height },
|
||||||
transform: effectiveTransform,
|
transform: effectiveTransform,
|
||||||
window: photoWindow,
|
window: photoWindow,
|
||||||
anchorBox: photoClamp.anchorBox,
|
|
||||||
name,
|
name,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
measure,
|
measure,
|
||||||
|
|
@ -337,7 +319,6 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
photoSize,
|
photoSize,
|
||||||
effectiveTransform,
|
effectiveTransform,
|
||||||
photoWindow,
|
photoWindow,
|
||||||
photoClamp,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const handlePhotoFile = async (file: File | undefined) => {
|
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
|
const dy = (event.clientY - drag.y) * scale
|
||||||
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
||||||
withTransform((transform, size, window) =>
|
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) => {
|
const panBy = (dx: number, dy: number) => {
|
||||||
withTransform((transform, size, window) =>
|
withTransform((transform, size, window) =>
|
||||||
panCardPhotoTransform(transform, size, window, dx, dy, photoClamp),
|
panCardPhotoTransform(transform, size, window, dx, dy),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const zoomTo = (zoom: number) => {
|
const zoomTo = (zoom: number) => {
|
||||||
withTransform((transform, size, window) =>
|
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
|
<input
|
||||||
id={zoomInputId}
|
id={zoomInputId}
|
||||||
type="range"
|
type="range"
|
||||||
min={photoMinZoom}
|
min={CARD_PHOTO_MIN_ZOOM}
|
||||||
max={CARD_PHOTO_MAX_ZOOM}
|
max={CARD_PHOTO_MAX_ZOOM}
|
||||||
step={0.05}
|
step={0.05}
|
||||||
value={effectiveTransform.zoom}
|
value={effectiveTransform.zoom}
|
||||||
|
|
@ -753,15 +734,21 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
|
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
|
||||||
<p className="text-sm font-black text-(--pt-red)">
|
<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>
|
||||||
<p className="mt-1 text-sm leading-5 text-(--campaign-ink)">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
|
{teamError.reason === 'unsupported' ? null : (
|
||||||
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
|
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
|
||||||
</p>
|
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
|
||||||
{CARD_PHOTO_PRIVACY_NOTE}
|
{CARD_PHOTO_PRIVACY_NOTE}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -10,41 +10,35 @@
|
||||||
* wasm, lazily imported only when the visitor picks the team model. The
|
* wasm, lazily imported only when the visitor picks the team model. The
|
||||||
* `@imgly` packages stay out of the bundle (AGPL + ~40 MB on mobile).
|
* `@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`
|
* Hardened browsers (IronFox, Tor-like builds) disable WebGL and/or WebAssembly
|
||||||
* FaceDetector (see `blaze_face_full_range.LICENSE.txt`) on the cutout canvas:
|
* by default, which breaks the engine before any photo is processed. The engine
|
||||||
* the largest face feeds the proportional framing. The detector is a second
|
* now probes both up front (a truthful retry message instead of a misleading
|
||||||
* lazy singleton and every detection failure is swallowed into `face: null` so
|
* "try another photo") — MediaPipe's vision graph needs a WebGL context even
|
||||||
* the card never fails or surfaces an error because of it.
|
* 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 {
|
import { loadCardPhoto } from '@/components/cards/cardCanvas'
|
||||||
CARD_CUTOUT_ALPHA_THRESHOLD,
|
import { CARD_CUTOUT_ALPHA_THRESHOLD, type CardAlphaBbox } from '@/lib/cardPhotoTransform'
|
||||||
type CardAlphaBbox,
|
|
||||||
type CardFaceBox,
|
|
||||||
} from '@/lib/cardPhotoTransform'
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
/** Test seam: the e2e build stubs the engine through this flag. */
|
/** 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 WASM_DIR = '/cards/mediapipe/wasm'
|
||||||
const MODEL_URL = '/cards/selfie_segmenter.tflite'
|
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 CUTOUT_MAX_EDGE = 1600
|
||||||
const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1'
|
const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1'
|
||||||
const STUB_DELAY_MS = 1500
|
const STUB_DELAY_MS = 1500
|
||||||
const STUB_PROGRESS = 0.36
|
const STUB_PROGRESS = 0.36
|
||||||
/** Upscale target so the synthetic bbox passes the real cutout checks. */
|
/** Upscale target so the synthetic bbox passes the real cutout checks. */
|
||||||
const STUB_MIN_EDGE = 400
|
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 = {
|
export type CardCutoutProgress = {
|
||||||
phase: 'download' | 'process'
|
phase: 'download' | 'process'
|
||||||
|
|
@ -52,7 +46,7 @@ export type CardCutoutProgress = {
|
||||||
ratio: number
|
ratio: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type CardCutoutFailureReason = 'engine' | 'empty'
|
type CardCutoutFailureReason = 'engine' | 'empty' | 'unsupported'
|
||||||
|
|
||||||
export type CardCutoutResult =
|
export type CardCutoutResult =
|
||||||
| {
|
| {
|
||||||
|
|
@ -61,17 +55,15 @@ export type CardCutoutResult =
|
||||||
width: number
|
width: number
|
||||||
height: number
|
height: number
|
||||||
bbox: CardAlphaBbox
|
bbox: CardAlphaBbox
|
||||||
/** S18 — largest detected face in canvas pixels; null falls back to S15. */
|
|
||||||
face: CardFaceBox | null
|
|
||||||
}
|
}
|
||||||
| { ok: false; reason: CardCutoutFailureReason }
|
| { ok: false; reason: CardCutoutFailureReason }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes the background of `file` on this device. Resolves with the cutout
|
* Removes the background of `file` on this device. Resolves with the cutout
|
||||||
* canvas + its alpha bbox + the largest detected face (`face: null` when the
|
* canvas + its alpha bbox, or fails with `engine` (decode/inference/asset
|
||||||
* detector is unavailable or finds none — the composer falls back to the S15
|
* failure), `empty` (blank segmentation) or `unsupported` (the browser blocks
|
||||||
* framing in silence), or fails with `engine` (decode/inference/asset failure)
|
* WebAssembly/WebGL — the local engine cannot run at all) — all recoverable in
|
||||||
* or `empty` (blank segmentation) — both recoverable in the composer.
|
* the composer.
|
||||||
*/
|
*/
|
||||||
export const removeCardPhotoBackground = async (
|
export const removeCardPhotoBackground = async (
|
||||||
file: File,
|
file: File,
|
||||||
|
|
@ -79,13 +71,61 @@ export const removeCardPhotoBackground = async (
|
||||||
): Promise<CardCutoutResult> => {
|
): Promise<CardCutoutResult> => {
|
||||||
const report = createProgressReporter(onProgress)
|
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 {
|
try {
|
||||||
return STUB_ENABLED ? await stubCutout(file, report) : await engineCutout(file, report)
|
return await engineCutout(file, report)
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, reason: 'engine' }
|
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
|
const PROGRESS_MIN_INTERVAL_MS = 100
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -148,65 +188,6 @@ const fetchModelBuffer = async (url: string, errorPrefix: string) => {
|
||||||
return new Uint8Array(await response.arrayBuffer())
|
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
|
* 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.
|
* 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)
|
const segmenter = await loadSegmenter(onProgress)
|
||||||
onProgress({ phase: 'process', ratio: 0.5 })
|
onProgress({ phase: 'process', ratio: 0.5 })
|
||||||
|
|
||||||
const bitmap = await createImageBitmap(file)
|
// The photo is decoded through the shared card-photo loader (`<img>`, EXIF
|
||||||
const { width, height } = cappedSize(bitmap.width, bitmap.height)
|
// 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.
|
// Let the processing state paint before the synchronous inference.
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
|
||||||
const result = segmenter.segment(bitmap)
|
const result = segmenter.segment(canvas)
|
||||||
let mask: MPMask | null = null
|
let mask: MPMask | null = null
|
||||||
try {
|
try {
|
||||||
mask = result.confidenceMasks?.[0] ?? null
|
mask = result.confidenceMasks?.[0] ?? null
|
||||||
if (!mask) return { ok: false, reason: 'engine' }
|
if (!mask) return { ok: false, reason: 'engine' }
|
||||||
|
|
||||||
const canvas = composeCutout(bitmap, width, height, mask)
|
applyMask(canvas, mask)
|
||||||
const bbox = readAlphaBbox(canvas)
|
const bbox = readAlphaBbox(canvas)
|
||||||
if (!bbox) return { ok: false, reason: 'empty' }
|
if (!bbox) return { ok: false, reason: 'empty' }
|
||||||
|
|
||||||
const face = await detectCardFace(canvas)
|
|
||||||
onProgress({ phase: 'process', ratio: 1 })
|
onProgress({ phase: 'process', ratio: 1 })
|
||||||
return { ok: true, canvas, width, height, bbox, face }
|
return { ok: true, canvas, width, height, bbox }
|
||||||
} finally {
|
} finally {
|
||||||
mask?.close()
|
mask?.close()
|
||||||
result.close()
|
result.close()
|
||||||
bitmap.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const composeCutout = (
|
const applyMask = (canvas: HTMLCanvasElement, mask: MPMask): void => {
|
||||||
image: ImageBitmap,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
mask: MPMask,
|
|
||||||
): HTMLCanvasElement => {
|
|
||||||
const maskCanvas = document.createElement('canvas')
|
const maskCanvas = document.createElement('canvas')
|
||||||
maskCanvas.width = mask.width
|
maskCanvas.width = mask.width
|
||||||
maskCanvas.height = mask.height
|
maskCanvas.height = mask.height
|
||||||
|
|
@ -296,20 +282,14 @@ const composeCutout = (
|
||||||
}
|
}
|
||||||
maskCtx.putImageData(maskImage, 0, 0)
|
maskCtx.putImageData(maskImage, 0, 0)
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
|
||||||
canvas.width = width
|
|
||||||
canvas.height = height
|
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
if (!ctx) throw new Error('card-cutout-context')
|
if (!ctx) throw new Error('card-cutout-context')
|
||||||
|
|
||||||
ctx.drawImage(image, 0, 0, width, height)
|
|
||||||
ctx.globalCompositeOperation = 'destination-in'
|
ctx.globalCompositeOperation = 'destination-in'
|
||||||
ctx.imageSmoothingEnabled = true
|
ctx.imageSmoothingEnabled = true
|
||||||
ctx.imageSmoothingQuality = 'high'
|
ctx.imageSmoothingQuality = 'high'
|
||||||
ctx.drawImage(maskCanvas, 0, 0, width, height)
|
ctx.drawImage(maskCanvas, 0, 0, canvas.width, canvas.height)
|
||||||
ctx.globalCompositeOperation = 'source-over'
|
ctx.globalCompositeOperation = 'source-over'
|
||||||
|
|
||||||
return canvas
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => {
|
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 }
|
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 longest = Math.max(width, height)
|
||||||
const scale = longest > CUTOUT_MAX_EDGE ? CUTOUT_MAX_EDGE / longest : 1
|
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'
|
if (typeof window === 'undefined') return 'ok'
|
||||||
const mode = window.__cardsCutoutStub
|
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
|
* Test seam (`NEXT_PUBLIC_CARDS_CUTOUT_STUB=1`, e2e builds only): a
|
||||||
* deterministic cutout with the same shape as the engine result. `slow` keeps
|
* deterministic cutout with the same shape as the engine result. `slow` keeps
|
||||||
* the processing state visible; `error` exercises the retry path; `noface`
|
* the processing state visible; `error` exercises the retry path.
|
||||||
* returns no detected face so the S15 fallback framing runs.
|
|
||||||
*/
|
*/
|
||||||
const stubCutout = async (
|
const stubCutout = async (
|
||||||
file: File,
|
file: File,
|
||||||
|
|
@ -404,22 +384,11 @@ const stubCutout = async (
|
||||||
ctx.globalCompositeOperation = 'source-over'
|
ctx.globalCompositeOperation = 'source-over'
|
||||||
bitmap.close()
|
bitmap.close()
|
||||||
|
|
||||||
const stubFaceSize = Math.round(width * STUB_FACE_WIDTH_RATIO)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
canvas,
|
canvas,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
bbox: { x: 0, y: 0, 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 { removeCardPhotoBackground } from '@/components/cards/cardCutout'
|
||||||
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
|
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
|
||||||
import { TEAM_CARD_FACE_REFERENCE_SIZE, type CardRect } from '@/lib/cardModels'
|
import type { CardRect } from '@/lib/cardModels'
|
||||||
import {
|
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
|
||||||
frameCardPhotoOnFace,
|
|
||||||
type CardAlphaBbox,
|
|
||||||
type CardFaceBox,
|
|
||||||
type CardPhotoTransform,
|
|
||||||
} from '@/lib/cardPhotoTransform'
|
|
||||||
|
|
||||||
export type CardCutoutState =
|
export type CardCutoutState =
|
||||||
| { status: 'idle' }
|
| { status: 'idle' }
|
||||||
|
|
@ -26,13 +21,10 @@ export type CardCutoutState =
|
||||||
harmonized: HTMLCanvasElement | null
|
harmonized: HTMLCanvasElement | null
|
||||||
width: number
|
width: number
|
||||||
height: 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
|
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,
|
* 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
|
const transform = photoWindow
|
||||||
? frameCardPhotoOnFace(
|
? frameCardPhotoOnBbox(
|
||||||
{ width: result.width, height: result.height },
|
{ width: result.width, height: result.height },
|
||||||
photoWindow,
|
photoWindow,
|
||||||
result.bbox,
|
result.bbox,
|
||||||
result.face,
|
|
||||||
TEAM_CARD_FACE_REFERENCE_SIZE,
|
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
if (!transform) {
|
if (!transform) {
|
||||||
|
|
@ -100,8 +90,6 @@ export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenc
|
||||||
width: result.width,
|
width: result.width,
|
||||||
height: result.height,
|
height: result.height,
|
||||||
transform,
|
transform,
|
||||||
bbox: result.bbox,
|
|
||||||
face: result.face,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[photoWindow, harmonyReferenceSrc],
|
[photoWindow, harmonyReferenceSrc],
|
||||||
|
|
|
||||||
|
|
@ -200,19 +200,6 @@ export const TEAM_CARD_NAME_SLOT = {
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
} as const satisfies CardNameBannerSlot
|
} 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 BRAND_FALLBACK_FONT = 'sans-serif'
|
||||||
|
|
||||||
export const isCardModelId = (value: unknown): value is CardModelId =>
|
export const isCardModelId = (value: unknown): value is CardModelId =>
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,6 @@
|
||||||
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
|
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
|
||||||
* [1, 4] and pins the offsets so no gap can open at the window edges.
|
* [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
|
* `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.
|
* 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_MIN_ZOOM = 1
|
||||||
export const CARD_PHOTO_MAX_ZOOM = 4
|
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
|
* 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
|
* segmentation: the card would show a speck, so the composer fails closed and
|
||||||
|
|
@ -64,48 +45,12 @@ export type CardAlphaBbox = {
|
||||||
height: number
|
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 =>
|
export const cardPhotoTransformsEqual = (a: CardPhotoTransform, b: CardPhotoTransform): boolean =>
|
||||||
a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY
|
a.zoom === b.zoom && a.offsetX === b.offsetX && a.offsetY === b.offsetY
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number): number =>
|
const clamp = (value: number, min: number, max: number): number =>
|
||||||
Math.min(Math.max(value, min), max)
|
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 => {
|
export const coverScale = (source: CardPhotoSize, window: CardRect): number => {
|
||||||
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
|
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
|
||||||
|
|
||||||
|
|
@ -116,9 +61,8 @@ export const cardPhotoDrawRect = (
|
||||||
transform: CardPhotoTransform,
|
transform: CardPhotoTransform,
|
||||||
source: CardPhotoSize,
|
source: CardPhotoSize,
|
||||||
window: CardRect,
|
window: CardRect,
|
||||||
clampOptions?: CardPhotoClamp,
|
|
||||||
): CardRect => {
|
): CardRect => {
|
||||||
const clamped = clampCardPhotoTransform(transform, source, window, clampOptions)
|
const clamped = clampCardPhotoTransform(transform, source, window)
|
||||||
const scale = coverScale(source, window) * clamped.zoom
|
const scale = coverScale(source, window) * clamped.zoom
|
||||||
|
|
||||||
return {
|
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 = (
|
export const clampCardPhotoTransform = (
|
||||||
transform: CardPhotoTransform,
|
transform: CardPhotoTransform,
|
||||||
source: CardPhotoSize,
|
source: CardPhotoSize,
|
||||||
window: CardRect,
|
window: CardRect,
|
||||||
{ minZoom = CARD_PHOTO_MIN_ZOOM, anchorBox }: CardPhotoClamp = {},
|
|
||||||
): CardPhotoTransform => {
|
): 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 scale = coverScale(source, window) * zoom
|
||||||
const width = source.width * scale
|
const width = source.width * scale
|
||||||
const height = source.height * scale
|
const height = source.height * scale
|
||||||
const anchorRange = anchorBox
|
|
||||||
? anchorXOffsetRange(anchorBox, scale, window.x, window.width)
|
|
||||||
: null
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoom,
|
zoom,
|
||||||
offsetX: clampPhotoOffset(transform.offsetX, window.x, window.width, width, anchorRange),
|
offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x),
|
||||||
offsetY: clampPhotoOffset(transform.offsetY, window.y, window.height, height),
|
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. */
|
/** Nudges the offset by a step in card pixels, clamped to the window. */
|
||||||
export const panCardPhotoTransform = (
|
export const panCardPhotoTransform = (
|
||||||
transform: CardPhotoTransform,
|
transform: CardPhotoTransform,
|
||||||
|
|
@ -305,51 +156,39 @@ export const panCardPhotoTransform = (
|
||||||
window: CardRect,
|
window: CardRect,
|
||||||
dx: number,
|
dx: number,
|
||||||
dy: number,
|
dy: number,
|
||||||
clampOptions?: CardPhotoClamp,
|
|
||||||
): CardPhotoTransform =>
|
): CardPhotoTransform =>
|
||||||
clampCardPhotoTransform(
|
clampCardPhotoTransform(
|
||||||
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
|
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
|
||||||
source,
|
source,
|
||||||
window,
|
window,
|
||||||
clampOptions,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rescales around a window anchor (used by the zoom control): keeps the point
|
* 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
|
* under `anchor` stable from old to new zoom, then clamps.
|
||||||
* passes its own `minZoom` (and, on S20, the `anchorBox`) so the visitor can
|
|
||||||
* fine-tune below the cover floor.
|
|
||||||
*/
|
*/
|
||||||
export const zoomCardPhotoTransform = (
|
export const zoomCardPhotoTransform = (
|
||||||
transform: CardPhotoTransform,
|
transform: CardPhotoTransform,
|
||||||
source: CardPhotoSize,
|
source: CardPhotoSize,
|
||||||
window: CardRect,
|
window: CardRect,
|
||||||
nextZoom: number,
|
nextZoom: number,
|
||||||
{
|
anchor: { x: number; y: number } = {
|
||||||
anchor,
|
|
||||||
minZoom = CARD_PHOTO_MIN_ZOOM,
|
|
||||||
anchorBox,
|
|
||||||
}: { anchor?: { x: number; y: number } } & CardPhotoClamp = {},
|
|
||||||
): CardPhotoTransform => {
|
|
||||||
const resolvedAnchor = anchor ?? {
|
|
||||||
x: window.x + window.width / 2,
|
x: window.x + window.width / 2,
|
||||||
y: window.y + window.height / 2,
|
y: window.y + window.height / 2,
|
||||||
}
|
},
|
||||||
const zoom = clamp(nextZoom, minZoom, CARD_PHOTO_MAX_ZOOM)
|
): CardPhotoTransform => {
|
||||||
if (zoom === transform.zoom) {
|
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
|
||||||
return clampCardPhotoTransform(transform, source, window, { minZoom, anchorBox })
|
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window)
|
||||||
}
|
|
||||||
|
|
||||||
const ratio = zoom / transform.zoom
|
const ratio = zoom / transform.zoom
|
||||||
|
|
||||||
return clampCardPhotoTransform(
|
return clampCardPhotoTransform(
|
||||||
{
|
{
|
||||||
zoom,
|
zoom,
|
||||||
offsetX: resolvedAnchor.x - (resolvedAnchor.x - transform.offsetX) * ratio,
|
offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio,
|
||||||
offsetY: resolvedAnchor.y - (resolvedAnchor.y - transform.offsetY) * ratio,
|
offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio,
|
||||||
},
|
},
|
||||||
source,
|
source,
|
||||||
window,
|
window,
|
||||||
{ minZoom, anchorBox },
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,8 @@ import {
|
||||||
type CardTextMetrics,
|
type CardTextMetrics,
|
||||||
} from './cardNameFit'
|
} from './cardNameFit'
|
||||||
import {
|
import {
|
||||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
|
||||||
cardPhotoDrawRect,
|
cardPhotoDrawRect,
|
||||||
clampCardPhotoTransform,
|
clampCardPhotoTransform,
|
||||||
type CardAlphaBbox,
|
|
||||||
type CardPhotoClamp,
|
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
type CardPhotoTransform,
|
type CardPhotoTransform,
|
||||||
} from './cardPhotoTransform'
|
} from './cardPhotoTransform'
|
||||||
|
|
@ -219,8 +216,6 @@ export type TeamCardRenderArgs = {
|
||||||
fontFamily: string
|
fontFamily: string
|
||||||
measure: CardMeasureText
|
measure: CardMeasureText
|
||||||
slot?: CardNameBannerSlot
|
slot?: CardNameBannerSlot
|
||||||
/** S20 — measured anchor (face, else silhouette) of the fine-tuning clamp. */
|
|
||||||
anchorBox?: CardAlphaBbox | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TeamCardRenderResult = {
|
export type TeamCardRenderResult = {
|
||||||
|
|
@ -241,11 +236,7 @@ export const renderTeamCard = (
|
||||||
): TeamCardRenderResult => {
|
): TeamCardRenderResult => {
|
||||||
ctx.drawImage(args.base, 0, 0, model.width, model.height)
|
ctx.drawImage(args.base, 0, 0, model.width, model.height)
|
||||||
|
|
||||||
const clampOptions: CardPhotoClamp = {
|
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window)
|
||||||
minZoom: CARD_TEAM_PHOTO_MIN_ZOOM,
|
|
||||||
anchorBox: args.anchorBox,
|
|
||||||
}
|
|
||||||
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window, clampOptions)
|
|
||||||
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
|
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
|
||||||
|
|
||||||
ctx.drawImage(args.overlay, 0, 0, model.width, model.height)
|
ctx.drawImage(args.overlay, 0, 0, model.width, model.height)
|
||||||
|
|
@ -266,6 +257,6 @@ export const renderTeamCard = (
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fit,
|
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). */
|
/** Mirrors the stub's deterministic `slow` download progress (cardCutout.ts). */
|
||||||
const STUB_CUTOUT_PERCENT = 36
|
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) => {
|
page.addInitScript((value: string) => {
|
||||||
;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value
|
;(window as unknown as { __cardsCutoutStub?: string }).__cardsCutoutStub = value
|
||||||
}, mode)
|
}, mode)
|
||||||
|
|
@ -1995,10 +1995,9 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
|
||||||
const readSample = () =>
|
const readSample = () =>
|
||||||
canvas.evaluate((element) => {
|
canvas.evaluate((element) => {
|
||||||
const node = element as HTMLCanvasElement
|
const node = element as HTMLCanvasElement
|
||||||
// S18 — the proportional framing draws the stub photo smaller than the
|
// S15 — the auto-framing covers the slot, so the window center is the
|
||||||
// slot, so the sample sits inside the drawn ellipse (window center)
|
// stub ellipse center.
|
||||||
// instead of the S15 cover point (582, 727).
|
return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data]
|
||||||
return [...node.getContext('2d')!.getImageData(582, 550, 1, 1).data]
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Default ON: the preview pixel is the harmonized variant, not the raw photo.
|
// 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
|
* S15 — the auto-framing after the S18 proportional framing was reverted: the
|
||||||
* (`ok`/`slow`) or none (`noface`), so both branches run through the composer
|
* stub cutout (bbox = whole canvas) must cover the photo window at the cover
|
||||||
* without touching the real detector; the unit specs pin the framing math.
|
* 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.describe('Cards personalizados (S15 — enquadramento automático)', () => {
|
||||||
test('a cutout without a detected face falls back to the S15 framing in silence', async ({
|
test('covers the photo window at the cover floor and downloads the PNG', async ({ page }) => {
|
||||||
page,
|
await setCutoutStub(page, 'ok')
|
||||||
}) => {
|
|
||||||
await setCutoutStub(page, 'noface')
|
|
||||||
await page.goto('/cards?model=time-de-voce')
|
await page.goto('/cards?model=time-de-voce')
|
||||||
|
|
||||||
const dialog = page.getByRole('dialog')
|
const dialog = page.getByRole('dialog')
|
||||||
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
|
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Maria')
|
||||||
await uploadBustPhoto(dialog)
|
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('heading', { name: 'Confira seu card' })).toBeVisible()
|
||||||
await expect(dialog.getByRole('alert')).toHaveCount(0)
|
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)
|
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' })
|
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
|
||||||
await expect(primary).toBeEnabled()
|
await expect(primary).toBeEnabled()
|
||||||
|
|
||||||
await primary.click()
|
await primary.click()
|
||||||
|
|
||||||
const [download] = await Promise.all([
|
const [download] = await Promise.all([
|
||||||
page.waitForEvent('download'),
|
page.waitForEvent('download'),
|
||||||
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
|
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
|
||||||
|
|
@ -2088,22 +2103,4 @@ test.describe('Cards personalizados (S18 — cabeça proporcional)', () => {
|
||||||
.equals(PNG_SIGNATURE),
|
.equals(PNG_SIGNATURE),
|
||||||
).toBe(true)
|
).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 {
|
import {
|
||||||
CARD_MODELS,
|
CARD_MODELS,
|
||||||
NAME_CARD_SLOT,
|
NAME_CARD_SLOT,
|
||||||
TEAM_CARD_FACE_REFERENCE_SIZE,
|
|
||||||
TEAM_CARD_LABEL,
|
TEAM_CARD_LABEL,
|
||||||
TEAM_CARD_NAME_BANNER,
|
TEAM_CARD_NAME_BANNER,
|
||||||
TEAM_CARD_NAME_SLOT,
|
TEAM_CARD_NAME_SLOT,
|
||||||
|
|
@ -11,7 +10,7 @@ import {
|
||||||
isCardModelId,
|
isCardModelId,
|
||||||
} from '@/lib/cardModels'
|
} 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', () => {
|
it('exposes the four shipped models with unique ids', () => {
|
||||||
expect(CARD_MODELS.map((model) => model.id)).toEqual([
|
expect(CARD_MODELS.map((model) => model.id)).toEqual([
|
||||||
'eu-sou-solla',
|
'eu-sou-solla',
|
||||||
|
|
@ -133,10 +132,6 @@ describe('card model catalog (S13/S15/S18)', () => {
|
||||||
expect(lowestCorner).toBeLessThan(model.photoWindow!.y)
|
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', () => {
|
it('sanitizes query-param values through isCardModelId', () => {
|
||||||
expect(isCardModelId('eu-sou-solla')).toBe(true)
|
expect(isCardModelId('eu-sou-solla')).toBe(true)
|
||||||
expect(isCardModelId('perfil-quadrado')).toBe(true)
|
expect(isCardModelId('perfil-quadrado')).toBe(true)
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,14 @@ import type { CardRect } from '@/lib/cardModels'
|
||||||
import {
|
import {
|
||||||
CARD_PHOTO_MAX_ZOOM,
|
CARD_PHOTO_MAX_ZOOM,
|
||||||
CARD_PHOTO_MIN_ZOOM,
|
CARD_PHOTO_MIN_ZOOM,
|
||||||
CARD_TEAM_PHOTO_MIN_ZOOM,
|
|
||||||
cardPhotoDrawRect,
|
cardPhotoDrawRect,
|
||||||
centerCardPhotoTransform,
|
centerCardPhotoTransform,
|
||||||
clampCardPhotoTransform,
|
clampCardPhotoTransform,
|
||||||
coverScale,
|
coverScale,
|
||||||
frameCardPhotoOnBbox,
|
frameCardPhotoOnBbox,
|
||||||
frameCardPhotoOnFace,
|
|
||||||
panCardPhotoTransform,
|
panCardPhotoTransform,
|
||||||
resolveCardPhotoAnchor,
|
|
||||||
zoomCardPhotoTransform,
|
zoomCardPhotoTransform,
|
||||||
type CardAlphaBbox,
|
type CardAlphaBbox,
|
||||||
type CardFaceBox,
|
|
||||||
type CardPhotoClamp,
|
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
} from '@/lib/cardPhotoTransform'
|
} 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', () => {
|
describe('clampCardPhotoTransform', () => {
|
||||||
it('clamps the zoom to [1, 4]', () => {
|
it('clamps the zoom to [1, 4]', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -274,24 +139,6 @@ describe('clampCardPhotoTransform', () => {
|
||||||
expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 })
|
expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 })
|
||||||
expect(coversWindow(cardPhotoDrawRect(tooFarLeft, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
|
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', () => {
|
describe('panCardPhotoTransform', () => {
|
||||||
|
|
@ -305,202 +152,12 @@ describe('panCardPhotoTransform', () => {
|
||||||
const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999)
|
const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999)
|
||||||
expect(overPanned).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
|
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', () => {
|
describe('zoomCardPhotoTransform', () => {
|
||||||
it('keeps the anchor point stable while zooming', () => {
|
it('keeps the anchor point stable while zooming', () => {
|
||||||
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||||
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, {
|
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 })
|
||||||
anchor: { x: 500, y: 370 },
|
|
||||||
})
|
|
||||||
const scale = coverScale(LANDSCAPE, WINDOW) * 2
|
const scale = coverScale(LANDSCAPE, WINDOW) * 2
|
||||||
|
|
||||||
expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5)
|
expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5)
|
||||||
|
|
@ -525,17 +182,4 @@ describe('zoomCardPhotoTransform', () => {
|
||||||
|
|
||||||
expect(same).toEqual(start)
|
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 {
|
import {
|
||||||
centerCardPhotoTransform,
|
centerCardPhotoTransform,
|
||||||
frameCardPhotoOnBbox,
|
frameCardPhotoOnBbox,
|
||||||
type CardAlphaBbox,
|
|
||||||
type CardPhotoSize,
|
type CardPhotoSize,
|
||||||
} from '@/lib/cardPhotoTransform'
|
} from '@/lib/cardPhotoTransform'
|
||||||
import {
|
import {
|
||||||
|
|
@ -245,37 +244,6 @@ describe('renderTeamCard', () => {
|
||||||
expect(rectCalls).toHaveLength(1)
|
expect(rectCalls).toHaveLength(1)
|
||||||
expect(textCalls.map((call) => call.text)).toEqual(['TIME DE'])
|
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)', () => {
|
describe('resolveCardBannerWidth (S16)', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue