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"` — что отдаёт |
|
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
|
||||||
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
|
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
|
||||||
| `output`? | mime/ext/quality для скачивания (не у всех) |
|
| `output`? | mime/ext/quality для скачивания (не у всех) |
|
||||||
|
| `runMask`? | ч/б превью «что изменится»; тогл на тайле результата, в цепочку не идёт |
|
||||||
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
|
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
|
||||||
|
|
||||||
`Page` (записи в `registry/pages/<group>.ts`):
|
`Page` (записи в `registry/pages/<group>.ts`):
|
||||||
|
|||||||
+10
-13
@@ -131,24 +131,21 @@
|
|||||||
отдельное решение о формате.
|
отдельное решение о формате.
|
||||||
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
|
- [ ] **Идея 12. HARD-серия** (glitch, barcode, signature-extract,
|
||||||
handwritten-digital).
|
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 и качество
|
## UI и качество
|
||||||
|
|
||||||
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
- [ ] split to parts - посмотреть вариант с toggle component - может будет
|
||||||
удобнее чем sectioned buttons
|
удобнее чем sectioned buttons
|
||||||
|
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
|
||||||
|
заменить на Toggle-компонент, когда тот будет доделан (см. пункт про
|
||||||
|
визуал toggle в «Pipeline»): возможно, будет удобнее и понятнее
|
||||||
|
- [ ] маска подгружается с задержкой при нажатии на чип «Маска» — сейчас
|
||||||
|
`runMask` считается только для включённых шагов (эффект в
|
||||||
|
`SchemaToolView.svelte`). Альтернатива: считать маску всегда вместе с
|
||||||
|
результатом, по нажатию просто показывать другую картинку. Оценить
|
||||||
|
достоинства/недостатки: отклик мгновенный, но лишний проход по пикселям на
|
||||||
|
каждый ран каждого шага с `runMask` (на больших изображениях — заметная
|
||||||
|
цена за функцию, которую могут не открыть)
|
||||||
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
|
||||||
через drag-n-drop. звучит сложно, но надо придумать процесс
|
через drag-n-drop. звучит сложно, но надо придумать процесс
|
||||||
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
- [ ] fav: текущий ui слишком дерганный — при клике на звёздочку элементы
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
dims?: string;
|
dims?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
noteTone?: "warning" | "info";
|
noteTone?: "warning" | "info";
|
||||||
|
actions?: Snippet;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
label,
|
label,
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
dims = undefined,
|
dims = undefined,
|
||||||
note = undefined,
|
note = undefined,
|
||||||
noteTone = "warning",
|
noteTone = "warning",
|
||||||
|
actions = undefined,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span class="tile-note">
|
<span class="tile-note">
|
||||||
|
{#if actions}
|
||||||
|
{@render actions()}
|
||||||
|
{/if}
|
||||||
{#if note}
|
{#if note}
|
||||||
<Badge tone={noteTone} label={note} />
|
<Badge tone={noteTone} label={note} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
import SchemaSourceTile from "./SchemaSourceTile.svelte";
|
||||||
import PreviewTile from "./PreviewTile.svelte";
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
import Toggle from "$lib/components/ui/Toggle.svelte";
|
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||||
|
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||||
import { toDataUrl } from "$lib/core/io";
|
import { toDataUrl } from "$lib/core/io";
|
||||||
import type { PixelImage } from "$lib/core/types";
|
import type { PixelImage } from "$lib/core/types";
|
||||||
import type { OutputMime } from "$lib/core/io";
|
import type { OutputMime } from "$lib/core/io";
|
||||||
@@ -27,9 +28,16 @@
|
|||||||
textSource?: string;
|
textSource?: string;
|
||||||
textResult?: string | null;
|
textResult?: string | null;
|
||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
|
mask?: PixelImage | null;
|
||||||
|
maskOn?: boolean;
|
||||||
|
ontogglemask?: () => void;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
stepResults?: (PixelImage | null)[];
|
stepResults?: (PixelImage | null)[];
|
||||||
|
stepMaskable?: boolean[];
|
||||||
|
stepMaskOn?: boolean[];
|
||||||
|
stepMasks?: (PixelImage | null)[];
|
||||||
|
ontogglestepmask?: (i: number) => void;
|
||||||
aligned?: boolean;
|
aligned?: boolean;
|
||||||
ontogglealign?: () => void;
|
ontogglealign?: () => void;
|
||||||
format?: OutputMime;
|
format?: OutputMime;
|
||||||
@@ -57,9 +65,16 @@
|
|||||||
textSource = "",
|
textSource = "",
|
||||||
textResult = null,
|
textResult = null,
|
||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
|
mask = null,
|
||||||
|
maskOn = false,
|
||||||
|
ontogglemask,
|
||||||
running = false,
|
running = false,
|
||||||
error = "",
|
error = "",
|
||||||
stepResults = [],
|
stepResults = [],
|
||||||
|
stepMaskable = [],
|
||||||
|
stepMaskOn = [],
|
||||||
|
stepMasks = [],
|
||||||
|
ontogglestepmask,
|
||||||
aligned = false,
|
aligned = false,
|
||||||
ontogglealign,
|
ontogglealign,
|
||||||
format = "image/png",
|
format = "image/png",
|
||||||
@@ -132,7 +147,21 @@
|
|||||||
viewMode="image"
|
viewMode="image"
|
||||||
dims={`${img.width} × ${img.height}`}
|
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>
|
</PreviewTile>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
@@ -145,6 +174,9 @@
|
|||||||
{textVars}
|
{textVars}
|
||||||
{toolId}
|
{toolId}
|
||||||
{running}
|
{running}
|
||||||
|
{mask}
|
||||||
|
{maskOn}
|
||||||
|
{ontogglemask}
|
||||||
oncopy={oncopytext}
|
oncopy={oncopytext}
|
||||||
{ondownloadtxt}
|
{ondownloadtxt}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { t } from "$lib/i18n/t";
|
import { t } from "$lib/i18n/t";
|
||||||
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
|
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
|
||||||
import { Check } from "@lucide/svelte";
|
import { Check } from "@lucide/svelte";
|
||||||
|
import ChipButton from "$lib/components/ui/ChipButton.svelte";
|
||||||
import SchemaTextResult from "./SchemaTextResult.svelte";
|
import SchemaTextResult from "./SchemaTextResult.svelte";
|
||||||
import PreviewTile from "./PreviewTile.svelte";
|
import PreviewTile from "./PreviewTile.svelte";
|
||||||
|
|
||||||
@@ -16,6 +17,9 @@
|
|||||||
textVars?: Record<string, string | number>;
|
textVars?: Record<string, string | number>;
|
||||||
toolId?: string;
|
toolId?: string;
|
||||||
running?: boolean;
|
running?: boolean;
|
||||||
|
mask?: PixelImage | null;
|
||||||
|
maskOn?: boolean;
|
||||||
|
ontogglemask?: () => void;
|
||||||
oncopy?: () => void;
|
oncopy?: () => void;
|
||||||
ondownloadtxt?: () => void;
|
ondownloadtxt?: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,11 +32,15 @@
|
|||||||
textVars = undefined,
|
textVars = undefined,
|
||||||
toolId = "",
|
toolId = "",
|
||||||
running = false,
|
running = false,
|
||||||
|
mask = null,
|
||||||
|
maskOn = false,
|
||||||
|
ontogglemask,
|
||||||
oncopy,
|
oncopy,
|
||||||
ondownloadtxt,
|
ondownloadtxt,
|
||||||
}: Props = $props();
|
}: 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(
|
const resultDims = $derived(
|
||||||
resultKind === "image" && result
|
resultKind === "image" && result
|
||||||
? `${result.width} × ${result.height}`
|
? `${result.width} × ${result.height}`
|
||||||
@@ -68,6 +76,15 @@
|
|||||||
note={resultNote ? t(resultNote.key) : undefined}
|
note={resultNote ? t(resultNote.key) : undefined}
|
||||||
noteTone={resultNote?.tone ?? "warning"}
|
noteTone={resultNote?.tone ?? "warning"}
|
||||||
>
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
{#if ontogglemask}
|
||||||
|
<ChipButton
|
||||||
|
label={t("resultCard.maskToggle")}
|
||||||
|
active={maskOn}
|
||||||
|
onclick={ontogglemask}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
<div
|
<div
|
||||||
class="canvas"
|
class="canvas"
|
||||||
class:checker={resultKind === "image"}
|
class:checker={resultKind === "image"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import StepCard from "$lib/components/display/StepCard.svelte";
|
import StepCard from "$lib/components/display/StepCard.svelte";
|
||||||
import Button from "$lib/components/ui/Button.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 { ButtonVariantDefine } from "$lib/components/ui/define";
|
||||||
import Toggle from "$lib/components/ui/Toggle.svelte";
|
import Toggle from "$lib/components/ui/Toggle.svelte";
|
||||||
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
import { isFav, toggleFav } from "$lib/favorites.svelte";
|
||||||
@@ -114,6 +115,10 @@
|
|||||||
let formatQuality = $state<Record<string, number>>({});
|
let formatQuality = $state<Record<string, number>>({});
|
||||||
// Optional download size limit (KB); undefined = no limit.
|
// Optional download size limit (KB); undefined = no limit.
|
||||||
let limitKb = $state<number | undefined>(undefined);
|
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);
|
let dragFrom = $state<number | null>(null);
|
||||||
|
|
||||||
const lastTool = $derived(
|
const lastTool = $derived(
|
||||||
@@ -165,6 +170,10 @@
|
|||||||
: current.text;
|
: 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);
|
const debouncedRun = debounce(() => run(), 200);
|
||||||
|
|
||||||
function touchedFor(key: string): SvelteSet<string> {
|
function touchedFor(key: string): SvelteSet<string> {
|
||||||
@@ -209,6 +218,8 @@
|
|||||||
textResult = null;
|
textResult = null;
|
||||||
verdictVars = undefined;
|
verdictVars = undefined;
|
||||||
displayError = null;
|
displayError = null;
|
||||||
|
maskOnKeys.clear();
|
||||||
|
maskResults = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStepValue(
|
function setStepValue(
|
||||||
@@ -517,6 +528,41 @@
|
|||||||
return () => debouncedRun.cancel();
|
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 {
|
function toDisplayError(e: unknown): DisplayError {
|
||||||
if (e instanceof ToolError) {
|
if (e instanceof ToolError) {
|
||||||
return { kind: "i18n", key: e.key, vars: e.vars };
|
return { kind: "i18n", key: e.key, vars: e.vars };
|
||||||
@@ -614,6 +660,16 @@
|
|||||||
</StepCard>
|
</StepCard>
|
||||||
{/snippet}
|
{/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}
|
{#if !alignedMode}
|
||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<section class="settings">
|
<section class="settings">
|
||||||
@@ -648,6 +704,15 @@
|
|||||||
{alphaLoss}
|
{alphaLoss}
|
||||||
{stepResults}
|
{stepResults}
|
||||||
{aligned}
|
{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)}
|
ontogglealign={() => (aligned = !aligned)}
|
||||||
onformat={(v) => (format = v)}
|
onformat={(v) => (format = v)}
|
||||||
onquality={setFormatQuality}
|
onquality={setFormatQuality}
|
||||||
@@ -746,6 +811,11 @@
|
|||||||
textVars={verdictVars}
|
textVars={verdictVars}
|
||||||
toolId={lastTool.id}
|
toolId={lastTool.id}
|
||||||
{running}
|
{running}
|
||||||
|
mask={stepMasks[i] ?? null}
|
||||||
|
maskOn={stepMaskOn[i] ?? false}
|
||||||
|
ontogglemask={stepMaskable[i]
|
||||||
|
? () => toggleStepMask(steps[i].key)
|
||||||
|
: undefined}
|
||||||
oncopy={copyText}
|
oncopy={copyText}
|
||||||
ondownloadtxt={downloadText}
|
ondownloadtxt={downloadText}
|
||||||
/>
|
/>
|
||||||
@@ -755,8 +825,16 @@
|
|||||||
viewMode="image"
|
viewMode="image"
|
||||||
dims={out ? `${out.width} × ${out.height}` : undefined}
|
dims={out ? `${out.width} × ${out.height}` : undefined}
|
||||||
>
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
{@render maskChip(i)}
|
||||||
|
{/snippet}
|
||||||
{#if out}
|
{#if out}
|
||||||
<img src={toDataUrl(out)} alt="" />
|
<img
|
||||||
|
src={toDataUrl(
|
||||||
|
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
|
||||||
|
)}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="empty">{t("resultCard.noResult")}</span>
|
<span class="empty">{t("resultCard.noResult")}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { vignette } from "./effects";
|
import { vignette, vignetteMask } from "./effects";
|
||||||
import { makeImage } from "./test-helpers";
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe("vignette", () => {
|
describe("vignette", () => {
|
||||||
@@ -17,3 +17,19 @@ describe("vignette", () => {
|
|||||||
expect(centerR).toBeGreaterThan(150);
|
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;
|
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 { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
diffMask,
|
||||||
extractByColor,
|
extractByColor,
|
||||||
isGrayscaleish,
|
isGrayscaleish,
|
||||||
luma01,
|
luma01,
|
||||||
@@ -74,3 +75,26 @@ describe("rarityPredicate + extractByColor", () => {
|
|||||||
expect(out.data[7]).toBe(255);
|
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;
|
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".
|
* Counts color frequencies and returns a predicate "occurs at most limit times".
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
buildAlphaMask,
|
buildAlphaMask,
|
||||||
closingImage,
|
closingImage,
|
||||||
contourImage,
|
contourImage,
|
||||||
|
contourMask,
|
||||||
dilateImage,
|
dilateImage,
|
||||||
dilateMask,
|
dilateMask,
|
||||||
erodeImage,
|
erodeImage,
|
||||||
@@ -189,3 +190,24 @@ describe("contourImage", () => {
|
|||||||
expect(at(1, 3)).toEqual([0, 0, 0, 0]);
|
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;
|
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(
|
export function openingMask(
|
||||||
mask: Mask,
|
mask: Mask,
|
||||||
w: number,
|
w: number,
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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";
|
import { makeImage } from "./test-helpers";
|
||||||
|
|
||||||
describe("shape predicates", () => {
|
describe("shape predicates", () => {
|
||||||
@@ -88,3 +95,25 @@ describe("renderShape", () => {
|
|||||||
expect(opaqueAt(2, 1)).toBe(false);
|
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
|
* 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
|
* outside.
|
||||||
* to the edge regardless of size. Free space derives from extent (half-size).
|
|
||||||
*/
|
*/
|
||||||
export function renderShape(
|
export function renderShape(
|
||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
@@ -71,11 +92,13 @@ export function renderShape(
|
|||||||
extent: ShapeExtent = { x: 0, y: 0 },
|
extent: ShapeExtent = { x: 0, y: 0 },
|
||||||
): PixelImage {
|
): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
const minDim = Math.min(img.width, img.height);
|
const { cx, cy, minDim } = shapeOrigin(
|
||||||
const freeX = Math.max(0, img.width / (2 * minDim) - extent.x);
|
img.width,
|
||||||
const freeY = Math.max(0, img.height / (2 * minDim) - extent.y);
|
img.height,
|
||||||
const cx = img.width / 2 + offsetXFrac * 2 * freeX * minDim;
|
offsetXFrac,
|
||||||
const cy = img.height / 2 + offsetYFrac * 2 * freeY * minDim;
|
offsetYFrac,
|
||||||
|
extent,
|
||||||
|
);
|
||||||
for (let y = 0; y < img.height; y++) {
|
for (let y = 0; y < img.height; y++) {
|
||||||
for (let x = 0; x < img.width; x++) {
|
for (let x = 0; x < img.width; x++) {
|
||||||
const di = (y * img.width + x) * 4;
|
const di = (y * img.width + x) * 4;
|
||||||
@@ -88,3 +111,34 @@ export function renderShape(
|
|||||||
}
|
}
|
||||||
return out;
|
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",
|
breakChain: "✂ Break the chain",
|
||||||
noResult: "no result yet",
|
noResult: "no result yet",
|
||||||
result: "Result",
|
result: "Result",
|
||||||
|
maskToggle: "Mask",
|
||||||
previewPanel: "Preview",
|
previewPanel: "Preview",
|
||||||
parts: "parts",
|
parts: "parts",
|
||||||
clamped: "clamped to image bounds",
|
clamped: "clamped to image bounds",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export const ru: Dict = {
|
|||||||
breakChain: "✂ Оборвать цепочку",
|
breakChain: "✂ Оборвать цепочку",
|
||||||
noResult: "результата пока нет",
|
noResult: "результата пока нет",
|
||||||
result: "Результат",
|
result: "Результат",
|
||||||
|
maskToggle: "Маска",
|
||||||
previewPanel: "Предпросмотр",
|
previewPanel: "Предпросмотр",
|
||||||
parts: "частей",
|
parts: "частей",
|
||||||
clamped: "уменьшено до границ изображения",
|
clamped: "уменьшено до границ изображения",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { PAGES, TOOLS } from ".";
|
|||||||
import { PREVIEW_GROUPS } from "../catalog";
|
import { PREVIEW_GROUPS } from "../catalog";
|
||||||
import type { PixelImage } from "../core/types";
|
import type { PixelImage } from "../core/types";
|
||||||
import type { OutputMime } from "../core/io";
|
import type { OutputMime } from "../core/io";
|
||||||
|
import { makeImage } from "../core/test-helpers";
|
||||||
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||||
import type { FileResult, ToolResult } from "./types";
|
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", () => {
|
it("source-aware dimension defaults apply only to image tools", () => {
|
||||||
for (const tool of TOOLS) {
|
for (const tool of TOOLS) {
|
||||||
for (const field of Object.values(tool.schema.fields)) {
|
for (const field of Object.values(tool.schema.fields)) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
colorMask,
|
||||||
extractAlphaMask,
|
extractAlphaMask,
|
||||||
flattenOntoColor,
|
flattenOntoColor,
|
||||||
hardenAlpha,
|
hardenAlpha,
|
||||||
@@ -7,10 +8,12 @@ import {
|
|||||||
roundCorners,
|
roundCorners,
|
||||||
setAlphaChannel,
|
setAlphaChannel,
|
||||||
} from "../../core/alpha";
|
} from "../../core/alpha";
|
||||||
import { removeBackground } from "../../core/background";
|
import { backgroundMaskPreview, removeBackground } from "../../core/background";
|
||||||
|
import { diffMask, renderPredicateMask } from "../../core/masks";
|
||||||
import {
|
import {
|
||||||
closingImage,
|
closingImage,
|
||||||
contourImage,
|
contourImage,
|
||||||
|
contourMask,
|
||||||
dilateImage,
|
dilateImage,
|
||||||
erodeImage,
|
erodeImage,
|
||||||
openingImage,
|
openingImage,
|
||||||
@@ -21,12 +24,23 @@ import {
|
|||||||
boxTest,
|
boxTest,
|
||||||
circleTest,
|
circleTest,
|
||||||
renderShape,
|
renderShape,
|
||||||
|
renderShapeMask,
|
||||||
starTest,
|
starTest,
|
||||||
wavyTest,
|
wavyTest,
|
||||||
} from "../../core/shapes";
|
} from "../../core/shapes";
|
||||||
|
import type { PixelImage } from "../../core/types";
|
||||||
import type { Offset } from "../../registry-schema";
|
import type { Offset } from "../../registry-schema";
|
||||||
import { field, toolSchema } 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 {
|
interface AddStrokeParams {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -95,6 +109,10 @@ const removeColor: Tool<RemoveColorParams> = {
|
|||||||
schema: removeColorSchema,
|
schema: removeColorSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance)),
|
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 {
|
interface CircleMaskParams {
|
||||||
@@ -142,6 +160,18 @@ const circleMask: Tool<CircleMaskParams> = {
|
|||||||
{ x: p.size / 200, y: p.size / 200 },
|
{ 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 {
|
interface SquareMaskParams {
|
||||||
@@ -201,6 +231,18 @@ const squareMask: Tool<SquareMaskParams> = {
|
|||||||
{ x: p.widthPct / 200, y: p.heightPct / 200 },
|
{ 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 {
|
interface StarMaskParams {
|
||||||
@@ -276,6 +318,18 @@ const starMask: Tool<StarMaskParams> = {
|
|||||||
{ x: p.size / 200, y: p.size / 200 },
|
{ 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 {
|
interface WavyMaskParams {
|
||||||
@@ -351,6 +405,18 @@ const wavyMask: Tool<WavyMaskParams> = {
|
|||||||
{ x: (p.size + p.amplitude) / 200, y: (p.size + p.amplitude) / 200 },
|
{ 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 {}
|
interface EmptyParams {}
|
||||||
@@ -362,6 +428,10 @@ const removeAlphaChannel: Tool<EmptyParams> = {
|
|||||||
schema: removeAlphaChannelSchema,
|
schema: removeAlphaChannelSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img) => flattenOntoColor(img, "#ffffff")),
|
run: imgTool((img) => flattenOntoColor(img, "#ffffff")),
|
||||||
|
runMask: (ctx) =>
|
||||||
|
renderPredicateMask(requireSource(ctx), (_r, _g, _b, a) => a < 255, {
|
||||||
|
mode: "binary",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SetAlphaChannelParams {
|
interface SetAlphaChannelParams {
|
||||||
@@ -413,6 +483,7 @@ const roundCornersTool: Tool<RoundCornersParams> = {
|
|||||||
schema: roundCornersSchema,
|
schema: roundCornersSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => roundCorners(img, p.radius)),
|
run: imgTool((img, p) => roundCorners(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => roundCorners(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const invertAlphaSchema = toolSchema<EmptyParams>({});
|
export const invertAlphaSchema = toolSchema<EmptyParams>({});
|
||||||
@@ -422,6 +493,7 @@ const invertAlphaTool: Tool<EmptyParams> = {
|
|||||||
schema: invertAlphaSchema,
|
schema: invertAlphaSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img) => invertAlpha(img)),
|
run: imgTool((img) => invertAlpha(img)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, invertAlpha),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface RemoveBackgroundParams {
|
interface RemoveBackgroundParams {
|
||||||
@@ -476,6 +548,15 @@ const removeBackgroundTool: Tool<RemoveBackgroundParams> = {
|
|||||||
smoothPasses: p.smooth,
|
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 {
|
interface MakeThickerParams {
|
||||||
@@ -497,6 +578,7 @@ const makeThickerTool: Tool<MakeThickerParams> = {
|
|||||||
schema: makeThickerSchema,
|
schema: makeThickerSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => dilateImage(img, p.radius)),
|
run: imgTool((img, p) => dilateImage(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => dilateImage(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MakeThinnerParams {
|
interface MakeThinnerParams {
|
||||||
@@ -518,6 +600,7 @@ const makeThinnerTool: Tool<MakeThinnerParams> = {
|
|||||||
schema: makeThinnerSchema,
|
schema: makeThinnerSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => erodeImage(img, p.radius)),
|
run: imgTool((img, p) => erodeImage(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => erodeImage(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatherEdgesParams {
|
interface FeatherEdgesParams {
|
||||||
@@ -539,6 +622,7 @@ const featherEdgesTool: Tool<FeatherEdgesParams> = {
|
|||||||
schema: featherEdgesSchema,
|
schema: featherEdgesSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => featherAlpha(img, p.radius)),
|
run: imgTool((img, p) => featherAlpha(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => featherAlpha(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CleanEdgesParams {
|
interface CleanEdgesParams {
|
||||||
@@ -560,6 +644,10 @@ const cleanEdgesTool: Tool<CleanEdgesParams> = {
|
|||||||
schema: cleanEdgesSchema,
|
schema: cleanEdgesSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => defringe(img, p.radius)),
|
run: imgTool((img, p) => defringe(img, p.radius)),
|
||||||
|
runMask: (ctx) => {
|
||||||
|
const img = requireSource(ctx);
|
||||||
|
return diffMask(img, defringe(img, ctx.params.radius));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
interface HardenAlphaParams {
|
interface HardenAlphaParams {
|
||||||
@@ -581,6 +669,8 @@ const hardenAlphaTool: Tool<HardenAlphaParams> = {
|
|||||||
schema: hardenAlphaSchema,
|
schema: hardenAlphaSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => hardenAlpha(img, p.threshold)),
|
run: imgTool((img, p) => hardenAlpha(img, p.threshold)),
|
||||||
|
runMask: (ctx) =>
|
||||||
|
alphaOfResult(ctx, (img, p) => hardenAlpha(img, p.threshold)),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface DespeckleAlphaParams {
|
interface DespeckleAlphaParams {
|
||||||
@@ -602,6 +692,7 @@ const despeckleAlphaTool: Tool<DespeckleAlphaParams> = {
|
|||||||
schema: despeckleAlphaSchema,
|
schema: despeckleAlphaSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => openingImage(img, p.radius)),
|
run: imgTool((img, p) => openingImage(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => openingImage(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface CloseHolesParams {
|
interface CloseHolesParams {
|
||||||
@@ -623,6 +714,7 @@ const closeHolesTool: Tool<CloseHolesParams> = {
|
|||||||
schema: closeHolesSchema,
|
schema: closeHolesSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => closingImage(img, p.radius)),
|
run: imgTool((img, p) => closingImage(img, p.radius)),
|
||||||
|
runMask: (ctx) => alphaOfResult(ctx, (img, p) => closingImage(img, p.radius)),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const alphaTools = [
|
export const alphaTools = [
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from "../../core/masks";
|
} from "../../core/masks";
|
||||||
import { base64ToBytes, looksLikePng, stripDataUri } from "../../core/textio";
|
import { base64ToBytes, looksLikePng, stripDataUri } from "../../core/textio";
|
||||||
import { field, toolSchema } from "../../registry-schema";
|
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 {
|
interface ExtractColorParams {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -36,6 +37,10 @@ const extractColor: Tool<ExtractColorParams> = {
|
|||||||
schema: extractColorSchema,
|
schema: extractColorSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => extractByColor(img, p.color, p.tolerance)),
|
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 {
|
interface MaskParams {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { gaussianBlur, sharpen as sharpenImage } from "../../core/convolution";
|
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 { jpegRoundtrip } from "../../core/io";
|
||||||
import {
|
import {
|
||||||
addNoise,
|
addNoise,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
silhouette,
|
silhouette,
|
||||||
} from "../../core/pixel-fx";
|
} from "../../core/pixel-fx";
|
||||||
import { field, toolSchema } from "../../registry-schema";
|
import { field, toolSchema } from "../../registry-schema";
|
||||||
import { imgTool, type Tool } from "../types";
|
import { imgTool, requireSource, type Tool } from "../types";
|
||||||
|
|
||||||
interface BlurParams {
|
interface BlurParams {
|
||||||
radius: number;
|
radius: number;
|
||||||
@@ -94,6 +94,10 @@ const vignetteTool: Tool<VignetteParams> = {
|
|||||||
schema: vignetteSchema,
|
schema: vignetteSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => vignette(img, p.strength)),
|
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 {
|
interface PixelateParams {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type Anchor9,
|
type Anchor9,
|
||||||
type FlipAxis,
|
type FlipAxis,
|
||||||
} from "../../core/geometry";
|
} from "../../core/geometry";
|
||||||
|
import { renderPredicateMask } from "../../core/masks";
|
||||||
import { field, toolSchema, type Dimension } from "../../registry-schema";
|
import { field, toolSchema, type Dimension } from "../../registry-schema";
|
||||||
import {
|
import {
|
||||||
imgTool,
|
imgTool,
|
||||||
@@ -547,6 +548,10 @@ const centerByAlphaTool: Tool<EmptyParams> = {
|
|||||||
schema: centerByAlphaSchema,
|
schema: centerByAlphaSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img) => centerByAlpha(img)),
|
run: imgTool((img) => centerByAlpha(img)),
|
||||||
|
runMask: (ctx) =>
|
||||||
|
renderPredicateMask(requireSource(ctx), (_r, _g, _b, a) => a > 0, {
|
||||||
|
mode: "binary",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SkewParams {
|
interface SkewParams {
|
||||||
@@ -639,6 +644,12 @@ const trimEmptySpaceTool: Tool<TrimEmptySpaceParams> = {
|
|||||||
schema: trimEmptySpaceSchema,
|
schema: trimEmptySpaceSchema,
|
||||||
input: "image",
|
input: "image",
|
||||||
run: imgTool((img, p) => trimToContent(img, p.threshold)),
|
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";
|
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. */
|
/** Needs the DOM (canvas/document); the preview executor runs it directly, not in a worker. */
|
||||||
domOnly?: boolean;
|
domOnly?: boolean;
|
||||||
output?: OutputFormat;
|
output?: OutputFormat;
|
||||||
|
runMask?: (ctx: ToolContext<P>) => PixelImage;
|
||||||
/** Note at the result tile when the outcome differs from requested (clamped, aspect-corrected, grew past canvas). */
|
/** Note at the result tile when the outcome differs from requested (clamped, aspect-corrected, grew past canvas). */
|
||||||
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
|
resultNote?: (params: P, result: PixelImage) => ResultNote | null;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user