From f286044ab92773af36b712d847b4eccc1f31d81b Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 5 Sep 2026 15:11:26 +0500 Subject: [PATCH] feat: update use dimension tools --- docs/plan-composite-params.md | 44 +-- .../lib/components/kit/SchemaFields.svelte | 47 +++- .../lib/components/kit/SchemaPreview.svelte | 31 ++- .../kit/fields/DimensionField.svelte | 6 +- web/src/lib/registry-new/color.ts | 10 +- web/src/lib/registry-new/generate.ts | 251 +++++++++++++++++- web/src/lib/registry-new/geometry.ts | 164 +++++++++++- web/src/lib/registry-new/registry-new.test.ts | 140 +++++++++- web/src/lib/registry-schema.ts | 7 +- 9 files changed, 648 insertions(+), 52 deletions(-) diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index e7ea325..1e35930 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -2,18 +2,22 @@ > Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент > в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены -> все одиночные инструменты (18 шт). Фаза 3 — **начата**: введён составной тип -> `dimension` (вложенный объект в Params + `field.dimension`, новый kind в -> `registry-schema` + мерge `DimensionField` в UI), переведён пилот -> `create-empty-png`; preview научен применять **генераторы** (`executeGenerate`, -> кнопка Generate вместо загрузки файла). Следующее: перевести остальные -> dimension-инструменты (single-color → random-noise → ...). +> все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension` +> полностью переведён** (12 инструментов: create-empty, single-color, +> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid, +> placeholder, fit-on-background, change-canvas-size, resize, crop); preview +> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и +> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension). +> Следующее: `color-pair` (пилот blend-two-png → two-colors → step-colors). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` -> (по файлу на категорию) + `web/src/lib/preview/categories.ts`, -> `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`, -> `preview/executor/index.ts` (`executeGenerate`). Старый `web/src/lib/registry.ts` -> разбит по категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель). +> (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate) +> +> - `web/src/lib/preview/categories.ts`, +> `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`, +> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte` +> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям +> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель). ## Ключевая стратегия: параллельная сборка, старый UI не трогаем @@ -330,15 +334,17 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — ### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам) -24. `dimension` — составной тип введён ✔ (`field.dimension` + kind `dimension` - в `registry-schema`, вложенный объект `size: { width, height }` в Params, - виджет `kit/fields/DimensionField.svelte`). - Пилот `create-empty-png` переведён ✔; preview поддержал **генераторы** - (`preview/executor` + `executeGenerate`, кнопка Generate в `SchemaToolView`/ - `SchemaPreview`, без входного файла). - Остальные по 1: single-color → random-noise → fit-on-background → - change-canvas-size → placeholder → draw-grid → random-colors → - color-spectrum → linear-gradient → resize → crop +24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`, + `single-color-png`, `random-noise-png`, `linear-gradient-png`, + `color-spectrum-png`, `random-colors-png`, `draw-grid-png`, + `placeholder-png` — генераторы в `registry-new/generate.ts`; + `fit-on-background-png`, `change-canvas-size-png`, `resize-png`, + `crop-png` — в `registry-new/geometry.ts`). Составной тип во всех видах: + вложенный объект `size: { width, height }` в Params + `field.dimension`, + виджет `kit/fields/DimensionField.svelte`. + Preview: генераторы применяются через `executeGenerate` (кнопка Generate), + `SchemaFields` рендерит все kinds схемы (slider/number/color/select/ + checkbox/dimension). Тесты: 592 passed. 25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors → linear-gradient (уже переведён в dimension, здесь добавляется к нему) 26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask → diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index 3c56e42..576cca8 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -51,11 +51,37 @@ /> + {:else if field.spec.kind === "select"} + + {:else if field.spec.kind === "checkbox"} + {:else if field.spec.kind === "dimension"}

