diff --git a/.gitignore b/.gitignore index 5e9c7c1b..26cc0e7a 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ hugo/static-qa/ !hugo/static/images/devtoberfest/arcade/**/*.png !hugo/static/images/devtoberfest/selfie/thumbnails/*.png !hugo/static/images/devtoberfest/selfie/stickers/*.png +!hugo/static/images/devtoberfest/selfie/backgrounds/*.png hugo/static/js/* !hugo/static/js/joule.js !hugo/static/js/joule-render.js diff --git a/docs/superpowers/plans/2026-08-08-selfie-generative-bg.md b/docs/superpowers/plans/2026-08-08-selfie-generative-bg.md new file mode 100644 index 00000000..3631cec0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-selfie-generative-bg.md @@ -0,0 +1,1092 @@ +# Selfie Generative Background + Cartoonify Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two client-side "generative" capabilities to the Devtoberfest selfie composer — a themed background swap (curated vector scenes behind the cut-out person) and a cartoonify style-transfer effect (AnimeGANv2 ONNX run in-browser) — baked into the exported PNG, with nothing ever leaving the browser. + +**Architecture:** Two independent, bounded units reusing existing seams. The themed background is a new bottom-most Konva layer (`bgLayer`) added in `buildStage`, driven by a `setBackground` stage method and a `BackgroundPicker.vue` mirroring the sticker/effect pickers. Cartoonify is a new async effect: `EffectId` gains `'cartoon'`, a new `applyEffectAsync` awaits an ONNX pass for cartoon and delegates all six existing CSS presets to the unchanged synchronous `applyEffect`; `exportPng` awaits `applyEffectAsync` so the bake order (effect before border) is preserved. Both fail soft to the un-modified composite. + +**Tech Stack:** Vue 3 SFCs (` + +``` + +- [ ] **Step 4: Add the picker styles** + +Append to `hugo-apps/src/selfie/styles.css`: + +```css +/* ---- Background scene picker (#1520) ---- */ +.selfie-bg-controls { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .35rem; } +.selfie-bg-btn { background: var(--sapButton_Background, #fff); color: var(--sapButton_TextColor, #0070f2); border: 1px solid var(--sapButton_BorderColor, #0070f2); } +.selfie-bg-btn.is-active { background: var(--sapButton_Emphasized_Background, #0070f2); color: #fff; } +.selfie-bg-thumb { cursor: pointer; padding: 0; width: 48px; height: 48px; border: 2px solid transparent; border-radius: 8px; overflow: hidden; background: var(--sapTile_Background, #fff); } +.selfie-bg-thumb.is-active { border-color: var(--sapButton_Emphasized_Background, #0070f2); } +.selfie-bg-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/BackgroundPicker.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add hugo-apps/src/selfie/BackgroundPicker.vue hugo-apps/src/selfie/__tests__/BackgroundPicker.test.ts hugo-apps/src/selfie/styles.css +git commit -m "feat(#1520): BackgroundPicker.vue scene picker" +``` + +--- + +## Task 5: Vendor the AnimeGAN model + add onnxruntime-web (license gate) + +**Files:** +- Create: `scripts/vendor-animegan.mjs` +- Modify: `hugo-apps/package.json` (add `onnxruntime-web` dep + `vendor:animegan` script; chain into `vendor`/`setup`) +- Create (build output, committed after license clears): `hugo/static/vendor/animegan/model.onnx` + `.animegan-vendored-version` sentinel + +**Interfaces:** +- Consumes: nothing at code level. +- Produces: model served at `/vendor/animegan/model.onnx`; `onnxruntime-web` installed for Task 6. + +**⚠️ LICENSE GATE — this task blocks committing the binary until the license is verified.** + +- [ ] **Step 1: Verify the model license (BLOCKING)** + +Research the AnimeGANv2 `face_paint_512_v2` ONNX export and confirm a permissive license (MIT / Apache-2.0 / CC-BY family). The commonly-used `bryandlee/animegan2-pytorch` weights are **MIT**; several ONNX re-exports carry it forward. Record the exact source URL, the license name, and the SHA-256 of the downloaded file. + +**Decision point:** +- License clears (MIT/Apache/CC) → proceed to Step 2 with that URL. +- License does NOT clear → do NOT commit a binary. Substitute another small (<25 MB), 512×512-ish, face-oriented style-transfer ONNX with a verified permissive license. If none is found, STOP and report BLOCKED: the background-swap half (Tasks 1-4) ships alone and cartoonify (Tasks 6-7) becomes a documented follow-up. Do not proceed to Task 6 without a licensed model on disk. + +- [ ] **Step 2: Add onnxruntime-web + the vendor script to package.json** + +In `hugo-apps/package.json`, add to `dependencies` (pin a current 1.x): `"onnxruntime-web": "^1.20.0"`. Add to `scripts`: `"vendor:animegan": "node ../scripts/vendor-animegan.mjs"`. Chain it wherever `vendor:imgly` is invoked (the `setup`/`vendor` script) so a fresh worktree fetches both. + +- [ ] **Step 3: Write the vendor script** + +Create `scripts/vendor-animegan.mjs` following the `scripts/vendor-imgly.cjs` discipline (header documenting source URL + license + SHA; idempotent via a `.animegan-vendored-version` sentinel; wipe-and-refetch on version drift; no CDN reference left in runtime code). Concretely: + +```js +// Vendors the AnimeGANv2 face_paint_512_v2 ONNX model for self-hosting (#1520). +// +// Source: +// License: (verified permissive before commit) +// SHA-256: +// +// Runtime never fetches from a CDN — the model is served same-origin at +// /vendor/animegan/model.onnx (approuter CSP), exactly like the imgly assets. +// +// Idempotency: a .animegan-vendored-version sentinel records the model version; +// a version change (or a missing sentinel) wipes and re-fetches. + +import { createWriteStream } from 'fs' +import { mkdir, readFile, writeFile, rm } from 'fs/promises' +import { createHash } from 'crypto' +import path from 'path' + +const MODEL_URL = '' +const EXPECTED_SHA256 = '' +const VERSION = 'face_paint_512_v2' +const DEST_DIR = path.resolve(process.cwd(), 'hugo/static/vendor/animegan') +const MODEL_PATH = path.join(DEST_DIR, 'model.onnx') +const SENTINEL = path.join(DEST_DIR, '.animegan-vendored-version') + +async function sentinelMatches() { + try { return (await readFile(SENTINEL, 'utf8')).trim() === VERSION } catch { return false } +} + +async function main() { + if (await sentinelMatches()) { console.log('animegan: up to date'); return } + await rm(DEST_DIR, { recursive: true, force: true }) + await mkdir(DEST_DIR, { recursive: true }) + const res = await fetch(MODEL_URL) + if (!res.ok) throw new Error(`animegan fetch failed: ${res.status}`) + const buf = Buffer.from(await res.arrayBuffer()) + const sha = createHash('sha256').update(buf).digest('hex') + if (EXPECTED_SHA256 && sha !== EXPECTED_SHA256) { + throw new Error(`animegan SHA mismatch: got ${sha}`) + } + await writeFile(MODEL_PATH, buf) + await writeFile(SENTINEL, VERSION + '\n') + console.log(`animegan: vendored model.onnx (${(buf.length / 1e6).toFixed(1)} MB)`) +} +main().catch((e) => { console.error(e); process.exit(1) }) +``` + +Replace the three `` placeholders with the real values from Step 1. + +- [ ] **Step 4: Install + run the vendor script** + +Run (from the repo root): +```bash +cd hugo-apps && npm install && cd .. +node scripts/vendor-animegan.mjs +ls -l hugo/static/vendor/animegan/ +``` +Expected: `model.onnx` present (single-digit-to-~25 MB) + `.animegan-vendored-version`. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/vendor-animegan.mjs hugo-apps/package.json hugo-apps/package-lock.json hugo/static/vendor/animegan/ +git commit -m "feat(#1520): vendor AnimeGANv2 model + onnxruntime-web (license verified)" +``` + +--- + +## Task 6: `stylize.ts` cartoonify + async effect path + +**Files:** +- Create: `hugo-apps/src/selfie/stylize.ts` +- Modify: `hugo-apps/src/selfie/effects.ts` (add `'cartoon'` to `EffectId` line 6 + `EFFECT_IDS` line 118; add `cartoon` table entry; add `applyEffectAsync`) +- Test: `hugo-apps/src/selfie/__tests__/stylize.test.ts` +- Modify test: `hugo-apps/src/selfie/__tests__/effects.test.ts` + +**Interfaces:** +- Consumes: `applyEffect` (existing sync dispatcher). +- Produces: + - `stylize.ts`: `export async function cartoonify(canvas: HTMLCanvasElement): Promise` — returns a stylized canvas, or the **input canvas unchanged** on any failure. + - `effects.ts`: `EffectId` includes `'cartoon'`; `EFFECT_IDS` ends with `'cartoon'`; `export async function applyEffectAsync(canvas: HTMLCanvasElement, id: EffectId): Promise`. + +- [ ] **Step 1: Write the failing stylize test** + +Create `hugo-apps/src/selfie/__tests__/stylize.test.ts`: + +```ts +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock onnxruntime-web so no real WASM/model loads in unit tests. +const runMock = vi.fn() +const createMock = vi.fn(async () => ({ run: runMock })) +vi.mock('onnxruntime-web', () => ({ + InferenceSession: { create: createMock }, + Tensor: class { constructor(public type: string, public data: unknown, public dims: number[]) {} }, + env: { wasm: {} }, +})) + +import { cartoonify } from '../stylize' + +// A canvas stub whose 2D context yields predictable pixel data. +function fakeCanvas(w = 256, h = 256): HTMLCanvasElement { + const ctx = { + drawImage: vi.fn(), + getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(512 * 512 * 4).fill(128), width: 512, height: 512 })), + putImageData: vi.fn(), + } + return { width: w, height: h, getContext: vi.fn(() => ctx) } as unknown as HTMLCanvasElement +} + +beforeEach(() => { runMock.mockReset(); createMock.mockClear() }) + +describe('cartoonify fail-soft', () => { + it('returns the INPUT canvas unchanged when session creation throws', async () => { + createMock.mockRejectedValueOnce(new Error('no wasm')) + const c = fakeCanvas() + expect(await cartoonify(c)).toBe(c) + }) + + it('returns the INPUT canvas unchanged when inference throws', async () => { + runMock.mockRejectedValueOnce(new Error('run failed')) + const c = fakeCanvas() + expect(await cartoonify(c)).toBe(c) + }) + + it('returns the INPUT canvas unchanged when the 2D context is null', async () => { + const c = { width: 256, height: 256, getContext: vi.fn(() => null) } as unknown as HTMLCanvasElement + expect(await cartoonify(c)).toBe(c) + }) +}) +``` + +Note: the success-path pixel round-trip is hard to assert meaningfully against a mock without pinning the exact tensor layout; the fail-soft paths are the behavioral contract that matters and are fully covered here. If the implementer wants a success assertion, have `runMock` resolve `{ output: new Tensor('float32', new Float32Array(3*512*512).fill(0), [1,3,512,512]) }` and assert the return is a NEW canvas (not `c`) — but keep it only if the tensor key/layout in the implementation is stable. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/stylize.test.ts` +Expected: FAIL — cannot resolve `../stylize`. + +- [ ] **Step 3: Implement stylize.ts** + +Create `hugo-apps/src/selfie/stylize.ts`: + +```ts +// Cartoonify style transfer for the selfie composer (#1520). Runs the +// self-hosted AnimeGANv2 face_paint_512_v2 ONNX model in-browser via +// onnxruntime-web — the same in-browser posture as imgly background removal. +// Lazy-imported so neither the runtime nor the model touch the page-load path. +// Fail-soft: ANY failure returns the input canvas unchanged. + +const MODEL_URL = '/vendor/animegan/model.onnx' +const SIZE = 512 // model's fixed square I/O + +let sessionPromise: Promise | null = null + +async function getSession(ort: typeof import('onnxruntime-web')) { + if (!sessionPromise) { + // Self-hosted WASM binaries live alongside the app bundle (no CDN). + ort.env.wasm.wasmPaths = '/vendor/onnxruntime/' + sessionPromise = ort.InferenceSession.create(MODEL_URL, { + executionProviders: ['webgpu', 'wasm'], + }) + } + return sessionPromise +} + +export async function cartoonify(canvas: HTMLCanvasElement): Promise { + try { + const ort = await import('onnxruntime-web') + const session = (await getSession(ort)) as import('onnxruntime-web').InferenceSession + + // Downscale the composite into a SIZE×SIZE working canvas. + const work = document.createElement('canvas') + work.width = SIZE; work.height = SIZE + const wctx = work.getContext('2d') + if (!wctx) return canvas + wctx.drawImage(canvas, 0, 0, SIZE, SIZE) + const { data } = wctx.getImageData(0, 0, SIZE, SIZE) + + // RGBA uint8 → planar RGB float32 in [-1,1], NCHW. + const chw = new Float32Array(3 * SIZE * SIZE) + const plane = SIZE * SIZE + for (let i = 0; i < plane; i++) { + chw[i] = data[i * 4] / 127.5 - 1 + chw[plane + i] = data[i * 4 + 1] / 127.5 - 1 + chw[2 * plane + i] = data[i * 4 + 2] / 127.5 - 1 + } + const input = new ort.Tensor('float32', chw, [1, 3, SIZE, SIZE]) + const feeds: Record = { [session.inputNames[0]]: input } + const out = await session.run(feeds as never) + const outTensor = out[session.outputNames[0]] as import('onnxruntime-web').Tensor + const od = outTensor.data as Float32Array + + // Planar RGB float32 [-1,1] → RGBA uint8. + const rgba = new Uint8ClampedArray(plane * 4) + for (let i = 0; i < plane; i++) { + rgba[i * 4] = (od[i] + 1) * 127.5 + rgba[i * 4 + 1] = (od[plane + i] + 1) * 127.5 + rgba[i * 4 + 2] = (od[2 * plane + i] + 1) * 127.5 + rgba[i * 4 + 3] = 255 + } + wctx.putImageData(new ImageData(rgba, SIZE, SIZE), 0, 0) + + // Upscale the stylized result back onto a canvas of the input's dimensions. + const outCanvas = document.createElement('canvas') + outCanvas.width = canvas.width; outCanvas.height = canvas.height + const octx = outCanvas.getContext('2d') + if (!octx) return canvas + octx.drawImage(work, 0, 0, canvas.width, canvas.height) + return outCanvas + } catch (e) { + console.warn('[selfie] cartoonify failed; exporting without it', e) + return canvas + } +} +``` + +Note: `ort.env.wasm.wasmPaths` points at `/vendor/onnxruntime/`. If Task 5 vendored the ORT WASM binaries elsewhere, set this to the actual served path. If ORT's default packaged WASM works under the approuter CSP without a custom path, delete that line — but verify no CDN fetch happens (Network tab shows same-origin only). + +- [ ] **Step 4: Run stylize test to verify it passes** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/stylize.test.ts` +Expected: PASS (3 fail-soft tests). + +- [ ] **Step 5: Extend effects.ts for the async cartoon path** + +In `hugo-apps/src/selfie/effects.ts`: + +1. Line 6 — add `'cartoon'`: +```ts +export type EffectId = 'none' | 'duotone' | 'warm' | 'mono' | 'vignette' | 'joule' | 'cartoon' +``` + +2. Add a `cartoon` entry to the `EFFECTS` table (after `joule`). Its sync `apply` returns the input untouched (the real bake is async, in `applyEffectAsync`); its preview is empty (no CSS approximation): +```ts + cartoon: { + label: 'Cartoon', + preview: {}, // no CSS approximation — the ONNX bake only materializes at export + apply: (canvas) => canvas, // sync no-op; async bake lives in applyEffectAsync/cartoonify + }, +``` + +3. Line 118 — append `'cartoon'` to the picker order: +```ts +export const EFFECT_IDS: EffectId[] = ['none', 'duotone', 'warm', 'mono', 'vignette', 'joule', 'cartoon'] +``` + +4. Add the async dispatcher (after `applyEffect`), importing `cartoonify`: +```ts +import { cartoonify } from './stylize' + +// Async effect dispatcher. 'cartoon' runs the in-browser ONNX style transfer; +// every other id delegates to the synchronous applyEffect. Fail-soft: a failed +// cartoonify returns the input canvas (cartoonify already guards internally; the +// try/catch is belt-and-braces, matching applyEffect's outer guard). +export async function applyEffectAsync(canvas: HTMLCanvasElement, id: EffectId): Promise { + if (id === 'cartoon') { + try { return await cartoonify(canvas) } catch { return canvas } + } + return applyEffect(canvas, id) +} +``` + +- [ ] **Step 6: Update the existing effects.test.ts assertions for the new id** + +In `hugo-apps/src/selfie/__tests__/effects.test.ts`, the `EFFECT_IDS` exact-match test (line 36-43) now fails because the list grew. Update it and add async coverage. Replace the `'lists none first…'` test body's array and keep the per-entry `apply` check (cartoon's `apply` is a function too): + +```ts + it('lists none first and exposes every preset in order', () => { + expect(EFFECT_IDS).toEqual(['none', 'duotone', 'warm', 'mono', 'vignette', 'joule', 'cartoon']) + expect(EFFECT_IDS[0]).toBe('none') + for (const id of EFFECT_IDS) { + expect(typeof EFFECTS[id].label).toBe('string') + expect(typeof EFFECTS[id].apply).toBe('function') + } + }) +``` + +Add a new `describe` for `applyEffectAsync` at the end of the file. Mock `../stylize` so no ONNX loads: + +```ts +import { applyEffectAsync } from '../effects' +import { vi as _vi } from 'vitest' + +vi.mock('../stylize', () => ({ cartoonify: vi.fn(async (c: HTMLCanvasElement) => ({ ...c, _cartooned: true } as unknown as HTMLCanvasElement)) })) + +describe('applyEffectAsync', () => { + it('routes cartoon through cartoonify', async () => { + const { cartoonify } = await import('../stylize') + const c = composite() + await applyEffectAsync(c, 'cartoon') + expect(cartoonify).toHaveBeenCalledWith(c) + }) + + it('delegates non-cartoon ids to the sync applyEffect (mono returns via drawImage path)', async () => { + const c = composite() + const out = await applyEffectAsync(c, 'none') + expect(out).toBe(c) // none is a no-op in both paths + }) + + it('fail-soft: returns the input canvas when cartoonify throws', async () => { + const { cartoonify } = await import('../stylize') + ;(cartoonify as unknown as { mockRejectedValueOnce: (e: Error) => void }).mockRejectedValueOnce(new Error('boom')) + const c = composite() + expect(await applyEffectAsync(c, 'cartoon')).toBe(c) + }) +}) +``` + +Note: the `vi.mock('../stylize', …)` call must sit at the top of the file with the other imports (hoisted), not inside the describe. Move it up if vitest complains about hoist ordering. + +- [ ] **Step 7: Run the effects test to verify it passes** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/effects.test.ts` +Expected: PASS (existing + updated + 3 new async tests). + +- [ ] **Step 8: Commit** + +```bash +git add hugo-apps/src/selfie/stylize.ts hugo-apps/src/selfie/effects.ts hugo-apps/src/selfie/__tests__/stylize.test.ts hugo-apps/src/selfie/__tests__/effects.test.ts +git commit -m "feat(#1520): cartoonify style transfer via async effect path" +``` + +--- + +## Task 7: `exportPng` awaits the async effect path + +**Files:** +- Modify: `hugo-apps/src/selfie/compose.ts` (`exportPng` ~line 128-171) +- Test: `hugo-apps/src/selfie/__tests__/compose.test.ts` + +**Interfaces:** +- Consumes: `applyEffectAsync` from Task 6. +- Produces: `exportPng` bakes `'cartoon'` (and all effects) via the async dispatcher; bake order unchanged (effect before border). + +- [ ] **Step 1: Write the failing test** + +Add to `hugo-apps/src/selfie/__tests__/compose.test.ts` (mock `../effects` `applyEffectAsync` in that file's existing mock setup, or add a `vi.mock('../effects', …)`): + +```ts +describe('exportPng cartoon path', () => { + it('takes the canvas (not fast) path for cartoon and awaits applyEffectAsync', async () => { + // build a stage, call exportPng({ effect: 'cartoon' }); assert applyEffectAsync + // was called with the toCanvas() result and the id 'cartoon', and a Blob resolves. + }) + + it('bakes the effect BEFORE the polaroid border (order preserved)', async () => { + // exportPng({ effect: 'mono', border: {style,name} }): assert applyEffectAsync + // resolves before paintPolaroid is invoked (spy call order). + }) +}) +``` + +Fill the bodies against the file's mock (spy on `applyEffectAsync` and `paintPolaroid`; assert `.mock.invocationCallOrder`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/compose.test.ts` +Expected: FAIL — cartoon not treated as needing the canvas path / `applyEffectAsync` not called. + +- [ ] **Step 3: Implement in compose.ts** + +1. Update the import (line 5): +```ts +import { applyEffect, applyEffectAsync, type EffectId } from './effects' +``` +(Keep `applyEffect` imported only if still referenced; otherwise import just `applyEffectAsync`.) + +2. In `exportPng`, `needsCanvas` already covers `effect !== 'none'`, which includes `'cartoon'` — no change needed there. Make the pixel-pass branch async. Replace the synchronous `applyEffect` line and its surrounding executor. The current executor is `new Promise((resolve, reject) => { ... })`; change the `if (needsCanvas)` block to run async: + +```ts + if (needsCanvas) { + void (async () => { + try { + let composite = stage.toCanvas() as HTMLCanvasElement + // Effect bakes BEFORE the border so the white matte stays untinted. + if (effect && effect !== 'none') composite = await applyEffectAsync(composite, effect) + const finalCanvas = border ? paintPolaroid(composite, border) : composite + finalCanvas.toBlob((b: Blob | null) => { + restore() + b ? resolve(b) : reject(new Error('export failed')) + }, 'image/png') + } catch (e) { + restore() + reject(e as Error) + } + })() + return + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/compose.test.ts` +Expected: PASS (existing + Task 3 setBackground + the 2 new cartoon-path tests). + +- [ ] **Step 5: Commit** + +```bash +git add hugo-apps/src/selfie/compose.ts hugo-apps/src/selfie/__tests__/compose.test.ts +git commit -m "feat(#1520): exportPng awaits applyEffectAsync (cartoon bake)" +``` + +--- + +## Task 8: Wire BackgroundPicker + cartoon UX into Composer.vue + +**Files:** +- Modify: `hugo-apps/src/selfie/Composer.vue` +- Test: `hugo-apps/src/selfie/__tests__/Composer.test.ts` + +**Interfaces:** +- Consumes: `BackgroundPicker.vue` (Task 4), `backgroundUrl`/`BACKGROUNDS` (Task 1), `setBackground` (Task 3), `urlToImage` (already in Composer, line 62-69). +- Produces: background picking (forces `removeBg` on for a scene), cartoon inference spinner, failure note. No new emits beyond the existing set. + +- [ ] **Step 1: Write the failing tests** + +Add to `hugo-apps/src/selfie/__tests__/Composer.test.ts`. The file's `buildStage` mock (in the `vi.hoisted` block) must gain a `setBackground` spy — add `const setBackground = vi.fn()` there, include it in the resolved stage object, and export it on `h`. + +```ts + it('renders the background picker', async () => { + const w = mount(Composer, { props: { rawPhoto: raw, cutout: cut, removeBg: true, segmenting: false, ...base } }) + await flushPromises() + expect(w.find('[data-testid="bg-none"]').exists()).toBe(true) + expect(w.find('[data-testid="bg-terminal"]').exists()).toBe(true) + }) + + it('picking a scene sets it on the stage and forces removeBg on', async () => { + const w = mount(Composer, { props: { rawPhoto: raw, cutout: cut, removeBg: false, segmenting: false, ...base } }) + await flushPromises() + await w.find('[data-testid="bg-terminal"]').trigger('click') + await flushPromises() + expect(h.setBackground).toHaveBeenCalledTimes(1) // an was loaded + set + expect(w.emitted('update:removeBg')?.[0]?.[0]).toBe(true) // forced on + }) + + it('picking None clears the stage background', async () => { + const w = mount(Composer, { props: { rawPhoto: raw, cutout: cut, removeBg: true, segmenting: false, ...base } }) + await flushPromises() + await w.find('[data-testid="bg-terminal"]').trigger('click'); await flushPromises() + h.setBackground.mockClear() + await w.find('[data-testid="bg-none"]').trigger('click'); await flushPromises() + expect(h.setBackground).toHaveBeenCalledWith(null) + }) + + it('picking cartoon forwards effect: cartoon to exportPng', async () => { + const w = mount(Composer, { props: { rawPhoto: raw, cutout: cut, removeBg: true, segmenting: false, ...base } }) + await flushPromises() + await w.find('[data-testid="effect-cartoon"]').trigger('click') + await w.find('[data-testid="export"]').trigger('click') + await flushPromises() + expect(h.exportPng).toHaveBeenCalledWith({ effect: 'cartoon', border: undefined }) + }) +``` + +Note: the `Image` stub already in `Composer.test.ts`'s `beforeEach` resolves `onload` on the next microtask, so `urlToImage` for the scene resolves in tests — `setBackground` is reached. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npm test -- --project unit hugo-apps/src/selfie/__tests__/Composer.test.ts` +Expected: FAIL — `bg-none` not found / `setBackground` not called / no `effect-cartoon`. + +- [ ] **Step 3: Implement in Composer.vue** + +1. Imports (after the EffectPicker import, line 9-10): +```ts +import BackgroundPicker from './BackgroundPicker.vue' +import { backgroundUrl, BACKGROUNDS } from './backgrounds' +``` + +2. State (after `effectId`, line 35): +```ts +const backgroundId = ref('none') +const cartoonBusy = ref(false) +const effectNote = ref('') +``` + +3. Handler (after `doExport`, or near the other `on*` handlers): +```ts +async function onPickBackground(id: string) { + backgroundId.value = id + if (!stage) return + if (id === 'none') { stage.setBackground(null); return } + // A themed background is meaningless without the cutout — force removeBg on. + if (!props.removeBg) emit('update:removeBg', true) + const scene = BACKGROUNDS.find((b) => b.id === id) + if (!scene) return + try { + const img = await urlToImage(backgroundUrl(props.imgBase, scene.file)) + stage.setBackground(img) + } catch (e) { + console.warn('[selfie] background load failed', e) + } +} +``` + +4. `doExport` — reflect cartoon busy + surface a note if cartoon silently no-ops is out of scope (cartoonify is fail-soft internally); wrap the export in the busy flag: +```ts +async function doExport() { + if (!stage) return emit('fallback', effectiveBlob()) + effectNote.value = '' + if (effectId.value === 'cartoon') cartoonBusy.value = true + try { + const blob = await stage.exportPng({ + effect: effectId.value, + border: borderEnabled.value ? { style: borderStyle.value, name: borderName.value } : undefined, + }) + emit('export', blob) + } catch { emit('fallback', effectiveBlob()) } + finally { cartoonBusy.value = false } +} +``` + +5. Template — add the `BackgroundPicker` next to the `EffectPicker` (after line 188): +```html + +``` +And a busy hint + note near the export button (before/after the export `