feat: add dimension tools for new flow

This commit is contained in:
2026-09-05 10:06:25 +05:00
parent 1e9b35406d
commit 2601aa5b5c
12 changed files with 325 additions and 45 deletions
+16 -10
View File
@@ -2,16 +2,18 @@
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент > Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены > в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
> все одиночные инструменты (18 шт): `add-border-png`, `add-stroke-png`, > все одиночные инструменты (18 шт). Фаза 3 — **начата**: введён составной тип
> `find-contour-png` и весь блок 8-22 (convert/noise/pixelate/color/filters...). > `dimension` (вложенный объект в Params + `field.dimension`, новый kind в
> Новый registry `web/src/lib/registry-new/` разбит по категориям > `registry-schema` + мерge `DimensionField` в UI), переведён пилот
> (`geometry`, `alpha`, `convert`, `analyze`, `filters`, `color`), preview > `create-empty-png`; preview научен применять **генераторы** (`executeGenerate`,
> переключён на него. Следующее: Фаза 3 — инструменты с составными типами. > кнопка Generate вместо загрузки файла). Следующее: перевести остальные
> dimension-инструменты (single-color → random-noise → ...).
> >
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
> (по файлу на категорию) + `web/src/lib/preview/categories.ts`, > (по файлу на категорию) + `web/src/lib/preview/categories.ts`,
> `web/src/lib/registry-schema.ts`. Старый `web/src/lib/registry.ts` разбит по > `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`,
> категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель). > `preview/executor/index.ts` (`executeGenerate`). Старый `web/src/lib/registry.ts`
> разбит по категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
## Ключевая стратегия: параллельная сборка, старый UI не трогаем ## Ключевая стратегия: параллельная сборка, старый UI не трогаем
@@ -328,9 +330,13 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам) ### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
24. `dimension` — ввести составной тип + перевести **один** пилот 24. `dimension` — составной тип введён ✔ (`field.dimension` + kind `dimension`
(create-empty-png), ревью, затем остальные 11 по 1: в `registry-schema`, вложенный объект `size: { width, height }` в Params,
create-empty → single-color → random-noise → fit-on-background → виджет `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 → change-canvas-size → placeholder → draw-grid → random-colors →
color-spectrum → linear-gradient → resize → crop color-spectrum → linear-gradient → resize → crop
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors → 25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
+11 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { RotateCcw } from "@lucide/svelte"; import { RotateCcw } from "@lucide/svelte";
import type { ToolSchema } from "$lib/registry-schema"; import type { ToolSchema, Dimension } from "$lib/registry-schema";
import DimensionField from "./fields/DimensionField.svelte";
interface Props { interface Props {
schema: ToolSchema<Record<string, unknown>>; schema: ToolSchema<Record<string, unknown>>;
@@ -50,6 +51,15 @@
/> />
</span> </span>
</label> </label>
{:else if field.spec.kind === "dimension"}
<div class="control">
<DimensionField
label={labelOf(id)}
value={(values[id] as Dimension) ?? { width: field.spec.width, height: field.spec.height }}
spec={field.spec}
oninput={(v) => onchange(id, v)}
/>
</div>
{/if} {/if}
{/each} {/each}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, Upload } from "@lucide/svelte"; import { Check, Sparkles, Upload } from "@lucide/svelte";
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import MetaList from "$lib/components/kit/MetaList.svelte"; import MetaList from "$lib/components/kit/MetaList.svelte";
@@ -10,10 +10,12 @@
result: PixelImage | null; result: PixelImage | null;
running: boolean; running: boolean;
error: string; error: string;
isGenerator?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ongenerate?: () => void;
ondownload: () => void; ondownload: () => void;
} }
let { source, result, running, error, onupload, ondownload }: Props = let { source, result, running, error, isGenerator = false, onupload, ongenerate, ondownload }: Props =
$props(); $props();
let sourceUrl = $derived(source ? toDataUrl(source) : null); let sourceUrl = $derived(source ? toDataUrl(source) : null);
@@ -21,8 +23,17 @@
</script> </script>
<div class="panel-head"> <div class="panel-head">
<span class="label">SOURCE / RESULT</span> <span class="label">{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span>
<div class="head-actions"> <div class="head-actions">
{#if isGenerator}
<button
class="upload generate-btn"
onclick={ongenerate}
disabled={running}
>
<Sparkles size={14} /> {running ? "Generating…" : "Generate"}
</button>
{:else}
<label class="upload"> <label class="upload">
<Upload size={14} /> Open image <Upload size={14} /> Open image
<input <input
@@ -34,6 +45,7 @@
}} }}
/> />
</label> </label>
{/if}
<DownloadButton label="Download result" onclick={ondownload} /> <DownloadButton label="Download result" onclick={ondownload} />
</div> </div>
</div> </div>
@@ -43,6 +55,7 @@
{/if} {/if}
<div class="pair"> <div class="pair">
{#if !isGenerator}
<figure class="tile"> <figure class="tile">
<figcaption><span>SOURCE</span></figcaption> <figcaption><span>SOURCE</span></figcaption>
<div class="canvas"> <div class="canvas">
@@ -53,7 +66,8 @@
{/if} {/if}
</div> </div>
</figure> </figure>
<figure class="tile"> {/if}
<figure class="tile" class:full={isGenerator}>
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption> <figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
<div class="canvas checker"> <div class="canvas checker">
{#if resultUrl} {#if resultUrl}
@@ -61,7 +75,7 @@
{:else if running} {:else if running}
<Check size={22} /> <Check size={22} />
{:else} {:else}
<span class="empty">no result yet</span> <span class="empty">{isGenerator ? "click Generate" : "no result yet"}</span>
{/if} {/if}
</div> </div>
</figure> </figure>
@@ -115,6 +129,15 @@
color: var(--color-text-muted); color: var(--color-text-muted);
cursor: pointer; 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 { .upload input {
display: none; display: none;
} }
@@ -128,6 +151,9 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: var(--space-l); gap: var(--space-l);
} }
.pair > .full {
grid-column: 1 / -1;
}
.tile figcaption { .tile figcaption {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -4,7 +4,7 @@
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { decodeFile, encode } from "$lib/core/io"; import { decodeFile, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; 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 type { ToolEntry } from "$lib/registry-new";
import { import {
defaultSchemaParams, defaultSchemaParams,
@@ -21,6 +21,8 @@
tool.schema as ToolSchema<Record<string, unknown>> | undefined, tool.schema as ToolSchema<Record<string, unknown>> | undefined,
); );
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
let values = $state<Record<string, unknown>>({}); let values = $state<Record<string, unknown>>({});
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
@@ -56,12 +58,32 @@
} }
async function run() { 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; running = true;
error = ""; error = "";
const params = sanitizeSchemaParams(schema, values); const params = sanitizeSchemaParams(schema, values);
try { try {
result = await executeStep(tool, source, params); result = await executeGenerate(tool, params);
} catch (e) { } catch (e) {
error = errorText(e); error = errorText(e);
} finally { } finally {
@@ -85,7 +107,7 @@
}); });
$effect(() => { $effect(() => {
if (!source || !started) return; if (isGenerator || !source || !started) return;
void values; void values;
debouncedRun(); debouncedRun();
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
@@ -122,7 +144,9 @@
{result} {result}
{running} {running}
{error} {error}
{isGenerator}
onupload={handleFile} onupload={handleFile}
ongenerate={runGenerate}
ondownload={download} ondownload={download}
/> />
</section> </section>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { Dimension, DimensionSpec } from "$lib/registry-schema";
import NumberField from "./NumberField.svelte";
interface Props {
label: string;
value: Dimension;
spec: DimensionSpec;
oninput?: (value: Dimension) => void;
}
let { label, value = $bindable(), spec, oninput }: Props = $props();
function setAxis(axis: "width" | "height", n: number) {
value = { ...value, [axis]: n };
oninput?.(value);
}
</script>
<div class="dimension-field">
<span class="dimension-label">{label}</span>
<NumberField
label="Width"
value={value.width}
min={spec.min}
max={spec.max}
oninput={(n) => setAxis("width", n)}
/>
<NumberField
label="Height"
value={value.height}
min={spec.min}
max={spec.max}
oninput={(n) => setAxis("height", n)}
/>
</div>
<style>
.dimension-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.dimension-label {
grid-column: 1 / -1;
color: var(--foreground);
font-size: 11px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
</style>
+18
View File
@@ -8,6 +8,9 @@ type MaybeRunnable = {
img: PixelImage, img: PixelImage,
params: Record<string, unknown>, params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage; ) => Promise<PixelImage> | PixelImage;
generate?: (
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
}; };
export async function executeStep( export async function executeStep(
@@ -37,6 +40,21 @@ export async function executeStep(
} }
} }
/**
* Применение инструмента-генератора (без входного изображения).
* Выполняется напрямую: worker-протокол рассчитан на передачу исходника,
* а генераторам вход не нужен.
*/
export async function executeGenerate(
tool: MaybeRunnable,
params: Record<string, unknown>,
): Promise<PixelImage> {
if (!tool.generate) {
throw new Error("errors.noGenerate");
}
return await tool.generate(params);
}
async function runDirect( async function runDirect(
tool: MaybeRunnable, tool: MaybeRunnable,
img: PixelImage, img: PixelImage,
+1 -1
View File
@@ -1 +1 @@
export { executeStep } from "./executor"; export { executeGenerate, executeStep } from "./executor";
+36
View File
@@ -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<CreateEmptyParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
transparent: field.checkbox({ default: true }),
color: field.color({ default: "#ffffff" }),
});
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,
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];
+2
View File
@@ -5,6 +5,7 @@ import { convertEntries } from "./convert";
import { analyzeEntries } from "./analyze"; import { analyzeEntries } from "./analyze";
import { filtersEntries } from "./filters"; import { filtersEntries } from "./filters";
import { colorEntries } from "./color"; import { colorEntries } from "./color";
import { generateEntries } from "./generate";
export type { ToolEntry } from "./types"; export type { ToolEntry } from "./types";
@@ -15,6 +16,7 @@ export const TOOLS: ToolEntry[] = [
...analyzeEntries, ...analyzeEntries,
...filtersEntries, ...filtersEntries,
...colorEntries, ...colorEntries,
...generateEntries,
] as unknown as ToolEntry[]; ] as unknown as ToolEntry[];
export function getTool(id: string): ToolEntry | undefined { export function getTool(id: string): ToolEntry | undefined {
@@ -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);
});
});
+2 -1
View File
@@ -13,7 +13,8 @@ export type ToolEntry<P = Record<string, unknown>> = {
description: string; description: string;
category: CategoryId; category: CategoryId;
schema: ToolSchema<P>; schema: ToolSchema<P>;
run(img: PixelImage, params: P): Promise<PixelImage> | PixelImage; /** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage; generate?(params: P): Promise<PixelImage> | PixelImage;
toText?(img: PixelImage, params: P): Promise<string> | string; toText?(img: PixelImage, params: P): Promise<string> | string;
runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage; runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage;
+53 -2
View File
@@ -49,8 +49,29 @@ export interface CheckboxSpec {
default: boolean; 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 = export type FieldSpec =
NumberSpec | SliderSpec | ColorSpec | SelectSpec | TextSpec | CheckboxSpec; | NumberSpec
| SliderSpec
| ColorSpec
| SelectSpec
| TextSpec
| CheckboxSpec
| DimensionSpec;
/** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ /** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
export interface Field<T> { export interface Field<T> {
@@ -91,6 +112,9 @@ export const field = {
checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({ checkbox: (s: Omit<CheckboxSpec, "kind">): Field<boolean> => ({
spec: { kind: "checkbox", ...s }, spec: { kind: "checkbox", ...s },
}), }),
dimension: (s: { min: number; max: number; width: number; height: number }): Field<Dimension> => ({
spec: { kind: "dimension", ...s },
}),
}; };
/** /**
@@ -116,7 +140,12 @@ export function defaultSchemaParams<P>(
): Record<keyof P, unknown> { ): Record<keyof P, unknown> {
const out = {} as Record<keyof P, unknown>; const out = {} as Record<keyof P, unknown>;
for (const key of Object.keys(schema.fields) as (keyof P)[]) { 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; return out;
} }
@@ -156,6 +185,28 @@ export function sanitizeSchemaParams<P>(
case "text": case "text":
out[key] = typeof raw === "string" ? raw : spec.default; out[key] = typeof raw === "string" ? raw : spec.default;
break; break;
case "dimension": {
const r =
typeof raw === "object" &&
raw !== null &&
"width" in raw &&
"height" in raw
? (raw as Record<string, unknown>)
: 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; return out;