From c22c6e55741f99a6c8a79a82b7b3deadd33fa3dc Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Fri, 9 Oct 2026 14:09:51 +0500 Subject: [PATCH] feat: add show mask chip for tools --- docs/architecture.md | 1 + docs/backlog.md | 23 ++--- .../lib/components/schema/PreviewTile.svelte | 5 + .../components/schema/SchemaPreview.svelte | 34 ++++++- .../components/schema/SchemaResultTile.svelte | 19 +++- .../components/schema/SchemaToolView.svelte | 80 +++++++++++++++- web/src/lib/core/effects.test.ts | 18 +++- web/src/lib/core/effects.ts | 27 ++++++ web/src/lib/core/masks.test.ts | 24 +++++ web/src/lib/core/masks.ts | 24 +++++ web/src/lib/core/morphology.test.ts | 22 +++++ web/src/lib/core/morphology.ts | 17 ++++ web/src/lib/core/shapes.test.ts | 31 +++++- web/src/lib/core/shapes.ts | 68 +++++++++++-- web/src/lib/i18n/en.ts | 1 + web/src/lib/i18n/ru.ts | 1 + web/src/lib/registry/registry.test.ts | 33 +++++++ web/src/lib/registry/tools/alpha.ts | 96 ++++++++++++++++++- web/src/lib/registry/tools/analyze.ts | 7 +- web/src/lib/registry/tools/filters.ts | 8 +- web/src/lib/registry/tools/geometry.ts | 11 +++ web/src/lib/registry/types.ts | 1 + 22 files changed, 521 insertions(+), 30 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9d6f6d9..2acdbdb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -122,6 +122,7 @@ registry/{tools,pages}/*.ts | `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт | | `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую | | `output`? | mime/ext/quality для скачивания (не у всех) | +| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт | | `run(ctx)` | единственный метод: `ctx` → `ToolResult` | `Page` (записи в `registry/pages/.ts`): diff --git a/docs/backlog.md b/docs/backlog.md index c7abebd..e9189c3 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -131,24 +131,21 @@ отдельное решение о формате. - [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract, handwritten-digital). -- [ ] **Идея 14. Отображение маски для инструментов (`runMask`)** — придумать и - реализовать процесс показа маски результата. **Контекст:** удалённый UI-v1 - умел показывать ч/б превью-маску «что будет вырезано» отдельным полем; при - переходе на единый `run(ctx)` этого поля не осталось — executor и - schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:** - дать каждому `run` возможность вернуть (или инструмент — объявить) - `runMask` — ч/б изображение маски изменений либо иной подход (отдельное - поле `mask` + функция маски, второй результат, overlay). Оценка — на - усмотрение при проработке (S/M). Пока живём без неё. **Идея 2:** каждый - `run` возвращает два изображения — для отображения (показывает маску или - сам результат) и для передачи в следующий инструмент (всегда результат). - Оценка — на усмотрение при проработке (S/M). Пока живём без неё. Первые - кандидаты на маску: `remove-color-from-png`, `remove-background-png`. ## UI и качество - [ ] split to parts - посмотреть вариант с toggle component - может будет удобнее чем sectioned buttons +- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать + заменить на Toggle-компонент, когда тот будет доделан (см. пункт про + визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее +- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас + `runMask` считается только для включённых шагов (эффект в + `SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с + результатом, по нажатию просто показывать другую картинку. Оценить + достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на + каждый ран каждого шага с `runMask` (на больших изображениях — заметная + цена за функцию, которую могут не открыть) - [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить через drag-n-drop. звучит сложно, но надо придумать процесс - [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы diff --git a/web/src/lib/components/schema/PreviewTile.svelte b/web/src/lib/components/schema/PreviewTile.svelte index 5228f94..79c6359 100644 --- a/web/src/lib/components/schema/PreviewTile.svelte +++ b/web/src/lib/components/schema/PreviewTile.svelte @@ -15,6 +15,7 @@ dims?: string; note?: string; noteTone?: "warning" | "info"; + actions?: Snippet; } let { label, @@ -26,6 +27,7 @@ dims = undefined, note = undefined, noteTone = "warning", + actions = undefined, }: Props = $props(); @@ -44,6 +46,9 @@ {/if} + {#if actions} + {@render actions()} + {/if} {#if note} {/if} diff --git a/web/src/lib/components/schema/SchemaPreview.svelte b/web/src/lib/components/schema/SchemaPreview.svelte index fd65bc0..50de80c 100644 --- a/web/src/lib/components/schema/SchemaPreview.svelte +++ b/web/src/lib/components/schema/SchemaPreview.svelte @@ -5,6 +5,7 @@ import SchemaSourceTile from "./SchemaSourceTile.svelte"; import PreviewTile from "./PreviewTile.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte"; + import ChipButton from "$lib/components/ui/ChipButton.svelte"; import { toDataUrl } from "$lib/core/io"; import type { PixelImage } from "$lib/core/types"; import type { OutputMime } from "$lib/core/io"; @@ -27,9 +28,16 @@ textSource?: string; textResult?: string | null; textVars?: Record; + mask?: PixelImage | null; + maskOn?: boolean; + ontogglemask?: () => void; running?: boolean; error?: string; stepResults?: (PixelImage | null)[]; + stepMaskable?: boolean[]; + stepMaskOn?: boolean[]; + stepMasks?: (PixelImage | null)[]; + ontogglestepmask?: (i: number) => void; aligned?: boolean; ontogglealign?: () => void; format?: OutputMime; @@ -57,9 +65,16 @@ textSource = "", textResult = null, textVars = undefined, + mask = null, + maskOn = false, + ontogglemask, running = false, error = "", stepResults = [], + stepMaskable = [], + stepMaskOn = [], + stepMasks = [], + ontogglestepmask, aligned = false, ontogglealign, format = "image/png", @@ -132,7 +147,21 @@ viewMode="image" dims={`${img.width} × ${img.height}`} > - + {#snippet actions()} + {#if stepMaskable[i]} + ontogglestepmask?.(i)} + /> + {/if} + {/snippet} + {/if} {/each} @@ -145,6 +174,9 @@ {textVars} {toolId} {running} + {mask} + {maskOn} + {ontogglemask} oncopy={oncopytext} {ondownloadtxt} /> diff --git a/web/src/lib/components/schema/SchemaResultTile.svelte b/web/src/lib/components/schema/SchemaResultTile.svelte index 99445f3..5fc746e 100644 --- a/web/src/lib/components/schema/SchemaResultTile.svelte +++ b/web/src/lib/components/schema/SchemaResultTile.svelte @@ -4,6 +4,7 @@ import { t } from "$lib/i18n/t"; import type { FileResult, ResultKind, ResultNote } from "$lib/registry"; import { Check } from "@lucide/svelte"; + import ChipButton from "$lib/components/ui/ChipButton.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte"; import PreviewTile from "./PreviewTile.svelte"; @@ -16,6 +17,9 @@ textVars?: Record; toolId?: string; running?: boolean; + mask?: PixelImage | null; + maskOn?: boolean; + ontogglemask?: () => void; oncopy?: () => void; ondownloadtxt?: () => void; } @@ -28,11 +32,15 @@ textVars = undefined, toolId = "", running = false, + mask = null, + maskOn = false, + ontogglemask, oncopy, ondownloadtxt, }: Props = $props(); - const resultUrl = $derived(result ? toDataUrl(result) : null); + const shown = $derived(maskOn && mask ? mask : result); + const resultUrl = $derived(shown ? toDataUrl(shown) : null); const resultDims = $derived( resultKind === "image" && result ? `${result.width} × ${result.height}` @@ -68,6 +76,15 @@ note={resultNote ? t(resultNote.key) : undefined} noteTone={resultNote?.tone ?? "warning"} > + {#snippet actions()} + {#if ontogglemask} + + {/if} + {/snippet}
>({}); // Optional download size limit (KB); undefined = no limit. let limitKb = $state(undefined); + // Mask preview per step (keyed by step.key): the toggle lives on every + // step result tile, not just the final one. + const maskOnKeys = new SvelteSet(); + let maskResults = $state>({}); let dragFrom = $state(null); const lastTool = $derived( @@ -165,6 +170,10 @@ : current.text; }); + const stepMaskable = $derived(steps.map((s) => !!getTool(s.id)?.runMask)); + const stepMaskOn = $derived(steps.map((s) => maskOnKeys.has(s.key))); + const stepMasks = $derived(steps.map((s) => maskResults[s.key] ?? null)); + const debouncedRun = debounce(() => run(), 200); function touchedFor(key: string): SvelteSet { @@ -209,6 +218,8 @@ textResult = null; verdictVars = undefined; displayError = null; + maskOnKeys.clear(); + maskResults = {}; } function setStepValue( @@ -517,6 +528,41 @@ return () => debouncedRun.cancel(); }); + // Mask preview: recomputed on the main thread per toggled step from that + // step's input; a mask never enters the chain itself. + $effect(() => { + void steps; + void stepResults; + void source; + const next: Record = {}; + steps.forEach((step, i) => { + if (!maskOnKeys.has(step.key)) return; + const stepTool = getTool(step.id); + if (!stepTool?.runMask) return; + const input = i === 0 ? source : stepResults[i - 1]; + if (!input) return; + try { + next[step.key] = stepTool.runMask({ + params: step.params, + source: input, + }); + } catch { + next[step.key] = null; + } + }); + maskResults = next; + }); + + function toggleStepMask(key: string) { + if (maskOnKeys.has(key)) maskOnKeys.delete(key); + else maskOnKeys.add(key); + } + + function toggleStepMaskByIndex(i: number) { + const step = steps[i]; + if (step) toggleStepMask(step.key); + } + function toDisplayError(e: unknown): DisplayError { if (e instanceof ToolError) { return { kind: "i18n", key: e.key, vars: e.vars }; @@ -614,6 +660,16 @@ {/snippet} + {#snippet maskChip(i: number)} + {#if stepMaskable[i]} + toggleStepMask(steps[i].key)} + /> + {/if} + {/snippet} + {#if !alignedMode}
@@ -648,6 +704,15 @@ {alphaLoss} {stepResults} {aligned} + {stepMaskable} + {stepMaskOn} + {stepMasks} + ontogglestepmask={toggleStepMaskByIndex} + mask={stepMasks.at(-1) ?? null} + maskOn={stepMaskOn.at(-1) ?? false} + ontogglemask={stepMaskable.at(-1) + ? () => toggleStepMask(steps[steps.length - 1].key) + : undefined} ontogglealign={() => (aligned = !aligned)} onformat={(v) => (format = v)} onquality={setFormatQuality} @@ -746,6 +811,11 @@ textVars={verdictVars} toolId={lastTool.id} {running} + mask={stepMasks[i] ?? null} + maskOn={stepMaskOn[i] ?? false} + ontogglemask={stepMaskable[i] + ? () => toggleStepMask(steps[i].key) + : undefined} oncopy={copyText} ondownloadtxt={downloadText} /> @@ -755,8 +825,16 @@ viewMode="image" dims={out ? `${out.width} × ${out.height}` : undefined} > + {#snippet actions()} + {@render maskChip(i)} + {/snippet} {#if out} - + {:else} {t("resultCard.noResult")} {/if} diff --git a/web/src/lib/core/effects.test.ts b/web/src/lib/core/effects.test.ts index 56489c7..da2640e 100644 --- a/web/src/lib/core/effects.test.ts +++ b/web/src/lib/core/effects.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { vignette } from "./effects"; +import { vignette, vignetteMask } from "./effects"; import { makeImage } from "./test-helpers"; describe("vignette", () => { @@ -17,3 +17,19 @@ describe("vignette", () => { expect(centerR).toBeGreaterThan(150); }); }); + +describe("vignetteMask", () => { + it("white at corners, black at center, opaque", () => { + const out = vignetteMask(7, 7, 80); + const center = out.data[(3 * 7 + 3) * 4]; + const corner = out.data[0]; + expect(corner).toBeGreaterThan(center); + expect(corner).toBeGreaterThan(100); + expect(out.data[3]).toBe(255); + }); + + it("zero strength -> all black", () => { + const out = vignetteMask(3, 3, 0); + expect(Math.max(...out.data.filter((_, i) => i % 4 !== 3))).toBe(0); + }); +}); diff --git a/web/src/lib/core/effects.ts b/web/src/lib/core/effects.ts index b9852f3..94a1cc4 100644 --- a/web/src/lib/core/effects.ts +++ b/web/src/lib/core/effects.ts @@ -22,3 +22,30 @@ export function vignette(img: PixelImage, strengthPercent: number): PixelImage { } return out; } + +/** B/w preview of the vignette falloff: white where darkening is strongest. */ +export function vignetteMask( + width: number, + height: number, + strengthPercent: number, +): PixelImage { + const strength = Math.min(Math.max(strengthPercent, 0), 100) / 100; + const cx = (width - 1) / 2; + const cy = (height - 1) / 2; + const maxDist = Math.sqrt(cx * cx + cy * cy) || 1; + const out = createPixelImage(width, height); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const dx = x - cx; + const dy = y - cy; + const t = Math.sqrt(dx * dx + dy * dy) / maxDist; + const v = Math.round(strength * t * t * 255); + const di = (y * width + x) * 4; + out.data[di] = v; + out.data[di + 1] = v; + out.data[di + 2] = v; + out.data[di + 3] = 255; + } + } + return out; +} diff --git a/web/src/lib/core/masks.test.ts b/web/src/lib/core/masks.test.ts index b3a1284..dbe82e6 100644 --- a/web/src/lib/core/masks.test.ts +++ b/web/src/lib/core/masks.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + diffMask, extractByColor, isGrayscaleish, luma01, @@ -74,3 +75,26 @@ describe("rarityPredicate + extractByColor", () => { expect(out.data[7]).toBe(255); }); }); + +describe("diffMask", () => { + const before = makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 128], + ]); + + it("white where channels differ, black where equal, always opaque", () => { + const after = makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + ]); + const out = diffMask(before, after); + expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]); + expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]); + }); + + it("size mismatch means everything changed", () => { + const smaller = makeImage(1, 1, [[255, 0, 0, 255]]); + const out = diffMask(before, smaller); + expect([...out.data.slice(0, 4)]).toEqual([255, 255, 255, 255]); + }); +}); diff --git a/web/src/lib/core/masks.ts b/web/src/lib/core/masks.ts index 819eb9b..89bfb9b 100644 --- a/web/src/lib/core/masks.ts +++ b/web/src/lib/core/masks.ts @@ -117,6 +117,30 @@ export function extractByColor( return out; } +/** + * Pixels that differ between before/after become white, the rest black. + * Size mismatch (crop/trim) means everything changed: all white. + */ +export function diffMask(before: PixelImage, after: PixelImage): PixelImage { + const out = createPixelImage(before.width, before.height); + const sameSize = + before.width === after.width && before.height === after.height; + for (let i = 0; i < out.data.length; i += 4) { + const changed = + !sameSize || + before.data[i] !== after.data[i] || + before.data[i + 1] !== after.data[i + 1] || + before.data[i + 2] !== after.data[i + 2] || + before.data[i + 3] !== after.data[i + 3]; + const v = changed ? 255 : 0; + out.data[i] = v; + out.data[i + 1] = v; + out.data[i + 2] = v; + out.data[i + 3] = 255; + } + return out; +} + /** * Counts color frequencies and returns a predicate "occurs at most limit times". */ diff --git a/web/src/lib/core/morphology.test.ts b/web/src/lib/core/morphology.test.ts index d3c5d86..6455e52 100644 --- a/web/src/lib/core/morphology.test.ts +++ b/web/src/lib/core/morphology.test.ts @@ -3,6 +3,7 @@ import { buildAlphaMask, closingImage, contourImage, + contourMask, dilateImage, dilateMask, erodeImage, @@ -189,3 +190,24 @@ describe("contourImage", () => { expect(at(1, 3)).toEqual([0, 0, 0, 0]); }); }); + +describe("contourMask", () => { + it("white line on the alpha edge, black elsewhere", () => { + const pixels = []; + for (let y = 0; y < 7; y++) { + for (let x = 0; x < 7; x++) { + pixels.push( + x >= 2 && x <= 4 && y >= 2 && y <= 4 + ? [255, 255, 255, 255] + : [0, 0, 0, 0], + ); + } + } + const out = contourMask(makeImage(7, 7, pixels), 1); + const at = (x: number, y: number) => out.data[(y * 7 + x) * 4]; + expect(at(2, 2)).toBe(255); + expect(at(3, 3)).toBe(0); + expect(at(0, 0)).toBe(0); + expect(out.data[3]).toBe(255); + }); +}); diff --git a/web/src/lib/core/morphology.ts b/web/src/lib/core/morphology.ts index e7bc41b..26ab387 100644 --- a/web/src/lib/core/morphology.ts +++ b/web/src/lib/core/morphology.ts @@ -167,6 +167,23 @@ export function contourImage( return out; } +/** B/w preview of the contour line: white where the stroke will land. */ +export function contourMask(img: PixelImage, radiusPx: number): PixelImage { + const r = Math.max(1, Math.trunc(radiusPx)); + const mask = buildAlphaMask(img); + const inner = erodeMask(mask, img.width, img.height, r); + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < mask.length; i++) { + const v = mask[i] === 1 && inner[i] === 0 ? 255 : 0; + const di = i * 4; + out.data[di] = v; + out.data[di + 1] = v; + out.data[di + 2] = v; + out.data[di + 3] = 255; + } + return out; +} + export function openingMask( mask: Mask, w: number, diff --git a/web/src/lib/core/shapes.test.ts b/web/src/lib/core/shapes.test.ts index ae7c312..1d99b61 100644 --- a/web/src/lib/core/shapes.test.ts +++ b/web/src/lib/core/shapes.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { boxTest, circleTest, renderShape, starTest, wavyTest } from "./shapes"; +import { + boxTest, + circleTest, + renderShape, + renderShapeMask, + starTest, + wavyTest, +} from "./shapes"; import { makeImage } from "./test-helpers"; describe("shape predicates", () => { @@ -88,3 +95,25 @@ describe("renderShape", () => { expect(opaqueAt(2, 1)).toBe(false); }); }); + +describe("renderShapeMask", () => { + it("white inside the shape, black outside, same geometry as renderShape", () => { + const shape = renderShape( + makeImage(5, 5, new Array(25).fill([200, 100, 50, 255])), + circleTest(0.4), + 0, + 0, + { x: 0.4, y: 0.4 }, + ); + const mask = renderShapeMask(5, 5, circleTest(0.4), 0, 0, { + x: 0.4, + y: 0.4, + }); + for (let i = 0; i < 25; i++) { + const shaped = shape.data[i * 4 + 3] === 255; + const v = mask.data[i * 4]; + expect(v, `pixel ${i}`).toBe(shaped ? 255 : 0); + expect(mask.data[i * 4 + 3]).toBe(255); + } + }); +}); diff --git a/web/src/lib/core/shapes.ts b/web/src/lib/core/shapes.ts index dad8036..c85815b 100644 --- a/web/src/lib/core/shapes.ts +++ b/web/src/lib/core/shapes.ts @@ -58,10 +58,31 @@ export function wavyTest( }; } +/** + * Shared geometry: shape center in pixels from offset fractions and extent. + * Offset is a fraction of free space per axis: +-0.5 pins the shape to the + * edge regardless of size. Free space derives from extent (half-size). + */ +function shapeOrigin( + width: number, + height: number, + offsetXFrac: number, + offsetYFrac: number, + extent: ShapeExtent, +): { cx: number; cy: number; minDim: number } { + const minDim = Math.min(width, height); + const freeX = Math.max(0, width / (2 * minDim) - extent.x); + const freeY = Math.max(0, height / (2 * minDim) - extent.y); + return { + cx: width / 2 + offsetXFrac * 2 * freeX * minDim, + cy: height / 2 + offsetYFrac * 2 * freeY * minDim, + minDim, + }; +} + /** * Cuts a shape out of the image: source pixels kept inside, alpha zeroed - * outside. Offset is a fraction of free space per axis: +-0.5 pins the shape - * to the edge regardless of size. Free space derives from extent (half-size). + * outside. */ export function renderShape( img: PixelImage, @@ -71,11 +92,13 @@ export function renderShape( extent: ShapeExtent = { x: 0, y: 0 }, ): PixelImage { const out = createPixelImage(img.width, img.height); - const minDim = Math.min(img.width, img.height); - const freeX = Math.max(0, img.width / (2 * minDim) - extent.x); - const freeY = Math.max(0, img.height / (2 * minDim) - extent.y); - const cx = img.width / 2 + offsetXFrac * 2 * freeX * minDim; - const cy = img.height / 2 + offsetYFrac * 2 * freeY * minDim; + const { cx, cy, minDim } = shapeOrigin( + img.width, + img.height, + offsetXFrac, + offsetYFrac, + extent, + ); for (let y = 0; y < img.height; y++) { for (let x = 0; x < img.width; x++) { const di = (y * img.width + x) * 4; @@ -88,3 +111,34 @@ export function renderShape( } return out; } + +/** B/w preview of the shape itself: white inside, black outside. */ +export function renderShapeMask( + width: number, + height: number, + test: ShapeTest, + offsetXFrac = 0, + offsetYFrac = 0, + extent: ShapeExtent = { x: 0, y: 0 }, +): PixelImage { + const out = createPixelImage(width, height); + const { cx, cy, minDim } = shapeOrigin( + width, + height, + offsetXFrac, + offsetYFrac, + extent, + ); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const di = (y * width + x) * 4; + const inside = test((x + 0.5 - cx) / minDim, (y + 0.5 - cy) / minDim); + const v = inside ? 255 : 0; + out.data[di] = v; + out.data[di + 1] = v; + out.data[di + 2] = v; + out.data[di + 3] = 255; + } + } + return out; +} diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index f3c1f4c..1e5f499 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -99,6 +99,7 @@ export const en: Dict = { breakChain: "✂ Break the chain", noResult: "no result yet", result: "Result", + maskToggle: "Mask", previewPanel: "Preview", parts: "parts", clamped: "clamped to image bounds", diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts index 32f42b3..708ef7b 100644 --- a/web/src/lib/i18n/ru.ts +++ b/web/src/lib/i18n/ru.ts @@ -100,6 +100,7 @@ export const ru: Dict = { breakChain: "✂ Оборвать цепочку", noResult: "результата пока нет", result: "Результат", + maskToggle: "Маска", previewPanel: "Предпросмотр", parts: "частей", clamped: "уменьшено до границ изображения", diff --git a/web/src/lib/registry/registry.test.ts b/web/src/lib/registry/registry.test.ts index e90e610..013cfdb 100644 --- a/web/src/lib/registry/registry.test.ts +++ b/web/src/lib/registry/registry.test.ts @@ -3,6 +3,7 @@ import { PAGES, TOOLS } from "."; import { PREVIEW_GROUPS } from "../catalog"; import type { PixelImage } from "../core/types"; import type { OutputMime } from "../core/io"; +import { makeImage } from "../core/test-helpers"; import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema"; import type { FileResult, ToolResult } from "./types"; @@ -154,6 +155,38 @@ describe("registry-new (migrated tools)", () => { } }); + it("runMask returns an opaque source-sized mask for tools declaring it", () => { + // Mask preview contract: same geometry as the step input, fully opaque + // b/w rendering, synchronous and DOM-free. + const source = makeImage(4, 3, [ + [255, 0, 0, 255], + [0, 255, 0, 255], + [0, 0, 255, 128], + [255, 255, 255, 0], + [0, 0, 0, 255], + [255, 255, 0, 255], + [0, 255, 255, 64], + [255, 0, 255, 255], + [128, 128, 128, 255], + [32, 64, 128, 200], + [255, 128, 0, 255], + [0, 128, 255, 255], + ]); + const withMask = TOOLS.filter((tool) => tool.runMask); + expect(withMask.length).toBeGreaterThan(0); + for (const tool of withMask) { + const mask = tool.runMask!({ + params: defaultSchemaParams(tool.schema), + source, + }); + expect(mask.width, `${tool.id}: width`).toBe(source.width); + expect(mask.height, `${tool.id}: height`).toBe(source.height); + for (let a = 3; a < mask.data.length; a += 4) { + expect(mask.data[a], `${tool.id}: alpha @${a}`).toBe(255); + } + } + }); + it("source-aware dimension defaults apply only to image tools", () => { for (const tool of TOOLS) { for (const field of Object.values(tool.schema.fields)) { diff --git a/web/src/lib/registry/tools/alpha.ts b/web/src/lib/registry/tools/alpha.ts index 120fb93..cbcbe11 100644 --- a/web/src/lib/registry/tools/alpha.ts +++ b/web/src/lib/registry/tools/alpha.ts @@ -1,4 +1,5 @@ import { + colorMask, extractAlphaMask, flattenOntoColor, hardenAlpha, @@ -7,10 +8,12 @@ import { roundCorners, setAlphaChannel, } from "../../core/alpha"; -import { removeBackground } from "../../core/background"; +import { backgroundMaskPreview, removeBackground } from "../../core/background"; +import { diffMask, renderPredicateMask } from "../../core/masks"; import { closingImage, contourImage, + contourMask, dilateImage, erodeImage, openingImage, @@ -21,12 +24,23 @@ import { boxTest, circleTest, renderShape, + renderShapeMask, starTest, wavyTest, } from "../../core/shapes"; +import type { PixelImage } from "../../core/types"; import type { Offset } from "../../registry-schema"; import { field, toolSchema } from "../../registry-schema"; -import { imgTool, type Tool } from "../types"; +import { imgTool, requireSource, type Tool, type ToolContext } from "../types"; + +/** Mask of an alpha-morphology tool: grayscale alpha of the result. */ +function alphaOfResult

( + ctx: ToolContext

, + fn: (img: PixelImage, p: P) => PixelImage, +): PixelImage { + const img = requireSource(ctx); + return extractAlphaMask(fn(img, ctx.params)); +} interface AddStrokeParams { color: string; @@ -95,6 +109,10 @@ const removeColor: Tool = { schema: removeColorSchema, input: "image", run: imgTool((img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance)), + runMask: (ctx) => { + const img = requireSource(ctx); + return colorMask(img, ctx.params.targetColor, ctx.params.tolerance); + }, }; interface CircleMaskParams { @@ -142,6 +160,18 @@ const circleMask: Tool = { { x: p.size / 200, y: p.size / 200 }, ), ), + runMask: (ctx) => { + const img = requireSource(ctx); + const p = ctx.params; + return renderShapeMask( + img.width, + img.height, + circleTest(p.size / 200), + p.offset.x / 100, + p.offset.y / 100, + { x: p.size / 200, y: p.size / 200 }, + ); + }, }; interface SquareMaskParams { @@ -201,6 +231,18 @@ const squareMask: Tool = { { x: p.widthPct / 200, y: p.heightPct / 200 }, ), ), + runMask: (ctx) => { + const img = requireSource(ctx); + const p = ctx.params; + return renderShapeMask( + img.width, + img.height, + boxTest(p.widthPct / 200, p.heightPct / 200), + p.offset.x / 100, + p.offset.y / 100, + { x: p.widthPct / 200, y: p.heightPct / 200 }, + ); + }, }; interface StarMaskParams { @@ -276,6 +318,18 @@ const starMask: Tool = { { x: p.size / 200, y: p.size / 200 }, ), ), + runMask: (ctx) => { + const img = requireSource(ctx); + const p = ctx.params; + return renderShapeMask( + img.width, + img.height, + starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation), + p.offset.x / 100, + p.offset.y / 100, + { x: p.size / 200, y: p.size / 200 }, + ); + }, }; interface WavyMaskParams { @@ -351,6 +405,18 @@ const wavyMask: Tool = { { x: (p.size + p.amplitude) / 200, y: (p.size + p.amplitude) / 200 }, ), ), + runMask: (ctx) => { + const img = requireSource(ctx); + const p = ctx.params; + return renderShapeMask( + img.width, + img.height, + wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase), + p.offset.x / 100, + p.offset.y / 100, + { x: (p.size + p.amplitude) / 200, y: (p.size + p.amplitude) / 200 }, + ); + }, }; interface EmptyParams {} @@ -362,6 +428,10 @@ const removeAlphaChannel: Tool = { schema: removeAlphaChannelSchema, input: "image", run: imgTool((img) => flattenOntoColor(img, "#ffffff")), + runMask: (ctx) => + renderPredicateMask(requireSource(ctx), (_r, _g, _b, a) => a < 255, { + mode: "binary", + }), }; interface SetAlphaChannelParams { @@ -413,6 +483,7 @@ const roundCornersTool: Tool = { schema: roundCornersSchema, input: "image", run: imgTool((img, p) => roundCorners(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => roundCorners(img, p.radius)), }; export const invertAlphaSchema = toolSchema({}); @@ -422,6 +493,7 @@ const invertAlphaTool: Tool = { schema: invertAlphaSchema, input: "image", run: imgTool((img) => invertAlpha(img)), + runMask: (ctx) => alphaOfResult(ctx, invertAlpha), }; interface RemoveBackgroundParams { @@ -476,6 +548,15 @@ const removeBackgroundTool: Tool = { smoothPasses: p.smooth, }), ), + runMask: (ctx) => { + const p = ctx.params; + return backgroundMaskPreview(requireSource(ctx), { + color: p.color, + tolerancePercent: p.tolerance, + outerOnly: p.outerOnly, + smoothPasses: p.smooth, + }); + }, }; interface MakeThickerParams { @@ -497,6 +578,7 @@ const makeThickerTool: Tool = { schema: makeThickerSchema, input: "image", run: imgTool((img, p) => dilateImage(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => dilateImage(img, p.radius)), }; interface MakeThinnerParams { @@ -518,6 +600,7 @@ const makeThinnerTool: Tool = { schema: makeThinnerSchema, input: "image", run: imgTool((img, p) => erodeImage(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => erodeImage(img, p.radius)), }; interface FeatherEdgesParams { @@ -539,6 +622,7 @@ const featherEdgesTool: Tool = { schema: featherEdgesSchema, input: "image", run: imgTool((img, p) => featherAlpha(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => featherAlpha(img, p.radius)), }; interface CleanEdgesParams { @@ -560,6 +644,10 @@ const cleanEdgesTool: Tool = { schema: cleanEdgesSchema, input: "image", run: imgTool((img, p) => defringe(img, p.radius)), + runMask: (ctx) => { + const img = requireSource(ctx); + return diffMask(img, defringe(img, ctx.params.radius)); + }, }; interface HardenAlphaParams { @@ -581,6 +669,8 @@ const hardenAlphaTool: Tool = { schema: hardenAlphaSchema, input: "image", run: imgTool((img, p) => hardenAlpha(img, p.threshold)), + runMask: (ctx) => + alphaOfResult(ctx, (img, p) => hardenAlpha(img, p.threshold)), }; interface DespeckleAlphaParams { @@ -602,6 +692,7 @@ const despeckleAlphaTool: Tool = { schema: despeckleAlphaSchema, input: "image", run: imgTool((img, p) => openingImage(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => openingImage(img, p.radius)), }; interface CloseHolesParams { @@ -623,6 +714,7 @@ const closeHolesTool: Tool = { schema: closeHolesSchema, input: "image", run: imgTool((img, p) => closingImage(img, p.radius)), + runMask: (ctx) => alphaOfResult(ctx, (img, p) => closingImage(img, p.radius)), }; export const alphaTools = [ diff --git a/web/src/lib/registry/tools/analyze.ts b/web/src/lib/registry/tools/analyze.ts index 9bb7da8..c5fe041 100644 --- a/web/src/lib/registry/tools/analyze.ts +++ b/web/src/lib/registry/tools/analyze.ts @@ -13,7 +13,8 @@ import { } from "../../core/masks"; import { base64ToBytes, looksLikePng, stripDataUri } from "../../core/textio"; import { field, toolSchema } from "../../registry-schema"; -import { imgTool, textGen, type Tool } from "../types"; +import { colorMask } from "../../core/alpha"; +import { imgTool, requireSource, textGen, type Tool } from "../types"; interface ExtractColorParams { color: string; @@ -36,6 +37,10 @@ const extractColor: Tool = { schema: extractColorSchema, input: "image", run: imgTool((img, p) => extractByColor(img, p.color, p.tolerance)), + runMask: (ctx) => { + const img = requireSource(ctx); + return colorMask(img, ctx.params.color, ctx.params.tolerance); + }, }; interface MaskParams { diff --git a/web/src/lib/registry/tools/filters.ts b/web/src/lib/registry/tools/filters.ts index 067a9d7..bf09c73 100644 --- a/web/src/lib/registry/tools/filters.ts +++ b/web/src/lib/registry/tools/filters.ts @@ -1,5 +1,5 @@ import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution"; -import { vignette } from "../../core/effects"; +import { vignette, vignetteMask } from "../../core/effects"; import { jpegRoundtrip } from "../../core/io"; import { addNoise, @@ -8,7 +8,7 @@ import { silhouette, } from "../../core/pixel-fx"; import { field, toolSchema } from "../../registry-schema"; -import { imgTool, type Tool } from "../types"; +import { imgTool, requireSource, type Tool } from "../types"; interface BlurParams { radius: number; @@ -94,6 +94,10 @@ const vignetteTool: Tool = { schema: vignetteSchema, input: "image", run: imgTool((img, p) => vignette(img, p.strength)), + runMask: (ctx) => { + const img = requireSource(ctx); + return vignetteMask(img.width, img.height, ctx.params.strength); + }, }; interface PixelateParams { diff --git a/web/src/lib/registry/tools/geometry.ts b/web/src/lib/registry/tools/geometry.ts index c4774c1..2561cc2 100644 --- a/web/src/lib/registry/tools/geometry.ts +++ b/web/src/lib/registry/tools/geometry.ts @@ -24,6 +24,7 @@ import { type Anchor9, type FlipAxis, } from "../../core/geometry"; +import { renderPredicateMask } from "../../core/masks"; import { field, toolSchema, type Dimension } from "../../registry-schema"; import { imgTool, @@ -547,6 +548,10 @@ const centerByAlphaTool: Tool = { schema: centerByAlphaSchema, input: "image", run: imgTool((img) => centerByAlpha(img)), + runMask: (ctx) => + renderPredicateMask(requireSource(ctx), (_r, _g, _b, a) => a > 0, { + mode: "binary", + }), }; interface SkewParams { @@ -639,6 +644,12 @@ const trimEmptySpaceTool: Tool = { schema: trimEmptySpaceSchema, input: "image", run: imgTool((img, p) => trimToContent(img, p.threshold)), + runMask: (ctx) => + renderPredicateMask( + requireSource(ctx), + (_r, _g, _b, a) => a > ctx.params.threshold, + { mode: "binary" }, + ), }; type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16"; diff --git a/web/src/lib/registry/types.ts b/web/src/lib/registry/types.ts index 2ebbabe..3a9e574 100644 --- a/web/src/lib/registry/types.ts +++ b/web/src/lib/registry/types.ts @@ -67,6 +67,7 @@ export type Tool

> = { /** Needs the DOM (canvas/document); the preview executor runs it directly, not in a worker. */ domOnly?: boolean; output?: OutputFormat; + runMask?: (ctx: ToolContext

) => PixelImage; /** Note at the result tile when the outcome differs from requested (clamped, aspect-corrected, grew past canvas). */ resultNote?: (params: P, result: PixelImage) => ResultNote | null; };