feat: add instruments registry

This commit is contained in:
2026-08-22 10:06:49 +05:00
parent 9ccc9f94ac
commit 719bda2e22
4 changed files with 368 additions and 2 deletions
+18 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { parseHex, removeColorToAlpha } from './alpha'; import { flattenOntoColor, parseHex, removeColorToAlpha } from './alpha';
import { makeImage } from './test-helpers'; import { makeImage } from './test-helpers';
describe('removeColorToAlpha', () => { describe('removeColorToAlpha', () => {
@@ -55,6 +55,23 @@ describe('removeColorToAlpha', () => {
}); });
}); });
describe('flattenOntoColor', () => {
it('непрозрачный пиксель не меняется, альфа становится 255', () => {
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff');
expect([...out.data]).toEqual([10, 20, 30, 255]);
});
it('полностью прозрачный пиксель становится цветом подложки', () => {
const out = flattenOntoColor(makeImage(1, 1, [[99, 99, 99, 0]]), '#ff8040');
expect([...out.data]).toEqual([255, 128, 64, 255]);
});
it('полупрозрачный пиксель смешивается с подложкой', () => {
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 128]]), '#ffffff');
expect([...out.data]).toEqual([132, 137, 142, 255]);
});
});
describe('parseHex', () => { describe('parseHex', () => {
it('разбирает #rrggbb, rrggbb, #rgb', () => { it('разбирает #rrggbb, rrggbb, #rgb', () => {
expect(parseHex('#ff8040')).toEqual([255, 128, 64]); expect(parseHex('#ff8040')).toEqual([255, 128, 64]);
+15 -1
View File
@@ -1,4 +1,4 @@
import type { PixelImage } from './types'; import { createPixelImage, type PixelImage } from './types';
const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255); const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255);
@@ -22,6 +22,20 @@ export function removeColorToAlpha(
return out; return out;
} }
export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
const [bgR, bgG, bgB] = parseHex(hex);
const out = createPixelImage(img.width, img.height);
for (let i = 0; i < out.data.length; i += 4) {
const a = img.data[i + 3] / 255;
const inv = 1 - a;
out.data[i] = img.data[i] * a + bgR * inv;
out.data[i + 1] = img.data[i + 1] * a + bgG * inv;
out.data[i + 2] = img.data[i + 2] * a + bgB * inv;
out.data[i + 3] = 255;
}
return out;
}
export function parseHex(hex: string): [number, number, number] { export function parseHex(hex: string): [number, number, number] {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim()); const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
if (!match) { if (!match) {
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { CATEGORIES } from './categories';
import { defaultParams, getTool, outputOf, TOOLS, type ParamDef, type ToolEntry } from './registry';
const PLAN_TOOL_IDS = [
'resize-png',
'crop-png',
'rotate-png',
'flip-png',
'grayscale-png',
'invert-colors-png',
'adjust-brightness-contrast-png',
'convert-png-to-jpg',
'convert-png-to-webp',
'remove-color-from-png',
'png-info'
];
describe('реестр инструментов', () => {
it('содержит ровно 11 инструментов из плана MVP', () => {
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
});
it('id уникальны и в kebab-case', () => {
const ids = TOOLS.map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) {
expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
}
});
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: категория валидна', (_, tool) => {
expect(CATEGORIES.map((c) => c.id)).toContain(tool.category);
});
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => {
expect(typeof tool.run).toBe('function');
expect(tool.title.length).toBeGreaterThan(0);
expect(tool.description.length).toBeGreaterThan(0);
});
it('у select дефолт входит в options, у number дефолт в диапазоне', () => {
for (const tool of TOOLS) {
for (const param of tool.params) {
if (param.type === 'select') {
expect(param.options.map((o) => o.value)).toContain(param.default);
expect(param.options.length).toBeGreaterThan(0);
}
if (param.type === 'number') {
expect(param.min === undefined || param.default >= param.min).toBe(true);
expect(param.max === undefined || param.default <= param.max).toBe(true);
}
if (param.type === 'color') {
expect(param.default).toMatch(/^#[0-9a-f]{6}$/i);
}
}
}
});
it('id параметров уникальны внутри инструмента', () => {
for (const tool of TOOLS) {
const ids = tool.params.map((p) => p.id);
expect(new Set(ids).size).toBe(ids.length);
}
});
it('qualityParamId ссылается на существующий числовой параметр', () => {
for (const tool of TOOLS) {
const output = outputOf(tool);
if (output?.qualityParamId) {
const param = tool.params.find(
(p): p is Extract<ParamDef, { type: 'number' }> => p.id === output.qualityParamId
);
expect(param).toBeDefined();
}
}
});
it('png-info не имеет формата вывода, конвертеры имеют jpeg/webp, остальные — png', () => {
expect(outputOf(getToolOrThrow('png-info'))).toBeUndefined();
expect(outputOf(getToolOrThrow('convert-png-to-jpg'))?.mime).toBe('image/jpeg');
expect(outputOf(getToolOrThrow('convert-png-to-webp'))?.mime).toBe('image/webp');
expect(outputOf(getToolOrThrow('grayscale-png'))).toEqual({ mime: 'image/png', ext: 'png' });
});
it('defaultParams собирает значения по умолчанию', () => {
const resize = getToolOrThrow('resize-png');
expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true });
});
});
function getToolOrThrow(id: string): ToolEntry {
const tool = getTool(id);
if (!tool) throw new Error(`Инструмент "${id}" не найден`);
return tool;
}
+239
View File
@@ -0,0 +1,239 @@
import type { CategoryId } from './categories';
import { flattenOntoColor, removeColorToAlpha } from './core/alpha';
import { brightnessContrast, grayscale, invert } from './core/color';
import { crop, flip, resize, rotate90 } from './core/geometry';
import type { OutputMime } from './core/io';
import { clonePixelImage, type PixelImage } from './core/types';
export type ParamDef =
| {
id: string;
label: string;
type: 'number';
min?: number;
max?: number;
step?: number;
default: number;
}
| {
id: string;
label: string;
type: 'select';
options: { value: string; label: string }[];
default: string;
}
| { id: string; label: string; type: 'checkbox'; default: boolean }
| { id: string; label: string; type: 'color'; default: string };
export type OutputFormat = {
mime: OutputMime;
ext: string;
qualityParamId?: string;
};
export type ToolEntry = {
id: string;
title: string;
description: string;
category: CategoryId;
params: ParamDef[];
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
resultType?: 'image' | 'info';
output?: OutputFormat;
};
const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
function num(params: Record<string, unknown>, id: string): number {
const v = params[id];
if (typeof v !== 'number' || !Number.isFinite(v)) {
throw new Error(`Параметр "${id}" должен быть числом`);
}
return v;
}
function str(params: Record<string, unknown>, id: string): string {
const v = params[id];
if (typeof v !== 'string') {
throw new Error(`Параметр "${id}" должен быть строкой`);
}
return v;
}
export const TOOLS: ToolEntry[] = [
{
id: 'resize-png',
title: 'Изменить размер PNG',
description:
'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций укажите только ширину или только высоту — вторая сторона рассчитается автоматически.',
category: 'geometry',
params: [
{ id: 'width', label: 'Ширина (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 },
{ id: 'height', label: 'Высота (0 — авто)', type: 'number', min: 0, max: 20000, step: 1, default: 0 },
{ id: 'keepAspect', label: 'Сохранять пропорции', type: 'checkbox', default: true }
],
run: (img, p) => {
const keepAspect = p['keepAspect'] === true;
let w = Math.trunc(num(p, 'width'));
let h = Math.trunc(num(p, 'height'));
if (keepAspect) {
if (w > 0 && h > 0) {
throw new Error('При сохранении пропорций укажите только ширину или только высоту');
}
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 Error('Укажите ширину и/или высоту нового размера');
}
return resize(img, w, h);
}
},
{
id: 'crop-png',
title: 'Обрезать PNG',
description:
'Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.',
category: 'geometry',
params: [
{ id: 'x', label: 'X (слева)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
{ id: 'y', label: 'Y (сверху)', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
{ id: 'width', label: 'Ширина области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 },
{ id: 'height', label: 'Высота области', type: 'number', min: -100000, max: 100000, step: 1, default: 0 }
],
run: (img, p) => {
const w = Math.trunc(num(p, 'width'));
const h = Math.trunc(num(p, 'height'));
if (w <= 0 || h <= 0) {
throw new Error('Укажите ширину и высоту области обрезки');
}
return crop(img, Math.trunc(num(p, 'x')), Math.trunc(num(p, 'y')), w, h);
}
},
{
id: 'rotate-png',
title: 'Повернуть PNG',
description: 'Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.',
category: 'geometry',
params: [
{
id: 'angle',
label: 'Угол поворота',
type: 'select',
default: '90',
options: [
{ value: '90', label: '90° по часовой' },
{ value: '180', label: '180°' },
{ value: '270', label: '270° по часовой' }
]
}
],
run: (img, p) => rotate90(img, Number(str(p, 'angle')) / 90)
},
{
id: 'flip-png',
title: 'Отразить PNG',
description: 'Зеркальное отражение по горизонтали или вертикали без потери качества.',
category: 'geometry',
params: [
{
id: 'axis',
label: 'Ось отражения',
type: 'select',
default: 'horizontal',
options: [
{ value: 'horizontal', label: 'По горизонтали (слева направо)' },
{ value: 'vertical', label: 'По вертикали (сверху вниз)' }
]
}
],
run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal')
},
{
id: 'grayscale-png',
title: 'Чёрно-белый PNG',
description: 'Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.',
category: 'color',
params: [],
run: (img) => grayscale(img)
},
{
id: 'invert-colors-png',
title: 'Инвертировать цвета PNG',
description: 'Обращает каждый цветовой канал (255 − значение). Альфа не меняется.',
category: 'color',
params: [],
run: (img) => invert(img)
},
{
id: 'adjust-brightness-contrast-png',
title: 'Яркость и контраст PNG',
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
category: 'color',
params: [
{ id: 'brightness', label: 'Яркость', type: 'number', min: -100, max: 100, step: 1, default: 0 },
{ id: 'contrast', label: 'Контраст', type: 'number', min: -100, max: 100, step: 1, default: 0 }
],
run: (img, p) => brightnessContrast(img, num(p, 'brightness'), num(p, 'contrast'))
},
{
id: 'convert-png-to-jpg',
title: 'Конвертировать PNG в JPG',
description:
'Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.',
category: 'convert',
params: [
{ id: 'background', label: 'Цвет подложки', type: 'color', default: '#ffffff' },
{ id: 'quality', label: 'Качество JPEG', type: 'number', min: 1, max: 100, step: 1, default: 90 }
],
output: { mime: 'image/jpeg', ext: 'jpg', qualityParamId: 'quality' },
run: (img, p) => flattenOntoColor(img, str(p, 'background'))
},
{
id: 'convert-png-to-webp',
title: 'Конвертировать PNG в WebP',
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
category: 'convert',
params: [{ id: 'quality', label: 'Качество WebP', type: 'number', min: 1, max: 100, step: 1, default: 90 }],
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
run: (img) => clonePixelImage(img)
},
{
id: 'remove-color-from-png',
title: 'Удалить цвет из PNG (прозрачность)',
description:
'Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.',
category: 'alpha',
params: [
{ id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' },
{ id: 'tolerance', label: 'Порог похожести, %', type: 'number', min: 0, max: 100, step: 1, default: 10 }
],
run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance'))
},
{
id: 'png-info',
title: 'Информация о PNG',
description:
'Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.',
category: 'analyze',
params: [],
resultType: 'info',
run: (img) => clonePixelImage(img)
}
];
export function getTool(id: string): ToolEntry | undefined {
return TOOLS.find((tool) => tool.id === id);
}
export function defaultParams(tool: ToolEntry): Record<string, unknown> {
return Object.fromEntries(tool.params.map((p) => [p.id, p.default]));
}
export function outputOf(tool: ToolEntry): OutputFormat | undefined {
if (tool.resultType === 'info') return undefined;
return tool.output ?? PNG_OUTPUT;
}