mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-16 14:36:35 +00:00
refactor: update path registry-new to registry
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import type { Offset } from "../registry-schema";
|
||||
import {
|
||||
extractAlphaMask,
|
||||
flattenOntoColor,
|
||||
hardenAlpha,
|
||||
invertAlpha,
|
||||
removeColorToAlpha,
|
||||
roundCorners,
|
||||
setAlphaChannel,
|
||||
} from "../core/alpha";
|
||||
import { removeBackground } from "../core/background";
|
||||
import {
|
||||
closingImage,
|
||||
contourImage,
|
||||
dilateImage,
|
||||
erodeImage,
|
||||
openingImage,
|
||||
strokeImage,
|
||||
} from "../core/morphology";
|
||||
import { defringe, featherAlpha } from "../core/pixel-fx";
|
||||
import {
|
||||
boxTest,
|
||||
circleTest,
|
||||
renderShape,
|
||||
starTest,
|
||||
wavyTest,
|
||||
} from "../core/shapes";
|
||||
|
||||
interface AddStrokeParams {
|
||||
color: string;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
export const addStrokeSchema = toolSchema<AddStrokeParams>({
|
||||
color: field.color({ default: "#ff0000" }),
|
||||
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
|
||||
});
|
||||
|
||||
const addStroke: ToolEntry<AddStrokeParams> = {
|
||||
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,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => strokeImage(img, p.thickness, p.color)),
|
||||
};
|
||||
|
||||
interface FindContourParams {
|
||||
color: string;
|
||||
thickness: number;
|
||||
}
|
||||
|
||||
export const findContourSchema = toolSchema<FindContourParams>({
|
||||
color: field.color({ default: "#000000" }),
|
||||
thickness: field.slider({ min: 1, max: 5, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const findContour: ToolEntry<FindContourParams> = {
|
||||
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",
|
||||
schema: findContourSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => contourImage(img, p.thickness, p.color)),
|
||||
};
|
||||
|
||||
interface RemoveColorParams {
|
||||
targetColor: string;
|
||||
tolerance: number;
|
||||
}
|
||||
|
||||
export const removeColorSchema = toolSchema<RemoveColorParams>({
|
||||
targetColor: field.color({ default: "#00ff00" }),
|
||||
tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
|
||||
});
|
||||
|
||||
const removeColor: ToolEntry<RemoveColorParams> = {
|
||||
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",
|
||||
schema: removeColorSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance)),
|
||||
};
|
||||
|
||||
interface CircleMaskParams {
|
||||
size: number;
|
||||
offset: Offset;
|
||||
}
|
||||
|
||||
const circleMaskSchema = toolSchema<CircleMaskParams>(
|
||||
{
|
||||
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Shape", fields: ["size"] },
|
||||
{ title: "Position", fields: ["offset"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const circleMask: ToolEntry<CircleMaskParams> = {
|
||||
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",
|
||||
schema: circleMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
circleTest(p.size / 200),
|
||||
p.offset.x / 100,
|
||||
p.offset.y / 100,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface SquareMaskParams {
|
||||
widthPct: number;
|
||||
heightPct: number;
|
||||
offset: Offset;
|
||||
}
|
||||
|
||||
const squareMaskSchema = toolSchema<SquareMaskParams>(
|
||||
{
|
||||
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
|
||||
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
|
||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{
|
||||
title: "Shape",
|
||||
cols: 2,
|
||||
fields: ["widthPct", "heightPct"],
|
||||
},
|
||||
{ title: "Position", fields: ["offset"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const squareMask: ToolEntry<SquareMaskParams> = {
|
||||
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",
|
||||
schema: squareMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
boxTest(p.widthPct / 200, p.heightPct / 200),
|
||||
p.offset.x / 100,
|
||||
p.offset.y / 100,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface StarMaskParams {
|
||||
points: number;
|
||||
innerRadius: number;
|
||||
size: number;
|
||||
rotation: number;
|
||||
offset: Offset;
|
||||
}
|
||||
|
||||
const starMaskSchema = toolSchema<StarMaskParams>(
|
||||
{
|
||||
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
|
||||
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
|
||||
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
|
||||
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
|
||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{
|
||||
title: "Shape",
|
||||
cols: 2,
|
||||
fields: ["points", "innerRadius", "size", "rotation"],
|
||||
},
|
||||
{ title: "Position", fields: ["offset"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const starMask: ToolEntry<StarMaskParams> = {
|
||||
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",
|
||||
schema: starMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation),
|
||||
p.offset.x / 100,
|
||||
p.offset.y / 100,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface WavyMaskParams {
|
||||
size: number;
|
||||
amplitude: number;
|
||||
waves: number;
|
||||
phase: number;
|
||||
offset: Offset;
|
||||
}
|
||||
|
||||
const wavyMaskSchema = toolSchema<WavyMaskParams>(
|
||||
{
|
||||
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
|
||||
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
|
||||
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
|
||||
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
|
||||
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{
|
||||
title: "Shape",
|
||||
cols: 2,
|
||||
fields: ["size", "amplitude", "waves", "phase"],
|
||||
},
|
||||
{ title: "Position", fields: ["offset"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const wavyMask: ToolEntry<WavyMaskParams> = {
|
||||
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",
|
||||
schema: wavyMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderShape(
|
||||
img,
|
||||
wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase),
|
||||
p.offset.x / 100,
|
||||
p.offset.y / 100,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface EmptyParams {}
|
||||
|
||||
export const removeAlphaChannelSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const removeAlphaChannel: ToolEntry<EmptyParams> = {
|
||||
id: "remove-alpha-channel-png",
|
||||
title: "Remove alpha channel PNG",
|
||||
description:
|
||||
"Composites the image over a white background and saves without transparency.",
|
||||
category: "alpha",
|
||||
schema: removeAlphaChannelSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => flattenOntoColor(img, "#ffffff")),
|
||||
};
|
||||
|
||||
interface SetAlphaChannelParams {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export const setAlphaChannelSchema = toolSchema<SetAlphaChannelParams>({
|
||||
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
|
||||
});
|
||||
|
||||
const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
|
||||
id: "set-alpha-channel-png",
|
||||
title: "Set alpha channel PNG",
|
||||
description: "Assigns the same opacity to all pixels; colors stay unchanged.",
|
||||
category: "alpha",
|
||||
schema: setAlphaChannelSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => setAlphaChannel(img, p.percent)),
|
||||
};
|
||||
|
||||
export const extractAlphaMaskSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const extractAlphaMaskTool: ToolEntry<EmptyParams> = {
|
||||
id: "extract-alpha-mask-png",
|
||||
title: "Extract alpha mask PNG",
|
||||
description: "Turns transparency into a black-and-white opaque mask.",
|
||||
category: "alpha",
|
||||
schema: extractAlphaMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => extractAlphaMask(img)),
|
||||
};
|
||||
|
||||
interface RoundCornersParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const roundCornersSchema = toolSchema<RoundCornersParams>({
|
||||
radius: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
|
||||
});
|
||||
|
||||
const roundCornersTool: ToolEntry<RoundCornersParams> = {
|
||||
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",
|
||||
schema: roundCornersSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => roundCorners(img, p.radius)),
|
||||
};
|
||||
|
||||
export const invertAlphaSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const invertAlphaTool: ToolEntry<EmptyParams> = {
|
||||
id: "invert-alpha-png",
|
||||
title: "Invert alpha PNG",
|
||||
description: "Opaque areas become transparent and vice versa.",
|
||||
category: "alpha",
|
||||
schema: invertAlphaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => invertAlpha(img)),
|
||||
};
|
||||
|
||||
interface RemoveBackgroundParams {
|
||||
color: string;
|
||||
tolerance: number;
|
||||
outerOnly: boolean;
|
||||
smooth: number;
|
||||
}
|
||||
|
||||
export const removeBackgroundSchema = toolSchema<RemoveBackgroundParams>(
|
||||
{
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
|
||||
outerOnly: field.checkbox({ default: true }),
|
||||
smooth: field.slider({ min: 0, max: 8, step: 1, default: 1 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{
|
||||
title: "Background",
|
||||
cols: 2,
|
||||
fields: ["color", "tolerance"],
|
||||
},
|
||||
{ title: "Options", fields: ["outerOnly", "smooth"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const removeBackgroundTool: ToolEntry<RemoveBackgroundParams> = {
|
||||
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",
|
||||
schema: removeBackgroundSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
removeBackground(img, {
|
||||
color: p.color,
|
||||
tolerancePercent: p.tolerance,
|
||||
outerOnly: p.outerOnly,
|
||||
smoothPasses: p.smooth,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
interface MakeThickerParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const makeThickerSchema = toolSchema<MakeThickerParams>({
|
||||
radius: field.slider({ min: 1, max: 10, step: 1, default: 2 }),
|
||||
});
|
||||
|
||||
const makeThickerTool: ToolEntry<MakeThickerParams> = {
|
||||
id: "make-thicker-png",
|
||||
title: "Thicken PNG",
|
||||
description: "Expands opaque areas by the given number of pixels.",
|
||||
category: "alpha",
|
||||
schema: makeThickerSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => dilateImage(img, p.radius)),
|
||||
};
|
||||
|
||||
interface MakeThinnerParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const makeThinnerSchema = toolSchema<MakeThinnerParams>({
|
||||
radius: field.slider({ min: 1, max: 10, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const makeThinnerTool: ToolEntry<MakeThinnerParams> = {
|
||||
id: "make-thinner-png",
|
||||
title: "Thin PNG",
|
||||
description: "Shrinks opaque areas — thins the strokes of text and details.",
|
||||
category: "alpha",
|
||||
schema: makeThinnerSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => erodeImage(img, p.radius)),
|
||||
};
|
||||
|
||||
interface FeatherEdgesParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const featherEdgesSchema = toolSchema<FeatherEdgesParams>({
|
||||
radius: field.slider({ min: 1, max: 20, step: 1, default: 3 }),
|
||||
});
|
||||
|
||||
const featherEdgesTool: ToolEntry<FeatherEdgesParams> = {
|
||||
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",
|
||||
schema: featherEdgesSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => featherAlpha(img, p.radius)),
|
||||
};
|
||||
|
||||
interface CleanEdgesParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const cleanEdgesSchema = toolSchema<CleanEdgesParams>({
|
||||
radius: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
|
||||
});
|
||||
|
||||
const cleanEdgesTool: ToolEntry<CleanEdgesParams> = {
|
||||
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",
|
||||
schema: cleanEdgesSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => defringe(img, p.radius)),
|
||||
};
|
||||
|
||||
interface HardenAlphaParams {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const hardenAlphaSchema = toolSchema<HardenAlphaParams>({
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
});
|
||||
|
||||
const hardenAlphaTool: ToolEntry<HardenAlphaParams> = {
|
||||
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",
|
||||
schema: hardenAlphaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => hardenAlpha(img, p.threshold)),
|
||||
};
|
||||
|
||||
interface DespeckleAlphaParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const despeckleAlphaSchema = toolSchema<DespeckleAlphaParams>({
|
||||
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const despeckleAlphaTool: ToolEntry<DespeckleAlphaParams> = {
|
||||
id: "despeckle-alpha-png",
|
||||
title: "Despeckle PNG",
|
||||
description:
|
||||
"Opening: removes lone semi-transparent pixels and small specks.",
|
||||
category: "alpha",
|
||||
schema: despeckleAlphaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => openingImage(img, p.radius)),
|
||||
};
|
||||
|
||||
interface CloseHolesParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const closeHolesSchema = toolSchema<CloseHolesParams>({
|
||||
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const closeHolesTool: ToolEntry<CloseHolesParams> = {
|
||||
id: "close-holes-png",
|
||||
title: "Close holes PNG",
|
||||
description: "Closing: fills lone transparent dots inside the object.",
|
||||
category: "alpha",
|
||||
schema: closeHolesSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => closingImage(img, p.radius)),
|
||||
};
|
||||
|
||||
export const alphaEntries = [
|
||||
addStroke,
|
||||
findContour,
|
||||
removeColor,
|
||||
circleMask,
|
||||
squareMask,
|
||||
starMask,
|
||||
wavyMask,
|
||||
removeAlphaChannel,
|
||||
setAlphaChannelTool,
|
||||
extractAlphaMaskTool,
|
||||
roundCornersTool,
|
||||
invertAlphaTool,
|
||||
removeBackgroundTool,
|
||||
makeThickerTool,
|
||||
makeThinnerTool,
|
||||
featherEdgesTool,
|
||||
cleanEdgesTool,
|
||||
hardenAlphaTool,
|
||||
despeckleAlphaTool,
|
||||
closeHolesTool,
|
||||
];
|
||||
@@ -0,0 +1,290 @@
|
||||
import { imgTool, textGen, type ToolEntry } from "./types";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import {
|
||||
extractByColor,
|
||||
isGrayscaleish,
|
||||
luma01,
|
||||
rarityPredicate,
|
||||
renderPredicateMask,
|
||||
} from "../core/masks";
|
||||
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
|
||||
import { encode } from "../core/io";
|
||||
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
|
||||
|
||||
interface ExtractColorParams {
|
||||
color: string;
|
||||
tolerance: number;
|
||||
}
|
||||
|
||||
export const extractColorSchema = toolSchema<ExtractColorParams>({
|
||||
color: field.color({ default: "#00ff88" }),
|
||||
tolerance: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
|
||||
});
|
||||
|
||||
const extractColor: ToolEntry<ExtractColorParams> = {
|
||||
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",
|
||||
schema: extractColorSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => extractByColor(img, p.color, p.tolerance)),
|
||||
};
|
||||
|
||||
interface MaskParams {
|
||||
mode: "binary" | "highlight";
|
||||
color: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
const maskBaseFields = {
|
||||
mode: field.select({
|
||||
default: "binary",
|
||||
options: [
|
||||
{ value: "binary", label: "Black & white mask" },
|
||||
{ value: "highlight", label: "Color highlight" },
|
||||
],
|
||||
}),
|
||||
color: field.color({ default: "#ff00aa" }),
|
||||
opacity: field.slider({ min: 0, max: 100, step: 5, default: 70 }),
|
||||
};
|
||||
|
||||
function renderMask(
|
||||
img: Parameters<typeof renderPredicateMask>[0],
|
||||
p: MaskParams,
|
||||
predicate: (r: number, g: number, b: number, a: number) => boolean,
|
||||
) {
|
||||
return renderPredicateMask(img, predicate, {
|
||||
mode: p.mode,
|
||||
color: p.color,
|
||||
opacityPercent: p.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
export const showTransparentSchema = toolSchema<MaskParams>({
|
||||
...maskBaseFields,
|
||||
mode: field.select({
|
||||
default: "highlight",
|
||||
options: [
|
||||
{ value: "binary", label: "Black & white mask" },
|
||||
{ value: "highlight", label: "Color highlight" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const showTransparent: ToolEntry<MaskParams> = {
|
||||
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.",
|
||||
category: "analyze",
|
||||
schema: showTransparentSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255)),
|
||||
};
|
||||
|
||||
interface GrayscalePixelsParams extends MaskParams {
|
||||
tolerance: number;
|
||||
}
|
||||
|
||||
export const showGrayscalePixelsSchema = toolSchema<GrayscalePixelsParams>({
|
||||
...maskBaseFields,
|
||||
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 0 }),
|
||||
});
|
||||
|
||||
const showGrayscalePixels: ToolEntry<GrayscalePixelsParams> = {
|
||||
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.",
|
||||
category: "analyze",
|
||||
schema: showGrayscalePixelsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderMask(img, p, (r, g, b) => isGrayscaleish(r, g, b, p.tolerance)),
|
||||
),
|
||||
};
|
||||
|
||||
interface ColorPixelsParams extends MaskParams {
|
||||
tolerance: number;
|
||||
}
|
||||
|
||||
export const showColorPixelsSchema = toolSchema<ColorPixelsParams>({
|
||||
...maskBaseFields,
|
||||
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 8 }),
|
||||
});
|
||||
|
||||
const showColorPixels: ToolEntry<ColorPixelsParams> = {
|
||||
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.",
|
||||
category: "analyze",
|
||||
schema: showColorPixelsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderMask(img, p, (r, g, b) => !isGrayscaleish(r, g, b, p.tolerance)),
|
||||
),
|
||||
};
|
||||
|
||||
interface LightPixelParams extends MaskParams {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const lightPixelMaskSchema = toolSchema<LightPixelParams>({
|
||||
...maskBaseFields,
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 70 }),
|
||||
});
|
||||
|
||||
const lightPixelMask: ToolEntry<LightPixelParams> = {
|
||||
id: "light-pixel-mask-png",
|
||||
title: "Light Pixel Mask PNG",
|
||||
description: "Selects pixels brighter than the luminance threshold.",
|
||||
category: "analyze",
|
||||
schema: lightPixelMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderMask(img, p, (r, g, b) => luma01(r, g, b) >= p.threshold / 100),
|
||||
),
|
||||
};
|
||||
|
||||
interface DarkPixelParams extends MaskParams {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const darkPixelMaskSchema = toolSchema<DarkPixelParams>({
|
||||
...maskBaseFields,
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
|
||||
});
|
||||
|
||||
const darkPixelMask: ToolEntry<DarkPixelParams> = {
|
||||
id: "dark-pixel-mask-png",
|
||||
title: "Dark Pixel Mask PNG",
|
||||
description: "Selects pixels darker than the luminance threshold.",
|
||||
category: "analyze",
|
||||
schema: darkPixelMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderMask(img, p, (r, g, b) => luma01(r, g, b) <= p.threshold / 100),
|
||||
),
|
||||
};
|
||||
|
||||
interface UniqueColorParams extends MaskParams {
|
||||
rarity: number;
|
||||
}
|
||||
|
||||
export const uniqueColorMaskSchema = toolSchema<UniqueColorParams>({
|
||||
...maskBaseFields,
|
||||
rarity: field.slider({ min: 1, max: 50, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const uniqueColorMask: ToolEntry<UniqueColorParams> = {
|
||||
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.",
|
||||
category: "analyze",
|
||||
schema: uniqueColorMaskSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => renderMask(img, p, rarityPredicate(img, p.rarity))),
|
||||
};
|
||||
|
||||
interface NoParams {}
|
||||
|
||||
const emptySchema = toolSchema<NoParams>({});
|
||||
|
||||
const verifyIsPng: ToolEntry<NoParams> = {
|
||||
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",
|
||||
schema: emptySchema,
|
||||
input: "text",
|
||||
result: "verdict",
|
||||
run: textGen((text) =>
|
||||
looksLikePng(base64ToBytes(stripDataUri(text)))
|
||||
? "Yes — valid PNG signature."
|
||||
: "No — the content is not a PNG.",
|
||||
),
|
||||
};
|
||||
|
||||
const pngIsGrayscale: ToolEntry<NoParams> = {
|
||||
id: "png-is-grayscale",
|
||||
title: "Check: is PNG grayscale?",
|
||||
description: "Reports whether the image consists only of shades of gray.",
|
||||
category: "analyze",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "verdict",
|
||||
run: imgTool((img) =>
|
||||
isGrayscale(img) ? "Yes — grayscale." : "No — contains colors.",
|
||||
),
|
||||
};
|
||||
|
||||
const pngFileSize: ToolEntry<NoParams> = {
|
||||
id: "png-file-size",
|
||||
title: "PNG File Size",
|
||||
description: "Encodes the image as PNG and reports the resulting file size.",
|
||||
category: "analyze",
|
||||
schema: emptySchema,
|
||||
domOnly: true,
|
||||
input: "image",
|
||||
result: "verdict",
|
||||
run: imgTool(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 `${kbText} KB`;
|
||||
}),
|
||||
};
|
||||
|
||||
const pngIsTransparent: ToolEntry<NoParams> = {
|
||||
id: "png-is-transparent",
|
||||
title: "Check: is PNG transparent?",
|
||||
description:
|
||||
"Reports whether the image contains transparent or semi-transparent pixels.",
|
||||
category: "analyze",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "verdict",
|
||||
run: imgTool((img) =>
|
||||
hasTransparency(img) ? "Yes — has transparency." : "No — fully opaque.",
|
||||
),
|
||||
};
|
||||
|
||||
const pngOrientation: ToolEntry<NoParams> = {
|
||||
id: "png-orientation",
|
||||
title: "PNG orientation",
|
||||
description: "Reports whether it is portrait, landscape or square.",
|
||||
category: "analyze",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "verdict",
|
||||
run: imgTool((img) => {
|
||||
switch (orientationOf(img)) {
|
||||
case "portrait":
|
||||
return "Portrait";
|
||||
case "landscape":
|
||||
return "Landscape";
|
||||
default:
|
||||
return "Square";
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
export const analyzeEntries = [
|
||||
extractColor,
|
||||
showTransparent,
|
||||
showGrayscalePixels,
|
||||
showColorPixels,
|
||||
lightPixelMask,
|
||||
darkPixelMask,
|
||||
uniqueColorMask,
|
||||
verifyIsPng,
|
||||
pngIsGrayscale,
|
||||
pngFileSize,
|
||||
pngIsTransparent,
|
||||
pngOrientation,
|
||||
];
|
||||
@@ -0,0 +1,525 @@
|
||||
import {
|
||||
autoContrast,
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
extractChannel,
|
||||
gammaCorrection,
|
||||
grayscale,
|
||||
invert,
|
||||
posterize,
|
||||
sepia,
|
||||
setOpacity,
|
||||
swapChannels,
|
||||
temperature,
|
||||
thresholdBlackWhite,
|
||||
tint,
|
||||
twoColors,
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel,
|
||||
} from "../core/color";
|
||||
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
|
||||
import { parseHexList } from "../core/palette";
|
||||
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
|
||||
import { field, toolSchema, type ColorPair } from "../registry-schema";
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
|
||||
interface TwoColorsParams {
|
||||
pair: ColorPair;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const twoColorsSchema = toolSchema<TwoColorsParams>({
|
||||
pair: field.colorPair({ from: "#ffffff", to: "#000000" }),
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
});
|
||||
|
||||
const twoColorsTool: ToolEntry<TwoColorsParams> = {
|
||||
id: "two-colors-png",
|
||||
title: "Two colors PNG",
|
||||
description:
|
||||
"Recolors the image into two chosen colors by luminance threshold.",
|
||||
category: "color",
|
||||
schema: twoColorsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold)),
|
||||
};
|
||||
|
||||
interface GammaParams {
|
||||
value: number;
|
||||
}
|
||||
|
||||
export const gammaSchema = toolSchema<GammaParams>({
|
||||
value: field.slider({ min: 0.1, max: 3, step: 0.05, default: 1 }),
|
||||
});
|
||||
|
||||
const gammaTool: ToolEntry<GammaParams> = {
|
||||
id: "gamma-png",
|
||||
title: "Gamma correction PNG",
|
||||
description:
|
||||
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
|
||||
category: "color",
|
||||
schema: gammaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => gammaCorrection(img, p.value)),
|
||||
};
|
||||
|
||||
interface TemperatureParams {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export const temperatureSchema = toolSchema<TemperatureParams>({
|
||||
percent: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
|
||||
});
|
||||
|
||||
const temperatureTool: ToolEntry<TemperatureParams> = {
|
||||
id: "temperature-png",
|
||||
title: "Temperature PNG",
|
||||
description:
|
||||
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
|
||||
category: "color",
|
||||
schema: temperatureSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => temperature(img, p.percent)),
|
||||
};
|
||||
|
||||
interface TintParams {
|
||||
color: string;
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const tintSchema = toolSchema<TintParams>({
|
||||
color: field.color({ default: "#ffb060" }),
|
||||
strength: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
|
||||
});
|
||||
|
||||
const tintTool: ToolEntry<TintParams> = {
|
||||
id: "tint-png",
|
||||
title: "Tint PNG",
|
||||
description:
|
||||
"Multiplies color channels by the chosen tint with the given strength.",
|
||||
category: "color",
|
||||
schema: tintSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => tint(img, p.color, p.strength)),
|
||||
};
|
||||
|
||||
interface QuantizeParams {
|
||||
colors: number;
|
||||
}
|
||||
|
||||
export const quantizeSchema = toolSchema<QuantizeParams>({
|
||||
colors: field.slider({ min: 2, max: 64, step: 1, default: 16 }),
|
||||
});
|
||||
|
||||
const quantizeTool: ToolEntry<QuantizeParams> = {
|
||||
id: "quantize-png",
|
||||
title: "Quantize PNG",
|
||||
description:
|
||||
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
|
||||
category: "color",
|
||||
schema: quantizeSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => quantizeImage(img, p.colors).image),
|
||||
};
|
||||
|
||||
interface CustomPaletteParams {
|
||||
colors: string;
|
||||
}
|
||||
|
||||
export const customPaletteSchema = toolSchema<CustomPaletteParams>({
|
||||
colors: field.text({ default: "#000000,#ffffff" }),
|
||||
});
|
||||
|
||||
const customPalette: ToolEntry<CustomPaletteParams> = {
|
||||
id: "custom-palette-png",
|
||||
title: "Custom Palette PNG",
|
||||
description:
|
||||
"Maps every pixel to the nearest color from your comma-separated hex list.",
|
||||
category: "color",
|
||||
schema: customPaletteSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => mapToNearest(img, parseHexList(p.colors))),
|
||||
};
|
||||
|
||||
interface DitheringParams {
|
||||
colors: number;
|
||||
pattern: "floyd-steinberg" | "bayer";
|
||||
}
|
||||
|
||||
export const ditheringSchema = toolSchema<DitheringParams>({
|
||||
colors: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
|
||||
pattern: field.select({
|
||||
default: "floyd-steinberg",
|
||||
options: [
|
||||
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
|
||||
{ value: "bayer", label: "Bayer 4x4" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const ditheringTool: ToolEntry<DitheringParams> = {
|
||||
id: "dithering-png",
|
||||
title: "Dithering PNG",
|
||||
description:
|
||||
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
|
||||
category: "color",
|
||||
schema: ditheringSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => ditherImage(img, p.colors, p.pattern)),
|
||||
};
|
||||
|
||||
interface EmptyParams {}
|
||||
|
||||
export const grayscaleSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const grayscaleTool: ToolEntry<EmptyParams> = {
|
||||
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",
|
||||
schema: grayscaleSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => grayscale(img)),
|
||||
};
|
||||
|
||||
export const invertColorsSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const invertColorsTool: ToolEntry<EmptyParams> = {
|
||||
id: "invert-colors-png",
|
||||
title: "Invert colors PNG",
|
||||
description: "Inverts each color channel (255 − value). Alpha is unchanged.",
|
||||
category: "color",
|
||||
schema: invertColorsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => invert(img)),
|
||||
};
|
||||
|
||||
interface BrightnessContrastParams {
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
}
|
||||
|
||||
export const brightnessContrastSchema = toolSchema<BrightnessContrastParams>(
|
||||
{
|
||||
brightness: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
|
||||
contrast: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Adjust", cols: 2, fields: ["brightness", "contrast"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const brightnessContrastTool: ToolEntry<BrightnessContrastParams> = {
|
||||
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",
|
||||
schema: brightnessContrastSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => brightnessContrast(img, p.brightness, p.contrast)),
|
||||
};
|
||||
|
||||
interface OpacityParams {
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export const opacitySchema = toolSchema<OpacityParams>({
|
||||
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
|
||||
});
|
||||
|
||||
const opacityTool: ToolEntry<OpacityParams> = {
|
||||
id: "change-png-opacity",
|
||||
title: "Change PNG opacity",
|
||||
description:
|
||||
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
|
||||
category: "color",
|
||||
schema: opacitySchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => setOpacity(img, p.percent)),
|
||||
};
|
||||
|
||||
export const sepiaSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const sepiaTool: ToolEntry<EmptyParams> = {
|
||||
id: "sepia-png",
|
||||
title: "Sepia effect",
|
||||
description: "Tints the image into the warm brown tones of classic sepia.",
|
||||
category: "color",
|
||||
schema: sepiaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => sepia(img)),
|
||||
};
|
||||
|
||||
interface HueShiftParams {
|
||||
degrees: number;
|
||||
}
|
||||
|
||||
export const hueShiftSchema = toolSchema<HueShiftParams>({
|
||||
degrees: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
|
||||
});
|
||||
|
||||
const hueShiftTool: ToolEntry<HueShiftParams> = {
|
||||
id: "change-png-hue",
|
||||
title: "Change hue PNG",
|
||||
description:
|
||||
"Shifts the hue around the circle. Saturation and lightness are preserved.",
|
||||
category: "color",
|
||||
schema: hueShiftSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => changeHue(img, p.degrees)),
|
||||
};
|
||||
|
||||
interface ExtractChannelParams {
|
||||
channel: RgbChannel;
|
||||
}
|
||||
|
||||
export const extractChannelSchema = toolSchema<ExtractChannelParams>({
|
||||
channel: field.select({
|
||||
default: "red",
|
||||
options: [
|
||||
{ value: "red", label: "Red" },
|
||||
{ value: "green", label: "Green" },
|
||||
{ value: "blue", label: "Blue" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const extractChannelTool: ToolEntry<ExtractChannelParams> = {
|
||||
id: "extract-channel-png",
|
||||
title: "Extract channel PNG",
|
||||
description:
|
||||
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
|
||||
category: "color",
|
||||
schema: extractChannelSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => extractChannel(img, p.channel)),
|
||||
};
|
||||
|
||||
interface SwapChannelsParams {
|
||||
pair: ChannelSwapPair;
|
||||
}
|
||||
|
||||
export const swapChannelsSchema = toolSchema<SwapChannelsParams>({
|
||||
pair: field.select({
|
||||
default: "r-g",
|
||||
options: [
|
||||
{ value: "r-g", label: "Red ↔ Green" },
|
||||
{ value: "r-b", label: "Red ↔ Blue" },
|
||||
{ value: "g-b", label: "Green ↔ Blue" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const swapChannelsTool: ToolEntry<SwapChannelsParams> = {
|
||||
id: "swap-channels-png",
|
||||
title: "Swap channels PNG",
|
||||
description:
|
||||
"Swaps two color channels — a quick way to get unusual coloring.",
|
||||
category: "color",
|
||||
schema: swapChannelsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => swapChannels(img, p.pair)),
|
||||
};
|
||||
|
||||
interface BlackAndWhiteParams {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const blackAndWhiteSchema = toolSchema<BlackAndWhiteParams>({
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
});
|
||||
|
||||
const blackAndWhiteTool: ToolEntry<BlackAndWhiteParams> = {
|
||||
id: "black-and-white-png",
|
||||
title: "Black & white threshold PNG",
|
||||
description:
|
||||
"Hard binarization by luminance: every pixel becomes black or white.",
|
||||
category: "color",
|
||||
schema: blackAndWhiteSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => thresholdBlackWhite(img, p.threshold)),
|
||||
};
|
||||
|
||||
interface PosterizeParams {
|
||||
levels: number;
|
||||
}
|
||||
|
||||
export const posterizeSchema = toolSchema<PosterizeParams>({
|
||||
levels: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
|
||||
});
|
||||
|
||||
const posterizeTool: ToolEntry<PosterizeParams> = {
|
||||
id: "posterize-png",
|
||||
title: "Posterize PNG",
|
||||
description: "Reduces the number of levels per channel — a poster effect.",
|
||||
category: "color",
|
||||
schema: posterizeSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => posterize(img, p.levels)),
|
||||
};
|
||||
|
||||
export const autoContrastSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const autoContrastTool: ToolEntry<EmptyParams> = {
|
||||
id: "auto-contrast-png",
|
||||
title: "Auto contrast PNG",
|
||||
description:
|
||||
"Stretches each channel's range across the full available brightness range.",
|
||||
category: "color",
|
||||
schema: autoContrastSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => autoContrast(img)),
|
||||
};
|
||||
|
||||
interface DecreaseColorCountParams {
|
||||
maxColors:
|
||||
"2" | "4" | "8" | "16" | "32" | "44" | "64" | "96" | "128" | "192" | "256";
|
||||
}
|
||||
|
||||
export const decreaseColorCountSchema = toolSchema<DecreaseColorCountParams>({
|
||||
maxColors: field.select({
|
||||
default: "16",
|
||||
options: [
|
||||
{ value: "2", label: "2" },
|
||||
{ value: "4", label: "4" },
|
||||
{ value: "8", label: "8" },
|
||||
{ value: "16", label: "16 (extreme)" },
|
||||
{ value: "32", label: "32" },
|
||||
{ value: "44", label: "44 (strong)" },
|
||||
{ value: "64", label: "64" },
|
||||
{ value: "96", label: "96 (balanced)" },
|
||||
{ value: "128", label: "128" },
|
||||
{ value: "192", label: "192 (light)" },
|
||||
{ value: "256", label: "256" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const decreaseColorCountTool: ToolEntry<DecreaseColorCountParams> = {
|
||||
id: "decrease-color-count-png",
|
||||
title: "Decrease Color Count PNG",
|
||||
description:
|
||||
"Median-cut engine as a quick way to drop to 2–256 colors. Presets marked (extreme/strong/balanced/light) match the classic compression levels.",
|
||||
category: "color",
|
||||
schema: decreaseColorCountSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => quantizeImage(img, Number(p.maxColors)).image),
|
||||
};
|
||||
|
||||
interface ChannelParams {
|
||||
component: string;
|
||||
display: "gray" | "color";
|
||||
}
|
||||
|
||||
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<ChannelParams>[] {
|
||||
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",
|
||||
schema: toolSchema<ChannelParams>({
|
||||
component: field.select({
|
||||
default: components[0],
|
||||
options: components.map((c) => ({
|
||||
value: c,
|
||||
label: c.toUpperCase(),
|
||||
})),
|
||||
}),
|
||||
display: field.select({
|
||||
default: "gray",
|
||||
options: [
|
||||
{ value: "gray", label: "Grayscale" },
|
||||
{ value: "color", label: "Space as RGB" },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
renderSpace(img, space.id, p.component, p.display),
|
||||
),
|
||||
} satisfies ToolEntry<ChannelParams>;
|
||||
});
|
||||
}
|
||||
|
||||
export const colorEntries = [
|
||||
twoColorsTool,
|
||||
gammaTool,
|
||||
temperatureTool,
|
||||
tintTool,
|
||||
quantizeTool,
|
||||
customPalette,
|
||||
ditheringTool,
|
||||
grayscaleTool,
|
||||
invertColorsTool,
|
||||
brightnessContrastTool,
|
||||
opacityTool,
|
||||
sepiaTool,
|
||||
hueShiftTool,
|
||||
extractChannelTool,
|
||||
swapChannelsTool,
|
||||
blackAndWhiteTool,
|
||||
posterizeTool,
|
||||
autoContrastTool,
|
||||
decreaseColorCountTool,
|
||||
...channelEntries(),
|
||||
];
|
||||
@@ -0,0 +1,253 @@
|
||||
import { imgTool, textGen, type ToolEntry } from "./types";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import { flattenOntoColor } from "../core/alpha";
|
||||
import {
|
||||
base64ToBytes,
|
||||
bytesToImage,
|
||||
imageToByteRows,
|
||||
imageToRgbValues,
|
||||
rgbValuesToImage,
|
||||
stripDataUri,
|
||||
} from "../core/textio";
|
||||
import { hexToPixels, pixelsToHex } from "../core/text";
|
||||
import { clonePixelImage } from "../core/types";
|
||||
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../core/io";
|
||||
|
||||
const widthProps = (defaultValue: number) =>
|
||||
field.number({ min: 1, max: 10000, step: 1, default: defaultValue });
|
||||
|
||||
interface ConvertToJpgParams {
|
||||
background: string;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export const convertToJpgSchema = toolSchema<ConvertToJpgParams>({
|
||||
background: field.color({ default: "#ffffff" }),
|
||||
quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
|
||||
});
|
||||
|
||||
const convertToJpg: ToolEntry<ConvertToJpgParams> = {
|
||||
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",
|
||||
schema: convertToJpgSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => flattenOntoColor(img, p.background)),
|
||||
output: { mime: "image/jpeg", ext: "jpg", qualityParamId: "quality" },
|
||||
};
|
||||
|
||||
interface ConvertToWebpParams {
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export const convertToWebpSchema = toolSchema<ConvertToWebpParams>({
|
||||
quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
|
||||
});
|
||||
|
||||
const convertToWebp: ToolEntry<ConvertToWebpParams> = {
|
||||
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",
|
||||
schema: convertToWebpSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => clonePixelImage(img)),
|
||||
output: { mime: "image/webp", ext: "webp", qualityParamId: "quality" },
|
||||
};
|
||||
|
||||
interface ConvertToBmpParams {}
|
||||
|
||||
export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({});
|
||||
|
||||
const convertToBmp: ToolEntry<ConvertToBmpParams> = {
|
||||
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",
|
||||
schema: convertToBmpSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => flattenOntoColor(img, "#000000")),
|
||||
output: { mime: "image/bmp", ext: "bmp" },
|
||||
};
|
||||
|
||||
interface NoParams {}
|
||||
|
||||
const emptySchema = toolSchema<NoParams>({});
|
||||
|
||||
const pngToBase64: ToolEntry<NoParams> = {
|
||||
id: "png-to-base64",
|
||||
title: "PNG to Base64",
|
||||
description:
|
||||
"Encodes the image into a base64 string for embedding in code or styles.",
|
||||
category: "convert",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
run: imgTool((img) => toBase64(img)),
|
||||
};
|
||||
|
||||
const pngToDataUri: ToolEntry<NoParams> = {
|
||||
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",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
run: imgTool((img) => toDataUrl(img)),
|
||||
};
|
||||
|
||||
const pngToHex: ToolEntry<NoParams> = {
|
||||
id: "png-to-hex",
|
||||
title: "PNG to HEX pixels",
|
||||
description:
|
||||
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
|
||||
category: "convert",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
run: imgTool((img) => pixelsToHex(img)),
|
||||
};
|
||||
|
||||
const pngToBytes: ToolEntry<NoParams> = {
|
||||
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",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
run: imgTool((img) => imageToByteRows(img)),
|
||||
};
|
||||
|
||||
const pngToRgbValues: ToolEntry<NoParams> = {
|
||||
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",
|
||||
schema: emptySchema,
|
||||
input: "image",
|
||||
result: "text",
|
||||
run: imgTool((img) => imageToRgbValues(img)),
|
||||
};
|
||||
|
||||
const base64ToPng: ToolEntry<NoParams> = {
|
||||
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",
|
||||
schema: emptySchema,
|
||||
input: "text",
|
||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||
};
|
||||
|
||||
const dataUriToPng: ToolEntry<NoParams> = {
|
||||
id: "data-uri-to-png",
|
||||
title: "Data URI to PNG",
|
||||
description: "Decodes data:image/…;base64,… back into an image file.",
|
||||
category: "convert",
|
||||
schema: emptySchema,
|
||||
input: "text",
|
||||
run: textGen((text) => decodeBytes(base64ToBytes(stripDataUri(text)))),
|
||||
};
|
||||
|
||||
interface HexToPngParams {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const hexToPngSchema = toolSchema<HexToPngParams>({
|
||||
width: widthProps(1),
|
||||
});
|
||||
|
||||
const hexToPng: ToolEntry<HexToPngParams> = {
|
||||
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",
|
||||
schema: hexToPngSchema,
|
||||
input: "text",
|
||||
run: textGen((text, p) => hexToPixels(text, Math.trunc(p.width))),
|
||||
};
|
||||
|
||||
interface BytesToPngParams {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const bytesToPngSchema = toolSchema<BytesToPngParams>({
|
||||
width: widthProps(32),
|
||||
});
|
||||
|
||||
const bytesToPng: ToolEntry<BytesToPngParams> = {
|
||||
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",
|
||||
schema: bytesToPngSchema,
|
||||
input: "text",
|
||||
run: textGen((text, p) => bytesToImage(text, Math.trunc(p.width))),
|
||||
};
|
||||
|
||||
interface RgbValuesToPngParams {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const rgbValuesToPngSchema = toolSchema<RgbValuesToPngParams>({
|
||||
width: widthProps(32),
|
||||
});
|
||||
|
||||
const rgbValuesToPng: ToolEntry<RgbValuesToPngParams> = {
|
||||
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",
|
||||
schema: rgbValuesToPngSchema,
|
||||
input: "text",
|
||||
run: textGen((text, p) => rgbValuesToImage(text, Math.trunc(p.width))),
|
||||
};
|
||||
|
||||
interface SvgToPngParams {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const svgToPngSchema = toolSchema<SvgToPngParams>({
|
||||
width: widthProps(512),
|
||||
});
|
||||
|
||||
const svgToPng: ToolEntry<SvgToPngParams> = {
|
||||
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",
|
||||
schema: svgToPngSchema,
|
||||
input: "text",
|
||||
domOnly: true,
|
||||
run: textGen((text, p) => decodeSvgText(text, Math.trunc(p.width))),
|
||||
};
|
||||
|
||||
export const convertEntries = [
|
||||
convertToJpg,
|
||||
convertToWebp,
|
||||
convertToBmp,
|
||||
pngToBase64,
|
||||
pngToDataUri,
|
||||
pngToHex,
|
||||
pngToBytes,
|
||||
pngToRgbValues,
|
||||
base64ToPng,
|
||||
dataUriToPng,
|
||||
hexToPng,
|
||||
bytesToPng,
|
||||
rgbValuesToPng,
|
||||
svgToPng,
|
||||
];
|
||||
@@ -0,0 +1,205 @@
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import {
|
||||
addNoise,
|
||||
pixelate,
|
||||
shuffleBlocks,
|
||||
silhouette,
|
||||
} from "../core/pixel-fx";
|
||||
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
|
||||
import { vignette } from "../core/effects";
|
||||
import { jpegRoundtrip } from "../core/io";
|
||||
|
||||
interface BlurParams {
|
||||
radius: number;
|
||||
}
|
||||
|
||||
export const blurSchema = toolSchema<BlurParams>({
|
||||
radius: field.slider({ min: 1, max: 32, step: 1, default: 4 }),
|
||||
});
|
||||
|
||||
const blurTool: ToolEntry<BlurParams> = {
|
||||
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",
|
||||
schema: blurSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => gaussianBlur(img, p.radius)),
|
||||
};
|
||||
|
||||
interface SharpenParams {
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const sharpenSchema = toolSchema<SharpenParams>({
|
||||
strength: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
});
|
||||
|
||||
const sharpenTool: ToolEntry<SharpenParams> = {
|
||||
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",
|
||||
schema: sharpenSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => sharpenImage(img, p.strength)),
|
||||
};
|
||||
|
||||
interface SilhouetteParams {
|
||||
color: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const silhouetteSchema = toolSchema<SilhouetteParams>({
|
||||
color: field.color({ default: "#111318" }),
|
||||
threshold: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
|
||||
});
|
||||
|
||||
const silhouetteTool: ToolEntry<SilhouetteParams> = {
|
||||
id: "silhouette-png",
|
||||
title: "Silhouette PNG",
|
||||
description:
|
||||
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
|
||||
category: "filters",
|
||||
schema: silhouetteSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => silhouette(img, p.color, p.threshold * 2.55)),
|
||||
};
|
||||
|
||||
interface VignetteParams {
|
||||
strength: number;
|
||||
}
|
||||
|
||||
export const vignetteSchema = toolSchema<VignetteParams>({
|
||||
strength: field.slider({ min: 0, max: 100, step: 5, default: 50 }),
|
||||
});
|
||||
|
||||
const vignetteTool: ToolEntry<VignetteParams> = {
|
||||
id: "vignette-png",
|
||||
title: "Vignette PNG",
|
||||
description:
|
||||
"Smoothly darkens the edges of the image, leaving the center untouched.",
|
||||
category: "filters",
|
||||
schema: vignetteSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => vignette(img, p.strength)),
|
||||
};
|
||||
|
||||
interface PixelateParams {
|
||||
blockSize: number;
|
||||
}
|
||||
|
||||
export const pixelateSchema = toolSchema<PixelateParams>({
|
||||
blockSize: field.slider({ min: 2, max: 64, step: 1, default: 8 }),
|
||||
});
|
||||
|
||||
const pixelateTool: ToolEntry<PixelateParams> = {
|
||||
id: "pixelate-png",
|
||||
title: "Pixelate PNG",
|
||||
description:
|
||||
"Averages every blockSize×blockSize area into one color — classic mosaic.",
|
||||
category: "filters",
|
||||
schema: pixelateSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => pixelate(img, p.blockSize)),
|
||||
};
|
||||
|
||||
interface RandomizePixelsParams {
|
||||
blockSize: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>(
|
||||
{
|
||||
blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
||||
seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [{ title: "Blocks", cols: 2, fields: ["blockSize", "seed"] }],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const randomizePixels: ToolEntry<RandomizePixelsParams> = {
|
||||
id: "randomize-pixels-png",
|
||||
title: "Randomize Pixels PNG",
|
||||
description:
|
||||
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
|
||||
category: "filters",
|
||||
schema: randomizePixelsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => shuffleBlocks(img, p.blockSize, p.seed)),
|
||||
};
|
||||
|
||||
interface AddNoiseParams {
|
||||
amount: number;
|
||||
mode: "mono" | "color";
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const addNoiseSchema = toolSchema<AddNoiseParams>(
|
||||
{
|
||||
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
|
||||
mode: field.select({
|
||||
default: "mono",
|
||||
options: [
|
||||
{ value: "mono", label: "Monochrome grain" },
|
||||
{ value: "color", label: "Color noise" },
|
||||
],
|
||||
}),
|
||||
seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Noise", cols: 2, fields: ["amount", "mode"] },
|
||||
{ title: "Seed", fields: ["seed"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const addNoiseTool: ToolEntry<AddNoiseParams> = {
|
||||
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",
|
||||
schema: addNoiseSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => addNoise(img, p.amount, p.mode, p.seed)),
|
||||
};
|
||||
|
||||
interface JpegArtifactsParams {
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export const jpegArtifactsSchema = toolSchema<JpegArtifactsParams>({
|
||||
quality: field.slider({ min: 1, max: 50, step: 1, default: 10 }),
|
||||
});
|
||||
|
||||
const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
|
||||
id: "jpeg-artifacts-png",
|
||||
title: "JPEG artifacts",
|
||||
description:
|
||||
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
|
||||
category: "filters",
|
||||
schema: jpegArtifactsSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => jpegRoundtrip(img, p.quality)),
|
||||
};
|
||||
|
||||
export const filtersEntries = [
|
||||
vignetteTool,
|
||||
pixelateTool,
|
||||
randomizePixels,
|
||||
addNoiseTool,
|
||||
jpegArtifacts,
|
||||
blurTool,
|
||||
sharpenTool,
|
||||
silhouetteTool,
|
||||
];
|
||||
@@ -0,0 +1,847 @@
|
||||
import { renderEmoji, renderTextToImage } from "../core/domText";
|
||||
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
||||
import { noiseImage, solidImage } from "../core/generate";
|
||||
import { changeCanvasSize } from "../core/geometry";
|
||||
import {
|
||||
analogousSet,
|
||||
complementarySet,
|
||||
hexToRgb,
|
||||
mixColors,
|
||||
monochromaticSet,
|
||||
renderBlend,
|
||||
renderSwatches,
|
||||
renderWheel,
|
||||
shadeSet,
|
||||
sortPalette,
|
||||
stepColors,
|
||||
tetradicSet,
|
||||
triadicSet,
|
||||
} from "../core/palette";
|
||||
import type { PixelImage } from "../core/types";
|
||||
import {
|
||||
field,
|
||||
toolSchema,
|
||||
type ColorPair,
|
||||
type Dimension,
|
||||
type FontStyle,
|
||||
type Gradient,
|
||||
} from "../registry-schema";
|
||||
import { genTool, type ToolEntry } from "./types";
|
||||
|
||||
function rgba(hex: string): [number, number, number, number] {
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
return [r, g, b, 255];
|
||||
}
|
||||
|
||||
/**
|
||||
* Линейный градиент по произвольному углу. 0° — слева направо, 90° — сверху
|
||||
* вниз (рост угла по часовой, ось Y вниз). Угол задаёт направление оси
|
||||
* градиента; t пикселя — нормализованная проекция на эту ось.
|
||||
*/
|
||||
function angleGradient(
|
||||
width: number,
|
||||
height: number,
|
||||
fromRgba: [number, number, number, number],
|
||||
toRgba: [number, number, number, number],
|
||||
angle: number,
|
||||
): PixelImage {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const vx = Math.cos(rad);
|
||||
const vy = Math.sin(rad);
|
||||
// Минимум и максимум проекции на ось достигаются в противоположных углах.
|
||||
const rightX = vx >= 0 ? width - 1 : 0;
|
||||
const bottomY = vy >= 0 ? height - 1 : 0;
|
||||
const projMax = rightX * vx + bottomY * vy;
|
||||
const projMin = (width - 1 - rightX) * vx + (height - 1 - bottomY) * vy;
|
||||
const span = projMax - projMin;
|
||||
const out: PixelImage = {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
};
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = span === 0 ? 0 : (x * vx + y * vy - projMin) / span;
|
||||
const i = (y * width + x) * 4;
|
||||
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
|
||||
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
|
||||
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
|
||||
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface CreateEmptyParams {
|
||||
size: Dimension;
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const createEmptySchema = toolSchema<CreateEmptyParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||
transparent: field.checkbox({ default: true }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Fill", fields: ["transparent", "color"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const createEmpty: ToolEntry<CreateEmptyParams> = {
|
||||
id: "create-empty-png",
|
||||
title: "Create empty PNG",
|
||||
description:
|
||||
"Creates a blank canvas of the chosen dimensions, either transparent or filled with a solid color.",
|
||||
category: "generate",
|
||||
schema: createEmptySchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
if (p.transparent) {
|
||||
return solidImage(w, h, [0, 0, 0, 0]);
|
||||
}
|
||||
return solidImage(w, h, rgba(p.color));
|
||||
}),
|
||||
};
|
||||
|
||||
interface SingleColorParams {
|
||||
size: Dimension;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const singleColorSchema = toolSchema<SingleColorParams>({
|
||||
size: field.dimension({ min: 1, max: 20000, width: 256, height: 256 }),
|
||||
color: field.color({ default: "#ff0000" }),
|
||||
});
|
||||
|
||||
const singleColor: ToolEntry<SingleColorParams> = {
|
||||
id: "single-color-png",
|
||||
title: "Create solid color PNG",
|
||||
description: "Generates a rectangle of the given size and color.",
|
||||
category: "generate",
|
||||
schema: singleColorSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return solidImage(w, h, rgba(p.color));
|
||||
}),
|
||||
};
|
||||
|
||||
interface RandomNoiseParams {
|
||||
size: Dimension;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const randomNoiseSchema = toolSchema<RandomNoiseParams>({
|
||||
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
|
||||
seed: field.number({ min: 0, max: 999999999, step: 1, default: 1 }),
|
||||
});
|
||||
|
||||
const randomNoise: ToolEntry<RandomNoiseParams> = {
|
||||
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",
|
||||
schema: randomNoiseSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return noiseImage(w, h, p.seed);
|
||||
}),
|
||||
};
|
||||
|
||||
interface LinearGradientParams {
|
||||
size: Dimension;
|
||||
gradient: Gradient;
|
||||
}
|
||||
|
||||
export const linearGradientSchema = toolSchema<LinearGradientParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||
gradient: field.gradient({ from: "#000000", to: "#ffffff", angle: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Colors", fields: ["gradient"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const linearGradient: ToolEntry<LinearGradientParams> = {
|
||||
id: "linear-gradient-png",
|
||||
title: "Create gradient PNG",
|
||||
description:
|
||||
"Generates a smooth transition between two colors along a chosen angle.",
|
||||
category: "generate",
|
||||
schema: linearGradientSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return angleGradient(
|
||||
w,
|
||||
h,
|
||||
rgba(p.gradient.from),
|
||||
rgba(p.gradient.to),
|
||||
p.gradient.angle,
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
interface ColorSpectrumParams {
|
||||
size: Dimension;
|
||||
direction: "horizontal" | "vertical";
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
}
|
||||
|
||||
export const colorSpectrumSchema = toolSchema<ColorSpectrumParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 5000, width: 1024, height: 128 }),
|
||||
direction: field.select({
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal" },
|
||||
{ value: "vertical", label: "Vertical" },
|
||||
],
|
||||
}),
|
||||
saturation: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
|
||||
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Spectrum", fields: ["direction", "saturation", "lightness"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const colorSpectrumTool: ToolEntry<ColorSpectrumParams> = {
|
||||
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",
|
||||
schema: colorSpectrumSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return colorSpectrum(w, h, p.direction, p.saturation, p.lightness);
|
||||
}),
|
||||
};
|
||||
|
||||
interface RandomColorsParams {
|
||||
size: Dimension;
|
||||
blockSize: number;
|
||||
seed: number;
|
||||
}
|
||||
|
||||
export const randomColorsSchema = toolSchema<RandomColorsParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
|
||||
blockSize: field.slider({ min: 4, max: 256, step: 2, default: 64 }),
|
||||
seed: field.number({ min: 0, max: 999999999, step: 1, default: 7 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Random", cols: 2, fields: ["blockSize", "seed"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const randomColors: ToolEntry<RandomColorsParams> = {
|
||||
id: "random-colors-png",
|
||||
title: "Random Color Blocks PNG",
|
||||
description:
|
||||
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
|
||||
category: "generate",
|
||||
schema: randomColorsSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return randomColorBlocks(w, h, p.blockSize, p.seed);
|
||||
}),
|
||||
};
|
||||
|
||||
interface DrawGridParams {
|
||||
size: Dimension;
|
||||
cols: number;
|
||||
rows: number;
|
||||
lineWidth: number;
|
||||
color: string;
|
||||
transparentBg: boolean;
|
||||
}
|
||||
|
||||
export const drawGridSchema = toolSchema<DrawGridParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 5000, width: 512, height: 512 }),
|
||||
cols: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
||||
rows: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
|
||||
lineWidth: field.slider({ min: 1, max: 40, step: 1, default: 2 }),
|
||||
color: field.color({ default: "#111318" }),
|
||||
transparentBg: field.checkbox({ default: true }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{
|
||||
title: "Grid",
|
||||
cols: 2,
|
||||
fields: ["cols", "rows", "lineWidth", "color", "transparentBg"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const drawGridTool: ToolEntry<DrawGridParams> = {
|
||||
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",
|
||||
schema: drawGridSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
return drawGrid(
|
||||
w,
|
||||
h,
|
||||
p.cols,
|
||||
p.rows,
|
||||
p.lineWidth,
|
||||
p.color,
|
||||
p.transparentBg,
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
interface PlaceholderParams {
|
||||
size: Dimension;
|
||||
backgroundColor: string;
|
||||
color: string;
|
||||
showText: boolean;
|
||||
}
|
||||
|
||||
export const placeholderSchema = toolSchema<PlaceholderParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 5000, width: 800, height: 400 }),
|
||||
backgroundColor: field.color({ default: "#dfe2e8" }),
|
||||
color: field.color({ default: "#5c6470" }),
|
||||
showText: field.checkbox({ default: true }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Colors", cols: 2, fields: ["backgroundColor", "color"] },
|
||||
{ title: "Text", fields: ["showText"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const placeholder: ToolEntry<PlaceholderParams> = {
|
||||
id: "placeholder-png",
|
||||
title: "Create Placeholder PNG",
|
||||
description:
|
||||
"Generates a placeholder rectangle with its dimensions printed in the center.",
|
||||
category: "generate",
|
||||
schema: placeholderSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
let out = solidImage(w, h, rgba(p.backgroundColor));
|
||||
if (p.showText) {
|
||||
const label = renderTextToImage({
|
||||
text: `${w} × ${h}`,
|
||||
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: p.color,
|
||||
backgroundColor: p.backgroundColor,
|
||||
transparentBg: false,
|
||||
padding: 0,
|
||||
});
|
||||
out = changeCanvasSize(label, w, h, "center");
|
||||
}
|
||||
return out;
|
||||
}),
|
||||
};
|
||||
|
||||
interface BlendTwoParams {
|
||||
pair: ColorPair;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const blendTwoSchema = toolSchema<BlendTwoParams>({
|
||||
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
|
||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
});
|
||||
|
||||
const blendTwo: ToolEntry<BlendTwoParams> = {
|
||||
id: "blend-two-png",
|
||||
title: "Blend Two Colors PNG",
|
||||
description: "A continuous horizontal gradient between two colors.",
|
||||
category: "generate",
|
||||
schema: blendTwoSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => renderBlend(p.pair.from, p.pair.to, p.width)),
|
||||
};
|
||||
|
||||
interface StepColorsParams {
|
||||
pair: ColorPair;
|
||||
steps: number;
|
||||
width: number;
|
||||
layout: "strip" | "grid";
|
||||
}
|
||||
|
||||
export const stepColorsSchema = toolSchema<StepColorsParams>(
|
||||
{
|
||||
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
|
||||
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
|
||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
layout: field.select({
|
||||
default: "grid",
|
||||
options: [
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "strip", label: "Strip" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Colors", fields: ["pair"] },
|
||||
{ title: "Output", cols: 2, fields: ["steps", "width", "layout"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const stepColorsTool: ToolEntry<StepColorsParams> = {
|
||||
id: "step-colors-png",
|
||||
title: "Color Steps PNG",
|
||||
description: "A discrete set of evenly spaced steps between two colors.",
|
||||
category: "generate",
|
||||
schema: stepColorsSchema,
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(
|
||||
stepColors(p.pair.from, p.pair.to, p.steps),
|
||||
p.width,
|
||||
p.layout,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface EmojiToPngParams {
|
||||
emoji: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const emojiToPngSchema = toolSchema<EmojiToPngParams>({
|
||||
emoji: field.text({ default: "😀" }),
|
||||
size: field.slider({ min: 32, max: 1024, step: 16, default: 256 }),
|
||||
});
|
||||
|
||||
const emojiToPng: ToolEntry<EmojiToPngParams> = {
|
||||
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",
|
||||
domOnly: true,
|
||||
schema: emojiToPngSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => renderEmoji(p.emoji, Math.round(p.size))),
|
||||
};
|
||||
|
||||
interface ColorWheelParams {
|
||||
size: number;
|
||||
lightness: number;
|
||||
}
|
||||
|
||||
export const colorWheelSchema = toolSchema<ColorWheelParams>({
|
||||
size: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
|
||||
});
|
||||
|
||||
const colorWheelTool: ToolEntry<ColorWheelParams> = {
|
||||
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",
|
||||
schema: colorWheelSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => renderWheel(p.size, p.lightness)),
|
||||
};
|
||||
|
||||
interface PaletteBaseParams {
|
||||
baseColor: string;
|
||||
width: number;
|
||||
layout: "strip" | "grid";
|
||||
}
|
||||
|
||||
const paletteBaseSchema = {
|
||||
baseColor: field.color({ default: "#2563eb" }),
|
||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
layout: field.select({
|
||||
default: "grid",
|
||||
options: [
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "strip", label: "Strip" },
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const paletteLayoutGroup = {
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Base color", fields: ["baseColor"] },
|
||||
{
|
||||
title: "Output",
|
||||
cols: 2,
|
||||
fields: ["width", "layout"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const complementaryTool: ToolEntry<PaletteBaseParams> = {
|
||||
id: "complementary-png",
|
||||
title: "Complementary Palette PNG",
|
||||
description:
|
||||
"Two opposite colors on the color wheel — the base and its complement.",
|
||||
category: "generate",
|
||||
schema: toolSchema<PaletteBaseParams>(
|
||||
{ ...paletteBaseSchema },
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(complementarySet(p.baseColor), p.width, p.layout),
|
||||
),
|
||||
};
|
||||
|
||||
const triadicTool: ToolEntry<PaletteBaseParams> = {
|
||||
id: "triadic-png",
|
||||
title: "Triadic Palette PNG",
|
||||
description: "Three colors evenly spaced 120° apart on the color wheel.",
|
||||
category: "generate",
|
||||
schema: toolSchema<PaletteBaseParams>(
|
||||
{
|
||||
...paletteBaseSchema,
|
||||
baseColor: field.color({ default: "#ff0000" }),
|
||||
},
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(triadicSet(p.baseColor), p.width, p.layout),
|
||||
),
|
||||
};
|
||||
|
||||
const tetradicTool: ToolEntry<PaletteBaseParams> = {
|
||||
id: "tetradic-png",
|
||||
title: "Tetradic Palette PNG",
|
||||
description:
|
||||
"Four colors in two complementary pairs, 90° apart on the wheel.",
|
||||
category: "generate",
|
||||
schema: toolSchema<PaletteBaseParams>(
|
||||
{
|
||||
...paletteBaseSchema,
|
||||
baseColor: field.color({ default: "#8000ff" }),
|
||||
},
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(tetradicSet(p.baseColor), p.width, p.layout),
|
||||
),
|
||||
};
|
||||
|
||||
interface AnalogousParams extends PaletteBaseParams {
|
||||
spread: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const analogousTool: ToolEntry<AnalogousParams> = {
|
||||
id: "analogous-png",
|
||||
title: "Analogous Palette PNG",
|
||||
description:
|
||||
"Neighboring hues around the base color — calm, related color scheme.",
|
||||
category: "generate",
|
||||
schema: toolSchema<AnalogousParams>(
|
||||
{
|
||||
...paletteBaseSchema,
|
||||
baseColor: field.color({ default: "#22c55e" }),
|
||||
spread: field.slider({ min: 10, max: 90, step: 5, default: 30 }),
|
||||
count: field.slider({ min: 3, max: 9, step: 1, default: 5 }),
|
||||
},
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(
|
||||
analogousSet(p.baseColor, p.spread, p.count),
|
||||
p.width,
|
||||
p.layout,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface MonochromaticParams extends PaletteBaseParams {
|
||||
count: number;
|
||||
range: number;
|
||||
}
|
||||
|
||||
const monochromaticTool: ToolEntry<MonochromaticParams> = {
|
||||
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",
|
||||
schema: toolSchema<MonochromaticParams>(
|
||||
{
|
||||
...paletteBaseSchema,
|
||||
baseColor: field.color({ default: "#0ea5e9" }),
|
||||
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
|
||||
range: field.slider({ min: 10, max: 90, step: 5, default: 40 }),
|
||||
},
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(
|
||||
monochromaticSet(p.baseColor, p.count, p.range),
|
||||
p.width,
|
||||
p.layout,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface ShadesParams extends PaletteBaseParams {
|
||||
count: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const shadesTool: ToolEntry<ShadesParams> = {
|
||||
id: "shades-png",
|
||||
title: "Shade Ramp PNG",
|
||||
description: "A ramp of the base color getting darker step by step.",
|
||||
category: "generate",
|
||||
schema: toolSchema<ShadesParams>(
|
||||
{
|
||||
...paletteBaseSchema,
|
||||
baseColor: field.color({ default: "#f59e0b" }),
|
||||
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
|
||||
depth: field.slider({ min: 10, max: 90, step: 5, default: 50 }),
|
||||
},
|
||||
{ ...paletteLayoutGroup },
|
||||
),
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(shadeSet(p.baseColor, p.count, p.depth), p.width, p.layout),
|
||||
),
|
||||
};
|
||||
|
||||
interface MixColorsParams {
|
||||
colors: string[];
|
||||
width: number;
|
||||
}
|
||||
|
||||
export const mixColorsSchema = toolSchema<MixColorsParams>(
|
||||
{
|
||||
colors: field.colors({ default: ["#ff0000", "#00ff00", "#0000ff"] }),
|
||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Colors", fields: ["colors"] },
|
||||
{ title: "Output", fields: ["width"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const mixColorsTool: ToolEntry<MixColorsParams> = {
|
||||
id: "mix-colors-png",
|
||||
title: "Mix Colors PNG",
|
||||
description:
|
||||
"Averages the selected colors into one swatch. Colors become one uniform fill.",
|
||||
category: "generate",
|
||||
schema: mixColorsSchema,
|
||||
input: "none",
|
||||
run: genTool((p) => renderSwatches([mixColors(p.colors)], p.width, "strip")),
|
||||
};
|
||||
|
||||
interface SortColorsParams {
|
||||
colors: string[];
|
||||
order: "hue" | "luma" | "sat";
|
||||
width: number;
|
||||
layout: "strip" | "grid";
|
||||
}
|
||||
|
||||
export const sortColorsSchema = toolSchema<SortColorsParams>(
|
||||
{
|
||||
colors: field.colors({
|
||||
default: [
|
||||
"#ff0000",
|
||||
"#ff8800",
|
||||
"#ffff00",
|
||||
"#00cc44",
|
||||
"#0066ff",
|
||||
"#8800ff",
|
||||
],
|
||||
}),
|
||||
order: field.select({
|
||||
default: "hue",
|
||||
options: [
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "luma", label: "Brightness" },
|
||||
{ value: "sat", label: "Saturation" },
|
||||
],
|
||||
}),
|
||||
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
|
||||
layout: field.select({
|
||||
default: "grid",
|
||||
options: [
|
||||
{ value: "grid", label: "Grid" },
|
||||
{ value: "strip", label: "Strip" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Colors", fields: ["colors"] },
|
||||
{
|
||||
title: "Output",
|
||||
cols: 2,
|
||||
fields: ["order", "width", "layout"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const sortColorsTool: ToolEntry<SortColorsParams> = {
|
||||
id: "sort-colors-png",
|
||||
title: "Sort Colors PNG",
|
||||
description:
|
||||
"Renders the chosen colors as swatches sorted by hue, brightness or saturation.",
|
||||
category: "generate",
|
||||
schema: sortColorsSchema,
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderSwatches(sortPalette(p.colors, p.order), p.width, p.layout),
|
||||
),
|
||||
};
|
||||
|
||||
interface TextToPngParams {
|
||||
text: string;
|
||||
style: FontStyle;
|
||||
transparentBg: boolean;
|
||||
backgroundColor: string;
|
||||
padding: number;
|
||||
}
|
||||
|
||||
export const textToPngSchema = toolSchema<TextToPngParams>(
|
||||
{
|
||||
text: field.text({ default: "Hello!", placeholder: "Your text" }),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 300,
|
||||
size: 96,
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: "#111318",
|
||||
}),
|
||||
transparentBg: field.checkbox({ default: false }),
|
||||
backgroundColor: field.color({ default: "#ffffff" }),
|
||||
padding: field.slider({ min: 0, max: 200, step: 2, default: 24 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Text", fields: ["text", "style"] },
|
||||
{ title: "Background", fields: ["transparentBg", "backgroundColor"] },
|
||||
{ title: "Padding", fields: ["padding"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const textToPng: ToolEntry<TextToPngParams> = {
|
||||
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",
|
||||
domOnly: true,
|
||||
schema: textToPngSchema,
|
||||
input: "none",
|
||||
run: genTool((p) =>
|
||||
renderTextToImage({
|
||||
text: p.text,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
backgroundColor: p.backgroundColor,
|
||||
transparentBg: p.transparentBg,
|
||||
padding: p.padding,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const generateEntries = [
|
||||
createEmpty,
|
||||
singleColor,
|
||||
randomNoise,
|
||||
linearGradient,
|
||||
colorSpectrumTool,
|
||||
randomColors,
|
||||
drawGridTool,
|
||||
placeholder,
|
||||
blendTwo,
|
||||
stepColorsTool,
|
||||
emojiToPng,
|
||||
colorWheelTool,
|
||||
complementaryTool,
|
||||
triadicTool,
|
||||
tetradicTool,
|
||||
analogousTool,
|
||||
monochromaticTool,
|
||||
shadesTool,
|
||||
mixColorsTool,
|
||||
sortColorsTool,
|
||||
textToPng,
|
||||
];
|
||||
@@ -0,0 +1,608 @@
|
||||
import { ToolError } from "../core/errors";
|
||||
import {
|
||||
rotateFreeImage,
|
||||
skewImage,
|
||||
transformImage,
|
||||
zoomImage,
|
||||
} from "../core/affine";
|
||||
import {
|
||||
centerByAlpha,
|
||||
changeCanvasSize,
|
||||
crop,
|
||||
cropToRatio,
|
||||
expandCanvas,
|
||||
flip,
|
||||
forceOrientation,
|
||||
padToRatio,
|
||||
resize,
|
||||
rotate90,
|
||||
symmetricCopy,
|
||||
tile,
|
||||
trimToContent,
|
||||
type Anchor9,
|
||||
type FlipAxis,
|
||||
} from "../core/geometry";
|
||||
import { field, toolSchema, type Dimension } from "../registry-schema";
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
|
||||
interface AddBorderParams {
|
||||
thickness: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const addBorderSchema = toolSchema<AddBorderParams>({
|
||||
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
|
||||
color: field.color({ default: "#000000" }),
|
||||
});
|
||||
|
||||
const addBorder: ToolEntry<AddBorderParams> = {
|
||||
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,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
p.thickness,
|
||||
p.thickness,
|
||||
p.thickness,
|
||||
p.thickness,
|
||||
p.color,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface FitOnBackgroundParams {
|
||||
size: Dimension;
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||
transparent: field.checkbox({ default: false }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Background", fields: ["transparent", "color"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
|
||||
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",
|
||||
schema: fitOnBackgroundSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => {
|
||||
const width = Math.trunc(p.size.width);
|
||||
const height = Math.trunc(p.size.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 ? undefined : p.color,
|
||||
);
|
||||
}),
|
||||
};
|
||||
|
||||
interface ChangeCanvasSizeParams {
|
||||
size: Dimension;
|
||||
anchor: Anchor9;
|
||||
}
|
||||
|
||||
export const changeCanvasSizeSchema = toolSchema<ChangeCanvasSizeParams>(
|
||||
{
|
||||
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||
anchor: field.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" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Anchor", fields: ["anchor"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const changeCanvasSizeTool: ToolEntry<ChangeCanvasSizeParams> = {
|
||||
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",
|
||||
schema: changeCanvasSizeSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
changeCanvasSize(
|
||||
img,
|
||||
Math.trunc(p.size.width),
|
||||
Math.trunc(p.size.height),
|
||||
p.anchor,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface ResizeParams {
|
||||
size: Dimension;
|
||||
keepAspect: boolean;
|
||||
}
|
||||
|
||||
export const resizeSchema = toolSchema<ResizeParams>(
|
||||
{
|
||||
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
|
||||
keepAspect: field.checkbox({ default: true }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Canvas", fields: ["size"] },
|
||||
{ title: "Scaling", fields: ["keepAspect"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const resizeTool: ToolEntry<ResizeParams> = {
|
||||
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",
|
||||
schema: resizeSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => {
|
||||
const keepAspect = p.keepAspect;
|
||||
let w = Math.trunc(p.size.width);
|
||||
let h = Math.trunc(p.size.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);
|
||||
}),
|
||||
};
|
||||
|
||||
interface CropParams {
|
||||
x: number;
|
||||
y: number;
|
||||
size: Dimension;
|
||||
}
|
||||
|
||||
export const cropSchema = toolSchema<CropParams>(
|
||||
{
|
||||
x: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
|
||||
y: field.number({ min: -100000, max: 100000, step: 1, default: 0 }),
|
||||
size: field.dimension({ min: 0, max: 100000, width: 0, height: 0 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Offset", fields: ["x", "y"] },
|
||||
{ title: "Crop area", fields: ["size"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const cropTool: ToolEntry<CropParams> = {
|
||||
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",
|
||||
schema: cropSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => {
|
||||
const w = Math.trunc(p.size.width);
|
||||
const h = Math.trunc(p.size.height);
|
||||
if (w <= 0 || h <= 0) {
|
||||
throw new ToolError("errors.cropSize");
|
||||
}
|
||||
return crop(img, Math.trunc(p.x), Math.trunc(p.y), w, h);
|
||||
}),
|
||||
};
|
||||
|
||||
interface RotateParams {
|
||||
angle: "90" | "180" | "270";
|
||||
}
|
||||
|
||||
export const rotateSchema = toolSchema<RotateParams>({
|
||||
angle: field.select({
|
||||
default: "90",
|
||||
options: [
|
||||
{ value: "90", label: "90° clockwise" },
|
||||
{ value: "180", label: "180°" },
|
||||
{ value: "270", label: "270° clockwise" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const rotateTool: ToolEntry<RotateParams> = {
|
||||
id: "rotate-png",
|
||||
title: "Rotate PNG",
|
||||
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
|
||||
category: "geometry",
|
||||
schema: rotateSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => rotate90(img, Number(p.angle) / 90)),
|
||||
};
|
||||
|
||||
interface FlipParams {
|
||||
axis: FlipAxis;
|
||||
}
|
||||
|
||||
export const flipSchema = toolSchema<FlipParams>({
|
||||
axis: field.select({
|
||||
default: "horizontal",
|
||||
options: [
|
||||
{ value: "horizontal", label: "Horizontal (left to right)" },
|
||||
{ value: "vertical", label: "Vertical (top to bottom)" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const flipTool: ToolEntry<FlipParams> = {
|
||||
id: "flip-png",
|
||||
title: "Flip PNG",
|
||||
description: "Mirrors horizontally or vertically without quality loss.",
|
||||
category: "geometry",
|
||||
schema: flipSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => flip(img, p.axis)),
|
||||
};
|
||||
|
||||
interface AddPaddingParams {
|
||||
padding: number;
|
||||
transparent: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const addPaddingSchema = toolSchema<AddPaddingParams>(
|
||||
{
|
||||
padding: field.number({ min: 1, max: 2000, step: 1, default: 10 }),
|
||||
transparent: field.checkbox({ default: true }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Padding", fields: ["padding"] },
|
||||
{ title: "Fill", fields: ["transparent", "color"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const addPaddingTool: ToolEntry<AddPaddingParams> = {
|
||||
id: "add-padding-png",
|
||||
title: "Add padding to PNG",
|
||||
description:
|
||||
"Expands the canvas on all sides by the chosen number of pixels.",
|
||||
category: "geometry",
|
||||
schema: addPaddingSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
p.padding,
|
||||
p.padding,
|
||||
p.padding,
|
||||
p.padding,
|
||||
p.transparent ? undefined : p.color,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
interface TileParams {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export const tileSchema = toolSchema<TileParams>({
|
||||
columns: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||
rows: field.number({ min: 1, max: 50, step: 1, default: 2 }),
|
||||
});
|
||||
|
||||
const tileTool: ToolEntry<TileParams> = {
|
||||
id: "tile-png",
|
||||
title: "Tile PNG",
|
||||
description: "Repeats the image in a grid of the chosen columns and rows.",
|
||||
category: "geometry",
|
||||
schema: tileSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => tile(img, p.columns, p.rows)),
|
||||
};
|
||||
|
||||
interface EmptyParams {}
|
||||
|
||||
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
|
||||
|
||||
const centerByAlphaTool: ToolEntry<EmptyParams> = {
|
||||
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",
|
||||
schema: centerByAlphaSchema,
|
||||
input: "image",
|
||||
run: imgTool((img) => centerByAlpha(img)),
|
||||
};
|
||||
|
||||
interface SkewParams {
|
||||
degX: number;
|
||||
degY: number;
|
||||
}
|
||||
|
||||
export const skewSchema = toolSchema<SkewParams>({
|
||||
degX: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||
degY: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
|
||||
});
|
||||
|
||||
const skewTool: ToolEntry<SkewParams> = {
|
||||
id: "skew-png",
|
||||
title: "Skew PNG",
|
||||
description:
|
||||
"Shifts content horizontally and vertically — a perspective effect.",
|
||||
category: "geometry",
|
||||
schema: skewSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => skewImage(img, p.degX, p.degY)),
|
||||
};
|
||||
|
||||
interface RotateFreeParams {
|
||||
angle: number;
|
||||
}
|
||||
|
||||
export const rotateFreeSchema = toolSchema<RotateFreeParams>({
|
||||
angle: field.slider({ min: -180, max: 180, step: 1, default: 15 }),
|
||||
});
|
||||
|
||||
const rotateFreeTool: ToolEntry<RotateFreeParams> = {
|
||||
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",
|
||||
schema: rotateFreeSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => rotateFreeImage(img, p.angle)),
|
||||
};
|
||||
|
||||
interface ZoomParams {
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export const zoomSchema = toolSchema<ZoomParams>({
|
||||
scale: field.slider({ min: 100, max: 500, step: 10, default: 200 }),
|
||||
});
|
||||
|
||||
const zoomTool: ToolEntry<ZoomParams> = {
|
||||
id: "zoom-png",
|
||||
title: "Zoom PNG",
|
||||
description:
|
||||
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
|
||||
category: "geometry",
|
||||
schema: zoomSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => zoomImage(img, p.scale)),
|
||||
};
|
||||
|
||||
interface TrimEmptySpaceParams {
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export const trimEmptySpaceSchema = toolSchema<TrimEmptySpaceParams>({
|
||||
threshold: field.slider({ min: 0, max: 254, step: 1, default: 0 }),
|
||||
});
|
||||
|
||||
const trimEmptySpaceTool: ToolEntry<TrimEmptySpaceParams> = {
|
||||
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",
|
||||
schema: trimEmptySpaceSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => trimToContent(img, p.threshold)),
|
||||
};
|
||||
|
||||
type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16";
|
||||
|
||||
interface ChangeAspectRatioParams {
|
||||
ratio: AspectRatio;
|
||||
mode: "crop" | "pad";
|
||||
}
|
||||
|
||||
export const changeAspectRatioSchema = toolSchema<ChangeAspectRatioParams>({
|
||||
ratio: field.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" },
|
||||
],
|
||||
}),
|
||||
mode: field.select({
|
||||
default: "crop",
|
||||
options: [
|
||||
{ value: "crop", label: "Crop to fill" },
|
||||
{ value: "pad", label: "Pad to fit" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const changeAspectRatioTool: ToolEntry<ChangeAspectRatioParams> = {
|
||||
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",
|
||||
schema: changeAspectRatioSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => {
|
||||
const [rw, rh] = p.ratio.split(":").map(Number);
|
||||
const ratio = rw / rh;
|
||||
return p.mode === "pad" ? padToRatio(img, ratio) : cropToRatio(img, ratio);
|
||||
}),
|
||||
};
|
||||
|
||||
interface SwapOrientationParams {
|
||||
target: "portrait" | "landscape";
|
||||
}
|
||||
|
||||
export const swapOrientationSchema = toolSchema<SwapOrientationParams>({
|
||||
target: field.select({
|
||||
default: "portrait",
|
||||
options: [
|
||||
{ value: "portrait", label: "Portrait" },
|
||||
{ value: "landscape", label: "Landscape" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const swapOrientationTool: ToolEntry<SwapOrientationParams> = {
|
||||
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",
|
||||
schema: swapOrientationSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => forceOrientation(img, p.target)),
|
||||
};
|
||||
|
||||
type SymmetricAxis = "horizontal" | "vertical";
|
||||
type KeepSide = "left" | "right" | "top" | "bottom";
|
||||
|
||||
interface SymmetricCopyParams {
|
||||
axis: SymmetricAxis;
|
||||
keepSide: KeepSide;
|
||||
}
|
||||
|
||||
export const symmetricCopySchema = toolSchema<SymmetricCopyParams>({
|
||||
axis: field.select({
|
||||
default: "vertical",
|
||||
options: [
|
||||
{ value: "vertical", label: "Vertical (double width)" },
|
||||
{ value: "horizontal", label: "Horizontal (double height)" },
|
||||
],
|
||||
}),
|
||||
keepSide: field.select({
|
||||
default: "left",
|
||||
options: [
|
||||
{ value: "left", label: "Left" },
|
||||
{ value: "right", label: "Right" },
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "bottom", label: "Bottom" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const symmetricCopyTool: ToolEntry<SymmetricCopyParams> = {
|
||||
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",
|
||||
schema: symmetricCopySchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) => symmetricCopy(img, p.axis, p.keepSide)),
|
||||
};
|
||||
|
||||
interface ShiftParams {
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const shiftSchema = toolSchema<ShiftParams>({
|
||||
offsetX: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||
offsetY: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
|
||||
color: field.color({ default: "#ffffff" }),
|
||||
});
|
||||
|
||||
const shiftTool: ToolEntry<ShiftParams> = {
|
||||
id: "shift-png",
|
||||
title: "Shift PNG",
|
||||
description: "Moves content by the given X and Y offset.",
|
||||
category: "geometry",
|
||||
schema: shiftSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
transformImage(
|
||||
img,
|
||||
[1, 0, 0, 1, -Math.trunc(p.offsetX), -Math.trunc(p.offsetY)],
|
||||
img.width,
|
||||
img.height,
|
||||
p.color,
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
export const geometryEntries = [
|
||||
addBorder,
|
||||
fitOnBackground,
|
||||
changeCanvasSizeTool,
|
||||
resizeTool,
|
||||
cropTool,
|
||||
rotateTool,
|
||||
flipTool,
|
||||
addPaddingTool,
|
||||
tileTool,
|
||||
centerByAlphaTool,
|
||||
skewTool,
|
||||
rotateFreeTool,
|
||||
zoomTool,
|
||||
trimEmptySpaceTool,
|
||||
changeAspectRatioTool,
|
||||
swapOrientationTool,
|
||||
symmetricCopyTool,
|
||||
shiftTool,
|
||||
];
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ToolEntry } from "./types";
|
||||
import { geometryEntries } from "./geometry";
|
||||
import { alphaEntries } from "./alpha";
|
||||
import { convertEntries } from "./convert";
|
||||
import { analyzeEntries } from "./analyze";
|
||||
import { filtersEntries } from "./filters";
|
||||
import { colorEntries } from "./color";
|
||||
import { generateEntries } from "./generate";
|
||||
import { textEntries } from "./text";
|
||||
|
||||
export {
|
||||
genTool,
|
||||
imgTool,
|
||||
textGen,
|
||||
requireSource,
|
||||
requireText,
|
||||
INPUT_MODES,
|
||||
RESULT_KINDS,
|
||||
} from "./types";
|
||||
export type {
|
||||
InputMode,
|
||||
ResultKind,
|
||||
ToolContext,
|
||||
ToolEntry,
|
||||
ToolResult,
|
||||
} from "./types";
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
...geometryEntries,
|
||||
...alphaEntries,
|
||||
...convertEntries,
|
||||
...analyzeEntries,
|
||||
...filtersEntries,
|
||||
...colorEntries,
|
||||
...generateEntries,
|
||||
...textEntries,
|
||||
] as unknown as ToolEntry[];
|
||||
|
||||
export function getTool(id: string): ToolEntry | undefined {
|
||||
return TOOLS.find((tool) => tool.id === id);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TOOLS } from ".";
|
||||
import { PREVIEW_GROUPS } from "../catalog";
|
||||
import type { PixelImage } from "../core/types";
|
||||
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||
import type { ToolResult } from "./types";
|
||||
|
||||
function asImage(result: ToolResult): PixelImage {
|
||||
if (typeof result === "string") {
|
||||
throw new Error("expected an image result");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("registry-new (переведённые инструменты)", () => {
|
||||
it("id уникальны", () => {
|
||||
const ids = TOOLS.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("PREVIEW_GROUPS строится без ошибок", () => {
|
||||
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
|
||||
expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe(
|
||||
TOOLS.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("guard: у всех инструментов задан валидный input", () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(tool.input, tool.id).toMatch(/^(image|text|none)$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("guard: input=image требует источник, input=text — текст", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const params = sanitizeSchemaParams(tool.schema, {});
|
||||
if (tool.input === "image") {
|
||||
expect(() => tool.run({ params }), tool.id).toThrow(
|
||||
"errors.sourceRequired",
|
||||
);
|
||||
} else if (tool.input === "text") {
|
||||
expect(() => tool.run({ params }), tool.id).toThrow(
|
||||
"errors.textRequired",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("guard: input=none работает без источника", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||
const img = asImage(
|
||||
await tool.run({ params: sanitizeSchemaParams(tool.schema, {}) }),
|
||||
);
|
||||
expect(img.width).toBe(800);
|
||||
expect(img.height).toBe(600);
|
||||
});
|
||||
|
||||
it("дефолты схем дают валидные параметры (включая dimension)", () => {
|
||||
for (const tool of TOOLS) {
|
||||
const defaults = defaultSchemaParams(tool.schema);
|
||||
const sanitized = sanitizeSchemaParams(tool.schema, defaults);
|
||||
expect(Object.keys(sanitized).sort()).toEqual(
|
||||
Object.keys(defaults).sort(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("create-empty: dimension-дефолты корректны", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.size).toEqual({ width: 800, height: 600 });
|
||||
expect(d.transparent).toBe(true);
|
||||
});
|
||||
|
||||
it("sanitize клампит dimension к min/max и чинит мусор", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 999999, height: -5 },
|
||||
transparent: false,
|
||||
color: "#ff0000",
|
||||
});
|
||||
expect(s.size).toEqual({ width: 20000, height: 1 });
|
||||
const bad = sanitizeSchemaParams(tool.schema, {});
|
||||
expect(bad.size).toEqual({ width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it("executeGenerate для create-empty даёт картинку нужного размера", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||
const params = sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 320, height: 200 },
|
||||
transparent: true,
|
||||
color: "#ff0000",
|
||||
});
|
||||
const img = asImage(await tool.run({ params }));
|
||||
expect(img.width).toBe(320);
|
||||
expect(img.height).toBe(200);
|
||||
expect(img.data[3]).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["single-color-png", { size: { width: 40, height: 30 }, color: "#00ff00" }],
|
||||
["random-noise-png", { size: { width: 40, height: 30 }, seed: 5 }],
|
||||
[
|
||||
"linear-gradient-png",
|
||||
{
|
||||
size: { width: 40, height: 30 },
|
||||
gradient: {
|
||||
from: "#000000",
|
||||
to: "#ffffff",
|
||||
angle: 90,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"color-spectrum-png",
|
||||
{
|
||||
size: { width: 40, height: 30 },
|
||||
direction: "horizontal",
|
||||
saturation: 100,
|
||||
lightness: 50,
|
||||
},
|
||||
],
|
||||
[
|
||||
"random-colors-png",
|
||||
{ size: { width: 40, height: 30 }, blockSize: 16, seed: 7 },
|
||||
],
|
||||
[
|
||||
"draw-grid-png",
|
||||
{
|
||||
size: { width: 40, height: 30 },
|
||||
cols: 4,
|
||||
rows: 3,
|
||||
lineWidth: 2,
|
||||
color: "#000000",
|
||||
transparentBg: true,
|
||||
},
|
||||
],
|
||||
])("генератор %s даёт картинку по dimension", async (id, params) => {
|
||||
const tool = TOOLS.find((t) => t.id === id)!;
|
||||
expect(tool.input).toBe("none");
|
||||
const sanitized = sanitizeSchemaParams(tool.schema, params);
|
||||
const img = asImage(await tool.run({ params: sanitized }));
|
||||
expect(img.width).toBe(40);
|
||||
expect(img.height).toBe(30);
|
||||
});
|
||||
|
||||
it("random-noisе детерминирован по seed", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "random-noise-png")!;
|
||||
const a = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 32, height: 32 },
|
||||
seed: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const b = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 32, height: 32 },
|
||||
seed: 1,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(a.data).toEqual(b.data);
|
||||
});
|
||||
|
||||
it("resize: keepAspect с одной стороной сохраняет пропорции", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "resize-png")!;
|
||||
const img = solid(100, 50);
|
||||
const out = asImage(
|
||||
await tool.run({
|
||||
source: img,
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 200, height: 0 },
|
||||
keepAspect: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(out.width).toBe(200);
|
||||
expect(out.height).toBe(100);
|
||||
});
|
||||
|
||||
it("crop вырезает область по x/y", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "crop-png")!;
|
||||
const img = solid(100, 100);
|
||||
const out = asImage(
|
||||
await tool.run({
|
||||
source: img,
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
x: 10,
|
||||
y: 20,
|
||||
size: { width: 30, height: 40 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(out.width).toBe(30);
|
||||
expect(out.height).toBe(40);
|
||||
});
|
||||
|
||||
it("fit-on-background центрирует картинку на канвасе", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "fit-on-background-png")!;
|
||||
const img = solid(20, 10);
|
||||
const out = asImage(
|
||||
await tool.run({
|
||||
source: img,
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 60, height: 30 },
|
||||
transparent: true,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(out.width).toBe(60);
|
||||
expect(out.height).toBe(30);
|
||||
expect(out.data[3]).toBe(0);
|
||||
});
|
||||
|
||||
it("change-canvas-size использует anchor для позиции", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "change-canvas-size-png")!;
|
||||
const img = solid(10, 10);
|
||||
const out = asImage(
|
||||
await tool.run({
|
||||
source: img,
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 20, height: 20 },
|
||||
anchor: "top-left",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(out.width).toBe(20);
|
||||
expect(out.height).toBe(20);
|
||||
const topLeft = out.data[3];
|
||||
const center = out.data[(10 * out.width + 10) * 4 + 3];
|
||||
expect(topLeft).toBe(255);
|
||||
expect(center).toBe(0);
|
||||
});
|
||||
|
||||
it("blend-two: дефолты пары и работа генератора", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "blend-two-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.pair).toEqual({ from: "#000000", to: "#ffffff" });
|
||||
const img = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
pair: { from: "#ff0000", to: "#0000ff" },
|
||||
width: 128,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.width).toBe(128);
|
||||
});
|
||||
|
||||
it("step-colors: рендерит steps полос по паре", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "step-colors-png")!;
|
||||
const img = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
pair: { from: "#000000", to: "#ffffff" },
|
||||
steps: 4,
|
||||
width: 128,
|
||||
layout: "strip",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.width).toBe(128);
|
||||
});
|
||||
|
||||
it("two-colors: перекрашивает светлые/тёмные пиксели по паре", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
|
||||
// 50×1, левая половина тёмная, правая светлая
|
||||
const data = new Uint8ClampedArray(50 * 4).fill(255);
|
||||
const img = { width: 50, height: 1, data };
|
||||
for (let x = 0; x < 25; x++) {
|
||||
const i = x * 4;
|
||||
data[i] = 0;
|
||||
data[i + 1] = 0;
|
||||
data[i + 2] = 0;
|
||||
}
|
||||
const out = asImage(
|
||||
await tool.run({
|
||||
source: img,
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
pair: { from: "#ffffff", to: "#ff0000" },
|
||||
threshold: 50,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Светлый пиксель → from (#ffffff), тёмный → to (#ff0000)
|
||||
expect(out.data[0]).toBe(255);
|
||||
expect(out.data[1]).toBe(0);
|
||||
expect(out.data[2]).toBe(0);
|
||||
expect(out.data[100]).toBe(255);
|
||||
expect(out.data[101]).toBe(255);
|
||||
expect(out.data[102]).toBe(255);
|
||||
});
|
||||
|
||||
it("linear-gradient: дефолты gradient и направление по углу", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.gradient).toEqual({
|
||||
from: "#000000",
|
||||
to: "#ffffff",
|
||||
angle: 0,
|
||||
});
|
||||
// 0° — слева направо: крайний левый пиксель = from, крайний правый = to
|
||||
let img = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 4, height: 1 },
|
||||
gradient: { from: "#000000", to: "#ffffff", angle: 0 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.data[0]).toBe(0);
|
||||
expect(img.data[12]).toBe(255);
|
||||
// 90° — сверху вниз: верхний пиксель = from, нижний = to
|
||||
img = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 1, height: 4 },
|
||||
gradient: { from: "#000000", to: "#ffffff", angle: 90 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.data[0]).toBe(0);
|
||||
expect(img.data[12]).toBe(255);
|
||||
// 180° — разворот: левый пиксель = to
|
||||
img = asImage(
|
||||
await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 4, height: 1 },
|
||||
gradient: { from: "#000000", to: "#ffffff", angle: 180 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.data[0]).toBe(255);
|
||||
expect(img.data[12]).toBe(0);
|
||||
});
|
||||
|
||||
it("sanitize чинит мусор в gradient и клампит angle в 0..360", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "linear-gradient-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
size: { width: 10, height: 10 },
|
||||
gradient: {
|
||||
from: "not-a-color",
|
||||
to: "#00ff00",
|
||||
angle: 720,
|
||||
extra: 1,
|
||||
},
|
||||
});
|
||||
expect(s.gradient).toEqual({
|
||||
from: "#000000",
|
||||
to: "#00ff00",
|
||||
angle: 360,
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitize чинит мусор в паре и клампит threshold", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
pair: { from: "not-a-color", to: "#00ff00", extra: 1 },
|
||||
threshold: 999,
|
||||
});
|
||||
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
|
||||
expect(s.threshold).toBe(100);
|
||||
});
|
||||
|
||||
it("circle-mask: срезает углы и сохраняет центр", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
|
||||
const img = asImage(
|
||||
await tool.run({
|
||||
source: solid(100, 100),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: 100,
|
||||
offset: { x: 0, y: 0 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Внешний угол (0,0) — вне круга радиуса 50 → прозрачен
|
||||
expect(img.data[3]).toBe(0);
|
||||
// Центр (50,50) — внутри
|
||||
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it("circle-mask: offset сдвигает фигуру", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
|
||||
const img = asImage(
|
||||
await tool.run({
|
||||
source: solid(100, 100),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
size: 50,
|
||||
offset: { x: 50, y: 0 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи
|
||||
expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255);
|
||||
expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0);
|
||||
});
|
||||
|
||||
it("star-mask: сохраняет центр, режет углы", async () => {
|
||||
const tool = TOOLS.find((t) => t.id === "star-mask-png")!;
|
||||
const img = asImage(
|
||||
await tool.run({
|
||||
source: solid(100, 100),
|
||||
params: sanitizeSchemaParams(tool.schema, {
|
||||
points: 5,
|
||||
innerRadius: 45,
|
||||
size: 100,
|
||||
rotation: 0,
|
||||
offset: { x: 0, y: 0 },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
|
||||
expect(img.data[3]).toBe(0);
|
||||
});
|
||||
|
||||
it("sanitize клампит offset к min/max и чинит мусор", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "wavy-mask-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
size: 90,
|
||||
amplitude: 8,
|
||||
waves: 8,
|
||||
phase: 0,
|
||||
offset: { x: 999, y: "abc", extra: true } as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
});
|
||||
expect(s.offset).toEqual({ x: 50, y: 0 });
|
||||
});
|
||||
|
||||
it("add-text: дефолты position9, font-style, plate и текстовых полей", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "add-text-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.position).toBe("bottom-right");
|
||||
expect(d.text).toBe("Hello!");
|
||||
expect(d.plate).toEqual({
|
||||
enabled: false,
|
||||
color: "#000000",
|
||||
opacity: 60,
|
||||
});
|
||||
expect(d.style).toEqual({
|
||||
font: "sans",
|
||||
size: 48,
|
||||
bold: true,
|
||||
color: "#ffffff",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitize чинит мусор в position9, font-style, plate и оставляет валидные значения", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "date-stamp-png")!;
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
format: "YYYY-MM-DD",
|
||||
style: {
|
||||
font: "comic-sans",
|
||||
size: -500,
|
||||
bold: "no",
|
||||
color: "red",
|
||||
},
|
||||
position: "somewhere-outside",
|
||||
margin: 20,
|
||||
plate: {
|
||||
enabled: "yes",
|
||||
color: "teal",
|
||||
opacity: 500,
|
||||
},
|
||||
});
|
||||
expect(s.position).toBe("bottom-right");
|
||||
expect(s.style).toEqual({
|
||||
font: "mono",
|
||||
size: 8,
|
||||
bold: false,
|
||||
color: "#ffffff",
|
||||
});
|
||||
expect(s.plate).toEqual({
|
||||
enabled: true,
|
||||
color: "#000000",
|
||||
opacity: 100,
|
||||
});
|
||||
const valid = sanitizeSchemaParams(tool.schema, {
|
||||
...defaultSchemaParams(tool.schema),
|
||||
position: "top-center",
|
||||
style: { font: "serif", size: 120, bold: true, color: "#00ff00" },
|
||||
});
|
||||
expect(valid.position).toBe("top-center");
|
||||
expect(valid.style).toEqual({
|
||||
font: "serif",
|
||||
size: 120,
|
||||
bold: true,
|
||||
color: "#00ff00",
|
||||
});
|
||||
});
|
||||
|
||||
it("text-to-png: дефолты font-style и sanitize", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "text-to-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
expect(d.style).toEqual({
|
||||
font: "sans",
|
||||
size: 96,
|
||||
bold: true,
|
||||
color: "#111318",
|
||||
});
|
||||
expect(d.transparentBg).toBe(false);
|
||||
const s = sanitizeSchemaParams(tool.schema, {
|
||||
style: { font: "sans", size: 9999, bold: false, color: "#123abc" },
|
||||
padding: 200,
|
||||
});
|
||||
expect(s.style).toEqual({
|
||||
font: "sans",
|
||||
size: 300,
|
||||
bold: false,
|
||||
color: "#123abc",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function solid(width: number, height: number) {
|
||||
const data = new Uint8ClampedArray(width * height * 4).fill(255);
|
||||
return { width, height, data };
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { formatStamp } from "../core/datefmt";
|
||||
import { drawTextBlock, drawTextTile } from "../core/domText";
|
||||
import type { Position9 } from "../core/textdraw";
|
||||
import type { FontStyle, Plate } from "../registry-schema";
|
||||
import { field, toolSchema } from "../registry-schema";
|
||||
import { imgTool, type ToolEntry } from "./types";
|
||||
|
||||
interface AddTextParams {
|
||||
text: string;
|
||||
style: FontStyle;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
plate: Plate;
|
||||
}
|
||||
|
||||
const addTextSchema = toolSchema<AddTextParams>(
|
||||
{
|
||||
text: field.text({ default: "Hello!", placeholder: "Your text" }),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 200,
|
||||
size: 48,
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
position: field.position9({ default: "bottom-right" }),
|
||||
margin: field.slider({ min: 0, max: 200, step: 1, default: 24 }),
|
||||
plate: field.plate({ enabled: false, color: "#000000", opacity: 60 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Text", fields: ["text", "style"] },
|
||||
{ title: "Placement", fields: ["position", "margin"] },
|
||||
{ title: "Plate", fields: ["plate"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const addText: ToolEntry<AddTextParams> = {
|
||||
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,
|
||||
schema: addTextSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: p.text,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
opacityPercent: 100,
|
||||
position: p.position,
|
||||
margin: p.margin,
|
||||
plateColor: p.plate.enabled ? p.plate.color : undefined,
|
||||
plateOpacityPercent: p.plate.opacity,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
interface DateStampParams {
|
||||
format: string;
|
||||
style: FontStyle;
|
||||
position: Position9;
|
||||
margin: number;
|
||||
plate: Plate;
|
||||
}
|
||||
|
||||
const dateStampSchema = toolSchema<DateStampParams>(
|
||||
{
|
||||
format: field.text({
|
||||
default: "YYYY-MM-DD",
|
||||
placeholder: "YYYY-MM-DD hh:mm",
|
||||
}),
|
||||
style: field.fontStyle({
|
||||
min: 8,
|
||||
max: 200,
|
||||
size: 32,
|
||||
font: "mono",
|
||||
bold: false,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
position: field.position9({ default: "bottom-right" }),
|
||||
margin: field.slider({ min: 0, max: 200, step: 1, default: 20 }),
|
||||
plate: field.plate({ enabled: true, color: "#000000", opacity: 55 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Text", fields: ["format", "style"] },
|
||||
{ title: "Placement", fields: ["position", "margin"] },
|
||||
{ title: "Plate", fields: ["plate"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const dateStamp: ToolEntry<DateStampParams> = {
|
||||
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,
|
||||
schema: dateStampSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
drawTextBlock(img, {
|
||||
text: formatStamp(new Date(), p.format),
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
opacityPercent: 100,
|
||||
position: p.position,
|
||||
margin: p.margin,
|
||||
plateColor: p.plate.enabled ? p.plate.color : undefined,
|
||||
plateOpacityPercent: p.plate.opacity,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
interface WatermarkTileParams {
|
||||
text: string;
|
||||
style: FontStyle;
|
||||
opacity: number;
|
||||
angle: number;
|
||||
stepX: number;
|
||||
stepY: number;
|
||||
}
|
||||
|
||||
const watermarkTileSchema = toolSchema<WatermarkTileParams>(
|
||||
{
|
||||
text: field.text({ default: "DRAFT", placeholder: "Watermark text" }),
|
||||
style: field.fontStyle({
|
||||
min: 12,
|
||||
max: 160,
|
||||
size: 56,
|
||||
font: "sans",
|
||||
bold: true,
|
||||
color: "#ffffff",
|
||||
}),
|
||||
opacity: field.slider({ min: 5, max: 100, step: 5, default: 30 }),
|
||||
angle: field.slider({ min: -90, max: 90, step: 1, default: -30 }),
|
||||
stepX: field.slider({ min: 40, max: 600, step: 10, default: 220 }),
|
||||
stepY: field.slider({ min: 40, max: 600, step: 10, default: 180 }),
|
||||
},
|
||||
{
|
||||
layout: {
|
||||
groups: [
|
||||
{ title: "Watermark", fields: ["text", "style", "opacity"] },
|
||||
{
|
||||
title: "Tile",
|
||||
cols: 2,
|
||||
fields: ["angle", "stepX", "stepY"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const watermarkTile: ToolEntry<WatermarkTileParams> = {
|
||||
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,
|
||||
schema: watermarkTileSchema,
|
||||
input: "image",
|
||||
run: imgTool((img, p) =>
|
||||
drawTextTile(img, {
|
||||
text: p.text,
|
||||
fontSize: p.style.size,
|
||||
font: p.style.font,
|
||||
bold: p.style.bold,
|
||||
color: p.style.color,
|
||||
opacityPercent: p.opacity,
|
||||
stepX: p.stepX,
|
||||
stepY: p.stepY,
|
||||
angleDeg: p.angle,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const textEntries = [addText, dateStamp, watermarkTile];
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CategoryId } from "../categories";
|
||||
import { ToolError } from "../core/errors";
|
||||
import type { OutputMime } from "../core/io";
|
||||
import type { PixelImage } from "../core/types";
|
||||
import type { ToolSchema } from "../registry-schema";
|
||||
|
||||
/** Формат скачивания, отличный от PNG (bmp/jpeg/webp). */
|
||||
export interface OutputFormat {
|
||||
mime: OutputMime;
|
||||
ext: string;
|
||||
qualityParamId?: string;
|
||||
}
|
||||
|
||||
/** Чем управляется инструмент. `image` — требуется входное изображение. */
|
||||
export const INPUT_MODES = {
|
||||
image: "image",
|
||||
text: "text",
|
||||
none: "none",
|
||||
} as const;
|
||||
export type InputMode = (typeof INPUT_MODES)[keyof typeof INPUT_MODES];
|
||||
|
||||
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
|
||||
export const RESULT_KINDS = {
|
||||
image: "image",
|
||||
text: "text",
|
||||
verdict: "verdict",
|
||||
} as const;
|
||||
export type ResultKind = (typeof RESULT_KINDS)[keyof typeof RESULT_KINDS];
|
||||
|
||||
/** Единый вход инструмента. `source`/`text` присутствуют строго по `input`. */
|
||||
export interface ToolContext<P> {
|
||||
params: P;
|
||||
source?: PixelImage;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export type ToolResult = PixelImage | string;
|
||||
|
||||
/**
|
||||
* Инструмент нового registry: полностью типизирован на `Params`, схема —
|
||||
* обязательна (единый источник дефолтов/валидации/UI). У каждого инструмента
|
||||
* ровно один метод `run(ctx)`; контракт входа декларируется через `input`
|
||||
* (обязательное поле), а результат — через `result`. Отдельных методов
|
||||
* `generate`/`runFromText`/`toText`/`textToText` нет.
|
||||
*/
|
||||
export type ToolEntry<P = Record<string, unknown>> = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: CategoryId;
|
||||
schema: ToolSchema<P>;
|
||||
/** Чем управляется инструмент: входным изображением, текстом или ничем. */
|
||||
input: InputMode;
|
||||
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
|
||||
result?: ResultKind;
|
||||
/** Единственный метод исполнения — и для генераторов, и для трансформаторов. */
|
||||
run(ctx: ToolContext<P>): Promise<ToolResult> | ToolResult;
|
||||
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
|
||||
domOnly?: boolean;
|
||||
/** Формат/качество скачивания результата; по умолчанию — PNG. */
|
||||
output?: OutputFormat;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
/** Гарантирует наличие входного изображения для трансформаторов. */
|
||||
export function requireSource<P>(ctx: ToolContext<P>): PixelImage {
|
||||
if (!ctx.source) {
|
||||
throw new ToolError("errors.sourceRequired");
|
||||
}
|
||||
return ctx.source;
|
||||
}
|
||||
|
||||
/** Гарантирует наличие текстового входа для text-инструментов. */
|
||||
export function requireText<P>(ctx: ToolContext<P>): string {
|
||||
if (!ctx.text?.trim()) {
|
||||
throw new ToolError("errors.textRequired");
|
||||
}
|
||||
return ctx.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обёртка для image-to-image инструментов: подставляет `src` и `params`
|
||||
* из контекста. Позволяет оставить тело инструмента в виде `(img, p) => …`.
|
||||
*/
|
||||
export function imgTool<P>(
|
||||
fn: (img: PixelImage, params: P) => ToolResult | Promise<ToolResult>,
|
||||
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
|
||||
return (ctx) => fn(requireSource(ctx), ctx.params);
|
||||
}
|
||||
|
||||
/** Обёртка для генераторов: инструменту нужны только параметры. */
|
||||
export function genTool<P>(
|
||||
fn: (params: P) => ToolResult | Promise<ToolResult>,
|
||||
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
|
||||
return (ctx) => fn(ctx.params);
|
||||
}
|
||||
|
||||
/** Обёртка для text-to-image инструментов: подставляет `text` и `params`. */
|
||||
export function textGen<P>(
|
||||
fn: (text: string, params: P) => ToolResult | Promise<ToolResult>,
|
||||
): (ctx: ToolContext<P>) => ToolResult | Promise<ToolResult> {
|
||||
return (ctx) => fn(requireText(ctx), ctx.params);
|
||||
}
|
||||
Reference in New Issue
Block a user