refactor: separate registry file
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { ToolError } from "./core/errors";
|
||||
|
||||
export function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
if (typeof v !== "number" || !Number.isFinite(v)) {
|
||||
throw new ToolError("errors.paramNumber", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function str(params: Record<string, unknown>, id: string): string {
|
||||
const v = params[id];
|
||||
if (typeof v !== "string") {
|
||||
throw new ToolError("errors.paramString", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function bool(params: Record<string, unknown>, id: string): boolean {
|
||||
const v = params[id];
|
||||
if (typeof v !== "boolean") {
|
||||
throw new ToolError("errors.paramBool", { id });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { CategoryId } from "../categories";
|
||||
import type { ToolSchema } from "../registry-schema";
|
||||
import type { PixelImage } from "../core/types";
|
||||
|
||||
/**
|
||||
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||
* обязательна (единый источник дефолтов/валидации/UI). Старого `ParamDef[]`
|
||||
* нет — это отдельная система от старого UI.
|
||||
*/
|
||||
export type ToolEntry<P = Record<string, unknown>> = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
schema: ToolSchema<P>;
|
||||
run: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
||||
generate?: (params: P) => Promise<PixelImage> | PixelImage;
|
||||
toText?: (img: PixelImage, params: P) => Promise<string> | string;
|
||||
runFromText?: (text: string, params: P) => Promise<PixelImage> | PixelImage;
|
||||
textToText?: (text: string) => Promise<string> | string;
|
||||
preview?: (img: PixelImage, params: P) => Promise<PixelImage> | PixelImage;
|
||||
icon?: string;
|
||||
};
|
||||
+42
-3773
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { ToolError } from "../core/errors";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import {
|
||||
colorMask,
|
||||
extractAlphaMask,
|
||||
flattenOntoColor,
|
||||
hardenAlpha,
|
||||
invertAlpha,
|
||||
removeColorToAlpha,
|
||||
roundCorners,
|
||||
setAlphaChannel,
|
||||
} from "../core/alpha";
|
||||
import { backgroundMaskPreview, removeBackground } from "../core/background";
|
||||
import {
|
||||
closingImage,
|
||||
contourImage,
|
||||
dilateImage,
|
||||
erodeImage,
|
||||
openingImage,
|
||||
strokeImage,
|
||||
} from "../core/morphology";
|
||||
import { featherAlpha, defringe } from "../core/pixel-fx";
|
||||
import { drawImageWatermark } from "../core/domText";
|
||||
import { getOverlay } from "../tools/overlay-store.svelte";
|
||||
import {
|
||||
renderShape,
|
||||
circleTest,
|
||||
boxTest,
|
||||
starTest,
|
||||
wavyTest,
|
||||
} from "../core/shapes";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
interface AddStrokeParams {
|
||||
color: string;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
const addStrokeSchema = toolSchema<AddStrokeParams>({
|
||||
color: field.color({ default: "#ff0000" }),
|
||||
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
|
||||
});
|
||||
|
||||
export function alphaEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "circle-mask-png",
|
||||
title: "Circle Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "size",
|
||||
label: "Diameter, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
circleTest(num(p, "size") / 200),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "square-mask-png",
|
||||
title: "Square Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a rectangle with sides as a share of the smaller side.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "widthPct",
|
||||
label: "Width, % of smaller side",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "heightPct",
|
||||
label: "Height, % of smaller side",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "star-mask-png",
|
||||
title: "Star Mask PNG",
|
||||
description:
|
||||
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "points",
|
||||
label: "Points",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 12,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "innerRadius",
|
||||
label: "Inner radius, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 1,
|
||||
default: 45,
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
label: "Outer radius, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "rotation",
|
||||
label: "Rotation, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
starTest(
|
||||
num(p, "points"),
|
||||
num(p, "innerRadius") / 100,
|
||||
num(p, "size") / 200,
|
||||
num(p, "rotation"),
|
||||
),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "wavy-mask-png",
|
||||
title: "Wavy Mask PNG",
|
||||
description:
|
||||
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "size",
|
||||
label: "Base radius, % of smaller side",
|
||||
type: "slider",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
{
|
||||
id: "amplitude",
|
||||
label: "Wave amplitude, %",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 30,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "waves",
|
||||
label: "Waves count",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 24,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "phase",
|
||||
label: "Phase, °",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 360,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, %",
|
||||
type: "slider",
|
||||
min: -50,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
wavyTest(
|
||||
num(p, "size") / 200,
|
||||
num(p, "amplitude") / 200,
|
||||
num(p, "waves"),
|
||||
num(p, "phase"),
|
||||
),
|
||||
num(p, "offsetX") / 100,
|
||||
num(p, "offsetY") / 100,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "remove-alpha-channel-png",
|
||||
title: "Remove alpha channel PNG",
|
||||
description:
|
||||
"Composites the image over a white background and saves without transparency.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, "#ffffff"),
|
||||
},
|
||||
{
|
||||
id: "set-alpha-channel-png",
|
||||
title: "Set alpha channel PNG",
|
||||
description:
|
||||
"Assigns the same opacity to all pixels; colors stay unchanged.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
run: (img, p) => setAlphaChannel(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "extract-alpha-mask-png",
|
||||
title: "Extract alpha mask PNG",
|
||||
description: "Turns transparency into a black-and-white opaque mask.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => extractAlphaMask(img),
|
||||
},
|
||||
{
|
||||
id: "round-corners-png",
|
||||
title: "Round corners PNG",
|
||||
description:
|
||||
"Clips corners by a radius set as a percentage of half the smaller side.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Corner radius, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) => roundCorners(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "invert-alpha-png",
|
||||
title: "Invert alpha PNG",
|
||||
description: "Opaque areas become transparent and vice versa.",
|
||||
category: "alpha",
|
||||
params: [],
|
||||
run: (img) => invertAlpha(img),
|
||||
},
|
||||
{
|
||||
id: "remove-background-png",
|
||||
title: "Remove background PNG (smart)",
|
||||
description:
|
||||
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity tolerance, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
{
|
||||
id: "outerOnly",
|
||||
label: "Outer regions only",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "smooth",
|
||||
label: "Edge smoothing, passes",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 8,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
removeBackground(img, {
|
||||
color: str(p, "color"),
|
||||
tolerancePercent: num(p, "tolerance"),
|
||||
outerOnly: p["outerOnly"] === true,
|
||||
smoothPasses: num(p, "smooth"),
|
||||
}),
|
||||
preview: (img, p) =>
|
||||
backgroundMaskPreview(img, {
|
||||
color: str(p, "color"),
|
||||
tolerancePercent: num(p, "tolerance"),
|
||||
outerOnly: p["outerOnly"] === true,
|
||||
smoothPasses: num(p, "smooth"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "add-stroke-png",
|
||||
title: "Outline PNG",
|
||||
description:
|
||||
"Adds a colored ring outline around the opaque content with the chosen thickness.",
|
||||
category: "alpha",
|
||||
schema: addStrokeSchema,
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Outline color",
|
||||
type: "color",
|
||||
default: "#ff0000",
|
||||
},
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Thickness, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")),
|
||||
},
|
||||
{
|
||||
id: "find-contour-png",
|
||||
title: "Find contour PNG",
|
||||
description:
|
||||
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Line color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Line thickness, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 5,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")),
|
||||
},
|
||||
{
|
||||
id: "make-thicker-png",
|
||||
title: "Thicken PNG",
|
||||
description: "Expands opaque areas by the given number of pixels.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Amount, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
],
|
||||
run: (img, p) => dilateImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "make-thinner-png",
|
||||
title: "Thin PNG",
|
||||
description:
|
||||
"Shrinks opaque areas — thins the strokes of text and details.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Amount, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => erodeImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "feather-edges-png",
|
||||
title: "Feather Edges PNG",
|
||||
description:
|
||||
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Feather radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 20,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => featherAlpha(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "clean-edges-png",
|
||||
title: "Clean Edges PNG (defringe)",
|
||||
description:
|
||||
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Search radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 10,
|
||||
step: 1,
|
||||
default: 3,
|
||||
},
|
||||
],
|
||||
run: (img, p) => defringe(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "harden-alpha-png",
|
||||
title: "Harden edges PNG",
|
||||
description:
|
||||
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Alpha threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => hardenAlpha(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "despeckle-alpha-png",
|
||||
title: "Despeckle PNG",
|
||||
description:
|
||||
"Opening: removes lone semi-transparent pixels and small specks.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Cleanup radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 3,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => openingImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "close-holes-png",
|
||||
title: "Close holes PNG",
|
||||
description: "Closing: fills lone transparent dots inside the object.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Closing radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 3,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => closingImage(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "remove-color-from-png",
|
||||
title: "Remove color from PNG (make transparent)",
|
||||
description:
|
||||
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
|
||||
category: "alpha",
|
||||
params: [
|
||||
{
|
||||
id: "targetColor",
|
||||
label: "Color to remove",
|
||||
type: "color",
|
||||
default: "#00ff00",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")),
|
||||
preview: (img, p) =>
|
||||
colorMask(img, str(p, "targetColor"), num(p, "tolerance")),
|
||||
},
|
||||
{
|
||||
id: "watermark-image-png",
|
||||
title: "Watermark Image PNG",
|
||||
description:
|
||||
"Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.",
|
||||
category: "alpha",
|
||||
domOnly: true,
|
||||
needsOverlaySource: true,
|
||||
params: [
|
||||
{
|
||||
id: "scale",
|
||||
label: "Mark width, % of canvas",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 24,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const mark = getOverlay();
|
||||
if (!mark) throw new ToolError("errors.noWatermark");
|
||||
return drawImageWatermark(img, {
|
||||
mark,
|
||||
scalePercent: num(p, "scale"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask,
|
||||
} from "../core/masks";
|
||||
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
|
||||
import { looksLikePng, base64ToBytes, stripDataUri } from "../core/textio";
|
||||
import { clonePixelImage, type PixelImage } from "../core/types";
|
||||
import { encode } from "../core/io";
|
||||
import { t } from "../i18n/t";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
type MaskToolSpec = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
defaultMode: "binary" | "highlight";
|
||||
predicate: (
|
||||
img: PixelImage,
|
||||
p: Record<string, unknown>,
|
||||
) => (r: number, g: number, b: number, a: number) => boolean;
|
||||
extraParams?: ToolEntry["params"];
|
||||
};
|
||||
|
||||
const MASK_TOOLS: MaskToolSpec[] = [
|
||||
{
|
||||
id: "show-transparent-png",
|
||||
title: "Show Transparent Areas PNG",
|
||||
description:
|
||||
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
|
||||
defaultMode: "highlight",
|
||||
predicate: (_img, _p) => (_r, _g, _b, a) => a < 255,
|
||||
},
|
||||
{
|
||||
id: "show-grayscale-pixels-png",
|
||||
title: "Show Grayscale Pixels PNG",
|
||||
description:
|
||||
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
isGrayscaleish(r, g, b, num(p, "tolerance")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Channel tolerance",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "show-color-pixels-png",
|
||||
title: "Show Color Pixels PNG",
|
||||
description:
|
||||
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
!isGrayscaleish(r, g, b, num(p, "tolerance")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Channel tolerance",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-pixel-mask-png",
|
||||
title: "Light Pixel Mask PNG",
|
||||
description: "Selects pixels brighter than the luminance threshold.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
luma01(r, g, b) >= num(p, "threshold") / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Luminance threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dark-pixel-mask-png",
|
||||
title: "Dark Pixel Mask PNG",
|
||||
description: "Selects pixels darker than the luminance threshold.",
|
||||
defaultMode: "binary",
|
||||
predicate: (_i, p) => (r, g, b) =>
|
||||
luma01(r, g, b) <= num(p, "threshold") / 100,
|
||||
extraParams: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Luminance threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "unique-color-mask-png",
|
||||
title: "Unique Color Mask PNG",
|
||||
description:
|
||||
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
|
||||
defaultMode: "binary",
|
||||
predicate: (img, p) => rarityPredicate(img, num(p, "rarity")),
|
||||
extraParams: [
|
||||
{
|
||||
id: "rarity",
|
||||
label: "Max occurrences",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function maskEntries(): ToolEntry[] {
|
||||
return MASK_TOOLS.map((spec) => ({
|
||||
id: spec.id,
|
||||
title: spec.title,
|
||||
description: spec.description,
|
||||
category: "analyze" as const,
|
||||
params: [
|
||||
...(spec.extraParams ?? []),
|
||||
{
|
||||
id: "mode",
|
||||
label: "Mask mode",
|
||||
type: "select" as const,
|
||||
default: spec.defaultMode,
|
||||
options: [
|
||||
{ value: "binary", label: "Black & white mask" },
|
||||
{ value: "highlight", label: "Color highlight" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Highlight color",
|
||||
type: "color",
|
||||
default: "#ff00aa",
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Highlight opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 70,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderPredicateMask(img, spec.predicate(img, p), {
|
||||
mode: str(p, "mode") === "highlight" ? "highlight" : "binary",
|
||||
color: str(p, "color"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function analyzeEntries(): ToolEntry[] {
|
||||
return [
|
||||
...maskEntries(),
|
||||
{
|
||||
id: "extract-color-from-png",
|
||||
title: "Extract Color from PNG",
|
||||
description:
|
||||
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
|
||||
category: "analyze",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Color to keep",
|
||||
type: "color",
|
||||
default: "#00ff88",
|
||||
},
|
||||
{
|
||||
id: "tolerance",
|
||||
label: "Similarity tolerance, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
extractByColor(img, str(p, "color"), num(p, "tolerance")),
|
||||
},
|
||||
{
|
||||
id: "verify-is-png",
|
||||
title: "Verify If Image Is a PNG",
|
||||
description:
|
||||
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
|
||||
category: "analyze",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
textToText: (text) =>
|
||||
looksLikePng(base64ToBytes(stripDataUri(text)))
|
||||
? "verifyYes"
|
||||
: "verifyNo",
|
||||
},
|
||||
{
|
||||
id: "png-info",
|
||||
title: "PNG info",
|
||||
description:
|
||||
"Shows dimensions, alpha presence and the number of unique colors of the uploaded image.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "info",
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-is-grayscale",
|
||||
title: "Check: is PNG grayscale?",
|
||||
description: "Reports whether the image consists only of shades of gray.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"),
|
||||
},
|
||||
{
|
||||
id: "png-file-size",
|
||||
title: "PNG File Size",
|
||||
description:
|
||||
"Encodes the image as PNG and reports the resulting file size.",
|
||||
category: "analyze",
|
||||
domOnly: true,
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: async (img) => {
|
||||
const blob = await encode(img, "image/png");
|
||||
const kb = blob.size / 1024;
|
||||
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
|
||||
return t("tools.png-file-size.results.line", {
|
||||
kb: kbText,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "png-is-transparent",
|
||||
title: "Check: is PNG transparent?",
|
||||
description:
|
||||
"Reports whether the image contains transparent or semi-transparent pixels.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) =>
|
||||
hasTransparency(img) ? "transparentYes" : "transparentNo",
|
||||
},
|
||||
{
|
||||
id: "png-orientation",
|
||||
title: "PNG orientation",
|
||||
description: "Reports whether it is portrait, landscape or square.",
|
||||
category: "analyze",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => {
|
||||
switch (orientationOf(img)) {
|
||||
case "portrait":
|
||||
return "orientationPortrait";
|
||||
case "landscape":
|
||||
return "orientationLandscape";
|
||||
default:
|
||||
return "orientationSquare";
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import {
|
||||
autoContrast,
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
gammaCorrection,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
temperature,
|
||||
tint,
|
||||
thresholdBlackWhite,
|
||||
twoColors,
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel,
|
||||
} from "../core/color";
|
||||
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
|
||||
import { quantizeImage, ditherImage, mapToNearest } from "../core/quantize";
|
||||
import { parseHexList } from "../core/palette";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
type SpaceEntry = {
|
||||
id: SpaceId;
|
||||
suffix: string;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const CHANNEL_SPACES: SpaceEntry[] = [
|
||||
{
|
||||
id: "hsl",
|
||||
suffix: "hsl",
|
||||
title: "Split PNG into HSL",
|
||||
description:
|
||||
"Decomposes the image into Hue, Saturation and Lightness components.",
|
||||
},
|
||||
{
|
||||
id: "hsv",
|
||||
suffix: "hsv",
|
||||
title: "Split PNG into HSV",
|
||||
description:
|
||||
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
|
||||
},
|
||||
{
|
||||
id: "hsi",
|
||||
suffix: "hsi",
|
||||
title: "Split PNG into HSI",
|
||||
description:
|
||||
"Decomposes the image into Hue, Saturation and Intensity components.",
|
||||
},
|
||||
{
|
||||
id: "cmyk",
|
||||
suffix: "cmyk",
|
||||
title: "Convert PNG to CMYK Colors",
|
||||
description:
|
||||
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
|
||||
},
|
||||
{
|
||||
id: "ycbcr",
|
||||
suffix: "ycbcr",
|
||||
title: "Convert PNG to YCbCr Colors",
|
||||
description:
|
||||
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
|
||||
},
|
||||
{
|
||||
id: "lab",
|
||||
suffix: "lab",
|
||||
title: "Convert PNG to LAB Colors",
|
||||
description:
|
||||
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
|
||||
},
|
||||
];
|
||||
|
||||
function channelEntries(): ToolEntry[] {
|
||||
return CHANNEL_SPACES.map((space) => {
|
||||
const components = SPACES[space.id].components;
|
||||
return {
|
||||
id: `png-to-${space.suffix}`,
|
||||
title: space.title,
|
||||
description: space.description,
|
||||
category: "color" as const,
|
||||
params: [
|
||||
{
|
||||
id: "component",
|
||||
label: "Component",
|
||||
type: "select" as const,
|
||||
default: components[0],
|
||||
options: components.map((c) => ({
|
||||
value: c,
|
||||
label: c.toUpperCase(),
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "display",
|
||||
label: "Display mode",
|
||||
type: "select" as const,
|
||||
default: "gray",
|
||||
options: [
|
||||
{ value: "gray", label: "Grayscale" },
|
||||
{ value: "color", label: "Space as RGB" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
renderSpace(
|
||||
img,
|
||||
space.id,
|
||||
str(p, "component"),
|
||||
str(p, "display") === "color" ? "color" : "gray",
|
||||
),
|
||||
} satisfies ToolEntry;
|
||||
});
|
||||
}
|
||||
|
||||
export function colorEntries(): ToolEntry[] {
|
||||
return [
|
||||
...channelEntries(),
|
||||
{
|
||||
id: "grayscale-png",
|
||||
title: "Grayscale PNG",
|
||||
description:
|
||||
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => grayscale(img),
|
||||
},
|
||||
{
|
||||
id: "invert-colors-png",
|
||||
title: "Invert colors PNG",
|
||||
description:
|
||||
"Inverts each color channel (255 − value). Alpha is unchanged.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => invert(img),
|
||||
},
|
||||
{
|
||||
id: "adjust-brightness-contrast-png",
|
||||
title: "Brightness & contrast PNG",
|
||||
description:
|
||||
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "brightness",
|
||||
label: "Brightness",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "contrast",
|
||||
label: "Contrast",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
brightnessContrast(img, num(p, "brightness"), num(p, "contrast")),
|
||||
},
|
||||
{
|
||||
id: "change-png-opacity",
|
||||
title: "Change PNG opacity",
|
||||
description:
|
||||
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
],
|
||||
run: (img, p) => setOpacity(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "sepia-png",
|
||||
title: "Sepia effect",
|
||||
description:
|
||||
"Tints the image into the warm brown tones of classic sepia.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => sepia(img),
|
||||
},
|
||||
{
|
||||
id: "change-png-hue",
|
||||
title: "Change hue PNG",
|
||||
description:
|
||||
"Shifts the hue around the circle. Saturation and lightness are preserved.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "degrees",
|
||||
label: "Hue shift, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => changeHue(img, num(p, "degrees")),
|
||||
},
|
||||
{
|
||||
id: "extract-channel-png",
|
||||
title: "Extract channel PNG",
|
||||
description:
|
||||
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "channel",
|
||||
label: "Channel",
|
||||
type: "select",
|
||||
default: "red",
|
||||
options: [
|
||||
{ value: "red", label: "Red" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "blue", label: "Blue" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel),
|
||||
},
|
||||
{
|
||||
id: "swap-channels-png",
|
||||
title: "Swap channels PNG",
|
||||
description:
|
||||
"Swaps two color channels — a quick way to get unusual coloring.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "pair",
|
||||
label: "Channel pair",
|
||||
type: "select",
|
||||
default: "r-g",
|
||||
options: [
|
||||
{ value: "r-g", label: "Red ↔ Green" },
|
||||
{ value: "r-b", label: "Red ↔ Blue" },
|
||||
{ value: "g-b", label: "Green ↔ Blue" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair),
|
||||
},
|
||||
{
|
||||
id: "black-and-white-png",
|
||||
title: "Black & white threshold PNG",
|
||||
description:
|
||||
"Hard binarization by luminance: every pixel becomes black or white.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Brightness threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "posterize-png",
|
||||
title: "Posterize PNG",
|
||||
description:
|
||||
"Reduces the number of levels per channel — a poster effect.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "levels",
|
||||
label: "Levels per channel",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 16,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
],
|
||||
run: (img, p) => posterize(img, num(p, "levels")),
|
||||
},
|
||||
{
|
||||
id: "two-colors-png",
|
||||
title: "Two colors PNG",
|
||||
description:
|
||||
"Recolors the image into two chosen colors by luminance threshold.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "lightColor",
|
||||
label: "Light areas color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "darkColor",
|
||||
label: "Dark areas color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Brightness threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
twoColors(
|
||||
img,
|
||||
str(p, "lightColor"),
|
||||
str(p, "darkColor"),
|
||||
num(p, "threshold"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "gamma-png",
|
||||
title: "Gamma correction PNG",
|
||||
description:
|
||||
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "value",
|
||||
label: "Gamma",
|
||||
type: "slider",
|
||||
min: 0.1,
|
||||
max: 3,
|
||||
step: 0.05,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
run: (img, p) => gammaCorrection(img, num(p, "value")),
|
||||
},
|
||||
{
|
||||
id: "auto-contrast-png",
|
||||
title: "Auto contrast PNG",
|
||||
description:
|
||||
"Stretches each channel's range across the full available brightness range.",
|
||||
category: "color",
|
||||
params: [],
|
||||
run: (img) => autoContrast(img),
|
||||
},
|
||||
{
|
||||
id: "temperature-png",
|
||||
title: "Temperature PNG",
|
||||
description:
|
||||
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "percent",
|
||||
label: "Temperature",
|
||||
type: "slider",
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => temperature(img, num(p, "percent")),
|
||||
},
|
||||
{
|
||||
id: "tint-png",
|
||||
title: "Tint PNG",
|
||||
description:
|
||||
"Multiplies color channels by the chosen tint with the given strength.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Tint color",
|
||||
type: "color",
|
||||
default: "#ffb060",
|
||||
},
|
||||
{
|
||||
id: "strength",
|
||||
label: "Strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 30,
|
||||
},
|
||||
],
|
||||
run: (img, p) => tint(img, str(p, "color"), num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "quantize-png",
|
||||
title: "Quantize PNG",
|
||||
description:
|
||||
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (k)",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 16,
|
||||
},
|
||||
],
|
||||
run: (img, p) => quantizeImage(img, num(p, "colors")).image,
|
||||
},
|
||||
{
|
||||
id: "decrease-color-count-png",
|
||||
title: "Decrease Color Count PNG",
|
||||
description:
|
||||
"Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "maxColors",
|
||||
label: "Max colors",
|
||||
type: "select",
|
||||
default: "16",
|
||||
options: [
|
||||
{ value: "2", label: "2" },
|
||||
{ value: "4", label: "4" },
|
||||
{ value: "8", label: "8" },
|
||||
{ value: "16", label: "16" },
|
||||
{ value: "32", label: "32" },
|
||||
{ value: "64", label: "64" },
|
||||
{ value: "128", label: "128" },
|
||||
{ value: "256", label: "256" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => quantizeImage(img, num(p, "maxColors")).image,
|
||||
},
|
||||
{
|
||||
id: "custom-palette-png",
|
||||
title: "Custom Palette PNG",
|
||||
description:
|
||||
"Maps every pixel to the nearest color from your comma-separated hex list.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Palette (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#000000,#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))),
|
||||
},
|
||||
{
|
||||
id: "dithering-png",
|
||||
title: "Dithering PNG",
|
||||
description:
|
||||
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
|
||||
category: "color",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (k)",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 16,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
{
|
||||
id: "pattern",
|
||||
label: "Pattern",
|
||||
type: "select",
|
||||
default: "floyd-steinberg",
|
||||
options: [
|
||||
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
|
||||
{ value: "bayer", label: "Bayer 4×4" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
ditherImage(
|
||||
img,
|
||||
num(p, "colors"),
|
||||
str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { clonePixelImage } from "../core/types";
|
||||
import { flattenOntoColor } from "../core/alpha";
|
||||
import { toBase64, toDataUrl, decodeSvgText, encode } from "../core/io";
|
||||
import { pixelsToHex, hexToPixels } from "../core/text";
|
||||
import {
|
||||
imageToByteRows,
|
||||
bytesToImage,
|
||||
imageToRgbValues,
|
||||
rgbValuesToImage,
|
||||
} from "../core/textio";
|
||||
import {
|
||||
COMPRESSION_LEVELS,
|
||||
findMaxColorsWithin,
|
||||
type CompressionLevel,
|
||||
} from "../core/compress";
|
||||
import { quantizeImage } from "../core/quantize";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
function decodeToPng(
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
): ToolEntry {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
category: "convert",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
};
|
||||
}
|
||||
|
||||
export function convertEntries(): ToolEntry[] {
|
||||
return [
|
||||
decodeToPng(
|
||||
"jpg-to-png",
|
||||
"Convert JPG to PNG",
|
||||
"Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.",
|
||||
),
|
||||
decodeToPng(
|
||||
"webp-to-png",
|
||||
"Convert WebP to PNG",
|
||||
"Re-encodes a WebP image into universal PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"gif-to-png",
|
||||
"Convert GIF to PNG",
|
||||
"Extracts the first frame of a GIF animation and saves it as PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"bmp-to-png",
|
||||
"Convert BMP to PNG",
|
||||
"Re-encodes BMP into compact lossless PNG.",
|
||||
),
|
||||
decodeToPng(
|
||||
"ico-to-png",
|
||||
"Convert ICO to PNG",
|
||||
"Turns an .ico icon into a regular PNG of the chosen size.",
|
||||
),
|
||||
{
|
||||
id: "png-to-bmp",
|
||||
title: "Convert PNG to BMP",
|
||||
description:
|
||||
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, "#000000"),
|
||||
output: { mime: "image/bmp", ext: "bmp" },
|
||||
},
|
||||
{
|
||||
id: "png-to-base64",
|
||||
title: "PNG to Base64",
|
||||
description:
|
||||
"Encodes the image into a base64 string for embedding in code or styles.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => toBase64(img),
|
||||
},
|
||||
{
|
||||
id: "base64-to-png",
|
||||
title: "Base64 to PNG",
|
||||
description:
|
||||
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-to-data-uri",
|
||||
title: "PNG to Data URI",
|
||||
description:
|
||||
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => toDataUrl(img),
|
||||
},
|
||||
{
|
||||
id: "data-uri-to-png",
|
||||
title: "Data URI to PNG",
|
||||
description: "Decodes data:image/…;base64,… back into an image file.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-to-hex",
|
||||
title: "PNG to HEX pixels",
|
||||
description:
|
||||
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => pixelsToHex(img),
|
||||
},
|
||||
{
|
||||
id: "hex-to-png",
|
||||
title: "HEX pixels to PNG",
|
||||
description:
|
||||
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
hexToPixels(text, Math.trunc(Number(p["width"]))),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-to-bytes",
|
||||
title: "PNG to Bytes",
|
||||
description:
|
||||
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => imageToByteRows(img),
|
||||
},
|
||||
{
|
||||
id: "bytes-to-png",
|
||||
title: "Bytes to PNG",
|
||||
description:
|
||||
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
bytesToImage(
|
||||
text,
|
||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
||||
),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "png-to-rgb-values",
|
||||
title: "PNG to RGB Values",
|
||||
description:
|
||||
"Lists every pixel as rgba(r, g, b, a), one image row per line.",
|
||||
category: "convert",
|
||||
params: [],
|
||||
resultType: "text",
|
||||
toText: (img) => imageToRgbValues(img),
|
||||
},
|
||||
{
|
||||
id: "rgb-values-to-png",
|
||||
title: "RGB Values to PNG",
|
||||
description:
|
||||
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Image width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
rgbValuesToImage(
|
||||
text,
|
||||
Math.trunc(num(p as Record<string, unknown>, "width")),
|
||||
),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "convert-png-to-jpg",
|
||||
title: "Convert PNG to JPG",
|
||||
description:
|
||||
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "background",
|
||||
label: "Backdrop color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "quality",
|
||||
label: "JPEG quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
],
|
||||
output: {
|
||||
mime: "image/jpeg",
|
||||
ext: "jpg",
|
||||
qualityParamId: "quality",
|
||||
},
|
||||
run: (img, p) => flattenOntoColor(img, str(p, "background")),
|
||||
},
|
||||
{
|
||||
id: "convert-png-to-webp",
|
||||
title: "Convert PNG to WebP",
|
||||
description:
|
||||
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "quality",
|
||||
label: "WebP quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 90,
|
||||
},
|
||||
],
|
||||
output: {
|
||||
mime: "image/webp",
|
||||
ext: "webp",
|
||||
qualityParamId: "quality",
|
||||
},
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
{
|
||||
id: "compress-png",
|
||||
title: "Compress PNG",
|
||||
description:
|
||||
"Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.",
|
||||
category: "convert",
|
||||
params: [
|
||||
{
|
||||
id: "level",
|
||||
label: "Compression level",
|
||||
type: "select",
|
||||
default: "balanced",
|
||||
options: [
|
||||
{ value: "light", label: "Light (192 colors)" },
|
||||
{ value: "balanced", label: "Balanced (96 colors)" },
|
||||
{ value: "strong", label: "Strong (44 colors)" },
|
||||
{ value: "extreme", label: "Extreme (16 colors)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const level = str(p, "level") as CompressionLevel;
|
||||
const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced;
|
||||
return quantizeImage(img, k).image;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reduce-to-size-png",
|
||||
title: "Reduce PNG to Size",
|
||||
description:
|
||||
"Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.",
|
||||
category: "convert",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "targetKB",
|
||||
label: "Target size, KB",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 2000,
|
||||
step: 5,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "maxColors",
|
||||
label: "Max colors to try",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 256,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
],
|
||||
run: async (img, p) => {
|
||||
const targetBytes = num(p, "targetKB") * 1024;
|
||||
const maxK = num(p, "maxColors");
|
||||
const encodeSize = async (k: number): Promise<number | null> => {
|
||||
const blob = await encode(quantizeImage(img, k).image, "image/png");
|
||||
return blob.size;
|
||||
};
|
||||
const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize);
|
||||
return quantizeImage(img, k).image;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "svg-to-png",
|
||||
title: "SVG to PNG",
|
||||
description:
|
||||
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
|
||||
category: "convert",
|
||||
sourceMode: "text",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Result width, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 10000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
runFromText: (text, p) =>
|
||||
decodeSvgText(text, Math.trunc(num(p, "width"))),
|
||||
run: (img) => clonePixelImage(img),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
|
||||
import { vignette } from "../core/effects";
|
||||
import {
|
||||
pixelate,
|
||||
shuffleBlocks,
|
||||
addNoise,
|
||||
silhouette,
|
||||
} from "../core/pixel-fx";
|
||||
import { jpegRoundtrip } from "../core/io";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
export function filterEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "blur-png",
|
||||
title: "Blur PNG",
|
||||
description:
|
||||
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "radius",
|
||||
label: "Radius, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 32,
|
||||
step: 1,
|
||||
default: 4,
|
||||
},
|
||||
],
|
||||
run: (img, p) => gaussianBlur(img, num(p, "radius")),
|
||||
},
|
||||
{
|
||||
id: "sharpen-png",
|
||||
title: "Sharpen PNG",
|
||||
description:
|
||||
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "strength",
|
||||
label: "Strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => sharpenImage(img, num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "vignette-png",
|
||||
title: "Vignette PNG",
|
||||
description:
|
||||
"Smoothly darkens the edges of the image, leaving the center untouched.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "strength",
|
||||
label: "Darkening strength, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
run: (img, p) => vignette(img, num(p, "strength")),
|
||||
},
|
||||
{
|
||||
id: "pixelate-png",
|
||||
title: "Pixelate PNG",
|
||||
description:
|
||||
"Averages every blockSize×blockSize area into one color — classic mosaic.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
],
|
||||
run: (img, p) => pixelate(img, num(p, "blockSize")),
|
||||
},
|
||||
{
|
||||
id: "randomize-pixels-png",
|
||||
title: "Randomize Pixels PNG",
|
||||
description:
|
||||
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
default: 42,
|
||||
},
|
||||
],
|
||||
run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")),
|
||||
},
|
||||
{
|
||||
id: "add-noise-png",
|
||||
title: "Add Noise to PNG",
|
||||
description:
|
||||
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "amount",
|
||||
label: "Amount, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 25,
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
label: "Noise type",
|
||||
type: "select",
|
||||
default: "mono",
|
||||
options: [
|
||||
{ value: "mono", label: "Monochrome grain" },
|
||||
{ value: "color", label: "Color noise" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999,
|
||||
step: 1,
|
||||
default: 1234,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
addNoise(
|
||||
img,
|
||||
num(p, "amount"),
|
||||
str(p, "mode") === "color" ? "color" : "mono",
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "silhouette-png",
|
||||
title: "Silhouette PNG",
|
||||
description:
|
||||
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "color",
|
||||
label: "Silhouette color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Visibility threshold, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
silhouette(img, str(p, "color"), num(p, "threshold") * 2.55),
|
||||
},
|
||||
{
|
||||
id: "jpeg-artifacts-png",
|
||||
title: "JPEG artifacts",
|
||||
description:
|
||||
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
|
||||
category: "filters",
|
||||
params: [
|
||||
{
|
||||
id: "quality",
|
||||
label: "JPEG quality",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
],
|
||||
run: (img, p) => jpegRoundtrip(img, num(p, "quality")),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { ToolError } from "../core/errors";
|
||||
import { solidImage, noiseImage, gradientImage } from "../core/generate";
|
||||
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
||||
import { renderTextToImage, renderEmoji, type TextFont } from "../core/domText";
|
||||
import { changeCanvasSize } from "../core/geometry";
|
||||
import {
|
||||
renderWheel,
|
||||
renderSwatches,
|
||||
renderBlend,
|
||||
complementarySet,
|
||||
triadicSet,
|
||||
tetradicSet,
|
||||
analogousSet,
|
||||
monochromaticSet,
|
||||
shadeSet,
|
||||
mixColors,
|
||||
parseHexList,
|
||||
stepColors,
|
||||
sortPalette,
|
||||
type SortKey,
|
||||
} from "../core/palette";
|
||||
import { num, str, bool } from "../registry-helpers";
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) {
|
||||
throw new ToolError("errors.badHex", { value: hex });
|
||||
}
|
||||
const d = match[1];
|
||||
return [
|
||||
parseInt(d.slice(0, 2), 16),
|
||||
parseInt(d.slice(2, 4), 16),
|
||||
parseInt(d.slice(4, 6), 16),
|
||||
alpha,
|
||||
];
|
||||
}
|
||||
|
||||
function paletteParams(baseDefault: string) {
|
||||
return [
|
||||
{
|
||||
id: "baseColor",
|
||||
label: "Base color",
|
||||
type: "color" as const,
|
||||
default: baseDefault,
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider" as const,
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "layout",
|
||||
label: "Layout",
|
||||
type: "select" as const,
|
||||
default: "grid",
|
||||
options: [
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "strip", label: "Strip" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function generateEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "create-empty-png",
|
||||
title: "Create empty PNG",
|
||||
description:
|
||||
"Generates a canvas of the chosen size — transparent or filled with color.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "single-color-png",
|
||||
title: "Create solid color PNG",
|
||||
description: "Generates a rectangle of the given size and color.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 256,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Color",
|
||||
type: "color",
|
||||
default: "#ff0000",
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
hexToRgba(str(p, "color")),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "random-noise-png",
|
||||
title: "Create random noise PNG",
|
||||
description:
|
||||
"Generates an image with random pixels. The seed fixes the result: one seed — one image.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999999,
|
||||
step: 1,
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
noiseImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "text-to-png",
|
||||
title: "Text to PNG",
|
||||
description:
|
||||
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "Hello!",
|
||||
placeholder: "Your text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 300,
|
||||
step: 1,
|
||||
default: 96,
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "transparentBg",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "backgroundColor",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "padding",
|
||||
label: "Padding, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 2,
|
||||
default: 24,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderTextToImage({
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
backgroundColor: str(p, "backgroundColor"),
|
||||
transparentBg: bool(p, "transparentBg"),
|
||||
padding: num(p, "padding"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "emoji-to-png",
|
||||
title: "Emoji to PNG",
|
||||
description:
|
||||
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "emoji",
|
||||
label: "Emoji / symbol",
|
||||
type: "text",
|
||||
default: "\u{1F600}",
|
||||
},
|
||||
{
|
||||
id: "size",
|
||||
label: "Size",
|
||||
type: "slider",
|
||||
min: 32,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 256,
|
||||
},
|
||||
],
|
||||
generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))),
|
||||
},
|
||||
{
|
||||
id: "placeholder-png",
|
||||
title: "Create Placeholder PNG",
|
||||
description:
|
||||
"Generates a placeholder rectangle with its dimensions printed in the center.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 400,
|
||||
},
|
||||
{
|
||||
id: "backgroundColor",
|
||||
label: "Background",
|
||||
type: "color",
|
||||
default: "#dfe2e8",
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#5c6470",
|
||||
},
|
||||
{
|
||||
id: "showText",
|
||||
label: "Print dimensions",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
generate: (p) => {
|
||||
const w = Math.trunc(num(p, "width"));
|
||||
const h = Math.trunc(num(p, "height"));
|
||||
const label = renderTextToImage({
|
||||
text: `${w} × ${h}`,
|
||||
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: str(p, "color"),
|
||||
backgroundColor: str(p, "backgroundColor"),
|
||||
transparentBg: false,
|
||||
padding: 0,
|
||||
});
|
||||
return changeCanvasSize(label, w, h, "center");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "linear-gradient-png",
|
||||
title: "Create gradient PNG",
|
||||
description:
|
||||
"Generates a smooth transition between two colors, horizontally or vertically.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "fromColor",
|
||||
label: "Start color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "toColor",
|
||||
label: "End color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
label: "Direction",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal" },
|
||||
{ value: "vertical", label: "Vertical" },
|
||||
],
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
gradientImage(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
hexToRgba(str(p, "fromColor")),
|
||||
hexToRgba(str(p, "toColor")),
|
||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "color-spectrum-png",
|
||||
title: "Color Spectrum PNG",
|
||||
description:
|
||||
"Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 1024,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 128,
|
||||
},
|
||||
{
|
||||
id: "direction",
|
||||
label: "Direction",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal" },
|
||||
{ value: "vertical", label: "Vertical" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "saturation",
|
||||
label: "Saturation, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 100,
|
||||
},
|
||||
{
|
||||
id: "lightness",
|
||||
label: "Lightness, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
colorSpectrum(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
|
||||
num(p, "saturation"),
|
||||
num(p, "lightness"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "random-colors-png",
|
||||
title: "Random Color Blocks PNG",
|
||||
description:
|
||||
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "blockSize",
|
||||
label: "Block size, px",
|
||||
type: "slider",
|
||||
min: 4,
|
||||
max: 256,
|
||||
step: 2,
|
||||
default: 64,
|
||||
},
|
||||
{
|
||||
id: "seed",
|
||||
label: "Seed",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 999999999,
|
||||
step: 1,
|
||||
default: 7,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
randomColorBlocks(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "blockSize"),
|
||||
num(p, "seed"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "draw-grid-png",
|
||||
title: "Draw Grid PNG",
|
||||
description:
|
||||
"Draws a grid with custom columns, rows and line width on a transparent or white background.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "cols",
|
||||
label: "Columns",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "rows",
|
||||
label: "Rows",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 64,
|
||||
step: 1,
|
||||
default: 8,
|
||||
},
|
||||
{
|
||||
id: "lineWidth",
|
||||
label: "Line width, px",
|
||||
type: "slider",
|
||||
min: 1,
|
||||
max: 40,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Line color",
|
||||
type: "color",
|
||||
default: "#111318",
|
||||
},
|
||||
{
|
||||
id: "transparentBg",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
drawGrid(
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
num(p, "cols"),
|
||||
num(p, "rows"),
|
||||
num(p, "lineWidth"),
|
||||
str(p, "color"),
|
||||
bool(p, "transparentBg"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "color-wheel-png",
|
||||
title: "Color Wheel PNG",
|
||||
description:
|
||||
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Size",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
{
|
||||
id: "lightness",
|
||||
label: "Lightness, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")),
|
||||
},
|
||||
{
|
||||
id: "complementary-png",
|
||||
title: "Complementary Palette PNG",
|
||||
description:
|
||||
"Two opposite colors on the color wheel — the base and its complement.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#2563eb"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
complementarySet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "triadic-png",
|
||||
title: "Triadic Palette PNG",
|
||||
description: "Three colors evenly spaced 120° apart on the color wheel.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#ff0000"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
triadicSet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "tetradic-png",
|
||||
title: "Tetradic Palette PNG",
|
||||
description:
|
||||
"Four colors in two complementary pairs, 90° apart on the wheel.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: paletteParams("#8000ff"),
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
tetradicSet(str(p, "baseColor")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "analogous-png",
|
||||
title: "Analogous Palette PNG",
|
||||
description:
|
||||
"Neighboring hues around the base color — calm, related color scheme.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#22c55e"),
|
||||
{
|
||||
id: "spread",
|
||||
label: "Hue spread, °",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 3,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "monochromatic-png",
|
||||
title: "Monochromatic Palette PNG",
|
||||
description:
|
||||
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#0ea5e9"),
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
label: "Lightness range, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 40,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
monochromaticSet(
|
||||
str(p, "baseColor"),
|
||||
num(p, "count"),
|
||||
num(p, "range"),
|
||||
),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "shades-png",
|
||||
title: "Shade Ramp PNG",
|
||||
description: "A ramp of the base color getting darker step by step.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
...paletteParams("#f59e0b"),
|
||||
{
|
||||
id: "count",
|
||||
label: "Colors",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 9,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "depth",
|
||||
label: "Darkening depth, %",
|
||||
type: "slider",
|
||||
min: 10,
|
||||
max: 90,
|
||||
step: 5,
|
||||
default: 50,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mix-colors-png",
|
||||
title: "Mix Colors PNG",
|
||||
description:
|
||||
"Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#ff0000,#00ff00,#0000ff",
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
[mixColors(parseHexList(str(p, "colors")))],
|
||||
num(p, "width"),
|
||||
"strip",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "blend-two-png",
|
||||
title: "Blend Two Colors PNG",
|
||||
description: "A continuous horizontal gradient between two colors.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colorA",
|
||||
label: "Color A",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "colorB",
|
||||
label: "Color B",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "slider",
|
||||
min: 128,
|
||||
max: 1024,
|
||||
step: 16,
|
||||
default: 512,
|
||||
},
|
||||
],
|
||||
generate: (p) =>
|
||||
renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")),
|
||||
},
|
||||
{
|
||||
id: "step-colors-png",
|
||||
title: "Color Steps PNG",
|
||||
description: "A discrete set of evenly spaced steps between two colors.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colorA",
|
||||
label: "Color A",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "colorB",
|
||||
label: "Color B",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "steps",
|
||||
label: "Steps",
|
||||
type: "slider",
|
||||
min: 2,
|
||||
max: 12,
|
||||
step: 1,
|
||||
default: 6,
|
||||
},
|
||||
...paletteParams("#808080").filter((q) => q.id !== "baseColor"),
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sort-colors-png",
|
||||
title: "Sort Colors PNG",
|
||||
description:
|
||||
"Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.",
|
||||
category: "generate",
|
||||
sourceMode: "none",
|
||||
params: [
|
||||
{
|
||||
id: "colors",
|
||||
label: "Colors (comma-separated hex)",
|
||||
type: "text",
|
||||
default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff",
|
||||
},
|
||||
{
|
||||
id: "order",
|
||||
label: "Sort by",
|
||||
type: "select",
|
||||
default: "hue",
|
||||
options: [
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "luma", label: "Brightness" },
|
||||
{ value: "sat", label: "Saturation" },
|
||||
],
|
||||
},
|
||||
...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"),
|
||||
],
|
||||
generate: (p) =>
|
||||
renderSwatches(
|
||||
sortPalette(
|
||||
parseHexList(str(p, "colors")),
|
||||
str(p, "order") as SortKey,
|
||||
),
|
||||
num(p, "width"),
|
||||
str(p, "layout") as "strip" | "grid",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,657 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { ToolError } from "../core/errors";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import {
|
||||
crop,
|
||||
expandCanvas,
|
||||
flip,
|
||||
resize,
|
||||
rotate90,
|
||||
tile,
|
||||
centerByAlpha,
|
||||
changeCanvasSize,
|
||||
cropToRatio,
|
||||
forceOrientation,
|
||||
padToRatio,
|
||||
symmetricCopy,
|
||||
trimToContent,
|
||||
type Anchor9,
|
||||
} from "../core/geometry";
|
||||
import {
|
||||
rotateFreeImage,
|
||||
skewImage,
|
||||
transformImage,
|
||||
zoomImage,
|
||||
} from "../core/affine";
|
||||
import { num, str } from "../registry-helpers";
|
||||
|
||||
interface AddBorderParams {
|
||||
thickness: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const addBorderSchema = toolSchema<AddBorderParams>({
|
||||
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
|
||||
color: field.color({ default: "#000000" }),
|
||||
});
|
||||
|
||||
export function geometryEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "resize-png",
|
||||
title: "Resize PNG",
|
||||
description:
|
||||
"Scales the image with bilinear interpolation. With aspect kept, one side defines the scale; if both are set, the image fits inside them.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width (0 = auto)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height (0 = auto)",
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "keepAspect",
|
||||
label: "Keep aspect ratio",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const keepAspect = p["keepAspect"] === true;
|
||||
let w = Math.trunc(num(p, "width"));
|
||||
let h = Math.trunc(num(p, "height"));
|
||||
if (keepAspect) {
|
||||
if (w > 0 && h > 0) {
|
||||
const scale = Math.min(w / img.width, h / img.height);
|
||||
w = Math.max(1, Math.round(img.width * scale));
|
||||
h = Math.max(1, Math.round(img.height * scale));
|
||||
} else if (w > 0) {
|
||||
h = Math.max(1, Math.round((img.height / img.width) * w));
|
||||
} else if (h > 0) {
|
||||
w = Math.max(1, Math.round((img.width / img.height) * h));
|
||||
}
|
||||
}
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new ToolError("errors.resizeSize");
|
||||
}
|
||||
return resize(img, w, h);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "crop-png",
|
||||
title: "Crop PNG",
|
||||
description:
|
||||
"Cuts out a rectangular area. Coordinates and sizes may go beyond the image — the area is clipped to the intersection.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "x",
|
||||
label: "X (from left)",
|
||||
type: "number",
|
||||
min: -100000,
|
||||
max: 100000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "y",
|
||||
label: "Y (from top)",
|
||||
type: "number",
|
||||
min: -100000,
|
||||
max: 100000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "width",
|
||||
label: "Area width",
|
||||
type: "number",
|
||||
min: -100000,
|
||||
max: 100000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Area height",
|
||||
type: "number",
|
||||
min: -100000,
|
||||
max: 100000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const w = Math.trunc(num(p, "width"));
|
||||
const h = Math.trunc(num(p, "height"));
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new ToolError("errors.cropSize");
|
||||
}
|
||||
return crop(
|
||||
img,
|
||||
Math.trunc(num(p, "x")),
|
||||
Math.trunc(num(p, "y")),
|
||||
w,
|
||||
h,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rotate-png",
|
||||
title: "Rotate PNG",
|
||||
description:
|
||||
"Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "angle",
|
||||
label: "Rotation angle",
|
||||
type: "select",
|
||||
default: "90",
|
||||
options: [
|
||||
{ value: "90", label: "90° clockwise" },
|
||||
{ value: "180", label: "180°" },
|
||||
{ value: "270", label: "270° clockwise" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90),
|
||||
},
|
||||
{
|
||||
id: "flip-png",
|
||||
title: "Flip PNG",
|
||||
description: "Mirrors horizontally or vertically without quality loss.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "axis",
|
||||
label: "Flip axis",
|
||||
type: "select",
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{
|
||||
value: "horizontal",
|
||||
label: "Horizontal (left to right)",
|
||||
},
|
||||
{
|
||||
value: "vertical",
|
||||
label: "Vertical (top to bottom)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"),
|
||||
},
|
||||
{
|
||||
id: "add-padding-png",
|
||||
title: "Add padding to PNG",
|
||||
description:
|
||||
"Expands the canvas on all sides by the chosen number of pixels.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "padding",
|
||||
label: "Padding, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 2000,
|
||||
step: 1,
|
||||
default: 10,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent padding",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Padding color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
num(p, "padding"),
|
||||
p["transparent"] === true ? undefined : str(p, "color"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "add-border-png",
|
||||
title: "Add border to PNG",
|
||||
description:
|
||||
"Draws a colored frame of the chosen thickness around the image.",
|
||||
category: "geometry",
|
||||
schema: addBorderSchema,
|
||||
params: [
|
||||
{
|
||||
id: "thickness",
|
||||
label: "Border thickness, px",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 500,
|
||||
step: 1,
|
||||
default: 5,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Border color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
num(p, "thickness"),
|
||||
str(p, "color"),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "fit-on-background-png",
|
||||
title: "Fit PNG onto background",
|
||||
description:
|
||||
"Places the image centered on a canvas of the given size with a transparent or colored background.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Canvas width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Canvas height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "transparent",
|
||||
label: "Transparent background",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const width = Math.trunc(num(p, "width"));
|
||||
const height = Math.trunc(num(p, "height"));
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new ToolError("errors.sizePositive");
|
||||
}
|
||||
const left = Math.max(0, Math.floor((width - img.width) / 2));
|
||||
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
||||
return expandCanvas(
|
||||
img,
|
||||
left,
|
||||
top,
|
||||
Math.max(0, width - img.width - left),
|
||||
Math.max(0, height - img.height - top),
|
||||
p["transparent"] === true ? undefined : str(p, "color"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tile-png",
|
||||
title: "Tile PNG",
|
||||
description:
|
||||
"Repeats the image in a grid of the chosen columns and rows.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "columns",
|
||||
label: "Columns",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
{
|
||||
id: "rows",
|
||||
label: "Rows",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 50,
|
||||
step: 1,
|
||||
default: 2,
|
||||
},
|
||||
],
|
||||
run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")),
|
||||
},
|
||||
{
|
||||
id: "center-by-alpha-png",
|
||||
title: "Center PNG by content",
|
||||
description:
|
||||
"Finds the opaque part of the image and centers it on the original canvas.",
|
||||
category: "geometry",
|
||||
params: [],
|
||||
run: (img) => centerByAlpha(img),
|
||||
},
|
||||
{
|
||||
id: "skew-png",
|
||||
title: "Skew PNG",
|
||||
description:
|
||||
"Shifts content horizontally and vertically — a perspective effect.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "degX",
|
||||
label: "Skew X, °",
|
||||
type: "slider",
|
||||
min: -80,
|
||||
max: 80,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "degY",
|
||||
label: "Skew Y, °",
|
||||
type: "slider",
|
||||
min: -80,
|
||||
max: 80,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")),
|
||||
},
|
||||
{
|
||||
id: "rotate-free-png",
|
||||
title: "Rotate by custom angle",
|
||||
description:
|
||||
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "angle",
|
||||
label: "Angle, °",
|
||||
type: "slider",
|
||||
min: -180,
|
||||
max: 180,
|
||||
step: 1,
|
||||
default: 15,
|
||||
},
|
||||
],
|
||||
run: (img, p) => rotateFreeImage(img, num(p, "angle")),
|
||||
},
|
||||
{
|
||||
id: "zoom-png",
|
||||
title: "Zoom PNG",
|
||||
description:
|
||||
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "scale",
|
||||
label: "Scale, %",
|
||||
type: "slider",
|
||||
min: 100,
|
||||
max: 500,
|
||||
step: 10,
|
||||
default: 200,
|
||||
},
|
||||
],
|
||||
run: (img, p) => zoomImage(img, num(p, "scale")),
|
||||
},
|
||||
{
|
||||
id: "trim-empty-space-png",
|
||||
title: "Trim Empty Space PNG",
|
||||
description:
|
||||
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "threshold",
|
||||
label: "Alpha threshold",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 254,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
],
|
||||
run: (img, p) => trimToContent(img, num(p, "threshold")),
|
||||
},
|
||||
{
|
||||
id: "change-canvas-size-png",
|
||||
title: "Change Canvas Size PNG",
|
||||
description:
|
||||
"Sets the exact canvas size: overflow is cropped, missing space is filled with transparency. Anchor picks which part of the image stays.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "width",
|
||||
label: "Width",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 800,
|
||||
},
|
||||
{
|
||||
id: "height",
|
||||
label: "Height",
|
||||
type: "number",
|
||||
min: 1,
|
||||
max: 20000,
|
||||
step: 1,
|
||||
default: 600,
|
||||
},
|
||||
{
|
||||
id: "anchor",
|
||||
label: "Anchor",
|
||||
type: "select",
|
||||
default: "center",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
changeCanvasSize(
|
||||
img,
|
||||
Math.trunc(num(p, "width")),
|
||||
Math.trunc(num(p, "height")),
|
||||
str(p, "anchor") as Anchor9,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "change-aspect-ratio-png",
|
||||
title: "Change Aspect Ratio PNG",
|
||||
description:
|
||||
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "ratio",
|
||||
label: "Target ratio",
|
||||
type: "select",
|
||||
default: "1:1",
|
||||
options: [
|
||||
{ value: "1:1", label: "1:1" },
|
||||
{ value: "4:3", label: "4:3" },
|
||||
{ value: "3:4", label: "3:4" },
|
||||
{ value: "3:2", label: "3:2" },
|
||||
{ value: "2:3", label: "2:3" },
|
||||
{ value: "16:9", label: "16:9" },
|
||||
{ value: "9:16", label: "9:16" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
label: "Mode",
|
||||
type: "select",
|
||||
default: "crop",
|
||||
options: [
|
||||
{ value: "crop", label: "Crop to fill" },
|
||||
{ value: "pad", label: "Pad to fit" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) => {
|
||||
const [rw, rh] = str(p, "ratio").split(":").map(Number);
|
||||
const ratio = rw / rh;
|
||||
return str(p, "mode") === "pad"
|
||||
? padToRatio(img, ratio)
|
||||
: cropToRatio(img, ratio);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "swap-orientation-png",
|
||||
title: "Swap Orientation PNG",
|
||||
description:
|
||||
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "target",
|
||||
label: "Target orientation",
|
||||
type: "select",
|
||||
default: "portrait",
|
||||
options: [
|
||||
{ value: "portrait", label: "Portrait" },
|
||||
{ value: "landscape", label: "Landscape" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
forceOrientation(
|
||||
img,
|
||||
str(p, "target") === "landscape" ? "landscape" : "portrait",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "symmetric-copy-png",
|
||||
title: "Symmetric Copy PNG",
|
||||
description:
|
||||
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "axis",
|
||||
label: "Mirror line",
|
||||
type: "select",
|
||||
default: "vertical",
|
||||
options: [
|
||||
{
|
||||
value: "vertical",
|
||||
label: "Vertical (double width)",
|
||||
},
|
||||
{
|
||||
value: "horizontal",
|
||||
label: "Horizontal (double height)",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "keepSide",
|
||||
label: "Keep side",
|
||||
type: "select",
|
||||
default: "left",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
symmetricCopy(
|
||||
img,
|
||||
str(p, "axis") === "horizontal" ? "horizontal" : "vertical",
|
||||
str(p, "keepSide") as "left" | "right" | "top" | "bottom",
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "shift-png",
|
||||
title: "Shift PNG",
|
||||
description: "Moves content by the given X and Y offset.",
|
||||
category: "geometry",
|
||||
params: [
|
||||
{
|
||||
id: "offsetX",
|
||||
label: "Offset X, px",
|
||||
type: "number",
|
||||
min: -5000,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "offsetY",
|
||||
label: "Offset Y, px",
|
||||
type: "number",
|
||||
min: -5000,
|
||||
max: 5000,
|
||||
step: 1,
|
||||
default: 0,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Background color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
transformImage(
|
||||
img,
|
||||
[
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
-Math.trunc(num(p, "offsetX")),
|
||||
-Math.trunc(num(p, "offsetY")),
|
||||
],
|
||||
img.width,
|
||||
img.height,
|
||||
str(p, "color"),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { ToolEntry } from "../registry";
|
||||
import { drawTextBlock, drawTextTile, type TextFont } from "../core/domText";
|
||||
import { formatStamp } from "../core/datefmt";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import { num, str, bool } from "../registry-helpers";
|
||||
|
||||
export function textEntries(): ToolEntry[] {
|
||||
return [
|
||||
{
|
||||
id: "add-text-png",
|
||||
title: "Add text to PNG",
|
||||
description:
|
||||
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
|
||||
category: "text",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "Hello!",
|
||||
placeholder: "Your text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 48,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{ id: "bold", label: "Bold", type: "checkbox", default: true },
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 24,
|
||||
},
|
||||
{
|
||||
id: "plate",
|
||||
label: "Backing plate",
|
||||
type: "checkbox",
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
id: "plateColor",
|
||||
label: "Plate color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "plateOpacity",
|
||||
label: "Plate opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 60,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: 100,
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
||||
plateOpacityPercent: num(p, "plateOpacity"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "date-stamp-png",
|
||||
title: "Date stamp PNG",
|
||||
description:
|
||||
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
|
||||
category: "text",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "format",
|
||||
label: "Format",
|
||||
type: "text",
|
||||
default: "YYYY-MM-DD",
|
||||
placeholder: "YYYY-MM-DD hh:mm",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 8,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 32,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "mono",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{ id: "bold", label: "Bold", type: "checkbox", default: false },
|
||||
{
|
||||
id: "position",
|
||||
label: "Position",
|
||||
type: "select",
|
||||
default: "bottom-right",
|
||||
options: [
|
||||
{ value: "top-left", label: "Top left" },
|
||||
{ value: "top-center", label: "Top center" },
|
||||
{ value: "top-right", label: "Top right" },
|
||||
{ value: "middle-left", label: "Middle left" },
|
||||
{ value: "center", label: "Center" },
|
||||
{ value: "middle-right", label: "Middle right" },
|
||||
{ value: "bottom-left", label: "Bottom left" },
|
||||
{ value: "bottom-center", label: "Bottom center" },
|
||||
{ value: "bottom-right", label: "Bottom right" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "margin",
|
||||
label: "Margin, px",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
default: 20,
|
||||
},
|
||||
{
|
||||
id: "plate",
|
||||
label: "Backing plate",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
id: "plateColor",
|
||||
label: "Plate color",
|
||||
type: "color",
|
||||
default: "#000000",
|
||||
},
|
||||
{
|
||||
id: "plateOpacity",
|
||||
label: "Plate opacity, %",
|
||||
type: "slider",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 55,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: formatStamp(new Date(), str(p, "format")),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: 100,
|
||||
position: str(p, "position") as Position9,
|
||||
margin: num(p, "margin"),
|
||||
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
|
||||
plateOpacityPercent: num(p, "plateOpacity"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "watermark-tile-png",
|
||||
title: "Watermark Tile PNG",
|
||||
description:
|
||||
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
|
||||
category: "text",
|
||||
domOnly: true,
|
||||
params: [
|
||||
{
|
||||
id: "text",
|
||||
label: "Text",
|
||||
type: "text",
|
||||
default: "DRAFT",
|
||||
placeholder: "Watermark text",
|
||||
},
|
||||
{
|
||||
id: "fontSize",
|
||||
label: "Font size, px",
|
||||
type: "slider",
|
||||
min: 12,
|
||||
max: 160,
|
||||
step: 1,
|
||||
default: 56,
|
||||
},
|
||||
{
|
||||
id: "color",
|
||||
label: "Text color",
|
||||
type: "color",
|
||||
default: "#ffffff",
|
||||
},
|
||||
{
|
||||
id: "opacity",
|
||||
label: "Opacity, %",
|
||||
type: "slider",
|
||||
min: 5,
|
||||
max: 100,
|
||||
step: 5,
|
||||
default: 30,
|
||||
},
|
||||
{
|
||||
id: "angle",
|
||||
label: "Angle, °",
|
||||
type: "slider",
|
||||
min: -90,
|
||||
max: 90,
|
||||
step: 1,
|
||||
default: -30,
|
||||
},
|
||||
{
|
||||
id: "stepX",
|
||||
label: "Step X, px",
|
||||
type: "slider",
|
||||
min: 40,
|
||||
max: 600,
|
||||
step: 10,
|
||||
default: 220,
|
||||
},
|
||||
{
|
||||
id: "stepY",
|
||||
label: "Step Y, px",
|
||||
type: "slider",
|
||||
min: 40,
|
||||
max: 600,
|
||||
step: 10,
|
||||
default: 180,
|
||||
},
|
||||
{
|
||||
id: "font",
|
||||
label: "Font",
|
||||
type: "select",
|
||||
default: "sans",
|
||||
options: [
|
||||
{ value: "sans", label: "Sans-serif" },
|
||||
{ value: "serif", label: "Serif" },
|
||||
{ value: "mono", label: "Monospace" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Bold",
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
},
|
||||
],
|
||||
run: (img, p) =>
|
||||
drawTextTile(img, {
|
||||
text: str(p, "text"),
|
||||
fontSize: num(p, "fontSize"),
|
||||
font: str(p, "font") as TextFont,
|
||||
bold: bool(p, "bold"),
|
||||
color: str(p, "color"),
|
||||
opacityPercent: num(p, "opacity"),
|
||||
stepX: num(p, "stepX"),
|
||||
stepY: num(p, "stepY"),
|
||||
angleDeg: num(p, "angle"),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user