From 3c8a1f87b491dae7b7bf8e94a40296c2db53c0ff Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sat, 19 Sep 2026 10:30:55 -0300 Subject: [PATCH] =?UTF-8?q?feat(S17):=20controle=20"Harmonizar=20cores"=20?= =?UTF-8?q?no=20ready=20do=20card=20Time=20de=20voc=C3=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .agents/rules/codebase-map.mdc | 4 +- ...-s17-card-time-de-voce-harmonizar-cores.md | 1 + ...ards-time-de-voce-harmonizar-cores-impl.md | 203 ++++++++++++++ ...me-de-voce-harmonizar-cores-ui-design.html | 20 +- src/components/cards/CardComposer.tsx | 65 ++++- src/components/cards/cardCopy.ts | 8 + src/components/cards/cardCutout.ts | 4 +- .../cards/cardPhotoHarmonyCanvas.ts | 78 ++++++ src/components/cards/useCardCutout.ts | 20 +- src/lib/cardPhotoHarmony.ts | 163 +++++++++++ src/lib/cardPhotoTransform.ts | 6 + tests/e2e/frontend.e2e.spec.ts | 51 ++++ tests/unit/cardPhotoHarmony.unit.spec.ts | 255 ++++++++++++++++++ 13 files changed, 850 insertions(+), 28 deletions(-) create mode 100644 docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md create mode 100644 docs/plans/cards-time-de-voce-harmonizar-cores-impl.md create mode 100644 src/components/cards/cardPhotoHarmonyCanvas.ts create mode 100644 src/lib/cardPhotoHarmony.ts create mode 100644 tests/unit/cardPhotoHarmony.unit.spec.ts diff --git a/.agents/rules/codebase-map.mdc b/.agents/rules/codebase-map.mdc index cb4e82a3..2fbc4007 100644 --- a/.agents/rules/codebase-map.mdc +++ b/.agents/rules/codebase-map.mdc @@ -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//` — 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//` — 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`). diff --git a/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md b/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md new file mode 100644 index 00000000..c91fdd99 --- /dev/null +++ b/docs/changelog/2026-09-19-s17-card-time-de-voce-harmonizar-cores.md @@ -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. diff --git a/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md b/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md new file mode 100644 index 00000000..88d8557c --- /dev/null +++ b/docs/plans/cards-time-de-voce-harmonizar-cores-impl.md @@ -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 ` + ) +} + type CardComposerProps = { model: CardModel shell: 'dialog' | 'drawer' @@ -121,6 +165,8 @@ export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposer const [previewImage, setPreviewImage] = useState(null) const [downloadError, setDownloadError] = useState(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(null) const fileInputRef = useRef(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 ? ( + setHarmonyEnabled((enabled) => !enabled)} + /> + ) : null} diff --git a/src/components/cards/cardCopy.ts b/src/components/cards/cardCopy.ts index 419880b3..f71cef9b 100644 --- a/src/components/cards/cardCopy.ts +++ b/src/components/cards/cardCopy.ts @@ -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). diff --git a/src/components/cards/cardCutout.ts b/src/components/cards/cardCutout.ts index 44ed988c..cfa5d842 100644 --- a/src/components/cards/cardCutout.ts +++ b/src/components/cards/cardCutout.ts @@ -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 diff --git a/src/components/cards/cardPhotoHarmonyCanvas.ts b/src/components/cards/cardPhotoHarmonyCanvas.ts new file mode 100644 index 00000000..2eac4f1b --- /dev/null +++ b/src/components/cards/cardPhotoHarmonyCanvas.ts @@ -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() + +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 => { + 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 + } +} diff --git a/src/components/cards/useCardCutout.ts b/src/components/cards/useCardCutout.ts index f0b52352..46a4e430 100644 --- a/src/components/cards/useCardCutout.ts +++ b/src/components/cards/useCardCutout.ts @@ -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({ status: 'idle' }) const fileRef = useRef(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(() => { diff --git a/src/lib/cardPhotoHarmony.ts b/src/lib/cardPhotoHarmony.ts new file mode 100644 index 00000000..0e1c8ef0 --- /dev/null +++ b/src/lib/cardPhotoHarmony.ts @@ -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 +} diff --git a/src/lib/cardPhotoTransform.ts b/src/lib/cardPhotoTransform.ts index 3e0c7ebd..7c9c50f8 100644 --- a/src/lib/cardPhotoTransform.ts +++ b/src/lib/cardPhotoTransform.ts @@ -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 diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 62c389a1..710dc122 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -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() diff --git a/tests/unit/cardPhotoHarmony.unit.spec.ts b/tests/unit/cardPhotoHarmony.unit.spec.ts new file mode 100644 index 00000000..9413d521 --- /dev/null +++ b/tests/unit/cardPhotoHarmony.unit.spec.ts @@ -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) => + new Uint8ClampedArray( + colors.flatMap(([red, green, blue, alpha = 255]) => [red, green, blue, alpha]), + ) + +const toneOf = (overrides: Partial = {}): 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), + ]) + }) +})