mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add show mask chip for tools
This commit is contained in:
@@ -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/<group>.ts`):
|
||||
|
||||
+10
-13
@@ -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 слишком дерганный — при клике на звёздочку элементы
|
||||
|
||||
@@ -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();
|
||||
</script>
|
||||
|
||||
@@ -44,6 +46,9 @@
|
||||
{/if}
|
||||
</span>
|
||||
<span class="tile-note">
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{/if}
|
||||
{#if note}
|
||||
<Badge tone={noteTone} label={note} />
|
||||
{/if}
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
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}`}
|
||||
>
|
||||
<img src={toDataUrl(img)} alt="" />
|
||||
{#snippet actions()}
|
||||
{#if stepMaskable[i]}
|
||||
<ChipButton
|
||||
label={t("resultCard.maskToggle")}
|
||||
active={stepMaskOn[i] ?? false}
|
||||
onclick={() => ontogglestepmask?.(i)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<img
|
||||
src={toDataUrl(
|
||||
stepMaskOn[i] && stepMasks[i] ? (stepMasks[i] as PixelImage) : img,
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
</PreviewTile>
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -145,6 +174,9 @@
|
||||
{textVars}
|
||||
{toolId}
|
||||
{running}
|
||||
{mask}
|
||||
{maskOn}
|
||||
{ontogglemask}
|
||||
oncopy={oncopytext}
|
||||
{ondownloadtxt}
|
||||
/>
|
||||
|
||||
@@ -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<string, string | number>;
|
||||
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}
|
||||
<ChipButton
|
||||
label={t("resultCard.maskToggle")}
|
||||
active={maskOn}
|
||||
onclick={ontogglemask}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<div
|
||||
class="canvas"
|
||||
class:checker={resultKind === "image"}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { resolve } from "$app/paths";
|
||||
import StepCard from "$lib/components/display/StepCard.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||
import { ButtonVariantDefine } from "$lib/components/ui/define";
|
||||
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||
@@ -114,6 +115,10 @@
|
||||
let formatQuality = $state<Record<string, number>>({});
|
||||
// Optional download size limit (KB); undefined = no limit.
|
||||
let limitKb = $state<number | undefined>(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<string>();
|
||||
let maskResults = $state<Record<string, PixelImage | null>>({});
|
||||
let dragFrom = $state<number | null>(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<string> {
|
||||
@@ -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<string, PixelImage | null> = {};
|
||||
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 @@
|
||||
</StepCard>
|
||||
{/snippet}
|
||||
|
||||
{#snippet maskChip(i: number)}
|
||||
{#if stepMaskable[i]}
|
||||
<ChipButton
|
||||
label={t("resultCard.maskToggle")}
|
||||
active={stepMaskOn[i]}
|
||||
onclick={() => toggleStepMask(steps[i].key)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#if !alignedMode}
|
||||
<div class="workspace">
|
||||
<section class="settings">
|
||||
@@ -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}
|
||||
<img src={toDataUrl(out)} alt="" />
|
||||
<img
|
||||
src={toDataUrl(
|
||||
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
|
||||
)}
|
||||
alt=""
|
||||
/>
|
||||
{:else}
|
||||
<span class="empty">{t("resultCard.noResult")}</span>
|
||||
{/if}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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".
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ru: Dict = {
|
||||
breakChain: "✂ Оборвать цепочку",
|
||||
noResult: "результата пока нет",
|
||||
result: "Результат",
|
||||
maskToggle: "Маска",
|
||||
previewPanel: "Предпросмотр",
|
||||
parts: "частей",
|
||||
clamped: "уменьшено до границ изображения",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<P>(
|
||||
ctx: ToolContext<P>,
|
||||
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<RemoveColorParams> = {
|
||||
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<CircleMaskParams> = {
|
||||
{ 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<SquareMaskParams> = {
|
||||
{ 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<StarMaskParams> = {
|
||||
{ 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<WavyMaskParams> = {
|
||||
{ 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<EmptyParams> = {
|
||||
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<RoundCornersParams> = {
|
||||
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<EmptyParams>({});
|
||||
@@ -422,6 +493,7 @@ const invertAlphaTool: Tool<EmptyParams> = {
|
||||
schema: invertAlphaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => invertAlpha(img)),
|
||||
runMask: (ctx) => alphaOfResult(ctx, invertAlpha),
|
||||
};
|
||||
|
||||
interface RemoveBackgroundParams {
|
||||
@@ -476,6 +548,15 @@ const removeBackgroundTool: Tool<RemoveBackgroundParams> = {
|
||||
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<MakeThickerParams> = {
|
||||
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<MakeThinnerParams> = {
|
||||
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<FeatherEdgesParams> = {
|
||||
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<CleanEdgesParams> = {
|
||||
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<HardenAlphaParams> = {
|
||||
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<DespeckleAlphaParams> = {
|
||||
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<CloseHolesParams> = {
|
||||
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 = [
|
||||
|
||||
@@ -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<ExtractColorParams> = {
|
||||
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 {
|
||||
|
||||
@@ -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<VignetteParams> = {
|
||||
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 {
|
||||
|
||||
@@ -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<EmptyParams> = {
|
||||
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<TrimEmptySpaceParams> = {
|
||||
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";
|
||||
|
||||
@@ -67,6 +67,7 @@ export type Tool<P = Record<string, unknown>> = {
|
||||
/** Needs the DOM (canvas/document); the preview executor runs it directly, not in a worker. */
|
||||
domOnly?: boolean;
|
||||
output?: OutputFormat;
|
||||
runMask?: (ctx: ToolContext<P>) => 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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user