onchange(id, v)} /> @@ -91,6 +117,25 @@ accent-color: var(--color-main); color: var(--color-text); } + .select-field { + width: 100%; + padding: var(--space-m) var(--space-l); + background: var(--color-background); + border: var(--size-border) solid var(--color-border); + border-radius: var(--radius-s); + color: var(--color-text); + font: var(--font-size-s) var(--font-mono); + } + .checkbox-control { + display: flex; + justify-content: space-between; + align-items: center; + } + .checkbox-field { + width: var(--space-xl); + height: var(--space-xl); + accent-color: var(--color-main); + } .color-control > span:first-child { justify-content: flex-start; } diff --git a/web/src/lib/components/kit/SchemaPreview.svelte b/web/src/lib/components/kit/SchemaPreview.svelte index 476cc18..a95d3e3 100644 --- a/web/src/lib/components/kit/SchemaPreview.svelte +++ b/web/src/lib/components/kit/SchemaPreview.svelte @@ -1,9 +1,9 @@
- {isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"} + {isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}
{#if isGenerator} {:else}
@@ -132,7 +145,7 @@ .generate-btn { background: var(--color-main); border-color: var(--color-main); - color: white; + color: var(--color-background); } .generate-btn:disabled { opacity: 0.6; diff --git a/web/src/lib/components/kit/fields/DimensionField.svelte b/web/src/lib/components/kit/fields/DimensionField.svelte index 431d6dd..b7e5421 100644 --- a/web/src/lib/components/kit/fields/DimensionField.svelte +++ b/web/src/lib/components/kit/fields/DimensionField.svelte @@ -42,9 +42,9 @@ } .dimension-label { grid-column: 1 / -1; - color: var(--foreground); - font-size: 11px; - letter-spacing: 0.5px; + color: var(--color-text); + font-size: var(--font-size-s); + letter-spacing: var(--space-text-l); text-transform: uppercase; } diff --git a/web/src/lib/registry-new/color.ts b/web/src/lib/registry-new/color.ts index 0c7ee47..7c8b6a9 100644 --- a/web/src/lib/registry-new/color.ts +++ b/web/src/lib/registry-new/color.ts @@ -1,12 +1,8 @@ -import type { ToolEntry } from "./types"; -import { field, toolSchema } from "../registry-schema"; -import { - ditherImage, - mapToNearest, - quantizeImage, -} from "../core/quantize"; import { gammaCorrection, temperature, tint } from "../core/color"; import { parseHexList } from "../core/palette"; +import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize"; +import { field, toolSchema } from "../registry-schema"; +import type { ToolEntry } from "./types"; interface GammaParams { value: number; diff --git a/web/src/lib/registry-new/generate.ts b/web/src/lib/registry-new/generate.ts index 9b4232b..f346f7e 100644 --- a/web/src/lib/registry-new/generate.ts +++ b/web/src/lib/registry-new/generate.ts @@ -1,7 +1,15 @@ -import type { ToolEntry } from "./types"; -import { field, toolSchema, type Dimension } from "../registry-schema"; -import { solidImage } from "../core/generate"; +import { renderTextToImage } from "../core/domText"; +import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools"; +import { gradientImage, noiseImage, solidImage } from "../core/generate"; +import { changeCanvasSize } from "../core/geometry"; import { hexToRgb } from "../core/palette"; +import { field, toolSchema, type Dimension } from "../registry-schema"; +import type { ToolEntry } from "./types"; + +function rgba(hex: string): [number, number, number, number] { + const { r, g, b } = hexToRgb(hex); + return [r, g, b, 255]; +} interface CreateEmptyParams { size: Dimension; @@ -28,9 +36,240 @@ const createEmpty: ToolEntry = { if (p.transparent) { return solidImage(w, h, [0, 0, 0, 0]); } - const { r, g, b } = hexToRgb(p.color); - return solidImage(w, h, [r, g, b, 255]); + return solidImage(w, h, rgba(p.color)); }, }; -export const generateEntries = [createEmpty]; +interface SingleColorParams { + size: Dimension; + color: string; +} + +export const singleColorSchema = toolSchema({ + size: field.dimension({ min: 1, max: 20000, width: 256, height: 256 }), + color: field.color({ default: "#ff0000" }), +}); + +const singleColor: ToolEntry = { + id: "single-color-png", + title: "Create solid color PNG", + description: "Generates a rectangle of the given size and color.", + category: "generate", + schema: singleColorSchema, + generate: (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({ + 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 = { + 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, + generate: (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; + fromColor: string; + toColor: string; + direction: "horizontal" | "vertical"; +} + +export const linearGradientSchema = toolSchema({ + size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }), + fromColor: field.color({ default: "#000000" }), + toColor: field.color({ default: "#ffffff" }), + direction: field.select({ + default: "horizontal", + options: [ + { value: "horizontal", label: "Horizontal" }, + { value: "vertical", label: "Vertical" }, + ], + }), +}); + +const linearGradient: ToolEntry = { + id: "linear-gradient-png", + title: "Create gradient PNG", + description: + "Generates a smooth transition between two colors, horizontally or vertically.", + category: "generate", + schema: linearGradientSchema, + generate: (p) => { + const w = Math.trunc(p.size.width); + const h = Math.trunc(p.size.height); + return gradientImage(w, h, rgba(p.fromColor), rgba(p.toColor), p.direction); + }, +}; + +interface ColorSpectrumParams { + size: Dimension; + direction: "horizontal" | "vertical"; + saturation: number; + lightness: number; +} + +export const colorSpectrumSchema = toolSchema({ + 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 }), +}); + +const colorSpectrumTool: ToolEntry = { + id: "color-spectrum-png", + title: "Color Spectrum PNG", + description: + "Full hue rainbow 0–360° along the chosen axis with adjustable saturation and lightness.", + category: "generate", + schema: colorSpectrumSchema, + generate: (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({ + 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 }), +}); + +const randomColors: ToolEntry = { + 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, + generate: (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({ + 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 }), +}); + +const drawGridTool: ToolEntry = { + 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, + generate: (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({ + 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 }), +}); + +const placeholder: ToolEntry = { + id: "placeholder-png", + title: "Create Placeholder PNG", + description: + "Generates a placeholder rectangle with its dimensions printed in the center.", + category: "generate", + schema: placeholderSchema, + generate: (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; + }, +}; + +export const generateEntries = [ + createEmpty, + singleColor, + randomNoise, + linearGradient, + colorSpectrumTool, + randomColors, + drawGridTool, + placeholder, +]; diff --git a/web/src/lib/registry-new/geometry.ts b/web/src/lib/registry-new/geometry.ts index 0df4f5b..513f240 100644 --- a/web/src/lib/registry-new/geometry.ts +++ b/web/src/lib/registry-new/geometry.ts @@ -1,6 +1,13 @@ +import { ToolError } from "../core/errors"; +import { + changeCanvasSize, + crop, + expandCanvas, + resize, + type Anchor9, +} from "../core/geometry"; +import { field, toolSchema, type Dimension } from "../registry-schema"; import type { ToolEntry } from "./types"; -import { field, toolSchema } from "../registry-schema"; -import { expandCanvas } from "../core/geometry"; interface AddBorderParams { thickness: number; @@ -30,4 +37,155 @@ const addBorder: ToolEntry = { ), }; -export const geometryEntries = [addBorder]; +interface FitOnBackgroundParams { + size: Dimension; + transparent: boolean; + color: string; +} + +export const fitOnBackgroundSchema = toolSchema({ + size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }), + transparent: field.checkbox({ default: false }), + color: field.color({ default: "#ffffff" }), +}); + +const fitOnBackground: ToolEntry = { + 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, + run: (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({ + 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" }, + ], + }), +}); + +const changeCanvasSizeTool: ToolEntry = { + 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, + run: (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({ + size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }), + keepAspect: field.checkbox({ default: true }), +}); + +const resizeTool: ToolEntry = { + 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, + run: (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({ + 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 }), +}); + +const cropTool: ToolEntry = { + 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, + run: (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); + }, +}; + +export const geometryEntries = [ + addBorder, + fitOnBackground, + changeCanvasSizeTool, + resizeTool, + cropTool, +]; diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts index b90c956..f53b773 100644 --- a/web/src/lib/registry-new/registry-new.test.ts +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { TOOLS } from "../registry-new"; import { PREVIEW_GROUPS } from "../preview/catalog"; +import { TOOLS } from "../registry-new"; import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema"; describe("registry-new (переведённые инструменты)", () => { @@ -11,14 +11,18 @@ describe("registry-new (переведённые инструменты)", () => it("PREVIEW_GROUPS строится без ошибок", () => { expect(PREVIEW_GROUPS.length).toBeGreaterThan(0); - expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe(TOOLS.length); + expect(PREVIEW_GROUPS.reduce((n, g) => n + g.tools.length, 0)).toBe( + TOOLS.length, + ); }); 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()); + expect(Object.keys(sanitized).sort()).toEqual( + Object.keys(defaults).sort(), + ); } }); @@ -53,4 +57,134 @@ describe("registry-new (переведённые инструменты)", () => 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 }, + fromColor: "#000000", + toColor: "#ffffff", + direction: "horizontal", + }, + ], + [ + "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.generate).toBeDefined(); + const sanitized = sanitizeSchemaParams(tool.schema, params); + const img = await tool.generate!(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 = await tool.generate!( + sanitizeSchemaParams(tool.schema, { + size: { width: 32, height: 32 }, + seed: 1, + }), + ); + const b = await tool.generate!( + 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 = await tool.run!( + img, + 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 = await tool.run!( + img, + 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 = await tool.run!( + img, + 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 = await tool.run!( + img, + 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); + }); }); + +function solid(width: number, height: number) { + const data = new Uint8ClampedArray(width * height * 4).fill(255); + return { width, height, data }; +} diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index d1e6bb1..83a2285 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -112,7 +112,12 @@ export const field = { checkbox: (s: Omit): Field => ({ spec: { kind: "checkbox", ...s }, }), - dimension: (s: { min: number; max: number; width: number; height: number }): Field => ({ + dimension: (s: { + min: number; + max: number; + width: number; + height: number; + }): Field => ({ spec: { kind: "dimension", ...s }, }), };