feat: update use dimension tools
This commit is contained in:
@@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
|
> Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
|
||||||
> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
|
> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
|
||||||
> все одиночные инструменты (18 шт). Фаза 3 — **начата**: введён составной тип
|
> все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension`
|
||||||
> `dimension` (вложенный объект в Params + `field.dimension`, новый kind в
|
> полностью переведён** (12 инструментов: create-empty, single-color,
|
||||||
> `registry-schema` + мерge `DimensionField` в UI), переведён пилот
|
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
|
||||||
> `create-empty-png`; preview научен применять **генераторы** (`executeGenerate`,
|
> placeholder, fit-on-background, change-canvas-size, resize, crop); preview
|
||||||
> кнопка Generate вместо загрузки файла). Следующее: перевести остальные
|
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
|
||||||
> dimension-инструменты (single-color → random-noise → ...).
|
> рендерить все 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`
|
> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
|
||||||
> (по файлу на категорию) + `web/src/lib/preview/categories.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`,
|
> `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`,
|
||||||
> `preview/executor/index.ts` (`executeGenerate`). Старый `web/src/lib/registry.ts`
|
> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte`
|
||||||
> разбит по категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
|
> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям
|
||||||
|
> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
|
||||||
|
|
||||||
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
|
## Ключевая стратегия: параллельная сборка, старый UI не трогаем
|
||||||
|
|
||||||
@@ -330,15 +334,17 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
|
|||||||
|
|
||||||
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
|
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
|
||||||
|
|
||||||
24. `dimension` — составной тип введён ✔ (`field.dimension` + kind `dimension`
|
24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`,
|
||||||
в `registry-schema`, вложенный объект `size: { width, height }` в Params,
|
`single-color-png`, `random-noise-png`, `linear-gradient-png`,
|
||||||
виджет `kit/fields/DimensionField.svelte`).
|
`color-spectrum-png`, `random-colors-png`, `draw-grid-png`,
|
||||||
Пилот `create-empty-png` переведён ✔; preview поддержал **генераторы**
|
`placeholder-png` — генераторы в `registry-new/generate.ts`;
|
||||||
(`preview/executor` + `executeGenerate`, кнопка Generate в `SchemaToolView`/
|
`fit-on-background-png`, `change-canvas-size-png`, `resize-png`,
|
||||||
`SchemaPreview`, без входного файла).
|
`crop-png` — в `registry-new/geometry.ts`). Составной тип во всех видах:
|
||||||
Остальные по 1: single-color → random-noise → fit-on-background →
|
вложенный объект `size: { width, height }` в Params + `field.dimension`,
|
||||||
change-canvas-size → placeholder → draw-grid → random-colors →
|
виджет `kit/fields/DimensionField.svelte`.
|
||||||
color-spectrum → linear-gradient → resize → crop
|
Preview: генераторы применяются через `executeGenerate` (кнопка Generate),
|
||||||
|
`SchemaFields` рендерит все kinds схемы (slider/number/color/select/
|
||||||
|
checkbox/dimension). Тесты: 592 passed.
|
||||||
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
|
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors →
|
||||||
linear-gradient (уже переведён в dimension, здесь добавляется к нему)
|
linear-gradient (уже переведён в dimension, здесь добавляется к нему)
|
||||||
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
|
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask →
|
||||||
|
|||||||
@@ -51,11 +51,37 @@
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
{:else if field.spec.kind === "select"}
|
||||||
|
<label class="control">
|
||||||
|
<span>{labelOf(id)}</span>
|
||||||
|
<select
|
||||||
|
class="select-field"
|
||||||
|
value={String(values[id] ?? field.spec.default)}
|
||||||
|
oninput={(e) => onchange(id, (e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{#each field.spec.options as option (option.value)}
|
||||||
|
<option value={option.value}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{:else if field.spec.kind === "checkbox"}
|
||||||
|
<label class="control checkbox-control">
|
||||||
|
<span>{labelOf(id)}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox-field"
|
||||||
|
checked={Boolean(values[id] ?? field.spec.default)}
|
||||||
|
onchange={(e) => onchange(id, (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
{:else if field.spec.kind === "dimension"}
|
{:else if field.spec.kind === "dimension"}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<DimensionField
|
<DimensionField
|
||||||
label={labelOf(id)}
|
label={labelOf(id)}
|
||||||
value={(values[id] as Dimension) ?? { width: field.spec.width, height: field.spec.height }}
|
value={(values[id] as Dimension) ?? {
|
||||||
|
width: field.spec.width,
|
||||||
|
height: field.spec.height,
|
||||||
|
}}
|
||||||
spec={field.spec}
|
spec={field.spec}
|
||||||
oninput={(v) => onchange(id, v)}
|
oninput={(v) => onchange(id, v)}
|
||||||
/>
|
/>
|
||||||
@@ -91,6 +117,25 @@
|
|||||||
accent-color: var(--color-main);
|
accent-color: var(--color-main);
|
||||||
color: var(--color-text);
|
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 {
|
.color-control > span:first-child {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, Sparkles, Upload } from "@lucide/svelte";
|
|
||||||
import { toDataUrl } from "$lib/core/io";
|
|
||||||
import type { PixelImage } from "$lib/core/types";
|
|
||||||
import MetaList from "$lib/components/kit/MetaList.svelte";
|
import MetaList from "$lib/components/kit/MetaList.svelte";
|
||||||
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
|
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
|
||||||
|
import { toDataUrl } from "$lib/core/io";
|
||||||
|
import type { PixelImage } from "$lib/core/types";
|
||||||
|
import { Check, Sparkles, Upload } from "@lucide/svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source: PixelImage | null;
|
source: PixelImage | null;
|
||||||
@@ -15,15 +15,25 @@
|
|||||||
ongenerate?: () => void;
|
ongenerate?: () => void;
|
||||||
ondownload: () => void;
|
ondownload: () => void;
|
||||||
}
|
}
|
||||||
let { source, result, running, error, isGenerator = false, onupload, ongenerate, ondownload }: Props =
|
let {
|
||||||
$props();
|
source,
|
||||||
|
result,
|
||||||
|
running,
|
||||||
|
error,
|
||||||
|
isGenerator = false,
|
||||||
|
onupload,
|
||||||
|
ongenerate,
|
||||||
|
ondownload,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let sourceUrl = $derived(source ? toDataUrl(source) : null);
|
let sourceUrl = $derived(source ? toDataUrl(source) : null);
|
||||||
let resultUrl = $derived(result ? toDataUrl(result) : null);
|
let resultUrl = $derived(result ? toDataUrl(result) : null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<span class="label">{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span>
|
<span class="label"
|
||||||
|
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
|
||||||
|
>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
{#if isGenerator}
|
{#if isGenerator}
|
||||||
<button
|
<button
|
||||||
@@ -31,7 +41,8 @@
|
|||||||
onclick={ongenerate}
|
onclick={ongenerate}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
>
|
>
|
||||||
<Sparkles size={14} /> {running ? "Generating…" : "Generate"}
|
<Sparkles size={14} />
|
||||||
|
{running ? "Generating…" : "Generate"}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<label class="upload">
|
<label class="upload">
|
||||||
@@ -75,7 +86,9 @@
|
|||||||
{:else if running}
|
{:else if running}
|
||||||
<Check size={22} />
|
<Check size={22} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="empty">{isGenerator ? "click Generate" : "no result yet"}</span>
|
<span class="empty">
|
||||||
|
{isGenerator ? "click Generate" : "no result yet"}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</figure>
|
</figure>
|
||||||
@@ -132,7 +145,7 @@
|
|||||||
.generate-btn {
|
.generate-btn {
|
||||||
background: var(--color-main);
|
background: var(--color-main);
|
||||||
border-color: var(--color-main);
|
border-color: var(--color-main);
|
||||||
color: white;
|
color: var(--color-background);
|
||||||
}
|
}
|
||||||
.generate-btn:disabled {
|
.generate-btn:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -42,9 +42,9 @@
|
|||||||
}
|
}
|
||||||
.dimension-label {
|
.dimension-label {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
color: var(--foreground);
|
color: var(--color-text);
|
||||||
font-size: 11px;
|
font-size: var(--font-size-s);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: var(--space-text-l);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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 { gammaCorrection, temperature, tint } from "../core/color";
|
||||||
import { parseHexList } from "../core/palette";
|
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 {
|
interface GammaParams {
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { ToolEntry } from "./types";
|
import { renderTextToImage } from "../core/domText";
|
||||||
import { field, toolSchema, type Dimension } from "../registry-schema";
|
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
|
||||||
import { solidImage } from "../core/generate";
|
import { gradientImage, noiseImage, solidImage } from "../core/generate";
|
||||||
|
import { changeCanvasSize } from "../core/geometry";
|
||||||
import { hexToRgb } from "../core/palette";
|
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 {
|
interface CreateEmptyParams {
|
||||||
size: Dimension;
|
size: Dimension;
|
||||||
@@ -28,9 +36,240 @@ const createEmpty: ToolEntry<CreateEmptyParams> = {
|
|||||||
if (p.transparent) {
|
if (p.transparent) {
|
||||||
return solidImage(w, h, [0, 0, 0, 0]);
|
return solidImage(w, h, [0, 0, 0, 0]);
|
||||||
}
|
}
|
||||||
const { r, g, b } = hexToRgb(p.color);
|
return solidImage(w, h, rgba(p.color));
|
||||||
return solidImage(w, h, [r, g, b, 255]);
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const generateEntries = [createEmpty];
|
interface SingleColorParams {
|
||||||
|
size: Dimension;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const singleColorSchema = toolSchema<SingleColorParams>({
|
||||||
|
size: field.dimension({ min: 1, max: 20000, width: 256, height: 256 }),
|
||||||
|
color: field.color({ default: "#ff0000" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const singleColor: ToolEntry<SingleColorParams> = {
|
||||||
|
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<RandomNoiseParams>({
|
||||||
|
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<RandomNoiseParams> = {
|
||||||
|
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<LinearGradientParams>({
|
||||||
|
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<LinearGradientParams> = {
|
||||||
|
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<ColorSpectrumParams>({
|
||||||
|
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<ColorSpectrumParams> = {
|
||||||
|
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<RandomColorsParams>({
|
||||||
|
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<RandomColorsParams> = {
|
||||||
|
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<DrawGridParams>({
|
||||||
|
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<DrawGridParams> = {
|
||||||
|
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<PlaceholderParams>({
|
||||||
|
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<PlaceholderParams> = {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|||||||
@@ -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 type { ToolEntry } from "./types";
|
||||||
import { field, toolSchema } from "../registry-schema";
|
|
||||||
import { expandCanvas } from "../core/geometry";
|
|
||||||
|
|
||||||
interface AddBorderParams {
|
interface AddBorderParams {
|
||||||
thickness: number;
|
thickness: number;
|
||||||
@@ -30,4 +37,155 @@ const addBorder: ToolEntry<AddBorderParams> = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const geometryEntries = [addBorder];
|
interface FitOnBackgroundParams {
|
||||||
|
size: Dimension;
|
||||||
|
transparent: boolean;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fitOnBackgroundSchema = toolSchema<FitOnBackgroundParams>({
|
||||||
|
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
|
||||||
|
transparent: field.checkbox({ default: false }),
|
||||||
|
color: field.color({ default: "#ffffff" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitOnBackground: ToolEntry<FitOnBackgroundParams> = {
|
||||||
|
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<ChangeCanvasSizeParams>({
|
||||||
|
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<ChangeCanvasSizeParams> = {
|
||||||
|
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<ResizeParams>({
|
||||||
|
size: field.dimension({ min: 0, max: 20000, width: 0, height: 0 }),
|
||||||
|
keepAspect: field.checkbox({ default: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resizeTool: ToolEntry<ResizeParams> = {
|
||||||
|
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<CropParams>({
|
||||||
|
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<CropParams> = {
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { TOOLS } from "../registry-new";
|
|
||||||
import { PREVIEW_GROUPS } from "../preview/catalog";
|
import { PREVIEW_GROUPS } from "../preview/catalog";
|
||||||
|
import { TOOLS } from "../registry-new";
|
||||||
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
import { defaultSchemaParams, sanitizeSchemaParams } from "../registry-schema";
|
||||||
|
|
||||||
describe("registry-new (переведённые инструменты)", () => {
|
describe("registry-new (переведённые инструменты)", () => {
|
||||||
@@ -11,14 +11,18 @@ describe("registry-new (переведённые инструменты)", () =>
|
|||||||
|
|
||||||
it("PREVIEW_GROUPS строится без ошибок", () => {
|
it("PREVIEW_GROUPS строится без ошибок", () => {
|
||||||
expect(PREVIEW_GROUPS.length).toBeGreaterThan(0);
|
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)", () => {
|
it("дефолты схем дают валидные параметры (включая dimension)", () => {
|
||||||
for (const tool of TOOLS) {
|
for (const tool of TOOLS) {
|
||||||
const defaults = defaultSchemaParams(tool.schema);
|
const defaults = defaultSchemaParams(tool.schema);
|
||||||
const sanitized = sanitizeSchemaParams(tool.schema, defaults);
|
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.height).toBe(200);
|
||||||
expect(img.data[3]).toBe(0);
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ 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> => ({
|
dimension: (s: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}): Field<Dimension> => ({
|
||||||
spec: { kind: "dimension", ...s },
|
spec: { kind: "dimension", ...s },
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user