mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
feat: add dimension tools for new flow
This commit is contained in:
@@ -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 →
|
||||||
|
|||||||
@@ -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,19 +23,29 @@
|
|||||||
</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">
|
||||||
<label class="upload">
|
{#if isGenerator}
|
||||||
<Upload size={14} /> Open image
|
<button
|
||||||
<input
|
class="upload generate-btn"
|
||||||
type="file"
|
onclick={ongenerate}
|
||||||
accept="image/*"
|
disabled={running}
|
||||||
onchange={(e) => {
|
>
|
||||||
const f = (e.target as HTMLInputElement).files?.[0];
|
<Sparkles size={14} /> {running ? "Generating…" : "Generate"}
|
||||||
if (f) onupload(f);
|
</button>
|
||||||
}}
|
{:else}
|
||||||
/>
|
<label class="upload">
|
||||||
</label>
|
<Upload size={14} /> Open image
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={(e) => {
|
||||||
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
|
if (f) onupload(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
<DownloadButton label="Download result" onclick={ondownload} />
|
<DownloadButton label="Download result" onclick={ondownload} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,17 +55,19 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="pair">
|
<div class="pair">
|
||||||
<figure class="tile">
|
{#if !isGenerator}
|
||||||
<figcaption><span>SOURCE</span></figcaption>
|
<figure class="tile">
|
||||||
<div class="canvas">
|
<figcaption><span>SOURCE</span></figcaption>
|
||||||
{#if sourceUrl}
|
<div class="canvas">
|
||||||
<img src={sourceUrl} alt="source" />
|
{#if sourceUrl}
|
||||||
{:else}
|
<img src={sourceUrl} alt="source" />
|
||||||
<span class="empty">choose an image</span>
|
{:else}
|
||||||
{/if}
|
<span class="empty">choose an image</span>
|
||||||
</div>
|
{/if}
|
||||||
</figure>
|
</div>
|
||||||
<figure class="tile">
|
</figure>
|
||||||
|
{/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>
|
||||||
@@ -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 @@
|
|||||||
export { executeStep } from "./executor";
|
export { executeGenerate, executeStep } from "./executor";
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user