feat: add show mask chip for tools

This commit is contained in:
2026-10-09 14:09:51 +05:00
parent a51e224d02
commit c22c6e5574
22 changed files with 521 additions and 30 deletions
@@ -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}
+17 -1
View File
@@ -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);
});
});
+27
View File
@@ -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;
}
+24
View File
@@ -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]);
});
});
+24
View File
@@ -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".
*/
+22
View File
@@ -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);
});
});
+17
View File
@@ -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,
+30 -1
View File
@@ -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);
}
});
});
+61 -7
View File
@@ -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;
}
+1
View File
@@ -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",
+1
View File
@@ -100,6 +100,7 @@ export const ru: Dict = {
breakChain: "✂ Оборвать цепочку",
noResult: "результата пока нет",
result: "Результат",
maskToggle: "Маска",
previewPanel: "Предпросмотр",
parts: "частей",
clamped: "уменьшено до границ изображения",
+33
View File
@@ -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)) {
+94 -2
View File
@@ -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 = [
+6 -1
View File
@@ -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 {
+6 -2
View File
@@ -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 {
+11
View File
@@ -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";
+1
View File
@@ -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;
};