mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
feat(S17): controle "Harmonizar cores" no ready do card Time de você
Ajuste local de brilho/contraste/saturação (clamps fixos ≤12%, alpha byte-a-byte) para aproximar a selfie do tom da arte-mestre; variante gerada 1× no ready do useCardCutout e escolhida pelo toggle no mesmo canvas da prévia e do PNG. Sem migration/Consent/rede/persistência; os 3 modelos e o recorte intocados. Crítica do designer certificada no tier primário (artefato revalidado sem DEGRADED).
This commit is contained in:
parent
12698ba4fc
commit
3c8a1f87b4
13 changed files with 850 additions and 28 deletions
|
|
@ -20,9 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
|
||||
- `src/utilities/<domínio>/` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`).
|
||||
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
|
||||
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`. Geometria/catálogo puros em `src/lib/card{Models,NameFit,PhotoTransform,Render}.ts`.
|
||||
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`/`cardCutout.ts`/`cardPhotoHarmonyCanvas.ts` (S17). Geometria/catálogo/tom puros em `src/lib/card{Models,NameFit,PhotoTransform,Render,Harmony}.ts`.
|
||||
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
|
||||
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`).
|
||||
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`, `cardPhotoHarmony`).
|
||||
- `src/migrations/` — história congelada; nunca editar retroativamente.
|
||||
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
**S17 — Card "Time de você": harmonizar as cores da foto do visitante (2026-09-19):** o estado pronto do modelo `time-de-voce` ganha o controle binário "Harmonizar cores" (LIGADO por padrão, `role="switch"` + `aria-checked`, alvo ≥44px, label/ajuda literais, `aria-describedby`), entre "Ajuste fino" e "Trocar foto" — só no ready do time (os 3 modelos existentes e o ramo de nome longo não o mostram). O ajuste é 100% no aparelho e sem persistência: math pura em `src/lib/cardPhotoHarmony.ts` (luma Rec.709 média/desvio + saturação HSV média, só alphas acima de `CARD_CUTOUT_ALPHA_THRESHOLD`; fatores de brilho/contraste/saturação com clamps fixos ≤12%; identidade 1±0,02; passada única preservando o alpha byte-a-byte) aplicada pelo adapter `cardPhotoHarmonyCanvas.ts`, que mede a referência na arte-mestre `team-card-base.png` (cache por imagem) e nunca muta o recorte; `useCardCutout.ready` passa a carregar `canvas` + `harmonized` (computada antes do primeiro paint — o default LIGADO não pisca a original) e o `CardComposer` só escolhe a variante que entra no `renderTeamCard` intocado, então prévia e PNG (mesmo canvas) levam a escolha; desligar devolve a original na hora, "Trocar foto" recomputa, falha de leitura esconde o controle (fail-closed, sem mentir um switch ligado). Sem migration/collection/Consent, sem rede/dependência nova; artes-mestre e recorte intocados e o alpha floor do bbox/amostra unificado em `CARD_CUTOUT_ALPHA_THRESHOLD` (`cardPhotoTransform`). Testes: 22 unit do módulo puro (alpha, clamps, identidade, monotonicidade, determinismo) + e2e novo (default LIGADO com `aria-checked`, alvo ≥44px, troca de bitmap da prévia ao desligar e volta idêntica ao religar por teclado, nota de privacidade visível, ausência no modelo de foto e no nome longo). Design: crítica final do `designer` certificada no tier primário `openai/gpt-5.6-sol` (artefato revalidado sem `DEGRADED`), com os ajustes de fidelidade aplicados — prévia `ready` a 180px no mobile (240px no desktop), superfície do switch sem hover persistente e "Trocar foto" em `mt-3`. Harness: `tests/unit/codebaseConventions.unit.spec.ts` passa a ler `git ls-files -z` — o path não-ASCII `public/campaign-kit/coração.png` (C204) vinha citado e derrubava o `verify` da main com ENOENT (commit separado). Verificação: `pnpm gate:fast` verde (376 arquivos/4066 testes), e2e `frontend` verde (30), screenshots 390/1280 dos estados ON/OFF e download do PNG no caminho harmonizado.
|
||||
203
docs/plans/cards-time-de-voce-harmonizar-cores-impl.md
Normal file
203
docs/plans/cards-time-de-voce-harmonizar-cores-impl.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# Impl: Card "Time de você": harmonizar as cores da foto do visitante com o time
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-09-19
|
||||
Issue: #1196
|
||||
Intenção: docs/plans/cards-time-de-voce-harmonizar-cores.md
|
||||
Appetite restante: herdado — ~0,5–1 dia eng (3 fases somam ~0,9 dia; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no estado pronto do modelo `time-de-voce`, um controle binário único ("Harmonizar cores", LIGADO por padrão) aplica um ajuste suave de brilho/contraste/saturação na foto recortada do visitante para aproximá-la do tom da arte-mestre; desligar devolve a original na hora, e a escolha vale para a prévia e para o PNG baixado — tudo no aparelho, sem persistência.
|
||||
- **O que NÃO negociar:**
|
||||
- 100% no aparelho: sem upload/serviço/rede, sem analytics, sem nova dependência; sem migration/collection/CMS/Consent.
|
||||
- Um único pipeline: a variante harmonizada alimenta o MESMO `renderTeamCard` e o MESMO canvas que a prévia e o download já usam.
|
||||
- Recorte intocável: nada de pele/forma/imperfeições, nada de matiz, nunca alterar a máscara/alpha nem o framing; os 3 modelos existentes e `renderTeamCard`/`CardDrawContext` ficam intocados.
|
||||
- Só no `time-de-voce` (gate `isTeamModel`); nota de privacidade existente visível e verdadeira; literais: rótulo `Harmonizar cores`, ajuda `Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.`
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- "Processar no recorte/hook ou no render": confirmado que não é no renderer; a variante nasce no ready do `useCardCutout` (decisão 4), o renderer fica intocado.
|
||||
- "Medir a base via `baseImage` do composer": o composer não passa a imagem ao hook — o adapter resolve a referência pelo `loadCardImage` já cacheado (`cardCanvas.ts:8-27`), com cache de tom por imagem; evita sincronizar dois ciclos e acoplar o hook a estado de UI.
|
||||
- "Botão binário estilizado": confirmado que não existe `ui/Switch` e que `ui/Toggle` é radix `aria-pressed`; o port usa `<button role="switch" aria-checked>` nativo (decisão 5).
|
||||
- **Correções ao achado do explorador (verificadas nos arquivos):**
|
||||
- O e2e JÁ tem guard automático de `console.error`/`pageerror`/5xx no fixture `tests/e2e/fixtures/e2eTest.ts` (fixture `auto: true`, :100-133) — não é preciso listener novo; o achado "sem assert de console-error" vale só como ausência de assert explícito.
|
||||
- O `filter: saturate()/brightness()/contrast()` do artefato (:109-113) é o mockup do resultado sobre um placeholder `<svg>`, não o mecanismo do port (o mecanismo é passada de pixels; ver decisão 1).
|
||||
- `#cfcac7` do trilho desligado não é token do tema (só existe no CSS do artefato) — é literal de port.
|
||||
- Demais pontos 1–13 e 15–16 conferidos e batem (incluindo :216-229 do unit de render, que continua intocado).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
BASE["team-card-base.png"] --> REF["adapter: mede tom de referência (WeakMap por imagem)"]
|
||||
CUT["cutout canvas (ready)"] --> ADP["adapter cardPhotoHarmonyCanvas"]
|
||||
REF --> ADP
|
||||
ADP --> LIB["lib pura cardPhotoHarmony: compute + apply"]
|
||||
LIB --> VAR["variante harmonizada (canvas novo; original intacto)"]
|
||||
VAR --> READY["useCardCutout ready: canvas + harmonized"]
|
||||
READY --> SW["CardComposer: harmonyEnabled (default ON) seleciona a variante"]
|
||||
SW --> RT["renderTeamCard (intocado)"]
|
||||
RT --> PREV["prévia"]
|
||||
RT --> PNG["PNG baixado"]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A) uma passada de pixels pura em `src/lib/` sobre o bitmap do recorte, com adapter DOM que gera a variante harmonizada; o ready do `useCardCutout` carrega original + harmonizada e o toggle só escolhe qual entra no `renderTeamCard` | B) `ctx.filter = 'brightness() contrast() saturate()'` no `drawImage` da foto (adicionando `filter` ao `CardDrawContext` e alterando `renderTeamCard`) | C) `filter` CSS na prévia + refazer o ajuste no export/PNG.
|
||||
**Recomendação:** A — `ctx.filter` tem suporte irregular no WebKit/iOS (o baseline do produto é mobile/iPhone) e tocaria o renderer; a passada de pixels é determinística, testável como math pura com dados sintéticos, preserva o alpha do recorte e serve prévia e PNG sem segundo pipeline.
|
||||
**Rejeitadas:** B porque dependeria de recurso instável no Safari e violaria a invariante "não alterar `renderTeamCard`/`CardDrawContext`"; C porque o PNG refaria o filtro por outro caminho (risco de divergir da prévia) — exatamente o "twin" de pipeline que a doutrina proíbe.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Plataforma do ajuste (Safari baseline).**
|
||||
**Opções:** A) passada de pixel pura + canvas variante | B) `ctx.filter` no draw | C) CSS filter na prévia + filtro próprio no export.
|
||||
**Recomendação:** A — ver acima; a variante é criada UMA vez e o toggle apenas troca a referência.
|
||||
**Rejeitadas:** B e C pelos motivos acima; nenhuma usa `ctx.filter` (não há precedente no repo).
|
||||
|
||||
2. **Fronteira lib pura × adapter DOM.**
|
||||
**Opções:** A) novo `src/lib/cardPhotoHarmony.ts` (medidas + compute + apply, puro) + novo `src/components/cards/cardPhotoHarmonyCanvas.ts` (canvas offscreen/getImageData/putImageData) | B) tudo em `cardCutout.ts` | C) tudo em `cardCanvas.ts` | D) importar `utilities/`.
|
||||
**Recomendação:** A — `src/lib` é puro e client-safe (nunca importa `utilities/`), então a math de pixels (ImageData-like) é unit-testável com dados sintéticos. O adapter novo é o dono de "canvas offscreen + leitura/escrita de pixels do tom", preocupação que hoje não tem dono: `cardCanvas.ts` é I/O de assets/PNG (não faz `getImageData`) e `cardCutout.ts` é segmentação/máscara — acoplar tom ali arriscaria o recorte. Sem twin: o adapter reusa `loadCardImage` do dono existente e a lib pura.
|
||||
**Rejeitadas:** B porque misturaria motor MediaPipe com tone mapping na mesma vizinhança e convidaria a mutar o bitmap do recorte; C porque colocaria duas preocupações no mesmo arquivo e todo modelo pagaria o import; D por invariante do repo.
|
||||
|
||||
3. **Fórmula e limites (números fixos).**
|
||||
**Opções:** A) medir a arte-mestre em runtime e a foto, com fator = `clamp(ref/src)` e limites fixos | B) perfil fixo calibrado uma vez | C) correção com matiz/temperatura/OKLab/white balance.
|
||||
**Recomendação:** A — segue o literal ("referência: arte-mestre"), com clamps que impedem "filtro da casa"; B foi a alternativa já registrada na intenção e fica só como plano B de contingência; C está fora do literal (só brilho, contraste, saturação).
|
||||
|
||||
| fator | cálculo | limites (clamp) |
|
||||
| --------- | ----------------------------------------- | --------------- |
|
||||
| brilho | `ref.meanLuma / src.meanLuma` | `[0,94, 1,08]` |
|
||||
| contraste | `ref.sdLuma / src.sdLuma` | `[0,94, 1,08]` |
|
||||
| saturação | `ref.meanSaturation / src.meanSaturation` | `[0,90, 1,12]` |
|
||||
- Medidas (puras): por pixel, luma Rec.709 (`0.2126r + 0.7152g + 0.0722b`, sRGB bytes, sem linearizar); `meanLuma`; `sdLuma` (desvio populacional); `meanSaturation` = média de `(max-min)/max` (HSV S; 0 quando max=0). Pixels com `alpha ≤ 32` ficam fora da amostra (mesmo limiar do recorte; sem amostras → `samples: 0`).
|
||||
- Guards: stat da fonte ≤ 0 ou não-finita → fator 1; referência com `samples: 0` → identidade.
|
||||
- Aplicação (ordem fixa): brilho (multiplica RGB) → contraste (`v' = (v-0.5)*c + 0.5`) → saturação (`v' = luma + (v-luma)*s`, luma do pixel já corrigido); arredonda e clampa `[0,255]`; **alpha copiado byte-a-byte**.
|
||||
- **Identidade:** se os três fatores ficam em `1 ± 0,02`, a variante É o original (nenhum canvas novo); é o comportamento esperado para foto já próxima do tom.
|
||||
- Baseline medido agora no repo (sanidade; o código mede em runtime): `team-card-base.png` 1080×1440 → `meanLuma 0,652`, `sdLuma 0,280`, `meanSaturation 0,441` (HSV, alpha>32). Os caps ficam iguais/logo acima do mockup do design (`saturate 1.12`, `brightness 1.06`, `contrast 1.06`) — delta máximo ≤ 12%.
|
||||
- Amostra e aplicação varrem a imagem inteira (cutout ≤ 1600px de aresta por `CUTOUT_MAX_EDGE`); é uma medida + uma aplicação por foto.
|
||||
|
||||
4. **Ciclo de vida e cache da variante.**
|
||||
**Opções:** A) computar dentro do `useCardCutout.start` (antes do `setState(ready)`), guardando `canvas` + `harmonized` no estado ready | B) `useMemo` no composer | C) `useEffect` separado com estado próprio | D) computar no clique do toggle.
|
||||
**Recomendação:** A — o hook já é o dono do ciclo do recorte (run-id, retry, desmontagem); o ready passa a carregar as duas variantes e o default LIGADO já vale no primeiro commit (sem frame original → "a prévia abre harmonizada"). "Trocar foto" chama `start` de novo e gera nova variante naturalmente; o toggle só escolhe qual variante entra em `renderTeamCard` (zero reprocessamento). Cache: o tom da referência fica num `WeakMap` por imagem no adapter (a imagem do asset já é cacheada por `loadCardImage`); a variante é cacheada no próprio estado ready — nenhuma estrutura global segura canvas grande. Falha da harmonização (referência indisponível/contexto 2D ausente) → `harmonized: null` e o composer esconde o controle (não mente um switch ligado sem efeito); é caso praticamente impossível (mesma origem, canvas 2D padrão).
|
||||
**Rejeitadas:** B porque compute em render é efeito colateral e bloqueia sem dono explícito; C porque espelharia os run-ids do hook (twin de ciclo) e deixaria um frame original visível; D porque o default LIGADO não estaria pronto no primeiro frame e religar poderia reprocessar.
|
||||
|
||||
5. **Controle.**
|
||||
**Opções:** A) `<button type="button" role="switch" aria-checked>` nativo com trilho/thumb, componente local `TeamHarmonySwitch` no `CardComposer.tsx` (padrão do `TeamNameField`) | B) reusar `ui/Toggle` (radix, `aria-pressed`) | C) criar `ui/Switch` shadcn/radix | D) arquivo novo `components/cards/TeamHarmonySwitch.tsx`.
|
||||
**Recomendação:** A — o literal exige `role=switch` + `aria-checked` e o artefato diz explicitamente "sem shadcn Switch"; o markup do design (:353-377, CSS :161-206) é botão nativo com trilho/thumb. Port classe-a-classe: linha inteira é o botão com `min-h-11`, trilho 44×26 com thumb 20px (`--pt-red` ligado / `#cfcac7` desligado, literal), label/ajuda sempre visíveis, foco visível com tokens do app. Uso único → componente local (extrair arquivo seria pass-through raso). As pílulas "LIGADO · alvo ≥44px"/"DESLIGADO" do artefato são anotação de crítica, não UI — não são portadas.
|
||||
**Rejeitadas:** B porque `aria-pressed` contradiz a semântica literal; C porque adiciona primitivo vetado pelo design e sem segundo uso; D porque cria módulo raso de uso único.
|
||||
|
||||
6. **Ramo `nameError` e "Trocar foto".**
|
||||
**Opções:** A) esconder o controle no `nameError` (junto do zoom, que já some) e manter a escolha do usuário ao trocar de foto | B) mostrar também no `nameError` | C) resetar o toggle para LIGADO em "Trocar foto".
|
||||
**Recomendação:** A — precedente do próprio composer (em `nameError` só o campo do nome aparece); o design só cobre o ready normal; ao corrigir o nome o controle reaparece com o estado que o usuário deixou. O reset para LIGADO acontece só ao desmontar/reabrir o composer (sem persistência), que é o "sair e voltar" do aceite.
|
||||
**Rejeitadas:** B porque o artefato não cobre e o ramo existe para focar a correção do nome; C porque reaplicaria um efeito recém-recusado na foto anterior (surpresa ruim) e custaria código extra.
|
||||
|
||||
7. **Testes.**
|
||||
**Opções:** A) unit do lib puro com dados sintéticos + estender o describe e2e do time | B) só e2e | C) int/e2e com engine real.
|
||||
**Recomendação:** A — unit: alpha preservado byte-a-byte, clamps exatos, guards de stat zero/não-finita, identidade (mesma referência, sem cópia), monotonicidade (brilho sobe luma; contraste afasta do meio; saturação abre canais; cinza neutro fica cinza), clamp `[0,255]` sem wrap, determinismo. E2E (no describe `Time de você`): novo teste com stub `ok` — switch visível com `aria-checked="true"` (default), alvo ≥ 44px, amostra de 1 pixel no centro da janela (`(582,727)`, dentro do recorte e fora do overlay) muda ao desligar e volta idêntica ao religar (prova de troca de bitmap, não só de ARIA), nota de privacidade visível; ausência do switch no modelo de foto e no `nameError` (um assert em cada teste existente). O guard de console/pageerror do fixture já cobre "sem console error"; o teste de download existente passa a exercitar o caminho harmonizado por padrão (fixture RGB sólido `30,120,200` cai longe da identidade: luma 0,418 vs 0,652; sat 0,849 vs 0,441). Sem int (nenhuma superfície de DB).
|
||||
**Rejeitadas:** B porque clamps/alpha merecem prova determinística fora do browser; C porque o engine real é craft/UAT (S15) e o stub existe para isso.
|
||||
|
||||
8. **A11y/teclado.**
|
||||
**Opções:** A) botão nativo `role=switch` (Tab + Space/Enter nativos), `aria-checked` booleano, linha inteira ≥44px, foco visível com tokens | B) `<input type=checkbox role=switch>` | C) teclado customizado em setas.
|
||||
**Recomendação:** A — semântica correta sem JS de teclado; `aria-checked` reflete o estado; toque e teclado no alvo da linha toda; foco com `focus-visible:ring-2` (`--pt-red`), como os demais controles do composer.
|
||||
**Rejeitadas:** B porque o design pede trilho/thumb e o input exigiria styling complexo; C porque inventaria interação sem necessidade.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/cardPhotoHarmony.ts`** (novo, puro, client-safe): `CardPhotoTone`, `CardHarmonyAdjustment`, `CARD_HARMONY_LIMITS`, `CARD_HARMONY_IDENTITY_EPSILON`; `measureCardPhotoTone(data)`, `computeCardHarmonyAdjustment`, `isCardHarmonyIdentity`, `applyCardPhotoHarmony(data, adjustment)`. O alpha floor da amostra é o mesmo do bbox: `CARD_CUTOUT_ALPHA_THRESHOLD` exportado em `cardPhotoTransform.ts` (dono do `CardAlphaBbox`) e importado por `cardCutout.ts` e `cardPhotoHarmony.ts` — fonte única, sem twin.
|
||||
- **`src/lib/cardPhotoTransform.ts`** (editar, 1 constante): `CARD_CUTOUT_ALPHA_THRESHOLD` — alpha floor compartilhado pelo bbox (`cardCutout`) e pela amostra de tom (S17).
|
||||
- **`src/components/cards/cardPhotoHarmonyCanvas.ts`** (novo, adapter DOM): `harmonizeCardCutout(cutout, referenceSrc): Promise<HTMLCanvasElement | null>` — `loadCardImage` (dono) + WeakMap de tom por imagem; `ctx.createImageData` + `set` + `putImageData`; nunca muta o canvas do recorte; devolve o próprio cutout quando identidade.
|
||||
- **`src/components/cards/useCardCutout.ts`** (editar): novo param `harmonyReferenceSrc?: string`; ready ganha `harmonized: HTMLCanvasElement | null`; harmoniza antes do `setState(ready)` com guard de run-id.
|
||||
- **`src/components/cards/CardComposer.tsx`** (editar): estado `harmonyEnabled` default `true` (junto de :112-123); draw effect (:214-230) passa a variante selecionada (`harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas`, com `harmonyEnabled` no array de deps); `TeamHarmonySwitch` local no ramo ready não-error, entre `{zoomAndPanControls}` e "Trocar foto"; esconde o controle quando `harmonized === null`.
|
||||
- **`src/components/cards/cardCopy.ts`** (editar): `CARD_HARMONY_LABEL = 'Harmonizar cores'` e `CARD_HARMONY_HELP = 'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.'` (dono da copy do estúdio).
|
||||
- **`tests/unit/cardPhotoHarmony.unit.spec.ts`** (novo): casos da decisão 7, com `Uint8ClampedArray` sintético — sem canvas/jsdom.
|
||||
- **`tests/e2e/frontend.e2e.spec.ts`** (editar): novo teste no describe `Cards personalizados (S15 — Time de você)` + asserts de ausência nos testes do modelo de foto (:1796) e do nome longo (:1930). O guard de console já é automático pelo fixture.
|
||||
- **`docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md`** (novo, uma entrada curta).
|
||||
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
|
||||
- **Access / Consent:** n/a — nenhuma superfície Payload; a foto e a escolha nunca saem do aparelho; sem chave nova de Consent e sem persistência.
|
||||
- **UI:** Impeccable C — port do artefato (controle no ready, depois de "Ajuste fino" e antes de "Trocar foto"; trilho `--pt-red`/cinza, thumb, label+ajuda sempre visíveis, cenas desktop 1280 e mobile 390); sem cor nova; alvo ≥44px; foco visível; `role=switch`/`aria-checked`.
|
||||
|
||||
### Dados → forma (não se aplica)
|
||||
|
||||
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única "forma" é o pixel da própria foto (insumo local) e o tom da arte-mestre. Nenhum dado é coletado, persistido ou apresentado; a escolha do toggle não vira métrica.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer do núcleo puro** — quota ~0,25 dia. `src/lib/cardPhotoHarmony.ts` + `tests/unit/cardPhotoHarmony.unit.spec.ts`: medidas, fórmula, clamps, guards, identidade, alpha preservado, monotonicidade, determinismo.
|
||||
Prova: `pnpm test:unit tests/unit/cardPhotoHarmony.unit.spec.ts` verde; nenhuma UI alterada ainda.
|
||||
2. **Pipeline + UI** — quota ~0,4 dia. Adapter `cardPhotoHarmonyCanvas.ts`; `useCardCutout` com `harmonized`; draw do composer escolhendo a variante; `TeamHarmonySwitch` + copy no ramo ready (não no `nameError`).
|
||||
Prova: `NEXT_PUBLIC_CARDS_CUTOUT_STUB=1 pnpm dev` em `/cards?model=time-de-voce` — prévia abre harmonizada, desligar/religar troca na hora, "Trocar foto" reaplica, `nameError` e os 3 modelos não mostram o switch; `pnpm gate:fast` verde.
|
||||
3. **Gates** — quota ~0,25 dia. E2E novo + asserts de ausência; `pnpm gate:fast`; `pnpm test:e2e tests/e2e/frontend.e2e.spec.ts` (o manifesto `scripts/lib/e2e-affected-manifest.mjs` já aciona `frontend` para `src/components/cards` e `src/lib/card` — sem mudança de manifesto); `pnpm push` (roda `gate:ci`).
|
||||
**Design tier:** crítica final do `designer` **certificada no tier primário** (`openai/gpt-5.6-sol`) — o artefato foi revalidado e perdeu o selo `DEGRADED`; registrar `Design tier:` no body do PR. Ajustes pedidos na crítica e aplicados: prévia `ready` a 180px no mobile (240px no desktop), superfície do switch sem hover persistente, "Trocar foto" em `mt-3`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- **`ctx.filter` no Safari.** Suporte irregular no WebKit; o baseline é iPhone — rejeitado (decisão 1).
|
||||
- **CSS filter na prévia + filtro separado no export.** Dois pipelines e PNG divergente — rejeitado.
|
||||
- **Editor de imagem** (slider/curvas/presets/ajuste manual) — anti-goal de produto.
|
||||
- **Matiz/temperatura/OKLab/white balance** — fora do literal (só brilho, contraste, saturação).
|
||||
- **Worker/OffscreenCanvas para a passada de pixels** — especulativo; só com jank medido no UAT (gatilho explícito).
|
||||
- **Persistência da preferência** (localStorage/CMS/Consent) — anti-goal.
|
||||
- **Retoque de beleza** (pele/forma/imperfeições) — anti-goal.
|
||||
- **Mudar recorte/máscara/framing, `renderTeamCard`/`CardDrawContext` ou os 3 modelos** — invariante.
|
||||
- **Nova dependência, upload, serviço ou analytics** — anti-goal.
|
||||
- **Novo primitivo `ui/Switch` shadcn/radix** — vetado pelo design (decisão 5).
|
||||
- **Medir a referência no servidor / pré-processar o asset** — anti-goal (100% no aparelho) e perde a verdade do asset servido.
|
||||
|
||||
## Revisão da sessão (simplify) — resolvido, adiado com gatilho, fora
|
||||
|
||||
**Resolvido no simplify (não reabrir):** alpha floor do bbox e da amostra de tom unificados em
|
||||
`CARD_CUTOUT_ALPHA_THRESHOLD` (`cardPhotoTransform`); `CardPhotoPixels` removido (as funções puras
|
||||
recebem `Uint8ClampedArray`); `IDENTITY_ADJUSTMENT` congelado (`Object.freeze`); `aria-describedby`
|
||||
|
||||
- `useId` no switch (nome acessível limpo, ajuda anunciada como descrição); primeira amostra de
|
||||
pixel do e2e pollada (fim do flake potencial); assert da nota de privacidade no teste novo;
|
||||
`?? 0` redundante removido do loop quente; codebase-map atualizado no mesmo PR.
|
||||
|
||||
**Adiados com gatilho (engenharia):**
|
||||
|
||||
- **Alpha-skip na passada de aplicação** (pixels com alpha ≤ 32 hoje fazem a conta invisível) —
|
||||
gatilho: jank medido no UAT; alpha-skip antes de subsample/worker.
|
||||
- **Testes do adapter `harmonizeCardCutout`** (identidade devolve o mesmo canvas; falha → `null`
|
||||
esconde o controle) — gatilho: 2º consumidor do adapter OU identidade virar aceite explícito de
|
||||
produto (hoje o e2e prova a troca de bitmap e prévia = PNG no mesmo canvas).
|
||||
- **`transition-[left]` do thumb** (anima layout) — trocar por `translate-x` se o switch for tocado
|
||||
por outro motivo; sem jank medido.
|
||||
|
||||
**Explicitamente fora (não reabrir):** catch fail-closed do adapter (esconde o controle em vez de
|
||||
mentir um switch ligado sem efeito; a math pura é coberta por unit); dedup do `clamp` (one-liner
|
||||
estável já repetido em vários módulos de `lib`; extrair módulo raso piora).
|
||||
|
||||
**Harness (main vermelha):** `tests/unit/codebaseConventions.unit.spec.ts` passa a usar
|
||||
`git ls-files -z` — sem o `-z`, o path não-ASCII `public/campaign-kit/coração.png` (C204) vinha
|
||||
citado e o `readFileSync` estourava ENOENT no `verify` da main; vai em commit separado neste PR.
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
| Risco | Mitigação / gatilho |
|
||||
| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Flash de foto original → harmonizada ao abrir a prévia | Computar no `start` antes do `setState(ready)` (um único commit); se algum dia entrar latência > ~300ms, aí sim worker + estado transitório explícito (nomeado em rabbit holes). |
|
||||
| Passada de pixels pesada em celular fraco (≤1600², 1 medida + 1 aplicação) | Uma única vez por foto; se o UAT acusar jank, subsample da medida (stride 2) antes de qualquer mudança estrutural. |
|
||||
| Referência "alta demais" (base inclui céu/candidato coberto: luma 0,652) empurrar quase toda selfie para o cap | Caps fixos (≤12%) tornam o pior caso aceitável; se o UAT achar claro demais, remedir excluindo a janela da foto ou usar mediana — decisão de produto registrada, não silenciosa. |
|
||||
| Identidade (1±0,02) fazer o toggle parecer inerte numa foto já no tom | Comportamento intencional ("foto já próxima do tom = identidade"); a fixture e2e está longe da identidade (luma 0,418 vs 0,652), então a troca de bitmap é observável. |
|
||||
| Memória +1 canvas RGBA por cutout (≤ ~10MB) | Limitado por `CUTOUT_MAX_EDGE`; liberado ao trocar de foto/desmontar o composer; o original é preservado para o toggle off. |
|
||||
| Amostra e2e em (582,727) ficar sob o overlay em mudança futura da arte | Ponto hoje transparente no front (front só cobre y≥971); se a arte mudar, ajustar o ponto de amostra (trigger barato). |
|
||||
| `getImageData` taint | Asset same-origin em `/cards` (sem CDN); sem mudança prevista. |
|
||||
| Falso "no-op" no e2e por console | Guard automático do fixture `e2eTest` já falha o teste em `console.error`/`pageerror`/5xx — nenhum listener novo. |
|
||||
|
||||
## Aceite de engenharia (checkboxes)
|
||||
|
||||
- [ ] Controle só no ready normal do `time-de-voce` (não no `nameError`, não nos outros 3 modelos); `role=switch` + `aria-checked`; alvo da linha ≥44px; toque e teclado (Space/Enter) com foco visível; label e ajuda nos literais exatos.
|
||||
- [ ] Default LIGADO: a prévia abre harmonizada sem frame original visível.
|
||||
- [ ] Desligar devolve a original instantaneamente; religar reaplica; nenhum reprocessamento de pixels na troca (cache no ready).
|
||||
- [ ] A escolha vale para a prévia E para o PNG (mesmo canvas único, `canvasRef` → `canvasToPngBlob`).
|
||||
- [ ] Nada persistido: fechar/reabrir volta ao padrão LIGADO (estado de mount).
|
||||
- [ ] Recorte/máscara intactos: alpha byte-a-byte, nenhuma mutação do canvas original; enquadramento/pan/zoom inalterados.
|
||||
- [ ] `renderTeamCard`, `CardDrawContext`, `cardCutout.ts` (compose/máscara/bbox), `cardModels.ts` e os 3 modelos sem mudança; pins do `cardRender.unit.spec.ts` verdes.
|
||||
- [ ] Só brilho, contraste e saturação via passada de pixels local; sem `ctx.filter`, sem CSS filter, sem rede/dependência/analytics.
|
||||
- [ ] Sem migration/collection/CMS/Consent; nota de privacidade visível e inalterada.
|
||||
- [ ] Unit do lib puro com alpha/clamps/identidade/determinismo; e2e com default, toggle (ARIA + amostra de bitmap), alvo ≥44px e ausências; `pnpm gate:fast` verde; e2e `frontend` acionado.
|
||||
- [ ] Changelog `docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md`; crítica visual do `designer` certificada no tier primário (`openai/gpt-5.6-sol`) antes do `pnpm push`.
|
||||
|
||||
## Self-score decision-quality: 5/5
|
||||
|
||||
1. **Decisões caras com rejeitadas:** pixel pass × `ctx.filter` × CSS filter (Safari/semântica de pixels); fronteira lib pura × adapter (novo dono de pixel pass, sem twin); contrato da variante no ready do hook; controle nativo × `ui/Toggle` × shadcn Switch.
|
||||
2. **Cabe no appetite:** ~0,9 dia em 3 fases, sem migration, sem dependência; tracer do núcleo puro primeiro.
|
||||
3. **Rabbit holes nomeados:** worker, editor, hue/Lab/white balance, persistência, CSS filter, shadcn Switch, retoque, servidor.
|
||||
4. **Depth check:** o `useCardCutout` (dono do ciclo) passa a carregar as duas variantes em vez de um segundo hook/estado espelhado; o switch fica local (uso único, sem módulo raso); o adapter novo existe porque nenhum dono cobre tone mapping e `cardCutout` não pode ser acoplado.
|
||||
5. **Intenção preservada:** default LIGADO, sem persistência, prévia+PNG, só `time-de-voce`, 100% local, copy literal, privacidade intacta, recorte e arte-mestre intocados.
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<title>DEGRADED — Design S17 · Card Time de você: harmonizar cores</title>
|
||||
<title>Design S17 · Card Time de você: harmonizar cores</title>
|
||||
<style type="text/tailwindcss">
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
|
|
@ -209,16 +209,10 @@
|
|||
</style>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 antialiased">
|
||||
<div
|
||||
class="border-b border-amber-500/60 bg-amber-500/10 px-4 py-2 text-center text-[11px] font-black tracking-[0.14em] text-amber-300 uppercase"
|
||||
>
|
||||
DEGRADED — não certificado; exige sign-off humano · tier opencode-go/deepseek-v4.1-flash
|
||||
</div>
|
||||
|
||||
<main class="mx-auto max-w-[1440px] px-4 py-10 sm:px-6 lg:px-10">
|
||||
<header class="max-w-4xl">
|
||||
<p class="m-0 text-xs font-black tracking-[0.12em] text-red-400 uppercase">
|
||||
Gate visual · S17 · DEGRADED
|
||||
Gate visual · S17
|
||||
</p>
|
||||
<h1 class="mt-2 text-2xl font-black tracking-[-0.02em] sm:text-3xl">
|
||||
Card “Time de você”: harmonizar as cores da foto
|
||||
|
|
@ -229,10 +223,6 @@
|
|||
desligar devolve a original na hora; vale para a prévia e para o PNG; nada persiste e não
|
||||
há editor.
|
||||
</p>
|
||||
<p class="mt-3 max-w-3xl text-xs leading-5 text-amber-300/90">
|
||||
<strong>DEGRADED:</strong> este design não está certificado. É insumo para decisão humana
|
||||
no gate/PR — o tier degradado nunca aprova.
|
||||
</p>
|
||||
<div class="mt-5 flex flex-wrap gap-2 text-[11px] font-bold text-zinc-300">
|
||||
<span class="rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2"
|
||||
>1280px · desktop</span
|
||||
|
|
@ -903,10 +893,8 @@
|
|||
</section>
|
||||
|
||||
<aside class="mt-14 border-t border-zinc-800 pt-8 text-xs leading-5 text-zinc-500">
|
||||
<p
|
||||
class="mb-3 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 font-bold text-amber-300"
|
||||
>
|
||||
DEGRADED — não certificado; exige sign-off humano. Tier: opencode-go/deepseek-v4.1-flash.
|
||||
<p class="mb-3 font-bold text-zinc-300">
|
||||
Design certificado no tier primário · openai/gpt-5.6-sol.
|
||||
</p>
|
||||
<p>
|
||||
<strong class="text-zinc-300">Copys literais do plano:</strong> rótulo “Harmonizar cores”;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import {
|
|||
loadCardImage,
|
||||
loadCardPhoto,
|
||||
} from '@/components/cards/cardCanvas'
|
||||
import { CARD_PHOTO_PRIVACY_NOTE, CARD_PRIVACY_NOTE } from '@/components/cards/cardCopy'
|
||||
import {
|
||||
CARD_HARMONY_HELP,
|
||||
CARD_HARMONY_LABEL,
|
||||
CARD_PHOTO_PRIVACY_NOTE,
|
||||
CARD_PRIVACY_NOTE,
|
||||
} from '@/components/cards/cardCopy'
|
||||
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
|
||||
import { useCardCutout } from '@/components/cards/useCardCutout'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
|
@ -53,12 +58,12 @@ const controlButtonClassName =
|
|||
const NAME_TOO_LONG_MESSAGE =
|
||||
'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.'
|
||||
|
||||
/** S15 design gate: the team preview width per state. */
|
||||
/** S15 design gate: the team preview width per state (S17 ready: 180px mobile / 240px desktop). */
|
||||
type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result'
|
||||
const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = {
|
||||
idle: 'max-w-[10.75rem]',
|
||||
processing: 'max-w-[9rem]',
|
||||
ready: 'max-w-[15rem]',
|
||||
ready: 'max-w-[11.25rem] sm:max-w-[15rem]',
|
||||
result: 'max-w-[11.875rem]',
|
||||
}
|
||||
|
||||
|
|
@ -101,6 +106,45 @@ const TeamNameField = ({
|
|||
</div>
|
||||
)
|
||||
|
||||
/**
|
||||
* S17 — the tone-harmony switch of the ready state. Native `role="switch"` (the
|
||||
* design gate's semantics; the studio has no shadcn Switch) with the whole row
|
||||
* as the target and the label/help always visible.
|
||||
*/
|
||||
const TeamHarmonySwitch = ({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) => {
|
||||
const helpId = useId()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-describedby={helpId}
|
||||
onClick={onToggle}
|
||||
className="mt-4 flex min-h-11 w-full items-start gap-3 rounded-lg border border-(--campaign-line) bg-white p-3 text-left focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`relative mt-1.5 inline-block h-[26px] w-11 flex-none rounded-full transition-colors ${
|
||||
enabled ? 'bg-(--pt-red)' : 'bg-[#cfcac7]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-[3px] size-5 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/25%)] transition-[left] ${
|
||||
enabled ? 'left-[21px]' : 'left-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-bold text-(--campaign-ink)">{CARD_HARMONY_LABEL}</span>
|
||||
<span id={helpId} className="mt-0.5 block text-xs leading-5 text-(--campaign-muted)">
|
||||
{CARD_HARMONY_HELP}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type CardComposerProps = {
|
||||
model: CardModel
|
||||
shell: 'dialog' | 'drawer'
|
||||
|
|
@ -121,6 +165,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
/** S17 — LIGADO by default; resets only when the composer unmounts (no persistence). */
|
||||
const [harmonyEnabled, setHarmonyEnabled] = useState(true)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
|
@ -131,7 +177,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const photoWindow = model.photoWindow
|
||||
const isNameModel = model.kind === 'name'
|
||||
const isTeamModel = model.kind === 'team'
|
||||
const cutout = useCardCutout(photoWindow)
|
||||
const cutout = useCardCutout(photoWindow, isTeamModel ? model.assetSrc : undefined)
|
||||
const cutoutState = cutout.state
|
||||
|
||||
const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null
|
||||
|
|
@ -215,7 +261,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
const result = renderTeamCard(ctx, model, {
|
||||
base: baseImage,
|
||||
overlay: overlayImage,
|
||||
photo: teamReady.canvas,
|
||||
photo: harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas,
|
||||
photoSize: { width: teamReady.width, height: teamReady.height },
|
||||
transform: effectiveTransform,
|
||||
window: photoWindow,
|
||||
|
|
@ -264,6 +310,7 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
isNameModel,
|
||||
isTeamModel,
|
||||
teamReady,
|
||||
harmonyEnabled,
|
||||
cutoutState.status,
|
||||
model,
|
||||
name,
|
||||
|
|
@ -733,10 +780,16 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer
|
|||
) : (
|
||||
<>
|
||||
{zoomAndPanControls}
|
||||
{teamReady.harmonized ? (
|
||||
<TeamHarmonySwitch
|
||||
enabled={harmonyEnabled}
|
||||
onToggle={() => setHarmonyEnabled((enabled) => !enabled)}
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`${secondaryButtonClassName} mt-4 w-full`}
|
||||
className={`${secondaryButtonClassName} mt-3 w-full`}
|
||||
>
|
||||
Trocar foto
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,14 @@ export const CARD_PRIVACY_NOTE =
|
|||
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
|
||||
export const CARD_PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
|
||||
|
||||
/**
|
||||
* S17 — the tone-harmony control of the team model. Literals of the intent and
|
||||
* the design gate; keep both identical between the plan and the composer.
|
||||
*/
|
||||
export const CARD_HARMONY_LABEL = 'Harmonizar cores'
|
||||
export const CARD_HARMONY_HELP =
|
||||
'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.'
|
||||
|
||||
/**
|
||||
* S14 — example name of the name-model tile. Drawn by the real composer
|
||||
* pipeline over the base image (same fit and left border as the generated card).
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
import type { ImageSegmenter, MPMask } from '@mediapipe/tasks-vision'
|
||||
|
||||
import type { CardAlphaBbox } from '@/lib/cardPhotoTransform'
|
||||
import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -25,7 +26,6 @@ declare global {
|
|||
const WASM_DIR = '/cards/mediapipe/wasm'
|
||||
const MODEL_URL = '/cards/selfie_segmenter.tflite'
|
||||
const CUTOUT_MAX_EDGE = 1600
|
||||
const ALPHA_THRESHOLD = 32
|
||||
const STUB_ENABLED = process.env.NEXT_PUBLIC_CARDS_CUTOUT_STUB === '1'
|
||||
const STUB_DELAY_MS = 1500
|
||||
const STUB_PROGRESS = 0.36
|
||||
|
|
@ -243,7 +243,7 @@ const readAlphaBbox = (canvas: HTMLCanvasElement): CardAlphaBbox | null => {
|
|||
for (let y = 0; y < height; y += 1) {
|
||||
const row = y * width * 4
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (pixels[row + x * 4 + 3]! > ALPHA_THRESHOLD) {
|
||||
if (pixels[row + x * 4 + 3] > CARD_CUTOUT_ALPHA_THRESHOLD) {
|
||||
if (x < minX) minX = x
|
||||
if (x > maxX) maxX = x
|
||||
if (y < minY) minY = y
|
||||
|
|
|
|||
78
src/components/cards/cardPhotoHarmonyCanvas.ts
Normal file
78
src/components/cards/cardPhotoHarmonyCanvas.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* S17 — browser adapter for the on-device tone harmony of the `Time de você`
|
||||
* cutout. Reads the reference tone from the official master (already cached by
|
||||
* `loadCardImage`) and the photo's tone through an offscreen canvas — the photo
|
||||
* still never leaves the device. The pixel math is pure (`lib/cardPhotoHarmony`);
|
||||
* this module only touches canvas/DOM and never mutates the cutout canvas, so
|
||||
* toggling the control can always bring the original back.
|
||||
*/
|
||||
|
||||
import { loadCardImage } from '@/components/cards/cardCanvas'
|
||||
import {
|
||||
applyCardPhotoHarmony,
|
||||
computeCardHarmonyAdjustment,
|
||||
isCardHarmonyIdentity,
|
||||
measureCardPhotoTone,
|
||||
type CardPhotoTone,
|
||||
} from '@/lib/cardPhotoHarmony'
|
||||
|
||||
const REFERENCE_TONE_CACHE = new WeakMap<HTMLImageElement, CardPhotoTone>()
|
||||
|
||||
const measureImageTone = (image: HTMLImageElement): CardPhotoTone | null => {
|
||||
const cached = REFERENCE_TONE_CACHE.get(image)
|
||||
if (cached) return cached
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
||||
if (!ctx) return null
|
||||
|
||||
ctx.drawImage(image, 0, 0)
|
||||
const tone = measureCardPhotoTone(ctx.getImageData(0, 0, canvas.width, canvas.height).data)
|
||||
REFERENCE_TONE_CACHE.set(image, tone)
|
||||
|
||||
return tone
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the harmonized variant of `cutout` (same size, same alpha) or `null`
|
||||
* when the reference cannot be read. A photo already on the reference tone
|
||||
* returns the `cutout` itself: the identity band means "nothing to adjust".
|
||||
*/
|
||||
export const harmonizeCardCutout = async (
|
||||
cutout: HTMLCanvasElement,
|
||||
referenceSrc: string,
|
||||
): Promise<HTMLCanvasElement | null> => {
|
||||
try {
|
||||
const referenceTone = measureImageTone(await loadCardImage(referenceSrc))
|
||||
if (!referenceTone) return null
|
||||
|
||||
const ctx = cutout.getContext('2d')
|
||||
if (!ctx) return null
|
||||
|
||||
const source = ctx.getImageData(0, 0, cutout.width, cutout.height)
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
measureCardPhotoTone(source.data),
|
||||
referenceTone,
|
||||
)
|
||||
if (isCardHarmonyIdentity(adjustment)) return cutout
|
||||
|
||||
const harmonized = applyCardPhotoHarmony(source.data, adjustment)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cutout.width
|
||||
canvas.height = cutout.height
|
||||
const outputCtx = canvas.getContext('2d')
|
||||
if (!outputCtx) return null
|
||||
|
||||
const image = outputCtx.createImageData(cutout.width, cutout.height)
|
||||
image.data.set(harmonized)
|
||||
outputCtx.putImageData(image, 0, 0)
|
||||
|
||||
return canvas
|
||||
} catch {
|
||||
// Fail closed: the composer hides the control instead of showing a switch
|
||||
// with no effect. The pixel math is pure and covered by unit tests.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { removeCardPhotoBackground } from '@/components/cards/cardCutout'
|
||||
import { harmonizeCardCutout } from '@/components/cards/cardPhotoHarmonyCanvas'
|
||||
import type { CardRect } from '@/lib/cardModels'
|
||||
import { frameCardPhotoOnBbox, type CardPhotoTransform } from '@/lib/cardPhotoTransform'
|
||||
|
||||
|
|
@ -12,6 +13,12 @@ export type CardCutoutState =
|
|||
| {
|
||||
status: 'ready'
|
||||
canvas: HTMLCanvasElement
|
||||
/**
|
||||
* S17 — tone-harmonized variant of `canvas` (same size/alpha). Equals
|
||||
* `canvas` when the photo is already on tone; `null` when the reference
|
||||
* could not be read (the composer then hides the control).
|
||||
*/
|
||||
harmonized: HTMLCanvasElement | null
|
||||
width: number
|
||||
height: number
|
||||
/** Initial framing: cutout top on the slot top, centered, covering it. */
|
||||
|
|
@ -25,8 +32,11 @@ export type CardCutoutState =
|
|||
* so a stale inference or an unmount never writes state. The engine itself is a
|
||||
* page-lifetime singleton in the adapter (re-creating the wasm session per open
|
||||
* would cost seconds); each result's masks/bitmap are released there.
|
||||
*
|
||||
* S17 — the ready state also carries the harmonized variant, computed once
|
||||
* before the first ready paint (default ON must not flash the raw photo).
|
||||
*/
|
||||
export const useCardCutout = (photoWindow: CardRect | undefined) => {
|
||||
export const useCardCutout = (photoWindow: CardRect | undefined, harmonyReferenceSrc?: string) => {
|
||||
const [state, setState] = useState<CardCutoutState>({ status: 'idle' })
|
||||
const fileRef = useRef<File | null>(null)
|
||||
const runRef = useRef(0)
|
||||
|
|
@ -68,15 +78,21 @@ export const useCardCutout = (photoWindow: CardRect | undefined) => {
|
|||
return
|
||||
}
|
||||
|
||||
const harmonized = harmonyReferenceSrc
|
||||
? await harmonizeCardCutout(result.canvas, harmonyReferenceSrc)
|
||||
: null
|
||||
if (runRef.current !== run) return
|
||||
|
||||
setState({
|
||||
status: 'ready',
|
||||
canvas: result.canvas,
|
||||
harmonized,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
transform,
|
||||
})
|
||||
},
|
||||
[photoWindow],
|
||||
[photoWindow, harmonyReferenceSrc],
|
||||
)
|
||||
|
||||
const retry = useCallback(() => {
|
||||
|
|
|
|||
163
src/lib/cardPhotoHarmony.ts
Normal file
163
src/lib/cardPhotoHarmony.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* S17 — pure tone math for the `Time de você` card: brings the visitor's cutout
|
||||
* photo closer to the official candidate photos in brightness, contrast and
|
||||
* saturation. No DOM here (the browser adapter lives in
|
||||
* `components/cards/cardPhotoHarmonyCanvas`), no hue/white balance and no skin
|
||||
* retouch — only the three measured channels, clamped to a mild range so the
|
||||
* result never reads as a preset filter. The alpha channel is always copied
|
||||
* byte-for-byte: the cutout mask is untouchable.
|
||||
*/
|
||||
|
||||
import { CARD_CUTOUT_ALPHA_THRESHOLD } from './cardPhotoTransform'
|
||||
|
||||
export type CardPhotoTone = {
|
||||
/** Rec.709 luma mean (0..1) over the pixels above the alpha threshold. */
|
||||
meanLuma: number
|
||||
/** Population standard deviation of the luma (0..1). */
|
||||
sdLuma: number
|
||||
/** Mean HSV saturation `(max-min)/max` (0..1); 0 when a pixel is black. */
|
||||
meanSaturation: number
|
||||
/** Sampled pixel count; 0 when every pixel is below the alpha threshold. */
|
||||
samples: number
|
||||
}
|
||||
|
||||
export type CardHarmonyAdjustment = {
|
||||
brightness: number
|
||||
contrast: number
|
||||
saturation: number
|
||||
}
|
||||
|
||||
/** Fixed mild deltas (≤ 12%): the adjustment harmonizes, it does not filter. */
|
||||
export const CARD_HARMONY_LIMITS = {
|
||||
brightness: { min: 0.94, max: 1.08 },
|
||||
contrast: { min: 0.94, max: 1.08 },
|
||||
saturation: { min: 0.9, max: 1.12 },
|
||||
} as const
|
||||
|
||||
/** Inside this band the adjustment is a no-op: the photo is already on tone. */
|
||||
export const CARD_HARMONY_IDENTITY_EPSILON = 0.02
|
||||
|
||||
const IDENTITY_ADJUSTMENT: CardHarmonyAdjustment = Object.freeze({
|
||||
brightness: 1,
|
||||
contrast: 1,
|
||||
saturation: 1,
|
||||
})
|
||||
|
||||
const LUMA_RED = 0.2126
|
||||
const LUMA_GREEN = 0.7152
|
||||
const LUMA_BLUE = 0.0722
|
||||
|
||||
const MID_RANGE = 127.5
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
const lumaOf = (red: number, green: number, blue: number): number =>
|
||||
LUMA_RED * red + LUMA_GREEN * green + LUMA_BLUE * blue
|
||||
|
||||
/**
|
||||
* S17 — measures the tone of an RGBA buffer. Fully transparent pixels are not
|
||||
* evidence of tone, so they stay out of the sample (same alpha floor as the
|
||||
* cutout bbox): a blank segmentation measures `samples: 0` instead of black.
|
||||
*/
|
||||
export const measureCardPhotoTone = (data: Uint8ClampedArray): CardPhotoTone => {
|
||||
let samples = 0
|
||||
let lumaSum = 0
|
||||
let lumaSquaredSum = 0
|
||||
let saturationSum = 0
|
||||
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
if (data[index + 3] <= CARD_CUTOUT_ALPHA_THRESHOLD) continue
|
||||
|
||||
const red = data[index]
|
||||
const green = data[index + 1]
|
||||
const blue = data[index + 2]
|
||||
const luma = lumaOf(red, green, blue) / 255
|
||||
const max = Math.max(red, green, blue)
|
||||
|
||||
samples += 1
|
||||
lumaSum += luma
|
||||
lumaSquaredSum += luma * luma
|
||||
saturationSum += max === 0 ? 0 : (max - Math.min(red, green, blue)) / max
|
||||
}
|
||||
|
||||
if (samples === 0) return { meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 }
|
||||
|
||||
const meanLuma = lumaSum / samples
|
||||
return {
|
||||
meanLuma,
|
||||
sdLuma: Math.sqrt(Math.max(0, lumaSquaredSum / samples - meanLuma * meanLuma)),
|
||||
meanSaturation: saturationSum / samples,
|
||||
samples,
|
||||
}
|
||||
}
|
||||
|
||||
const factorFrom = (
|
||||
reference: number,
|
||||
source: number,
|
||||
limit: { min: number; max: number },
|
||||
): number => {
|
||||
if (!Number.isFinite(reference) || !Number.isFinite(source) || source <= 0) return 1
|
||||
|
||||
const factor = reference / source
|
||||
return Number.isFinite(factor) ? clamp(factor, limit.min, limit.max) : 1
|
||||
}
|
||||
|
||||
/**
|
||||
* S17 — the three factors that bring `source` toward `reference`, each clamped.
|
||||
* A degenerate source stat (flat tone, zero saturation, empty sample) keeps its
|
||||
* factor at 1 instead of amplifying noise; an empty reference is identity.
|
||||
*/
|
||||
export const computeCardHarmonyAdjustment = (
|
||||
source: CardPhotoTone,
|
||||
reference: CardPhotoTone,
|
||||
): CardHarmonyAdjustment => {
|
||||
if (source.samples === 0 || reference.samples === 0) return IDENTITY_ADJUSTMENT
|
||||
|
||||
return {
|
||||
brightness: factorFrom(reference.meanLuma, source.meanLuma, CARD_HARMONY_LIMITS.brightness),
|
||||
contrast: factorFrom(reference.sdLuma, source.sdLuma, CARD_HARMONY_LIMITS.contrast),
|
||||
saturation: factorFrom(
|
||||
reference.meanSaturation,
|
||||
source.meanSaturation,
|
||||
CARD_HARMONY_LIMITS.saturation,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const isCardHarmonyIdentity = (adjustment: CardHarmonyAdjustment): boolean =>
|
||||
Math.abs(adjustment.brightness - 1) <= CARD_HARMONY_IDENTITY_EPSILON &&
|
||||
Math.abs(adjustment.contrast - 1) <= CARD_HARMONY_IDENTITY_EPSILON &&
|
||||
Math.abs(adjustment.saturation - 1) <= CARD_HARMONY_IDENTITY_EPSILON
|
||||
|
||||
/**
|
||||
* S17 — applies the adjustment in one pass, in a fixed order (brightness →
|
||||
* contrast → saturation) and with the pixel luma recomputed after the first two
|
||||
* so saturation pivots on the corrected color. Returns a new buffer; `alpha`
|
||||
* bytes are copied untouched.
|
||||
*/
|
||||
export const applyCardPhotoHarmony = (
|
||||
data: Uint8ClampedArray,
|
||||
adjustment: CardHarmonyAdjustment,
|
||||
): Uint8ClampedArray => {
|
||||
const output = new Uint8ClampedArray(data.length)
|
||||
const { brightness, contrast, saturation } = adjustment
|
||||
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
output[index + 3] = data[index + 3]
|
||||
|
||||
const brightRed = data[index] * brightness
|
||||
const brightGreen = data[index + 1] * brightness
|
||||
const brightBlue = data[index + 2] * brightness
|
||||
const correctedRed = (brightRed - MID_RANGE) * contrast + MID_RANGE
|
||||
const correctedGreen = (brightGreen - MID_RANGE) * contrast + MID_RANGE
|
||||
const correctedBlue = (brightBlue - MID_RANGE) * contrast + MID_RANGE
|
||||
const luma = lumaOf(correctedRed, correctedGreen, correctedBlue)
|
||||
|
||||
output[index] = Math.round(luma + (correctedRed - luma) * saturation)
|
||||
output[index + 1] = Math.round(luma + (correctedGreen - luma) * saturation)
|
||||
output[index + 2] = Math.round(luma + (correctedBlue - luma) * saturation)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
|
@ -20,6 +20,12 @@ export const CARD_PHOTO_MAX_ZOOM = 4
|
|||
*/
|
||||
const CARD_CUTOUT_MIN_BBOX = 24
|
||||
|
||||
/**
|
||||
* Alpha noise floor of a cutout (matches the mask's soft edge): the bbox read
|
||||
* ignores it and the S17 tone sample ignores it too, so both see the same photo.
|
||||
*/
|
||||
export const CARD_CUTOUT_ALPHA_THRESHOLD = 32
|
||||
|
||||
export type CardPhotoSize = {
|
||||
width: number
|
||||
height: number
|
||||
|
|
|
|||
|
|
@ -1787,6 +1787,8 @@ test.describe('Cards personalizados (S14)', () => {
|
|||
const canvas = dialog.locator('canvas')
|
||||
await expect(canvas).toHaveAttribute('width', '1000')
|
||||
await expect(canvas).toHaveAttribute('height', '1000')
|
||||
// S17 — the harmony control is exclusive of the team model.
|
||||
await expect(dialog.getByRole('switch')).toHaveCount(0)
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'foto-e2e.png',
|
||||
|
|
@ -1915,6 +1917,53 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
|
|||
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('harmonizes the cutout by default and toggles back to the original photo', 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()
|
||||
|
||||
const toggle = dialog.getByRole('switch', { name: 'Harmonizar cores' })
|
||||
await expect(toggle).toBeVisible()
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'Ajusta o brilho e as cores da sua foto para combinar com as fotos do card.',
|
||||
),
|
||||
).toBeVisible()
|
||||
const box = await toggle.boundingBox()
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44)
|
||||
|
||||
const canvas = dialog.locator('canvas')
|
||||
const readSample = () =>
|
||||
canvas.evaluate((element) => {
|
||||
const node = element as HTMLCanvasElement
|
||||
return [...node.getContext('2d')!.getImageData(582, 727, 1, 1).data]
|
||||
})
|
||||
|
||||
// Default ON: the preview pixel is the harmonized variant, not the raw photo.
|
||||
await expect.poll(readSample).not.toEqual([30, 120, 200, 255])
|
||||
const harmonized = await readSample()
|
||||
|
||||
await toggle.click()
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'false')
|
||||
await expect.poll(readSample).toEqual([30, 120, 200, 255])
|
||||
|
||||
// Re-enabling with the keyboard proves the native switch semantics.
|
||||
await toggle.focus()
|
||||
await page.keyboard.press('Space')
|
||||
await expect(toggle).toHaveAttribute('aria-checked', 'true')
|
||||
await expect.poll(readSample).toEqual(harmonized)
|
||||
await expect(
|
||||
dialog.getByText('Sua foto fica neste aparelho e não é enviada para nós.'),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('a name that cannot fit fails closed and keeps the cutout', async ({ page }) => {
|
||||
await setCutoutStub(page, 'ok')
|
||||
await page.goto('/cards?model=time-de-voce')
|
||||
|
|
@ -1925,6 +1974,8 @@ test.describe('Cards personalizados (S15 — Time de você)', () => {
|
|||
|
||||
await expect(dialog.getByRole('heading', { name: 'Encurte o nome' })).toBeVisible()
|
||||
await expect(dialog.getByRole('alert')).toContainText('nome mais curto')
|
||||
// S17 — the harmony control lives in the regular ready state only.
|
||||
await expect(dialog.getByRole('switch')).toHaveCount(0)
|
||||
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
|
||||
await expect(primary).toBeDisabled()
|
||||
await expect(dialog.getByText('permanecem salvos neste aparelho')).toBeVisible()
|
||||
|
|
|
|||
255
tests/unit/cardPhotoHarmony.unit.spec.ts
Normal file
255
tests/unit/cardPhotoHarmony.unit.spec.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CARD_HARMONY_IDENTITY_EPSILON,
|
||||
CARD_HARMONY_LIMITS,
|
||||
applyCardPhotoHarmony,
|
||||
computeCardHarmonyAdjustment,
|
||||
isCardHarmonyIdentity,
|
||||
measureCardPhotoTone,
|
||||
type CardHarmonyAdjustment,
|
||||
type CardPhotoTone,
|
||||
} from '@/lib/cardPhotoHarmony'
|
||||
import { CARD_CUTOUT_ALPHA_THRESHOLD } from '@/lib/cardPhotoTransform'
|
||||
|
||||
type Rgba = readonly [number, number, number, number?]
|
||||
|
||||
const pixelsOf = (colors: ReadonlyArray<Rgba>) =>
|
||||
new Uint8ClampedArray(
|
||||
colors.flatMap(([red, green, blue, alpha = 255]) => [red, green, blue, alpha]),
|
||||
)
|
||||
|
||||
const toneOf = (overrides: Partial<CardPhotoTone> = {}): CardPhotoTone => ({
|
||||
meanLuma: 0.5,
|
||||
sdLuma: 0.2,
|
||||
meanSaturation: 0.4,
|
||||
samples: 1,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const IDENTITY: CardHarmonyAdjustment = { brightness: 1, contrast: 1, saturation: 1 }
|
||||
|
||||
describe('measureCardPhotoTone', () => {
|
||||
it('measures Rec.709 luma and HSV saturation of an opaque color', () => {
|
||||
const tone = measureCardPhotoTone(pixelsOf([[30, 120, 200]]))
|
||||
|
||||
expect(tone.samples).toBe(1)
|
||||
expect(tone.meanLuma).toBeCloseTo(0.4182, 4)
|
||||
expect(tone.sdLuma).toBe(0)
|
||||
expect(tone.meanSaturation).toBeCloseTo(0.85, 5)
|
||||
})
|
||||
|
||||
it('ignores pixels at or below the alpha threshold (matches the cutout bbox)', () => {
|
||||
const tone = measureCardPhotoTone(
|
||||
pixelsOf([
|
||||
[255, 255, 255, 0],
|
||||
[255, 255, 255, CARD_CUTOUT_ALPHA_THRESHOLD],
|
||||
[0, 0, 0, CARD_CUTOUT_ALPHA_THRESHOLD + 1],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(tone.samples).toBe(1)
|
||||
expect(tone.meanLuma).toBe(0)
|
||||
})
|
||||
|
||||
it('reports an empty sample instead of black when nothing is opaque', () => {
|
||||
const tone = measureCardPhotoTone(pixelsOf([[10, 20, 30, 0]]))
|
||||
|
||||
expect(tone).toEqual({ meanLuma: 0, sdLuma: 0, meanSaturation: 0, samples: 0 })
|
||||
})
|
||||
|
||||
it('measures the population spread of a two-tone sample', () => {
|
||||
const tone = measureCardPhotoTone(
|
||||
pixelsOf([
|
||||
[0, 0, 0],
|
||||
[255, 255, 255],
|
||||
]),
|
||||
)
|
||||
|
||||
expect(tone.samples).toBe(2)
|
||||
expect(tone.meanLuma).toBeCloseTo(0.5, 5)
|
||||
expect(tone.sdLuma).toBeCloseTo(0.5, 5)
|
||||
expect(tone.meanSaturation).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCardHarmonyAdjustment', () => {
|
||||
it('is identity when the photo already matches the reference tone', () => {
|
||||
const tone = toneOf()
|
||||
const adjustment = computeCardHarmonyAdjustment(tone, tone)
|
||||
|
||||
expect(adjustment).toEqual(IDENTITY)
|
||||
expect(isCardHarmonyIdentity(adjustment)).toBe(true)
|
||||
})
|
||||
|
||||
it('raises brightness toward a brighter reference, capped by the fixed limit', () => {
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
toneOf({ meanLuma: 0.4 }),
|
||||
toneOf({ meanLuma: 0.8 }),
|
||||
)
|
||||
|
||||
expect(adjustment.brightness).toBe(CARD_HARMONY_LIMITS.brightness.max)
|
||||
expect(adjustment.contrast).toBe(1)
|
||||
expect(adjustment.saturation).toBe(1)
|
||||
})
|
||||
|
||||
it('moves saturation toward a desaturated reference, floored by the fixed limit', () => {
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
toneOf({ meanSaturation: 0.5 }),
|
||||
toneOf({ meanSaturation: 0.1 }),
|
||||
)
|
||||
|
||||
expect(adjustment.saturation).toBe(CARD_HARMONY_LIMITS.saturation.min)
|
||||
})
|
||||
|
||||
it('moves contrast toward a flatter reference, floored by the fixed limit', () => {
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
toneOf({ sdLuma: 0.3 }),
|
||||
toneOf({ sdLuma: 0.1 }),
|
||||
)
|
||||
|
||||
expect(adjustment.contrast).toBe(CARD_HARMONY_LIMITS.contrast.min)
|
||||
})
|
||||
|
||||
it('keeps the factor at 1 for a degenerate source stat instead of amplifying noise', () => {
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
toneOf({ sdLuma: 0, meanSaturation: 0 }),
|
||||
toneOf({ sdLuma: 0.3, meanSaturation: 0.5 }),
|
||||
)
|
||||
|
||||
expect(adjustment.contrast).toBe(1)
|
||||
expect(adjustment.saturation).toBe(1)
|
||||
})
|
||||
|
||||
it('is identity when either sample is empty', () => {
|
||||
expect(computeCardHarmonyAdjustment(toneOf({ samples: 0 }), toneOf())).toEqual(IDENTITY)
|
||||
expect(computeCardHarmonyAdjustment(toneOf(), toneOf({ samples: 0 }))).toEqual(IDENTITY)
|
||||
})
|
||||
|
||||
it('falls back to 1 for non-finite stats', () => {
|
||||
const adjustment = computeCardHarmonyAdjustment(
|
||||
toneOf({ meanLuma: Number.NaN }),
|
||||
toneOf({ meanLuma: Number.NaN, sdLuma: Number.POSITIVE_INFINITY }),
|
||||
)
|
||||
|
||||
expect(adjustment).toEqual(IDENTITY)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCardHarmonyIdentity', () => {
|
||||
it('accepts the epsilon band on both sides', () => {
|
||||
const inside = CARD_HARMONY_IDENTITY_EPSILON - 0.001
|
||||
expect(
|
||||
isCardHarmonyIdentity({
|
||||
brightness: 1 + inside,
|
||||
contrast: 1 - inside,
|
||||
saturation: 1,
|
||||
}),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects factors outside the epsilon band', () => {
|
||||
const outside = CARD_HARMONY_IDENTITY_EPSILON + 0.001
|
||||
expect(isCardHarmonyIdentity({ ...IDENTITY, brightness: 1 + outside })).toBe(false)
|
||||
expect(isCardHarmonyIdentity({ ...IDENTITY, saturation: 1 - outside })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyCardPhotoHarmony', () => {
|
||||
it('copies alpha byte-for-byte and never mutates the input buffer', () => {
|
||||
const source = pixelsOf([
|
||||
[10, 20, 30, 7],
|
||||
[40, 50, 60, 200],
|
||||
])
|
||||
const before = Uint8ClampedArray.from(source)
|
||||
|
||||
const result = applyCardPhotoHarmony(source, {
|
||||
brightness: 1.08,
|
||||
contrast: 1,
|
||||
saturation: 1.1,
|
||||
})
|
||||
|
||||
expect(source).toEqual(before)
|
||||
expect(result).not.toBe(source)
|
||||
expect(result[3]).toBe(7)
|
||||
expect(result[7]).toBe(200)
|
||||
})
|
||||
|
||||
it('returns the same color for an identity adjustment', () => {
|
||||
const source = pixelsOf([
|
||||
[11, 22, 33],
|
||||
[200, 100, 50],
|
||||
])
|
||||
|
||||
expect([...applyCardPhotoHarmony(source, IDENTITY)]).toEqual([...source])
|
||||
})
|
||||
|
||||
it('multiplies every channel by the brightness factor', () => {
|
||||
const result = applyCardPhotoHarmony(pixelsOf([[100, 100, 100]]), {
|
||||
...IDENTITY,
|
||||
brightness: 1.08,
|
||||
})
|
||||
|
||||
expect([...result]).toEqual([108, 108, 108, 255])
|
||||
})
|
||||
|
||||
it('pushes values away from the mid range with the contrast factor', () => {
|
||||
const result = applyCardPhotoHarmony(
|
||||
pixelsOf([
|
||||
[0, 0, 0],
|
||||
[255, 255, 255],
|
||||
]),
|
||||
{ ...IDENTITY, contrast: 1.08 },
|
||||
)
|
||||
|
||||
expect([...result]).toEqual([0, 0, 0, 255, 255, 255, 255, 255])
|
||||
})
|
||||
|
||||
it('collapses color into luma when saturation goes to zero', () => {
|
||||
const result = applyCardPhotoHarmony(pixelsOf([[200, 100, 50]]), {
|
||||
...IDENTITY,
|
||||
saturation: 0,
|
||||
})
|
||||
|
||||
expect([...result]).toEqual([118, 118, 118, 255])
|
||||
})
|
||||
|
||||
it('spreads the channels apart when saturation goes up', () => {
|
||||
const source = pixelsOf([[200, 100, 50]])
|
||||
const saturated = applyCardPhotoHarmony(source, { ...IDENTITY, saturation: 1.2 })
|
||||
|
||||
expect(saturated[0] - saturated[2]).toBeGreaterThan(source[0] - source[2])
|
||||
expect(saturated[1]).toBeLessThan(source[1])
|
||||
})
|
||||
|
||||
it('keeps a neutral gray neutral whatever the saturation', () => {
|
||||
const result = applyCardPhotoHarmony(pixelsOf([[128, 128, 128]]), {
|
||||
...IDENTITY,
|
||||
saturation: 1.12,
|
||||
})
|
||||
|
||||
expect(result[0]).toBe(result[1])
|
||||
expect(result[1]).toBe(result[2])
|
||||
})
|
||||
|
||||
it('clamps at both ends without wrapping', () => {
|
||||
const result = applyCardPhotoHarmony(
|
||||
pixelsOf([
|
||||
[250, 250, 250],
|
||||
[2, 2, 2],
|
||||
]),
|
||||
{ ...IDENTITY, brightness: 1.08, contrast: 1.08 },
|
||||
)
|
||||
|
||||
expect([...result].slice(0, 7)).toEqual([255, 255, 255, 255, 0, 0, 0])
|
||||
})
|
||||
|
||||
it('is deterministic across runs', () => {
|
||||
const source = pixelsOf([[123, 45, 210, 128]])
|
||||
const adjustment = { brightness: 1.04, contrast: 0.96, saturation: 1.08 }
|
||||
|
||||
expect([...applyCardPhotoHarmony(source, adjustment)]).toEqual([
|
||||
...applyCardPhotoHarmony(source, adjustment),
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue