diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index c0a66ef..e7ea325 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -2,16 +2,18 @@ > Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент > в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены -> все одиночные инструменты (18 шт): `add-border-png`, `add-stroke-png`, -> `find-contour-png` и весь блок 8-22 (convert/noise/pixelate/color/filters...). -> Новый registry `web/src/lib/registry-new/` разбит по категориям -> (`geometry`, `alpha`, `convert`, `analyze`, `filters`, `color`), preview -> переключён на него. Следующее: Фаза 3 — инструменты с составными типами. +> все одиночные инструменты (18 шт). Фаза 3 — **начата**: введён составной тип +> `dimension` (вложенный объект в Params + `field.dimension`, новый kind в +> `registry-schema` + мерge `DimensionField` в UI), переведён пилот +> `create-empty-png`; preview научен применять **генераторы** (`executeGenerate`, +> кнопка Generate вместо загрузки файла). Следующее: перевести остальные +> dimension-инструменты (single-color → random-noise → ...). > > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > (по файлу на категорию) + `web/src/lib/preview/categories.ts`, -> `web/src/lib/registry-schema.ts`. Старый `web/src/lib/registry.ts` разбит по -> категориям в `web/src/lib/registry/` (см. `registry.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` — тонкий баррель). ## Ключевая стратегия: параллельная сборка, старый UI не трогаем @@ -328,9 +330,13 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — ### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам) -24. `dimension` — ввести составной тип + перевести **один** пилот - (create-empty-png), ревью, затем остальные 11 по 1: - create-empty → single-color → random-noise → fit-on-background → +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 25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors → diff --git a/web/src/lib/components/kit/SchemaFields.svelte b/web/src/lib/components/kit/SchemaFields.svelte index bc3b232..3c56e42 100644 --- a/web/src/lib/components/kit/SchemaFields.svelte +++ b/web/src/lib/components/kit/SchemaFields.svelte @@ -1,6 +1,7 @@

- SOURCE / RESULT + {isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}
- + {#if isGenerator} + + {:else} + + {/if}
@@ -43,17 +55,19 @@ {/if}
-
-
SOURCE
-
- {#if sourceUrl} - source - {:else} - choose an image - {/if} -
-
-
+ {#if !isGenerator} +
+
SOURCE
+
+ {#if sourceUrl} + source + {:else} + choose an image + {/if} +
+
+ {/if} +
RESULT {running ? "…" : ""}
{#if resultUrl} @@ -61,7 +75,7 @@ {:else if running} {:else} - no result yet + {isGenerator ? "click Generate" : "no result yet"} {/if}
@@ -115,6 +129,15 @@ color: var(--color-text-muted); cursor: pointer; } + .generate-btn { + background: var(--color-main); + border-color: var(--color-main); + color: white; + } + .generate-btn:disabled { + opacity: 0.6; + cursor: default; + } .upload input { display: none; } @@ -128,6 +151,9 @@ grid-template-columns: 1fr 1fr; gap: var(--space-l); } + .pair > .full { + grid-column: 1 / -1; + } .tile figcaption { display: flex; align-items: center; diff --git a/web/src/lib/components/kit/SchemaToolView.svelte b/web/src/lib/components/kit/SchemaToolView.svelte index 4306a3b..9ee55de 100644 --- a/web/src/lib/components/kit/SchemaToolView.svelte +++ b/web/src/lib/components/kit/SchemaToolView.svelte @@ -4,7 +4,7 @@ import { debounce } from "$lib/core/debounce"; import { decodeFile, encode } from "$lib/core/io"; import type { PixelImage } from "$lib/core/types"; - import { executeStep } from "$lib/preview/executor"; + import { executeGenerate, executeStep } from "$lib/preview/executor"; import type { ToolEntry } from "$lib/registry-new"; import { defaultSchemaParams, @@ -21,6 +21,8 @@ tool.schema as ToolSchema> | undefined, ); + const isGenerator = $derived(Boolean(tool.generate && !tool.run)); + let values = $state>({}); let source = $state(null); let result = $state(null); @@ -56,12 +58,32 @@ } async function run() { - if (!schema || !tool.run || !source) return; + if (!schema) return; + if (isGenerator) { + if (!tool.generate) return; + await runGenerate(); + return; + } + if (!source) return; + error = ""; + running = true; + const params = sanitizeSchemaParams(schema, values); + try { + result = await executeStep(tool, source, params); + } catch (e) { + error = errorText(e); + } finally { + running = false; + } + } + + async function runGenerate() { + if (!schema || !tool.generate) return; running = true; error = ""; const params = sanitizeSchemaParams(schema, values); try { - result = await executeStep(tool, source, params); + result = await executeGenerate(tool, params); } catch (e) { error = errorText(e); } finally { @@ -85,7 +107,7 @@ }); $effect(() => { - if (!source || !started) return; + if (isGenerator || !source || !started) return; void values; debouncedRun(); return () => debouncedRun.cancel(); @@ -122,7 +144,9 @@ {result} {running} {error} + {isGenerator} onupload={handleFile} + ongenerate={runGenerate} ondownload={download} /> diff --git a/web/src/lib/components/kit/fields/DimensionField.svelte b/web/src/lib/components/kit/fields/DimensionField.svelte new file mode 100644 index 0000000..431d6dd --- /dev/null +++ b/web/src/lib/components/kit/fields/DimensionField.svelte @@ -0,0 +1,50 @@ + + +
+ {label} + setAxis("width", n)} + /> + setAxis("height", n)} + /> +
+ + diff --git a/web/src/lib/preview/executor/executor.ts b/web/src/lib/preview/executor/executor.ts index fd54cce..a7fafe1 100644 --- a/web/src/lib/preview/executor/executor.ts +++ b/web/src/lib/preview/executor/executor.ts @@ -8,6 +8,9 @@ type MaybeRunnable = { img: PixelImage, params: Record, ) => Promise | PixelImage; + generate?: ( + params: Record, + ) => Promise | PixelImage; }; export async function executeStep( @@ -37,6 +40,21 @@ export async function executeStep( } } +/** + * Применение инструмента-генератора (без входного изображения). + * Выполняется напрямую: worker-протокол рассчитан на передачу исходника, + * а генераторам вход не нужен. + */ +export async function executeGenerate( + tool: MaybeRunnable, + params: Record, +): Promise { + if (!tool.generate) { + throw new Error("errors.noGenerate"); + } + return await tool.generate(params); +} + async function runDirect( tool: MaybeRunnable, img: PixelImage, diff --git a/web/src/lib/preview/executor/index.ts b/web/src/lib/preview/executor/index.ts index e4220e2..02cb028 100644 --- a/web/src/lib/preview/executor/index.ts +++ b/web/src/lib/preview/executor/index.ts @@ -1 +1 @@ -export { executeStep } from "./executor"; +export { executeGenerate, executeStep } from "./executor"; diff --git a/web/src/lib/registry-new/generate.ts b/web/src/lib/registry-new/generate.ts new file mode 100644 index 0000000..9b4232b --- /dev/null +++ b/web/src/lib/registry-new/generate.ts @@ -0,0 +1,36 @@ +import type { ToolEntry } from "./types"; +import { field, toolSchema, type Dimension } from "../registry-schema"; +import { solidImage } from "../core/generate"; +import { hexToRgb } from "../core/palette"; + +interface CreateEmptyParams { + size: Dimension; + transparent: boolean; + color: string; +} + +export const createEmptySchema = toolSchema({ + size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }), + transparent: field.checkbox({ default: true }), + color: field.color({ default: "#ffffff" }), +}); + +const createEmpty: ToolEntry = { + 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, + generate: (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]); + } + const { r, g, b } = hexToRgb(p.color); + return solidImage(w, h, [r, g, b, 255]); + }, +}; + +export const generateEntries = [createEmpty]; diff --git a/web/src/lib/registry-new/index.ts b/web/src/lib/registry-new/index.ts index cfaec75..a6fc4a4 100644 --- a/web/src/lib/registry-new/index.ts +++ b/web/src/lib/registry-new/index.ts @@ -5,6 +5,7 @@ import { convertEntries } from "./convert"; import { analyzeEntries } from "./analyze"; import { filtersEntries } from "./filters"; import { colorEntries } from "./color"; +import { generateEntries } from "./generate"; export type { ToolEntry } from "./types"; @@ -15,6 +16,7 @@ export const TOOLS: ToolEntry[] = [ ...analyzeEntries, ...filtersEntries, ...colorEntries, + ...generateEntries, ] as unknown as ToolEntry[]; export function getTool(id: string): ToolEntry | undefined { diff --git a/web/src/lib/registry-new/registry-new.test.ts b/web/src/lib/registry-new/registry-new.test.ts new file mode 100644 index 0000000..b90c956 --- /dev/null +++ b/web/src/lib/registry-new/registry-new.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { TOOLS } from "../registry-new"; +import { PREVIEW_GROUPS } from "../preview/catalog"; +import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema"; + +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("дефолты схем дают валидные параметры (включая 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 = await tool.generate!(params); + expect(img.width).toBe(320); + expect(img.height).toBe(200); + expect(img.data[3]).toBe(0); + }); +}); diff --git a/web/src/lib/registry-new/types.ts b/web/src/lib/registry-new/types.ts index d8d2fca..c69dd3b 100644 --- a/web/src/lib/registry-new/types.ts +++ b/web/src/lib/registry-new/types.ts @@ -13,7 +13,8 @@ export type ToolEntry

> = { description: string; category: CategoryId; schema: ToolSchema

; - run(img: PixelImage, params: P): Promise | PixelImage; + /** Применение к входному изображению. Для чистых генераторов отсутствует. */ + run?(img: PixelImage, params: P): Promise | PixelImage; generate?(params: P): Promise | PixelImage; toText?(img: PixelImage, params: P): Promise | string; runFromText?(text: string, params: P): Promise | PixelImage; diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index 1e6d7de..d1e6bb1 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -49,8 +49,29 @@ export interface CheckboxSpec { default: boolean; } +/** Составное поле «размеры»: width + height как один объект. */ +export interface Dimension { + width: number; + height: number; +} + +export interface DimensionSpec { + kind: "dimension"; + /** Общий диапазон для обоих измерений. */ + min: number; + max: number; + width: number; + height: number; +} + export type FieldSpec = - NumberSpec | SliderSpec | ColorSpec | SelectSpec | TextSpec | CheckboxSpec; + | NumberSpec + | SliderSpec + | ColorSpec + | SelectSpec + | TextSpec + | CheckboxSpec + | DimensionSpec; /** `Field`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ export interface Field { @@ -91,6 +112,9 @@ export const field = { checkbox: (s: Omit): Field => ({ spec: { kind: "checkbox", ...s }, }), + dimension: (s: { min: number; max: number; width: number; height: number }): Field => ({ + spec: { kind: "dimension", ...s }, + }), }; /** @@ -116,7 +140,12 @@ export function defaultSchemaParams

( ): Record { const out = {} as Record; for (const key of Object.keys(schema.fields) as (keyof P)[]) { - out[key] = schema.fields[key].spec.default; + const spec = schema.fields[key].spec; + if (spec.kind === "dimension") { + out[key] = { width: spec.width, height: spec.height }; + } else { + out[key] = spec.default; + } } return out; } @@ -156,6 +185,28 @@ export function sanitizeSchemaParams

( case "text": out[key] = typeof raw === "string" ? raw : spec.default; break; + case "dimension": { + const r = + typeof raw === "object" && + raw !== null && + "width" in raw && + "height" in raw + ? (raw as Record) + : undefined; + const w = + typeof r?.width === "number" && Number.isFinite(r.width) + ? r.width + : spec.width; + const h = + typeof r?.height === "number" && Number.isFinite(r.height) + ? r.height + : spec.height; + out[key] = { + width: clamp(w, spec.min, spec.max), + height: clamp(h, spec.min, spec.max), + }; + break; + } } } return out;