refactor: update path registry-new to registry

This commit is contained in:
2026-09-10 21:14:53 +05:00
parent 0160b316c9
commit 75f6891e7c
21 changed files with 12 additions and 13 deletions
+545
View File
@@ -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,
];
+290
View File
@@ -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,
];
+525
View File
@@ -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: "FloydSteinberg" },
{ value: "bayer", label: "Bayer 4x4" },
],
}),
});
const ditheringTool: ToolEntry<DitheringParams> = {
id: "dithering-png",
title: "Dithering PNG",
description:
"Applies FloydSteinberg 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 2256 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 greenmagenta / blueyellow 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(),
];
+253
View File
@@ -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,
];
+205
View File
@@ -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,
];
+847
View File
@@ -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 0360° 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,
];
+608
View File
@@ -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,
];
+41
View File
@@ -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);
}
+523
View File
@@ -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 };
}
+192
View File
@@ -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];
+103
View File
@@ -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);
}