Compare commits

..
11 Commits
48 changed files with 7074 additions and 4507 deletions
+8
View File
@@ -20,3 +20,11 @@
6. **Избранные инструменты (fav tools)** — оценка S/M. 6. **Избранные инструменты (fav tools)** — оценка S/M.
Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге). Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге).
7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** — оценка M/L.
Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др. форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого — бесшовное внедрение выбора формата/качества прямо в кнопку/диалог **Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а pipeline при этом не усложняется лишним звеном.
Связано с планом типизации: в новом `ToolEntry<P>` под `run` в `registry-new` нет поля `output` (mime/ext/qualityParamId), которое есть у старых конвертеров — см. `plan-composite-params.md`, замечание по Фазе 2. Формат-метаданные должны переехать в понятие «формат результата» на уровне скачивания, а не отдельного инструмента.
**Решение «не сейчас» (исследование, 2026-09):**
- В старом UI этот паттерн уже работает: `convert-*`-инструменты не конвертируют в `run`, а только настраивают `output` (mime/ext/qualityParamId), который читает `DownloadButton` → `encode(img, mime, quality)`. То есть формат уже фактически «поле скачивания» в старом pipeline.
- В новом preview `registry-new` поля `output` нет, `executor.worker` возвращает только пиксели, а `SchemaToolView.download()` захардкожен на `image/png` + имя `with-border.png`. Делать ретработу сейчас = параллельный спец-проект.
- **Когда делать:** вместе с достройкой нового download/сохранения в новом UI (в рамках `plan-composite-params`, Фаза 4/UI). Тогда выбор формата встраивается в download-flow естественно, без выдёргивания отдельным проектом. Не начинать, пока не закрыта типизация pipeline.
+115 -36
View File
@@ -1,10 +1,28 @@
# План: составные типы параметров и полная типизация pipeline # План: составные типы параметров и полная типизация pipeline
> Статус: **в реализации.** Фаза 0 (фундамент) — сделана. Следующее: Фаза 1 — > Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент
> один полноценный рабочий инструмент в preview, затем масштабирование. > в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены
> все одиночные инструменты (18 шт). Фаза 3 — **составной тип `dimension`
> полностью переведён** (12 инструментов: create-empty, single-color,
> random-noise, linear-gradient, color-spectrum, random-colors, draw-grid,
> placeholder, fit-on-background, change-canvas-size, resize, crop); **составной
> тип `color-pair` полностью переведён** (4 инструмента: blend-two,
> step-colors, linear-gradient, two-colors); **составной тип `offset`
> полностью переведён** (4 инструмента: circle-mask, square-mask,
> star-mask, wavy-mask); preview
> научен применять **генераторы** (`executeGenerate`, кнопка Generate) и
> рендерить все kinds схемы (slider/number/color/select/checkbox/dimension/
> color-pair/offset).
> Следующее: `position9` (add-text → date-stamp → watermark-image).
> >
> Файлы: `web/src/lib/registry.ts` (3918 строк), `web/src/lib/preview/tool-views.ts` (193 строки), > Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts`
> `web/src/lib/registry-schema.ts`. > (по файлу на категорию: geometry/alpha/convert/analyze/filters/color/generate)
>
> - `web/src/lib/preview/categories.ts`,
> `web/src/lib/registry-schema.ts` (kind `dimension`), `kit/fields/DimensionField.svelte`,
> `preview/executor/index.ts` (`executeGenerate`), `SchemaFields.svelte`
> (полный рендер kinds). Старый `web/src/lib/registry.ts` разбит по категориям
> в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель).
## Ключевая стратегия: параллельная сборка, старый UI не трогаем ## Ключевая стратегия: параллельная сборка, старый UI не трогаем
@@ -17,6 +35,16 @@
сборка, изоляция от старого, затем новый становится основным и старый сборка, изоляция от старого, затем новый становится основным и старый
удаляется вместе со старым дизайном. удаляется вместе со старым дизайном.
**Правило копий (важно!):** если для нового UI/registry нужно внести изменения
в файл, который **прямо или косвенно** уже используется `(old)` веткой, — этот
файл **НЕ трогаем**. Вместо этого делаем **копию** в новом месте (например,
в `lib/preview/`) и правим копию. Это приводит к дублированию, но
**гарантированно не задевает старую ветку сайта**. Пример: категории — новый
`lib/preview/categories.ts` (object as const) копирует и заменяет собой
`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и
остаётся без изменений. После перехода (Фаза 5) копия становится основной,
а исходник удаляется вместе со старым UI.
**Разделение схем: две независимые схемы.** **Разделение схем: две независимые схемы.**
| Схема | Источник | Использование | | Схема | Источник | Использование |
@@ -30,6 +58,33 @@
инструментов, старый UI ничего не замечает. инструментов, старый UI ничего не замечает.
- Удаление старого UI (после перехода) попутно удаляет и старый `ParamDef[]` - Удаление старого UI (после перехода) попутно удаляет и старый `ParamDef[]`
### Разделение registry по UI (новое решение, Фаза 2+)
Помимо двух схем, registry тоже разделён по UI (по факту миграции):
- **Старый registry** (`web/src/lib/registry.ts` + `registry/` +
`registry-helpers.ts`) — работает на старом UI, использует `ParamDef[]` и
`tool-views.ts`. **Не трогаем**; идёт под удаление вместе со старым UI.
- **Новый registry** (`web/src/lib/registry-new/`) — строится **с нуля «как надо»**:
`ToolEntry<P>` с обязательным `schema`, типизированный `run`, **без** `ParamDef[]`
и **без** связи со старым. Импортирует core-функции (`expandCanvas`,
`strokeImage`, …) напрямую. Наполняется **по-инструментно** по мере миграции
(не разовым переводом всех 130). Preview показывает только переведённые
инструменты.
Preview (`catalog.ts`, маршруты `preview/**`, `SchemaToolView`, `SchemaFields`)
импортируют из `$lib/registry-new`. После удаления старого UI `registry-new/`
переименуется в `registry`.
**Executor/worker для нового UI (осознанное дублирование):** общий
`executor.worker.ts` резолвит инструменты по `id` в **старом** registry — для
новых инструментов их там нет. Поэтому `executor.ts` + `executor.worker.ts`
**скопированы** в `web/src/lib/preview/` и переписаны под `registry-new` +
`sanitizeSchemaParams`. Старый executor/worker под старый UI остаются
нетронутыми; `SchemaToolView` использует `$lib/preview/executor`. Это
дублирование необходимо до ухода старого UI (после — preview-executor заменяет
общий).
## Проблема (текущая) ## Проблема (текущая)
- `ParamDef` — плоский union из 6 примитивов (`number | slider | select | - `ParamDef` — плоский union из 6 примитивов (`number | slider | select |
@@ -227,7 +282,11 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
(лишний/неверный/отсутствующий ключ ловит TS), тесты, svelte-check без новых (лишний/неверный/отсутствующий ключ ловит TS), тесты, svelte-check без новых
ошибок. ошибок.
### Фаза 1 — ОДИН полноценный рабочий инструмент в preview (срез) ### Фаза 1 — ОДИН полноценный рабочий инструмент в preview (срез) ✔ (сделано)
Инструмент переехал в `registry-new` и рендерится через `SchemaToolView`
(`SchemaFields`/`SchemaPreview`); применение реально работает end-to-end до
картинки. Паттерн подтверждён. `find-contour-png` — следующий в этой фазе-2.
5. **Инструмент «добавить рамку» (`add-border-png`)** — сделать полностью 5. **Инструмент «добавить рамку» (`add-border-png`)** — сделать полностью
рабочим в preview: рабочим в preview:
@@ -249,39 +308,60 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
Одиночные/небольшие инструменты, где `interface Params` + `schema` не требуют Одиночные/небольшие инструменты, где `interface Params` + `schema` не требуют
составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым
проверенным в Фазе 1 паттерном: проверенным в Фазе 1 паттерном. **Переезжают в `registry-new`.**
6. add-stroke-png (color + slider) Переведено: `add-border-png` (Фаза 1), `add-stroke-png`, `find-contour-png`, плюс
7. find-contour-png (color + slider) весь блок простых инструментов ниже (пункты 8-22). Следующее — Фаза 3.
8. convert-png-to-jpg (color + slider)
9. convert-png-to-webp (slider)
10. remove-color-from-png (color + slider)
11. extract-color-from-png (color + slider)
12. add-noise-png (slider + select + number)
13. randomize-pixels-png (slider + number)
14. pixelate-png (slider)
15. vignette-png (slider)
16. gamma-png (slider)
17. temperature-png (slider)
18. tint-png (color + slider)
19. quantize-png (slider)
20. custom-palette-png (text)
21. dithering-png (slider + select)
22. jpeg-artifacts-png (slider)
(и т.п. — все одиночные инструменты в этом же ключе) 6. ~~add-stroke-png (color + slider)~~ → переведён в `registry-new` ✔
7. ~~find-contour-png (color + slider)~~ → переведён в `registry-new` ✔
8. ~~convert-png-to-jpg (color + slider)~~ → переведён в `registry-new` ✔
9. ~~convert-png-to-webp (slider)~~ → переведён в `registry-new` ✔
10. ~~remove-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
11. ~~extract-color-from-png (color + slider)~~ → переведён в `registry-new` ✔
12. ~~add-noise-png (slider + select + number)~~ → переведён в `registry-new` ✔
13. ~~randomize-pixels-png (slider + number)~~ → переведён в `registry-new` ✔
14. ~~pixelate-png (slider)~~ → переведён в `registry-new` ✔
15. ~~vignette-png (slider)~~ → переведён в `registry-new` ✔
16. ~~gamma-png (slider)~~ → переведён в `registry-new` ✔
17. ~~temperature-png (slider)~~ → переведён в `registry-new` ✔
18. ~~tint-png (color + slider)~~ → переведён в `registry-new` ✔
19. ~~quantize-png (slider)~~ → переведён в `registry-new` ✔
20. ~~custom-palette-png (text)~~ → переведён в `registry-new` ✔
21. ~~dithering-png (slider + select)~~ → переведён в `registry-new` ✔
22. ~~jpeg-artifacts-png (slider)~~ → переведён в `registry-new` ✔
> **Замечание (конвертеры):** старые `convert-png-to-jpg`/`convert-png-to-webp`
> несли `output`-метаданные (`mime`/`ext`/`qualityParamId`) для выбора формата
> вывода. В новом `ToolEntry<P>` поля `output` пока нет, поэтому при переводе
> эти метаданные не перенесены — preview пока отдаёт результат как PNG.
> Механика выбора формата/качества в новом UI — отдельный шаг (не блокирует Фазу 2).
### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам) ### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам)
24. `dimension` — ввести составной тип + перевести **один** пилот 24. `dimension` — **переведены все 12 инструментов** ✔ (`create-empty-png`,
(create-empty-png), ревью, затем остальные 11 по 1: `single-color-png`, `random-noise-png`, `linear-gradient-png`,
create-empty → single-color → random-noise → fit-on-background → `color-spectrum-png`, `random-colors-png`, `draw-grid-png`,
change-canvas-size → placeholder → draw-grid → random-colors → `placeholder-png` — генераторы в `registry-new/generate.ts`;
color-spectrum → linear-gradient → resize → crop `fit-on-background-png`, `change-canvas-size-png`, `resize-png`,
25. `color-pair` — пилот (blend-two-png), затем two-colors → step-colors → `crop-png` — в `registry-new/geometry.ts`). Составной тип во всех видах:
linear-gradient (уже переведён в dimension, здесь добавляется к нему) вложенный объект `size: { width, height }` в Params + `field.dimension`,
26. `offset` — пилот (circle-mask-png), затем square-mask → star-mask → виджет `kit/fields/DimensionField.svelte`.
wavy-mask Preview: генераторы применяются через `executeGenerate` (кнопка Generate),
`SchemaFields` рендерит все kinds схемы (slider/number/color/select/
checkbox/dimension). Тесты: 592 passed.
25. `color-pair` — **все 4 инструмента переведены** ✔ (`blend-two-png`,
`step-colors-png`, `linear-gradient-png` — генераторы в
`registry-new/generate.ts`, `two-colors-png` — run в `registry-new/color.ts`).
Составной тип во всех видах: вложенный объект `pair: { from, to }` +
`field.colorPair`, виджет `kit/fields/schema/ColorPairControl.svelte`,
kind `color-pair` в схеме (default/sanitize). Тесты: +4 (596 passed).
26. `offset` — **все 4 инструмента переведены** ✔ (`circle-mask-png`,
`square-mask-png`, `star-mask-png`, `wavy-mask-png` — run в
`registry-new/alpha.ts`). Составной тип во всех видах: вложенный объект
`offset: { x, y }` + `field.offset`, виджет
`kit/fields/schema/OffsetControl.svelte`, kind `offset` в схеме
(default/sanitize). Тесты: +4 (600 passed).
27. `position9` — перевести add-text → date-stamp → watermark-image 27. `position9` — перевести add-text → date-stamp → watermark-image
28. `font-style` — перевести text-to-png → add-text (если ещё не) → 28. `font-style` — перевести text-to-png → add-text (если ещё не) →
date-stamp date-stamp
@@ -335,9 +415,8 @@ Typed field builders + `Field<T>` + `toolSchema<P>()` + `ToolSchema<P>` —
- **Старый UI** (`ParamForm.svelte`, группа `(old)/`) — **не трогаем.** Работает - **Старый UI** (`ParamForm.svelte`, группа `(old)/`) — **не трогаем.** Работает
на `ParamDef[]` как раньше. на `ParamDef[]` как раньше.
- **Новый UI** (kit/`ParamControl.svelte`, новый pipeline) — читает - **Новый UI** (kit/`SchemaToolView` + `SchemaFields` + `SchemaPreview`, читает
`ToolSchema<P>`; поддержка составных типов и пер-инструмент layout `ToolSchema<P>`) — сейчас рендерит поля по схеме (number/slider/color).
(`schema.layout`).
- `tool-views.ts` — со временем поглощается registry (preview/lede/layout → - `tool-views.ts` — со временем поглощается registry (preview/lede/layout →
meta инструмента). Отдельный шаг, НЕ блокирует типизацию params. meta инструмента). Отдельный шаг, НЕ блокирует типизацию params.
@@ -1,67 +0,0 @@
<script lang="ts">
import ColorField from "$lib/components/kit/fields/ColorField.svelte";
import NumberField from "$lib/components/kit/fields/NumberField.svelte";
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
import SliderField from "$lib/components/kit/fields/SliderField.svelte";
import TextField from "$lib/components/kit/fields/TextField.svelte";
import ToggleRow from "$lib/components/kit/fields/ToggleRow.svelte";
import type { ParamDef } from "$lib/registry";
type ParamValue = string | number | boolean;
interface Props {
param: ParamDef;
value: ParamValue;
onInput: (value: ParamValue) => void;
}
let { param, value, onInput }: Props = $props();
// TODO: investigate - may be better move param fields switch to field component?
</script>
{#if param.type === "color"}
<ColorField
label={param.label}
value={value as string}
oninput={(v) => onInput(v)}
/>
{:else if param.type === "slider"}
<SliderField
label={param.label}
value={value as number}
min={param.min}
max={param.max}
step={param.step}
oninput={(v) => onInput(v)}
/>
{:else if param.type === "select"}
<SelectField
label={param.label}
value={value as string}
options={param.options}
onchange={(v) => onInput(v)}
/>
{:else if param.type === "checkbox"}
<ToggleRow
label={param.label}
checked={value as boolean}
onchange={(v) => onInput(v)}
/>
{:else if param.type === "text"}
<TextField
label={param.label}
value={value as string}
placeholder={param.placeholder}
oninput={(v) => onInput(v)}
/>
{:else if param.type === "number"}
<NumberField
label={param.label}
value={value as number}
min={param.min}
max={param.max}
step={param.step}
oninput={(v) => onInput(v)}
/>
{/if}
@@ -0,0 +1,99 @@
<script lang="ts">
import type {
FieldSpec,
FieldSpecKind,
ToolSchema,
} from "$lib/registry-schema";
import { RotateCcw } from "@lucide/svelte";
import type { Component } from "svelte";
import DimensionField from "./fields/DimensionField.svelte";
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
import ColorControl from "./fields/schema/ColorControl.svelte";
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
import RangeControl from "./fields/schema/RangeControl.svelte";
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
import OffsetControl from "./fields/schema/OffsetControl.svelte";
interface FieldControlProps {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: unknown) => void;
}
const FIELDS: Record<FieldSpecKind, Component<FieldControlProps>> = {
number: RangeControl,
slider: RangeControl,
color: ColorControl,
"color-pair": ColorPairControl,
select: SelectControl,
checkbox: CheckboxControl,
text: TextControl,
dimension: DimensionField,
offset: OffsetControl,
};
interface Props {
schema: ToolSchema<Record<string, unknown>>;
values: Record<string, unknown>;
onchange: (id: string, value: unknown) => void;
onreset: () => void;
}
let { schema, values, onchange, onreset }: Props = $props();
function labelOf(id: string): string {
return id
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[_-]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
</script>
{#each Object.entries(schema.fields) as [id, field] (id)}
{@const Control = FIELDS[field.spec.kind]}
<Control
label={labelOf(id)}
value={values[id]}
spec={field.spec}
onchange={(v) => onchange(id, v)}
/>
{/each}
<div class="panel-foot">
<button class="reset-btn" onclick={onreset}>
<RotateCcw size={14} /> Reset
</button>
<span class="auto-note"><i></i> updates automatically</span>
</div>
<style>
.panel-foot {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
margin-top: var(--space-xl);
padding-top: var(--space-l);
border-top: var(--size-border) solid var(--color-border);
}
.reset-btn {
display: inline-flex;
align-items: center;
gap: var(--space-m);
background: none;
border: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.reset-btn:hover {
color: var(--color-text);
}
.auto-note i {
width: var(--size-border-thick);
height: var(--size-border-thick);
border-radius: 50%;
background: var(--color-success);
}
</style>
@@ -0,0 +1,213 @@
<script lang="ts">
import MetaList from "$lib/components/kit/MetaList.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 {
source: PixelImage | null;
result: PixelImage | null;
running: boolean;
error: string;
isGenerator?: boolean;
onupload: (file: File) => void;
ongenerate?: () => void;
ondownload: () => void;
}
let {
source,
result,
running,
error,
isGenerator = false,
onupload,
ongenerate,
ondownload,
}: Props = $props();
let sourceUrl = $derived(source ? toDataUrl(source) : null);
let resultUrl = $derived(result ? toDataUrl(result) : null);
</script>
<div class="panel-head">
<span class="label"
>{isGenerator ? "GENERATOR / RESULT" : "SOURCE / RESULT"}</span
>
<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">
<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} />
</div>
</div>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
<div class="pair">
{#if !isGenerator}
<figure class="tile">
<figcaption><span>SOURCE</span></figcaption>
<div class="canvas">
{#if sourceUrl}
<img src={sourceUrl} alt="source" />
{:else}
<span class="empty">choose an image</span>
{/if}
</div>
</figure>
{/if}
<figure class="tile" class:full={isGenerator}>
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
<div class="canvas checker">
{#if resultUrl}
<img src={resultUrl} alt="result" />
{:else if running}
<Check size={22} />
{:else}
<span class="empty">
{isGenerator ? "click Generate" : "no result yet"}
</span>
{/if}
</div>
</figure>
</div>
<div class="meta">
<MetaList
items={[
{
caption: "SOURCE",
value: source ? `${source.width} × ${source.height} px` : "—",
},
{
caption: "RESULT",
value: result ? `${result.width} × ${result.height} px` : "—",
},
{ caption: "FORMAT", value: "PNG" },
]}
/>
</div>
<style>
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.label {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-main);
}
.head-actions {
display: flex;
align-items: center;
gap: var(--space-l);
flex-wrap: wrap;
}
.upload {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
cursor: pointer;
}
.generate-btn {
background: var(--color-main);
border-color: var(--color-main);
color: var(--color-background);
}
.generate-btn:disabled {
opacity: 0.6;
cursor: default;
}
.upload input {
display: none;
}
.error {
margin: 0 0 var(--space-l);
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-l);
}
.pair > .full {
grid-column: 1 / -1;
}
.tile figcaption {
display: flex;
align-items: center;
gap: var(--space-m);
margin-bottom: var(--space-m);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
}
.canvas {
display: flex;
align-items: center;
justify-content: center;
min-height: clamp(var(--space-brand), 30vh, 60vh);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
overflow: hidden;
background: var(--color-background-muted);
color: var(--color-text-muted);
}
.canvas img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.canvas.checker {
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 28px 28px;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.meta {
margin-top: var(--space-l);
}
@media (max-width: var(--bp-tablet)) {
.pair {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,240 @@
<script lang="ts">
import SchemaFields from "$lib/components/kit/SchemaFields.svelte";
import SchemaPreview from "$lib/components/kit/SchemaPreview.svelte";
import { debounce } from "$lib/core/debounce";
import { decodeFile, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { executeGenerate, executeStep } from "$lib/preview/executor";
import type { ToolEntry } from "$lib/registry-new";
import {
defaultSchemaParams,
sanitizeSchemaParams,
type ToolSchema,
} from "$lib/registry-schema";
interface Props {
tool: ToolEntry;
}
let { tool }: Props = $props();
const schema = $derived(
tool.schema as ToolSchema<Record<string, unknown>> | undefined,
);
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
let values = $state<Record<string, unknown>>({});
let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null);
let running = $state(false);
let error = $state("");
let started = $state(false);
const debouncedRun = debounce(() => run(), 200);
function init() {
if (!schema || started) return;
started = true;
values = defaultSchemaParams(schema);
}
function setValue(id: string, value: unknown) {
values = { ...values, [id]: value };
}
function reset() {
values = schema ? defaultSchemaParams(schema) : {};
result = null;
error = "";
}
async function handleFile(file: File) {
error = "";
try {
source = await decodeFile(file);
} catch (e) {
error = errorText(e);
}
}
async function run() {
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 executeGenerate(tool, params);
} catch (e) {
error = errorText(e);
} finally {
running = false;
}
}
async function download() {
if (!result) return;
const blob = await encode(result, "image/png");
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "with-border.png";
a.click();
URL.revokeObjectURL(url);
}
$effect(() => {
init();
});
$effect(() => {
if (isGenerator || !source || !started) return;
void values;
debouncedRun();
return () => debouncedRun.cancel();
});
function errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
</script>
{#if schema}
<div class="schema-tool">
<header class="header">
<div class="title-block">
<span class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</span>
<h1>{tool.title}</h1>
<p class="lede">{tool.description}</p>
</div>
<span class="status"><i></i> LIVE PREVIEW</span>
</header>
<div class="workspace">
<section class="panel settings">
<div class="panel-head">
<span class="label">TOOL SETTINGS</span>
<strong>Configure output</strong>
</div>
<SchemaFields {schema} {values} onchange={setValue} onreset={reset} />
</section>
<section class="panel">
<SchemaPreview
{source}
{result}
{running}
{error}
{isGenerator}
onupload={handleFile}
ongenerate={runGenerate}
ondownload={download}
/>
</section>
</div>
</div>
{:else}
<p class="no-schema">This tool has no schema yet.</p>
{/if}
<style>
.schema-tool {
padding: calc(var(--space-xxxl) + var(--space-l))
clamp(var(--space-m), 4vw, var(--space-xxxl));
}
.header {
display: flex;
justify-content: space-between;
gap: var(--space-l);
margin-bottom: var(--space-xxxl);
}
.title-block h1 {
margin: var(--space-s) 0 0;
font-size: clamp(var(--font-size-xl), 4vw, var(--font-size-2xl));
line-height: 1.1;
color: var(--color-text);
}
.eyebrow,
.label,
.status {
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
color: var(--color-text-muted);
}
.eyebrow span,
.label {
color: var(--color-main);
}
.lede {
max-width: 100%;
margin: var(--space-m) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-s);
line-height: 1.5;
}
.status {
display: inline-flex;
align-items: center;
gap: var(--space-m);
align-self: flex-start;
white-space: nowrap;
}
.status i {
width: var(--size-border-thick);
height: var(--size-border-thick);
border-radius: 50%;
background: var(--color-success);
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(0, 2fr);
gap: var(--space-xxl);
align-items: start;
}
.panel {
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
padding: var(--space-xl);
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--space-m);
padding-bottom: var(--space-l);
border-bottom: var(--size-border) solid var(--color-border);
margin-bottom: var(--space-xl);
}
.panel-head strong {
font-size: var(--font-size-l);
color: var(--color-text);
}
.no-schema {
font: var(--font-size-s) var(--font-mono);
}
@media (max-width: var(--bp-tablet)) {
.workspace {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,101 +0,0 @@
<script lang="ts">
import EmptyState from "$lib/components/kit/EmptyState.svelte";
import SelectField from "$lib/components/kit/fields/SelectField.svelte";
import Panel from "$lib/components/kit/layout/Panel.svelte";
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import StepCard from "$lib/components/kit/StepCard.svelte";
import Button from "$lib/components/kit/ui/Button.svelte";
import { getTool } from "$lib/registry";
import ParamControl from "./ParamControl.svelte";
type ParamValue = string | number | boolean;
interface ChainStep {
toolId: string;
params: Record<string, ParamValue>;
}
interface Props {
steps: ChainStep[];
chainOptions: { value: string; label: string }[];
addId: string;
onAddStep: () => void;
onSetAddId: (value: string) => void;
onRemoveStep: (index: number) => void;
onSetParam: (index: number, id: string, value: ParamValue) => void;
}
let {
steps,
chainOptions,
addId,
onAddStep,
onSetAddId,
onRemoveStep,
onSetParam,
}: Props = $props();
</script>
<Panel title="Pipeline" eyebrow="STEPS">
<div class="pipeline-body">
{#if steps.length === 0}
<EmptyState title="Pipeline empty" description="Add a tool step below." />
{/if}
{#each steps as step, i (step.toolId + i)}
{@const st = getTool(step.toolId)}
<StepCard
index={i + 1}
title={st?.title ?? step.toolId}
type={st?.category}
onremove={() => onRemoveStep(i)}
>
{#if st}
<div class="step-controls">
{#each st.params as p (p.id)}
<ParamControl
param={p}
value={step.params[p.id]}
onInput={(v) => onSetParam(i, p.id, v)}
/>
{/each}
</div>
{/if}
</StepCard>
{/each}
</div>
<SettingsFooter>
<div class="add-step">
<SelectField
label="Add step"
value={addId}
options={chainOptions}
onchange={(v) => onSetAddId(v)}
/>
<Button variant="outline" onclick={onAddStep} label="Add step" />
</div>
</SettingsFooter>
</Panel>
<style>
.pipeline-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0.75rem;
}
.step-controls {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.add-step {
display: flex;
align-items: flex-end;
gap: 0.75rem;
width: 100%;
}
.add-step :global(.select-field),
.add-step :global(select) {
min-width: 0;
}
</style>
-326
View File
@@ -1,326 +0,0 @@
<script lang="ts">
import type { ToolEntry } from "$lib/registry";
import {
getPreviewView,
initForm,
type FieldValue,
type PreviewToolView,
type SegmentedField,
} from "$lib/preview/tool-views";
import {
Copy,
Download,
Check,
RotateCcw,
ChevronDown,
} from "@lucide/svelte";
interface Props {
tool: ToolEntry;
}
let { tool }: Props = $props();
const view = $derived(getPreviewView(tool));
let form = $state<Record<string, FieldValue>>(view ? initForm(view) : {});
$effect(() => {
if (view) form = initForm(view);
});
function reset() {
if (view) form = initForm(view);
}
const num = (v: FieldValue) => v as number;
const str = (v: FieldValue) => v as string;
const bool = (v: FieldValue) => v as boolean;
const pair = (v: FieldValue) => v as { from: string; to: string };
const grad = $derived.by(() => {
if (!view || view.preview !== "gradient") return null;
const typeSel = num(form["type"]);
const type = (view.fields.find((f) => f.id === "type") as SegmentedField)
.options[typeSel];
const cp = pair(form["stops"]);
const dir = num(form["dir"]);
const op = num(form["op"]);
const css =
type === "Radial"
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
: `linear-gradient(${dir}deg, ${cp.from} 0%, ${cp.to} 100%)`;
return {
type,
cp,
dir,
op,
css,
code: `background: ${css};\nopacity: ${op / 100};`,
};
});
const DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
const SUBJECT_BG =
"linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
const SUBJECT_CLIP =
"polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
const CHECKER =
"repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
</script>
{#if view}
<div class="tool-page">
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
<div class="tool-title">
<div>
<h1>{view.title}</h1>
<p class="lede">{view.lede}</p>
</div>
<span class="tool-status"><i></i> LIVE PREVIEW</span>
</div>
<div class={view.layoutClass}>
<section class="settings-panel">
<div class="panel-heading">
<div>
<span class="label">{view.panelLabel}</span>
<strong>{view.panelStrong}</strong>
</div>
<span class="step-type">{view.stepType}</span>
</div>
{#each view.fields as f}
{#if f.kind === "segmented"}
{@const sel = num(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="segmented wide">
{#each f.options as opt, i}
<button
class={i === sel ? "selected" : ""}
onclick={() => (form[f.id] = i)}
>
{opt}
</button>
{/each}
</div>
</div>
{:else if f.kind === "color-pair"}
{@const cp = pair(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="color-row">
<div class="color-field">
<span class="swatch" style="background:{cp.from}"></span>
<input
value={cp.from}
oninput={(e) =>
(form[f.id] = { from: e.currentTarget.value, to: cp.to })}
/>
</div>
<span class="stop-arrow">→</span>
<div class="color-field">
<span class="swatch" style="background:{cp.to}"></span>
<input
value={cp.to}
oninput={(e) =>
(form[f.id] = {
from: cp.from,
to: e.currentTarget.value,
})}
/>
</div>
</div>
<div class="gradient-bar" style="background:{grad?.css}"></div>
</div>
{:else if f.kind === "slider" || f.kind === "direction"}
{@const v = num(form[f.id])}
<div class="setting-group">
<label
>{f.label}
<output>{v}{f.space ? " " : ""}{f.suffix}</output></label
>
<input
type="range"
min={f.min}
max={f.max}
value={v}
oninput={(e) => (form[f.id] = Number(e.currentTarget.value))}
/>
{#if f.kind === "direction"}
<div class="direction-grid">
{#each DIRS as a, i}
<button
class={a === v ? "selected" : ""}
onclick={() => (form[f.id] = a)}
>
{a}° {DIR_GLYPHS[i]}
</button>
{/each}
</div>
{:else if f.hints}
<div class="range-hints">
<span>{f.hints[0]}</span>
<span>{f.hints[1]}</span>
</div>
{/if}
</div>
{:else if f.kind === "color"}
{@const v = str(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="color-field">
<span class="swatch" style="background:{v}"></span>
<input
value={v}
oninput={(e) => (form[f.id] = e.currentTarget.value)}
/>
{#if f.native}
<input
class="native-color"
type="color"
aria-label="Choose background color"
value={v}
oninput={(e) => (form[f.id] = e.currentTarget.value)}
/>
{/if}
</div>
{#if f.reference}
<div class="color-reference">
<span style="background:{v}"></span> sampled from image background
</div>
{/if}
</div>
{:else if f.kind === "toggle"}
{@const on = bool(form[f.id])}
<div class="setting-group toggle-group">
<label>
<span>{f.label}</span>
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}
></b>
</label>
<p>{f.note}</p>
</div>
{/if}
{/each}
<div class="settings-footer">
<button class="reset-btn" onclick={reset}>
<RotateCcw size={14} /> Reset
</button>
<span class="auto-note"><i></i> updates automatically</span>
</div>
</section>
{#if view.preview === "gradient"}
<section class={view.previewClass}>
<div class="preview-toolbar">
<div>
<span class="label">{view.toolbarLabel}</span>
<strong>{view.fileName}</strong>
</div>
<div class="preview-actions">
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
<button class="download-btn"
><Download size={15} />
{view.downloadLabel}
<ChevronDown size={14} /></button
>
</div>
</div>
<div class="large-canvas">
<div
class="gradient-art"
style="background:{grad?.css};opacity:{grad ? grad.op / 100 : 1}"
>
<div class="art-mark">PNG</div>
<span>easy-png-tools</span>
</div>
</div>
<div class="code-block">
<div>
<span class="label">GENERATED CSS</span>
<button class="icon-btn" aria-label="Copy CSS"
><Copy size={14} /></button
>
</div>
<pre>{grad?.code}</pre>
</div>
</section>
{:else}
<section class={view.previewClass}>
<div class="preview-toolbar">
<div>
<span class="label">{view.toolbarLabel}</span>
<strong>{view.fileName}</strong>
</div>
<div class="preview-actions">
<span class="processed"><Check size={14} /> processed</span>
<button class="download-btn"
><Download size={15} /> Download result</button
>
</div>
</div>
<div class="comparison-grid">
<div class="image-card">
<div class="image-label">
<span>SOURCE</span><b>original.png</b>
</div>
<div class="remover-canvas source-canvas">
<div
class="subject subject-source"
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
>
<span>OBJECT</span>
</div>
<span class="canvas-size">1200 × 800</span>
</div>
</div>
<div class="image-card">
<div class="image-label">
<span>RESULT</span><b>removed-bg.png</b>
</div>
<div class="remover-canvas" style="background:{CHECKER}">
<div
class="subject"
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
>
<span>PNG</span>
</div>
<span class="canvas-size">1200 × 800</span>
</div>
</div>
</div>
<div class="result-meta">
<span>FORMAT <b>PNG-24</b></span>
<span>ALPHA <b>ENABLED</b></span>
<span>SIMILARITY <b>72 %</b></span>
</div>
</section>
{/if}
</div>
</div>
{:else}
<div class="tool-page">
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
<div class="tool-title">
<div>
<h1>{tool.title}</h1>
<p class="lede">{tool.description}</p>
</div>
<span class="tool-status"><i></i> LIVE PREVIEW</span>
</div>
<div class="tool-layout">
<section class="settings-panel">
<div class="panel-heading">
<div>
<span class="label">{tool.category.toUpperCase()} SETTINGS</span>
<strong>Configure output</strong>
</div>
<span class="step-type">TOOL 01</span>
</div>
<div class="settings-footer">
<span class="auto-note"><i></i> preview coming soon</span>
</div>
</section>
</div>
</div>
{/if}
@@ -0,0 +1,97 @@
<script lang="ts">
import type {
Dimension,
DimensionSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Dimension) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as DimensionSpec);
const current = $derived.by(() => {
const v = value as Partial<Dimension> | undefined;
return {
width:
typeof v?.width === "number" && Number.isFinite(v.width)
? v.width
: sp.width,
height:
typeof v?.height === "number" && Number.isFinite(v.height)
? v.height
: sp.height,
};
});
function setAxis(axis: "width" | "height", n: number) {
onchange?.({ ...current, [axis]: n });
}
</script>
<div class="control dimension-control">
<span class="dimension-label">{label}</span>
<div class="dimension-field">
<label class="dimension-axis">
<span>Width</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.width}
oninput={(e) =>
setAxis("width", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="dimension-axis">
<span>Height</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.height}
oninput={(e) =>
setAxis("height", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.dimension-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.dimension-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.dimension-axis > span {
display: block;
margin-bottom: var(--space-m);
}
.dimension-axis input {
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);
}
</style>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { CheckboxSpec, FieldSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: boolean) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as CheckboxSpec);
const current = $derived(
typeof value === "boolean" ? value : Boolean(sp.default),
);
</script>
<label class="control checkbox-control">
<span>{label}</span>
<input
type="checkbox"
class="checkbox-field"
checked={current}
onchange={(e) => onchange?.((e.target as HTMLInputElement).checked)}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.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);
}
</style>
@@ -0,0 +1,59 @@
<script lang="ts">
import type { ColorSpec, FieldSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorSpec);
const current = $derived(typeof value === "string" ? value : sp.default);
</script>
<label class="control color-control">
<span>{label}</span>
<span class="color-row">
<span class="swatch" style="background:{current}"></span>
<input
type="color"
value={current}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</span>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.color-control > span:first-child {
justify-content: flex-start;
}
.color-row {
display: flex;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
</style>
@@ -0,0 +1,97 @@
<script lang="ts">
import type {
ColorPair,
ColorPairSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: ColorPair) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorPairSpec);
const current = $derived.by(() => {
const v = value as Partial<ColorPair> | undefined;
return {
from: typeof v?.from === "string" ? v.from : sp.from,
to: typeof v?.to === "string" ? v.to : sp.to,
};
});
function setColor(axis: "from" | "to", hex: string) {
onchange?.({ ...current, [axis]: hex });
}
</script>
<div class="control color-pair-control">
<span class="pair-label">{label}</span>
<div class="pair-field">
<label class="pair-axis">
<span class="swatch" style="background:{current.from}"></span>
<input
type="color"
value={current.from}
oninput={(e) => setColor("from", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">From</span>
</label>
<label class="pair-axis">
<span class="swatch" style="background:{current.to}"></span>
<input
type="color"
value={current.to}
oninput={(e) => setColor("to", (e.target as HTMLInputElement).value)}
/>
<span class="axis-label">To</span>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.pair-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.pair-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.pair-axis {
display: grid;
grid-template-columns: var(--space-xxl) 1fr;
align-items: center;
gap: var(--space-m);
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.pair-axis input[type="color"] {
width: 100%;
height: var(--space-xxl);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: var(--color-background);
cursor: pointer;
}
.axis-label {
grid-column: 1 / -1;
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import type { FieldSpec, Offset, OffsetSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: Offset) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as OffsetSpec);
const current = $derived.by(() => {
const v = value as Partial<Offset> | undefined;
return {
x: typeof v?.x === "number" && Number.isFinite(v.x) ? v.x : sp.x,
y: typeof v?.y === "number" && Number.isFinite(v.y) ? v.y : sp.y,
};
});
function setAxis(axis: "x" | "y", n: number) {
onchange?.({ ...current, [axis]: n });
}
</script>
<div class="control offset-control">
<span class="offset-label">{label}</span>
<div class="offset-field">
<label class="offset-axis">
<span>X</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.x}
oninput={(e) =>
setAxis("x", Number((e.target as HTMLInputElement).value))}
/>
</label>
<label class="offset-axis">
<span>Y</span>
<input
type="number"
min={sp.min}
max={sp.max}
value={current.y}
oninput={(e) =>
setAxis("y", Number((e.target as HTMLInputElement).value))}
/>
</label>
</div>
</div>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.offset-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.offset-field {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
}
.offset-axis > span {
display: block;
margin-bottom: var(--space-m);
}
.offset-axis input {
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);
}
</style>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { FieldSpec, NumberSpec, SliderSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: number) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as NumberSpec | SliderSpec);
const current = $derived(
typeof value === "number" && Number.isFinite(value) ? value : sp.default,
);
function handle(e: Event) {
onchange?.(Number((e.target as HTMLInputElement).value));
}
</script>
<label class="control">
<span>
{label}
<output>{current}</output>
</span>
<input
type="range"
min={sp.min ?? 0}
max={sp.max ?? 100}
step={sp.step ?? 1}
value={current}
oninput={handle}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.control > span {
display: flex;
justify-content: space-between;
}
.control output {
color: var(--color-text);
}
input[type="range"] {
width: 100%;
accent-color: var(--color-main);
color: var(--color-text);
}
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import type { FieldSpec, SelectSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as SelectSpec);
const current = $derived(
typeof value === "string" && sp.options.some((o) => o.value === value)
? value
: sp.default,
);
</script>
<label class="control">
<span>{label}</span>
<select
class="select-field"
value={current}
oninput={(e) => onchange?.((e.target as HTMLSelectElement).value)}
>
{#each sp.options as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.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);
}
</style>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { FieldSpec, TextSpec } from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: string) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as TextSpec);
const current = $derived(typeof value === "string" ? value : sp.default);
</script>
<label class="control">
<span>{label}</span>
<input
class="text-field"
type="text"
value={current}
placeholder={sp.placeholder}
oninput={(e) => onchange?.((e.target as HTMLInputElement).value)}
/>
</label>
<style>
.control {
display: grid;
gap: var(--space-m);
margin-bottom: var(--space-xl);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-m);
}
.text-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);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
export function debounce(
fn: () => void,
ms: number,
): { (): void; cancel: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined;
const debounced = () => {
if (timer !== undefined) clearTimeout(timer);
timer = setTimeout(() => fn(), ms);
};
debounced.cancel = () => {
if (timer !== undefined) clearTimeout(timer);
};
return debounced;
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { CATEGORIES } from "../categories"; import { CATEGORY_IDS } from "./categories";
import { TOOLS, type ToolEntry } from "../registry"; import { TOOLS, type ToolEntry } from "../registry-new";
export type PreviewGroup = { export type PreviewGroup = {
id: string; id: string;
@@ -23,7 +23,7 @@ const GROUP_LABELS: Record<string, string> = {
* Preview-каталог: полный реестр инструментов, сгруппированный по категориям. * Preview-каталог: полный реестр инструментов, сгруппированный по категориям.
* Больше не урезаем до референс-набора — показываем все инструменты из TOOLS. * Больше не урезаем до референс-набора — показываем все инструменты из TOOLS.
*/ */
export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORIES.map((category) => ({ export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({
id: category, id: category,
label: GROUP_LABELS[category] ?? category.toUpperCase(), label: GROUP_LABELS[category] ?? category.toUpperCase(),
tools: TOOLS.filter((tool) => tool.category === category), tools: TOOLS.filter((tool) => tool.category === category),
+26
View File
@@ -0,0 +1,26 @@
/**
* Категории инструментов для нового registry/preview.
*
* Единая точка правды: тип `CategoryId` и порядок отображения в каталоге
* выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться.
* Этот файл — локальная копия подхода для нового UI; старый `../categories`
* (массив + union вручную) не трогаем — он обслуживает старый UI в `(old)/`.
*
* Человекочитаемые названия живут в словарях i18n: секция categories,
* ключ = CategoryId.
*/
export const CATEGORIES = {
convert: "convert",
alpha: "alpha",
color: "color",
geometry: "geometry",
filters: "filters",
text: "text",
analyze: "analyze",
generate: "generate",
} as const;
export type CategoryId = (typeof CATEGORIES)[keyof typeof CATEGORIES];
/** Порядок категорий в каталоге (как задано в `CATEGORIES`). */
export const CATEGORY_IDS = Object.keys(CATEGORIES) as CategoryId[];
+154
View File
@@ -0,0 +1,154 @@
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
type MaybeRunnable = {
id: string;
domOnly?: boolean;
run?: (
img: PixelImage,
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
generate?: (
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
};
export async function executeStep(
tool: MaybeRunnable,
img: PixelImage,
params: Record<string, unknown>,
): Promise<PixelImage> {
if (!tool.run) {
throw new Error("errors.noImageRun");
}
if (tool.domOnly) {
return await runDirect(tool, img, params);
}
if (typeof Worker === "undefined") {
return await runDirect(tool, img, params);
}
const worker = ensureWorker();
if (worker === null) {
return await runDirect(tool, img, params);
}
try {
return await runInWorker(worker, tool.id, img, params);
} catch (workerError) {
disableWorker();
void workerError;
return await runDirect(tool, img, params);
}
}
/**
* Применение инструмента-генератора (без входного изображения).
* Выполняется напрямую: 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(
tool: MaybeRunnable,
img: PixelImage,
params: Record<string, unknown>,
): Promise<PixelImage> {
if (!tool.run) {
throw new Error("errors.noImageRun");
}
return await tool.run(img, params);
}
let worker: Worker | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (img: PixelImage) => void; reject: (e: Error) => void }
>();
function ensureWorker(): Worker | null {
if (workerTried) return worker;
workerTried = true;
try {
const candidate = new Worker(
new URL("./executor.worker.ts", import.meta.url),
{
type: "module",
},
);
candidate.onmessage = (event: MessageEvent) => {
const payload = event.data as {
id: number;
ok: boolean;
width?: number;
height?: number;
data?: Uint8ClampedArray;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
const entry = pending.get(payload.id);
if (!entry) return;
pending.delete(payload.id);
if (payload.ok && payload.data && payload.width && payload.height) {
entry.resolve({
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data),
});
} else if (payload.errorKey) {
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
} else {
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
}
};
candidate.onerror = () => {
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error("errors.workerUnavailable"));
}
pending.clear();
};
worker = candidate;
} catch {
worker = null;
}
return worker;
}
function disableWorker(): void {
if (worker) {
worker.terminate();
}
worker = null;
}
function runInWorker(
workerInstance: Worker,
toolId: string,
img: PixelImage,
params: Record<string, unknown>,
): Promise<PixelImage> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
workerInstance.postMessage({
id,
toolId,
image: {
width: img.width,
height: img.height,
data: new Uint8ClampedArray(img.data),
},
params,
});
});
}
@@ -0,0 +1,51 @@
/// <reference lib="webworker" />
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import { getTool } from "$lib/registry-new";
import { sanitizeSchemaParams } from "$lib/registry-schema";
type WorkerRequest = {
id: number;
toolId: string;
image: { width: number; height: number; data: Uint8ClampedArray };
params: Record<string, unknown>;
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new Error("errors.noImageRun");
}
const image: PixelImage = {
width: request.image.width,
height: request.image.height,
data: new Uint8ClampedArray(request.image.data),
};
const output = await tool.run(
image,
sanitizeSchemaParams(tool.schema, request.params),
);
const payload = {
id: request.id,
ok: true,
width: output.width,
height: output.height,
data: output.data,
};
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
} catch (e) {
const toolError = e instanceof ToolError ? e : undefined;
(self as unknown as Worker).postMessage({
id: request.id,
ok: false,
error: e instanceof Error ? e.message : String(e),
errorKey: toolError?.key,
errorVars: toolError?.vars,
});
}
}
+1
View File
@@ -0,0 +1 @@
export { executeGenerate, executeStep } from "./executor";
-193
View File
@@ -1,193 +0,0 @@
import type { ToolEntry } from "$lib/registry";
export type SegmentedField = {
id: string;
kind: "segmented";
label: string;
options: string[];
selected: number;
};
export type ColorPairField = {
id: string;
kind: "color-pair";
label: string;
from: string;
to: string;
};
export type SliderField = {
id: string;
kind: "slider" | "direction";
label: string;
value: number;
min: number;
max: number;
suffix: string;
space?: boolean;
hints?: [string, string];
};
export type ColorField = {
id: string;
kind: "color";
label: string;
value: string;
native?: boolean;
reference?: boolean;
};
export type ToggleField = {
id: string;
kind: "toggle";
label: string;
checked: boolean;
note: string;
};
export type FieldDef =
SegmentedField | ColorPairField | SliderField | ColorField | ToggleField;
export interface PreviewToolView {
title: string;
lede: string;
panelLabel: string;
panelStrong: string;
stepType: string;
toolbarLabel: string;
fileName: string;
downloadLabel: string;
layoutClass: string;
previewClass: string;
preview: "gradient" | "comparison";
fields: FieldDef[];
}
export type FieldValue =
number | string | boolean | { from: string; to: string };
function initValue(f: FieldDef): FieldValue {
switch (f.kind) {
case "segmented":
return f.selected;
case "color-pair":
return { from: f.from, to: f.to };
case "color":
return f.value;
case "slider":
case "direction":
return f.value;
case "toggle":
return f.checked;
}
}
export function initForm(view: PreviewToolView): Record<string, FieldValue> {
const form: Record<string, FieldValue> = {};
for (const f of view.fields) form[f.id] = initValue(f);
return form;
}
const gradient: PreviewToolView = {
title: "Gradient background.",
lede: "Create a clean, export-ready gradient with precise control over color, direction and transparency.",
panelLabel: "GRADIENT SETTINGS",
panelStrong: "Configure output",
stepType: "TOOL 01",
toolbarLabel: "OUTPUT PREVIEW",
fileName: "gradient.png",
downloadLabel: "Download PNG",
layoutClass: "gradient-layout",
previewClass: "gradient-preview",
preview: "gradient",
fields: [
{
id: "type",
kind: "segmented",
label: "GRADIENT TYPE",
options: ["Linear", "Radial"],
selected: 0,
},
{
id: "stops",
kind: "color-pair",
label: "COLOR STOPS",
from: "#1769D2",
to: "#00A8C7",
},
{
id: "dir",
kind: "direction",
label: "DIRECTION",
value: 135,
min: 0,
max: 360,
suffix: "°",
space: true,
},
{
id: "op",
kind: "slider",
label: "OPACITY",
value: 100,
min: 0,
max: 100,
suffix: "%",
space: true,
},
],
};
const background: PreviewToolView = {
title: "Remove background.",
lede: "Select a background color and tune the edge detection. Changes are processed automatically in your browser.",
panelLabel: "REMOVER SETTINGS",
panelStrong: "Configure detection",
stepType: "TOOL 02",
toolbarLabel: "SOURCE / RESULT",
fileName: "comparison.png",
downloadLabel: "Download result",
layoutClass: "remover-layout",
previewClass: "remover-preview",
preview: "comparison",
fields: [
{
id: "color",
kind: "color",
label: "BACKGROUND COLOR",
value: "#E8EEF2",
native: true,
reference: true,
},
{
id: "sim",
kind: "slider",
label: "COLOR SIMILARITY",
value: 72,
min: 0,
max: 100,
suffix: "%",
space: true,
hints: ["strict edges", "more removal"],
},
{
id: "outer",
kind: "toggle",
label: "OUTER COLOR ONLY",
checked: true,
note: "Only remove connected background pixels from the edges.",
},
{
id: "mask",
kind: "toggle",
label: "SHOW MASK",
checked: false,
note: "Preview the detected transparency mask.",
},
],
};
export const PREVIEW_TOOL_VIEWS: Record<string, PreviewToolView> = {
"linear-gradient-png": gradient,
"remove-background-png": background,
};
export function getPreviewView(tool: ToolEntry): PreviewToolView | undefined {
return PREVIEW_TOOL_VIEWS[tool.id];
}
+25
View File
@@ -0,0 +1,25 @@
import { ToolError } from "./core/errors";
export function num(params: Record<string, unknown>, id: string): number {
const v = params[id];
if (typeof v !== "number" || !Number.isFinite(v)) {
throw new ToolError("errors.paramNumber", { id });
}
return v;
}
export function str(params: Record<string, unknown>, id: string): string {
const v = params[id];
if (typeof v !== "string") {
throw new ToolError("errors.paramString", { id });
}
return v;
}
export function bool(params: Record<string, unknown>, id: string): boolean {
const v = params[id];
if (typeof v !== "boolean") {
throw new ToolError("errors.paramBool", { id });
}
return v;
}
+201
View File
@@ -0,0 +1,201 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import type { Offset } from "../registry-schema";
import { colorMask, removeColorToAlpha } from "../core/alpha";
import { contourImage, strokeImage } from "../core/morphology";
import {
boxTest,
circleTest,
renderShape,
starTest,
wavyTest,
} from "../core/shapes";
interface AddStrokeParams {
color: string;
thickness: number;
}
export const addStrokeSchema = toolSchema<AddStrokeParams>({
color: field.color({ default: "#ff0000" }),
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
});
const addStroke: ToolEntry<AddStrokeParams> = {
id: "add-stroke-png",
title: "Outline PNG",
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
schema: addStrokeSchema,
run: (img, p) => strokeImage(img, p.thickness, p.color),
};
interface FindContourParams {
color: string;
thickness: number;
}
export const findContourSchema = toolSchema<FindContourParams>({
color: field.color({ default: "#000000" }),
thickness: field.slider({ min: 1, max: 5, step: 1, default: 1 }),
});
const findContour: ToolEntry<FindContourParams> = {
id: "find-contour-png",
title: "Find contour PNG",
description:
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
category: "alpha",
schema: findContourSchema,
run: (img, p) => contourImage(img, p.thickness, p.color),
};
interface RemoveColorParams {
targetColor: string;
tolerance: number;
}
export const removeColorSchema = toolSchema<RemoveColorParams>({
targetColor: field.color({ default: "#00ff00" }),
tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
});
const removeColor: ToolEntry<RemoveColorParams> = {
id: "remove-color-from-png",
title: "Remove color from PNG (make transparent)",
description:
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
category: "alpha",
schema: removeColorSchema,
run: (img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance),
preview: (img, p) => colorMask(img, p.targetColor, p.tolerance),
};
interface CircleMaskParams {
size: number;
offset: Offset;
}
const circleMaskSchema = toolSchema<CircleMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const circleMask: ToolEntry<CircleMaskParams> = {
id: "circle-mask-png",
title: "Circle Mask PNG",
description:
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
category: "alpha",
schema: circleMaskSchema,
run: (img, p) =>
renderShape(
img,
circleTest(p.size / 200),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface SquareMaskParams {
widthPct: number;
heightPct: number;
offset: Offset;
}
const squareMaskSchema = toolSchema<SquareMaskParams>({
widthPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
heightPct: field.slider({ min: 10, max: 100, step: 1, default: 100 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const squareMask: ToolEntry<SquareMaskParams> = {
id: "square-mask-png",
title: "Square Mask PNG",
description:
"Cuts the image into a rectangle with sides as a share of the smaller side.",
category: "alpha",
schema: squareMaskSchema,
run: (img, p) =>
renderShape(
img,
boxTest(p.widthPct / 200, p.heightPct / 200),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface StarMaskParams {
points: number;
innerRadius: number;
size: number;
rotation: number;
offset: Offset;
}
const starMaskSchema = toolSchema<StarMaskParams>({
points: field.slider({ min: 3, max: 12, step: 1, default: 5 }),
innerRadius: field.slider({ min: 10, max: 90, step: 1, default: 45 }),
size: field.slider({ min: 20, max: 100, step: 1, default: 100 }),
rotation: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const starMask: ToolEntry<StarMaskParams> = {
id: "star-mask-png",
title: "Star Mask PNG",
description:
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
category: "alpha",
schema: starMaskSchema,
run: (img, p) =>
renderShape(
img,
starTest(p.points, p.innerRadius / 100, p.size / 200, p.rotation),
p.offset.x / 100,
p.offset.y / 100,
),
};
interface WavyMaskParams {
size: number;
amplitude: number;
waves: number;
phase: number;
offset: Offset;
}
const wavyMaskSchema = toolSchema<WavyMaskParams>({
size: field.slider({ min: 20, max: 100, step: 1, default: 90 }),
amplitude: field.slider({ min: 2, max: 30, step: 1, default: 8 }),
waves: field.slider({ min: 3, max: 24, step: 1, default: 8 }),
phase: field.slider({ min: 0, max: 360, step: 1, default: 0 }),
offset: field.offset({ min: -50, max: 50, x: 0, y: 0 }),
});
const wavyMask: ToolEntry<WavyMaskParams> = {
id: "wavy-mask-png",
title: "Wavy Mask PNG",
description:
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
category: "alpha",
schema: wavyMaskSchema,
run: (img, p) =>
renderShape(
img,
wavyTest(p.size / 200, p.amplitude / 200, p.waves, p.phase),
p.offset.x / 100,
p.offset.y / 100,
),
};
export const alphaEntries = [
addStroke,
findContour,
removeColor,
circleMask,
squareMask,
starMask,
wavyMask,
];
+25
View File
@@ -0,0 +1,25 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { extractByColor } from "../core/masks";
interface ExtractColorParams {
color: string;
tolerance: number;
}
export const extractColorSchema = toolSchema<ExtractColorParams>({
color: field.color({ default: "#00ff88" }),
tolerance: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
});
const extractColor: ToolEntry<ExtractColorParams> = {
id: "extract-color-from-png",
title: "Extract Color from PNG",
description:
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
category: "analyze",
schema: extractColorSchema,
run: (img, p) => extractByColor(img, p.color, p.tolerance),
};
export const analyzeEntries = [extractColor];
+153
View File
@@ -0,0 +1,153 @@
import { gammaCorrection, temperature, twoColors, tint } from "../core/color";
import { parseHexList } from "../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
import { field, toolSchema, type ColorPair } from "../registry-schema";
import type { ToolEntry } from "./types";
interface TwoColorsParams {
pair: ColorPair;
threshold: number;
}
export const twoColorsSchema = toolSchema<TwoColorsParams>({
pair: field.colorPair({ from: "#ffffff", to: "#000000" }),
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const twoColorsTool: ToolEntry<TwoColorsParams> = {
id: "two-colors-png",
title: "Two colors PNG",
description:
"Recolors the image into two chosen colors by luminance threshold.",
category: "color",
schema: twoColorsSchema,
run: (img, p) => twoColors(img, p.pair.from, p.pair.to, p.threshold),
};
interface GammaParams {
value: number;
}
export const gammaSchema = toolSchema<GammaParams>({
value: field.slider({ min: 0.1, max: 3, step: 0.05, default: 1 }),
});
const gammaTool: ToolEntry<GammaParams> = {
id: "gamma-png",
title: "Gamma correction PNG",
description:
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
category: "color",
schema: gammaSchema,
run: (img, p) => gammaCorrection(img, p.value),
};
interface TemperatureParams {
percent: number;
}
export const temperatureSchema = toolSchema<TemperatureParams>({
percent: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
});
const temperatureTool: ToolEntry<TemperatureParams> = {
id: "temperature-png",
title: "Temperature PNG",
description:
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
category: "color",
schema: temperatureSchema,
run: (img, p) => temperature(img, p.percent),
};
interface TintParams {
color: string;
strength: number;
}
export const tintSchema = toolSchema<TintParams>({
color: field.color({ default: "#ffb060" }),
strength: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
});
const tintTool: ToolEntry<TintParams> = {
id: "tint-png",
title: "Tint PNG",
description:
"Multiplies color channels by the chosen tint with the given strength.",
category: "color",
schema: tintSchema,
run: (img, p) => tint(img, p.color, p.strength),
};
interface QuantizeParams {
colors: number;
}
export const quantizeSchema = toolSchema<QuantizeParams>({
colors: field.slider({ min: 2, max: 64, step: 1, default: 16 }),
});
const quantizeTool: ToolEntry<QuantizeParams> = {
id: "quantize-png",
title: "Quantize PNG",
description:
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
category: "color",
schema: quantizeSchema,
run: (img, p) => quantizeImage(img, p.colors).image,
};
interface CustomPaletteParams {
colors: string;
}
export const customPaletteSchema = toolSchema<CustomPaletteParams>({
colors: field.text({ default: "#000000,#ffffff" }),
});
const customPalette: ToolEntry<CustomPaletteParams> = {
id: "custom-palette-png",
title: "Custom Palette PNG",
description:
"Maps every pixel to the nearest color from your comma-separated hex list.",
category: "color",
schema: customPaletteSchema,
run: (img, p) => mapToNearest(img, parseHexList(p.colors)),
};
interface DitheringParams {
colors: number;
pattern: "floyd-steinberg" | "bayer";
}
export const ditheringSchema = toolSchema<DitheringParams>({
colors: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
pattern: field.select({
default: "floyd-steinberg",
options: [
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
{ value: "bayer", label: "Bayer 4x4" },
],
}),
});
const ditheringTool: ToolEntry<DitheringParams> = {
id: "dithering-png",
title: "Dithering PNG",
description:
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
category: "color",
schema: ditheringSchema,
run: (img, p) => ditherImage(img, p.colors, p.pattern),
};
export const colorEntries = [
twoColorsTool,
gammaTool,
temperatureTool,
tintTool,
quantizeTool,
customPalette,
ditheringTool,
];
+44
View File
@@ -0,0 +1,44 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { flattenOntoColor } from "../core/alpha";
import { clonePixelImage } from "../core/types";
interface ConvertToJpgParams {
background: string;
quality: number;
}
export const convertToJpgSchema = toolSchema<ConvertToJpgParams>({
background: field.color({ default: "#ffffff" }),
quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
});
const convertToJpg: ToolEntry<ConvertToJpgParams> = {
id: "convert-png-to-jpg",
title: "Convert PNG to JPG",
description:
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
category: "convert",
schema: convertToJpgSchema,
run: (img, p) => flattenOntoColor(img, p.background),
};
interface ConvertToWebpParams {
quality: number;
}
export const convertToWebpSchema = toolSchema<ConvertToWebpParams>({
quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }),
});
const convertToWebp: ToolEntry<ConvertToWebpParams> = {
id: "convert-png-to-webp",
title: "Convert PNG to WebP",
description:
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
category: "convert",
schema: convertToWebpSchema,
run: (img) => clonePixelImage(img),
};
export const convertEntries = [convertToJpg, convertToWebp];
+115
View File
@@ -0,0 +1,115 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { addNoise, pixelate, shuffleBlocks } from "../core/pixel-fx";
import { vignette } from "../core/effects";
import { jpegRoundtrip } from "../core/io";
interface VignetteParams {
strength: number;
}
export const vignetteSchema = toolSchema<VignetteParams>({
strength: field.slider({ min: 0, max: 100, step: 5, default: 50 }),
});
const vignetteTool: ToolEntry<VignetteParams> = {
id: "vignette-png",
title: "Vignette PNG",
description:
"Smoothly darkens the edges of the image, leaving the center untouched.",
category: "filters",
schema: vignetteSchema,
run: (img, p) => vignette(img, p.strength),
};
interface PixelateParams {
blockSize: number;
}
export const pixelateSchema = toolSchema<PixelateParams>({
blockSize: field.slider({ min: 2, max: 64, step: 1, default: 8 }),
});
const pixelateTool: ToolEntry<PixelateParams> = {
id: "pixelate-png",
title: "Pixelate PNG",
description:
"Averages every blockSize×blockSize area into one color — classic mosaic.",
category: "filters",
schema: pixelateSchema,
run: (img, p) => pixelate(img, p.blockSize),
};
interface RandomizePixelsParams {
blockSize: number;
seed: number;
}
export const randomizePixelsSchema = toolSchema<RandomizePixelsParams>({
blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }),
seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }),
});
const randomizePixels: ToolEntry<RandomizePixelsParams> = {
id: "randomize-pixels-png",
title: "Randomize Pixels PNG",
description:
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
category: "filters",
schema: randomizePixelsSchema,
run: (img, p) => shuffleBlocks(img, p.blockSize, p.seed),
};
interface AddNoiseParams {
amount: number;
mode: "mono" | "color";
seed: number;
}
export const addNoiseSchema = toolSchema<AddNoiseParams>({
amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }),
mode: field.select({
default: "mono",
options: [
{ value: "mono", label: "Monochrome grain" },
{ value: "color", label: "Color noise" },
],
}),
seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }),
});
const addNoiseTool: ToolEntry<AddNoiseParams> = {
id: "add-noise-png",
title: "Add Noise to PNG",
description:
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
category: "filters",
schema: addNoiseSchema,
run: (img, p) => addNoise(img, p.amount, p.mode, p.seed),
};
interface JpegArtifactsParams {
quality: number;
}
export const jpegArtifactsSchema = toolSchema<JpegArtifactsParams>({
quality: field.slider({ min: 1, max: 50, step: 1, default: 10 }),
});
const jpegArtifacts: ToolEntry<JpegArtifactsParams> = {
id: "jpeg-artifacts-png",
title: "JPEG artifacts",
description:
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
category: "filters",
schema: jpegArtifactsSchema,
run: (img, p) => jpegRoundtrip(img, p.quality),
};
export const filtersEntries = [
vignetteTool,
pixelateTool,
randomizePixels,
addNoiseTool,
jpegArtifacts,
];
+338
View File
@@ -0,0 +1,338 @@
import { renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { gradientImage, noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import {
hexToRgb,
renderBlend,
renderSwatches,
stepColors,
} from "../core/palette";
import {
field,
toolSchema,
type ColorPair,
type Dimension,
} from "../registry-schema";
import type { ToolEntry } from "./types";
function rgba(hex: string): [number, number, number, number] {
const { r, g, b } = hexToRgb(hex);
return [r, g, b, 255];
}
interface CreateEmptyParams {
size: Dimension;
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]);
}
return solidImage(w, h, rgba(p.color));
},
};
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;
pair: ColorPair;
direction: "horizontal" | "vertical";
}
export const linearGradientSchema = toolSchema<LinearGradientParams>({
size: field.dimension({ min: 1, max: 20000, width: 800, height: 600 }),
pair: field.colorPair({ from: "#000000", to: "#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.pair.from), rgba(p.pair.to), 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;
},
};
interface BlendTwoParams {
pair: ColorPair;
width: number;
}
export const blendTwoSchema = toolSchema<BlendTwoParams>({
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
});
const blendTwo: ToolEntry<BlendTwoParams> = {
id: "blend-two-png",
title: "Blend Two Colors PNG",
description: "A continuous horizontal gradient between two colors.",
category: "generate",
schema: blendTwoSchema,
generate: (p) => renderBlend(p.pair.from, p.pair.to, p.width),
};
interface StepColorsParams {
pair: ColorPair;
steps: number;
width: number;
layout: "strip" | "grid";
}
export const stepColorsSchema = toolSchema<StepColorsParams>({
pair: field.colorPair({ from: "#000000", to: "#ffffff" }),
steps: field.slider({ min: 2, max: 12, step: 1, default: 6 }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
layout: field.select({
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "strip", label: "Strip" },
],
}),
});
const stepColorsTool: ToolEntry<StepColorsParams> = {
id: "step-colors-png",
title: "Color Steps PNG",
description: "A discrete set of evenly spaced steps between two colors.",
category: "generate",
schema: stepColorsSchema,
generate: (p) =>
renderSwatches(
stepColors(p.pair.from, p.pair.to, p.steps),
p.width,
p.layout,
),
};
export const generateEntries = [
createEmpty,
singleColor,
randomNoise,
linearGradient,
colorSpectrumTool,
randomColors,
drawGridTool,
placeholder,
blendTwo,
stepColorsTool,
];
+191
View File
@@ -0,0 +1,191 @@
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";
interface AddBorderParams {
thickness: number;
color: string;
}
export const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
color: field.color({ default: "#000000" }),
});
const addBorder: ToolEntry<AddBorderParams> = {
id: "add-border-png",
title: "Add border to PNG",
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
schema: addBorderSchema,
run: (img, p) =>
expandCanvas(
img,
p.thickness,
p.thickness,
p.thickness,
p.thickness,
p.color,
),
};
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,
];
+24
View File
@@ -0,0 +1,24 @@
import type { ToolEntry } from "./types";
import { geometryEntries } from "./geometry";
import { alphaEntries } from "./alpha";
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";
export const TOOLS: ToolEntry[] = [
...geometryEntries,
...alphaEntries,
...convertEntries,
...analyzeEntries,
...filtersEntries,
...colorEntries,
...generateEntries,
] as unknown as ToolEntry[];
export function getTool(id: string): ToolEntry | undefined {
return TOOLS.find((tool) => tool.id === id);
}
@@ -0,0 +1,312 @@
import { describe, expect, it } from "vitest";
import { PREVIEW_GROUPS } from "../preview/catalog";
import { TOOLS } from "../registry-new";
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);
});
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 },
pair: { from: "#000000", to: "#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);
});
it("blend-two: дефолты пары и работа генератора", async () => {
const tool = TOOLS.find((t) => t.id === "blend-two-png")!;
const d = defaultSchemaParams(tool.schema);
expect(d.pair).toEqual({ from: "#000000", to: "#ffffff" });
const img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
pair: { from: "#ff0000", to: "#0000ff" },
width: 128,
}),
);
expect(img.width).toBe(128);
});
it("step-colors: рендерит steps полос по паре", async () => {
const tool = TOOLS.find((t) => t.id === "step-colors-png")!;
const img = await tool.generate!(
sanitizeSchemaParams(tool.schema, {
pair: { from: "#000000", to: "#ffffff" },
steps: 4,
width: 128,
layout: "strip",
}),
);
expect(img.width).toBe(128);
});
it("two-colors: перекрашивает светлые/тёмные пиксели по паре", async () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
// 50×1, левая половина тёмная, правая светлая
const data = new Uint8ClampedArray(50 * 4).fill(255);
const img = { width: 50, height: 1, data };
for (let x = 0; x < 25; x++) {
const i = x * 4;
data[i] = 0;
data[i + 1] = 0;
data[i + 2] = 0;
}
const out = await tool.run!(
img,
sanitizeSchemaParams(tool.schema, {
pair: { from: "#ffffff", to: "#ff0000" },
threshold: 50,
}),
);
// Светлый пиксель → from (#ffffff), тёмный → to (#ff0000)
expect(out.data[0]).toBe(255);
expect(out.data[1]).toBe(0);
expect(out.data[2]).toBe(0);
expect(out.data[100]).toBe(255);
expect(out.data[101]).toBe(255);
expect(out.data[102]).toBe(255);
});
it("sanitize чинит мусор в паре и клампит threshold", () => {
const tool = TOOLS.find((t) => t.id === "two-colors-png")!;
const s = sanitizeSchemaParams(tool.schema, {
pair: { from: "not-a-color", to: "#00ff00", extra: 1 },
threshold: 999,
});
expect(s.pair).toEqual({ from: "#ffffff", to: "#00ff00" });
expect(s.threshold).toBe(100);
});
it("circle-mask: срезает углы и сохраняет центр", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
size: 100,
offset: { x: 0, y: 0 },
}),
);
// Внешний угол (0,0) — вне круга радиуса 50 → прозрачен
expect(img.data[3]).toBe(0);
// Центр (50,50) — внутри
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
});
it("circle-mask: offset сдвигает фигуру", async () => {
const tool = TOOLS.find((t) => t.id === "circle-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
size: 50,
offset: { x: 50, y: 0 },
}),
);
// Радиус 25, центр смещён к x=100; пиксель (75,50) внутри, (49,50) снаружи
expect(img.data[(50 * img.width + 75) * 4 + 3]).toBe(255);
expect(img.data[(50 * img.width + 49) * 4 + 3]).toBe(0);
});
it("star-mask: сохраняет центр, режет углы", async () => {
const tool = TOOLS.find((t) => t.id === "star-mask-png")!;
const img = await tool.run!(
solid(100, 100),
sanitizeSchemaParams(tool.schema, {
points: 5,
innerRadius: 45,
size: 100,
rotation: 0,
offset: { x: 0, y: 0 },
}),
);
expect(img.data[(50 * img.width + 50) * 4 + 3]).toBe(255);
expect(img.data[3]).toBe(0);
});
it("sanitize клампит offset к min/max и чинит мусор", () => {
const tool = TOOLS.find((t) => t.id === "wavy-mask-png")!;
const s = sanitizeSchemaParams(tool.schema, {
size: 90,
amplitude: 8,
waves: 8,
phase: 0,
offset: { x: 999, y: "abc", extra: true } as unknown as Record<
string,
unknown
>,
});
expect(s.offset).toEqual({ x: 50, y: 0 });
});
});
function solid(width: number, height: number) {
const data = new Uint8ClampedArray(width * height * 4).fill(255);
return { width, height, data };
}
+24
View File
@@ -0,0 +1,24 @@
import type { CategoryId } from "../preview/categories";
import type { ToolSchema } from "../registry-schema";
import type { PixelImage } from "../core/types";
/**
* Инструмент нового registry: полностью типизирован на `Params`, схема —
* обязательна (единый источник дефолтов/валидации/UI). Старого `ParamDef[]`
* нет — это отдельная система от старого UI.
*/
export type ToolEntry<P = Record<string, unknown>> = {
id: string;
title: string;
description: string;
category: CategoryId;
schema: ToolSchema<P>;
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage;
toText?(img: PixelImage, params: P): Promise<string> | string;
runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage;
textToText?(text: string): Promise<string> | string;
preview?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
icon?: string;
};
+148 -3
View File
@@ -49,8 +49,69 @@ export interface CheckboxSpec {
default: boolean; default: boolean;
} }
export type FieldSpec = /** Составное поле «размеры»: width + height как один объект. */
NumberSpec | SliderSpec | ColorSpec | SelectSpec | TextSpec | CheckboxSpec; export interface Dimension {
width: number;
height: number;
}
export interface DimensionSpec {
kind: "dimension";
/** Общий диапазон для обоих измерений. */
min: number;
max: number;
width: number;
height: number;
}
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
export interface ColorPair {
from: string;
to: string;
}
export interface ColorPairSpec {
kind: "color-pair";
from: string;
to: string;
}
/** Смещение фигуры/объекта по центру: x + y в процентах. */
export interface Offset {
x: number;
y: number;
}
export interface OffsetSpec {
kind: "offset";
/** Общий диапазон для обеих осей (в процентах). */
min: number;
max: number;
x: number;
y: number;
}
/**
* «Объект as const» kind → спека поля. Единственный источник правды для
* перечня kinds: `FieldSpecKind` = ключи map, `FieldSpec` = значение по любому
* ключу (тот же union). Добавляем новый составной тип — добавляем сюда, и
* type-checker укажет, где его не хватает (record контролов, default/sanitize).
*/
export const fieldSpecs = {
number: {} as NumberSpec,
slider: {} as SliderSpec,
color: {} as ColorSpec,
select: {} as SelectSpec,
text: {} as TextSpec,
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
offset: {} as OffsetSpec,
} as const;
export type FieldSpecKind = keyof typeof fieldSpecs;
export type FieldSpecOf<K extends FieldSpecKind> = (typeof fieldSpecs)[K];
export type FieldSpec = FieldSpecOf<FieldSpecKind>;
/** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ /** `Field<T>`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */
export interface Field<T> { export interface Field<T> {
@@ -91,6 +152,25 @@ 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 },
}),
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
offset: (s: {
min: number;
max: number;
x: number;
y: number;
}): Field<Offset> => ({
spec: { kind: "offset", ...s },
}),
}; };
/** /**
@@ -116,7 +196,16 @@ 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 if (spec.kind === "color-pair") {
out[key] = { from: spec.from, to: spec.to };
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else {
out[key] = spec.default;
}
} }
return out; return out;
} }
@@ -156,6 +245,62 @@ 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;
}
case "color-pair": {
const r =
typeof raw === "object" &&
raw !== null &&
"from" in raw &&
"to" in raw
? (raw as Record<string, unknown>)
: undefined;
const from =
typeof r?.from === "string" && /^#[0-9a-f]{6}$/i.test(r.from)
? r.from
: spec.from;
const to =
typeof r?.to === "string" && /^#[0-9a-f]{6}$/i.test(r.to)
? r.to
: spec.to;
out[key] = { from, to };
break;
}
case "offset": {
const r =
typeof raw === "object" && raw !== null && "x" in raw && "y" in raw
? (raw as Record<string, unknown>)
: undefined;
const x =
typeof r?.x === "number" && Number.isFinite(r.x) ? r.x : spec.x;
const y =
typeof r?.y === "number" && Number.isFinite(r.y) ? r.y : spec.y;
out[key] = {
x: clamp(x, spec.min, spec.max),
y: clamp(y, spec.min, spec.max),
};
break;
}
} }
} }
return out; return out;
+42 -3773
View File
File diff suppressed because it is too large Load Diff
+686
View File
@@ -0,0 +1,686 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
colorMask,
extractAlphaMask,
flattenOntoColor,
hardenAlpha,
invertAlpha,
removeColorToAlpha,
roundCorners,
setAlphaChannel,
} from "../core/alpha";
import { backgroundMaskPreview, removeBackground } from "../core/background";
import {
closingImage,
contourImage,
dilateImage,
erodeImage,
openingImage,
strokeImage,
} from "../core/morphology";
import { featherAlpha, defringe } from "../core/pixel-fx";
import { drawImageWatermark } from "../core/domText";
import { getOverlay } from "../tools/overlay-store.svelte";
import {
renderShape,
circleTest,
boxTest,
starTest,
wavyTest,
} from "../core/shapes";
import type { Position9 } from "../core/textdraw";
import { num, str } from "../registry-helpers";
interface AddStrokeParams {
color: string;
thickness: number;
}
const addStrokeSchema = toolSchema<AddStrokeParams>({
color: field.color({ default: "#ff0000" }),
thickness: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
});
export function alphaEntries(): ToolEntry[] {
return [
{
id: "circle-mask-png",
title: "Circle Mask PNG",
description:
"Cuts the image into a circle. Diameter is set as a share of the smaller side.",
category: "alpha",
params: [
{
id: "size",
label: "Diameter, % of smaller side",
type: "slider",
min: 20,
max: 100,
step: 1,
default: 100,
},
{
id: "offsetX",
label: "Offset X, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
{
id: "offsetY",
label: "Offset Y, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
],
run: (img, p) =>
renderShape(
img,
circleTest(num(p, "size") / 200),
num(p, "offsetX") / 100,
num(p, "offsetY") / 100,
),
},
{
id: "square-mask-png",
title: "Square Mask PNG",
description:
"Cuts the image into a rectangle with sides as a share of the smaller side.",
category: "alpha",
params: [
{
id: "widthPct",
label: "Width, % of smaller side",
type: "slider",
min: 10,
max: 100,
step: 1,
default: 100,
},
{
id: "heightPct",
label: "Height, % of smaller side",
type: "slider",
min: 10,
max: 100,
step: 1,
default: 100,
},
{
id: "offsetX",
label: "Offset X, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
{
id: "offsetY",
label: "Offset Y, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
],
run: (img, p) =>
renderShape(
img,
boxTest(num(p, "widthPct") / 200, num(p, "heightPct") / 200),
num(p, "offsetX") / 100,
num(p, "offsetY") / 100,
),
},
{
id: "star-mask-png",
title: "Star Mask PNG",
description:
"Cuts the image into an n-pointed star with adjustable inner radius and rotation.",
category: "alpha",
params: [
{
id: "points",
label: "Points",
type: "slider",
min: 3,
max: 12,
step: 1,
default: 5,
},
{
id: "innerRadius",
label: "Inner radius, %",
type: "slider",
min: 10,
max: 90,
step: 1,
default: 45,
},
{
id: "size",
label: "Outer radius, % of smaller side",
type: "slider",
min: 20,
max: 100,
step: 1,
default: 100,
},
{
id: "rotation",
label: "Rotation, °",
type: "slider",
min: -180,
max: 180,
step: 1,
default: 0,
},
{
id: "offsetX",
label: "Offset X, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
{
id: "offsetY",
label: "Offset Y, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
],
run: (img, p) =>
renderShape(
img,
starTest(
num(p, "points"),
num(p, "innerRadius") / 100,
num(p, "size") / 200,
num(p, "rotation"),
),
num(p, "offsetX") / 100,
num(p, "offsetY") / 100,
),
},
{
id: "wavy-mask-png",
title: "Wavy Mask PNG",
description:
"Cuts the image into a wavy-edged circle: radius is modulated by a sine with chosen amplitude and frequency.",
category: "alpha",
params: [
{
id: "size",
label: "Base radius, % of smaller side",
type: "slider",
min: 20,
max: 100,
step: 1,
default: 90,
},
{
id: "amplitude",
label: "Wave amplitude, %",
type: "slider",
min: 2,
max: 30,
step: 1,
default: 8,
},
{
id: "waves",
label: "Waves count",
type: "slider",
min: 3,
max: 24,
step: 1,
default: 8,
},
{
id: "phase",
label: "Phase, °",
type: "slider",
min: 0,
max: 360,
step: 1,
default: 0,
},
{
id: "offsetX",
label: "Offset X, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
{
id: "offsetY",
label: "Offset Y, %",
type: "slider",
min: -50,
max: 50,
step: 1,
default: 0,
},
],
run: (img, p) =>
renderShape(
img,
wavyTest(
num(p, "size") / 200,
num(p, "amplitude") / 200,
num(p, "waves"),
num(p, "phase"),
),
num(p, "offsetX") / 100,
num(p, "offsetY") / 100,
),
},
{
id: "remove-alpha-channel-png",
title: "Remove alpha channel PNG",
description:
"Composites the image over a white background and saves without transparency.",
category: "alpha",
params: [],
run: (img) => flattenOntoColor(img, "#ffffff"),
},
{
id: "set-alpha-channel-png",
title: "Set alpha channel PNG",
description:
"Assigns the same opacity to all pixels; colors stay unchanged.",
category: "alpha",
params: [
{
id: "percent",
label: "Opacity, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 100,
},
],
run: (img, p) => setAlphaChannel(img, num(p, "percent")),
},
{
id: "extract-alpha-mask-png",
title: "Extract alpha mask PNG",
description: "Turns transparency into a black-and-white opaque mask.",
category: "alpha",
params: [],
run: (img) => extractAlphaMask(img),
},
{
id: "round-corners-png",
title: "Round corners PNG",
description:
"Clips corners by a radius set as a percentage of half the smaller side.",
category: "alpha",
params: [
{
id: "radius",
label: "Corner radius, %",
type: "slider",
min: 0,
max: 50,
step: 1,
default: 10,
},
],
run: (img, p) => roundCorners(img, num(p, "radius")),
},
{
id: "invert-alpha-png",
title: "Invert alpha PNG",
description: "Opaque areas become transparent and vice versa.",
category: "alpha",
params: [],
run: (img) => invertAlpha(img),
},
{
id: "remove-background-png",
title: "Remove background PNG (smart)",
description:
"Removes a solid background: by color with tolerance, outer regions from the edges only, or every matching pixel. Can smooth the boundary.",
category: "alpha",
params: [
{
id: "color",
label: "Background color",
type: "color",
default: "#ffffff",
},
{
id: "tolerance",
label: "Similarity tolerance, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 10,
},
{
id: "outerOnly",
label: "Outer regions only",
type: "checkbox",
default: true,
},
{
id: "smooth",
label: "Edge smoothing, passes",
type: "slider",
min: 0,
max: 8,
step: 1,
default: 1,
},
],
run: (img, p) =>
removeBackground(img, {
color: str(p, "color"),
tolerancePercent: num(p, "tolerance"),
outerOnly: p["outerOnly"] === true,
smoothPasses: num(p, "smooth"),
}),
preview: (img, p) =>
backgroundMaskPreview(img, {
color: str(p, "color"),
tolerancePercent: num(p, "tolerance"),
outerOnly: p["outerOnly"] === true,
smoothPasses: num(p, "smooth"),
}),
},
{
id: "add-stroke-png",
title: "Outline PNG",
description:
"Adds a colored ring outline around the opaque content with the chosen thickness.",
category: "alpha",
schema: addStrokeSchema,
params: [
{
id: "color",
label: "Outline color",
type: "color",
default: "#ff0000",
},
{
id: "thickness",
label: "Thickness, px",
type: "slider",
min: 1,
max: 10,
step: 1,
default: 3,
},
],
run: (img, p) => strokeImage(img, num(p, "thickness"), str(p, "color")),
},
{
id: "find-contour-png",
title: "Find contour PNG",
description:
"Leaves only a line along the boundary of opaque regions in the chosen color and thickness.",
category: "alpha",
params: [
{
id: "color",
label: "Line color",
type: "color",
default: "#000000",
},
{
id: "thickness",
label: "Line thickness, px",
type: "slider",
min: 1,
max: 5,
step: 1,
default: 1,
},
],
run: (img, p) => contourImage(img, num(p, "thickness"), str(p, "color")),
},
{
id: "make-thicker-png",
title: "Thicken PNG",
description: "Expands opaque areas by the given number of pixels.",
category: "alpha",
params: [
{
id: "radius",
label: "Amount, px",
type: "slider",
min: 1,
max: 10,
step: 1,
default: 2,
},
],
run: (img, p) => dilateImage(img, num(p, "radius")),
},
{
id: "make-thinner-png",
title: "Thin PNG",
description:
"Shrinks opaque areas — thins the strokes of text and details.",
category: "alpha",
params: [
{
id: "radius",
label: "Amount, px",
type: "slider",
min: 1,
max: 10,
step: 1,
default: 1,
},
],
run: (img, p) => erodeImage(img, num(p, "radius")),
},
{
id: "feather-edges-png",
title: "Feather Edges PNG",
description:
"Blurs only the alpha channel: hard cutout edges become soft and gradual, colors stay untouched.",
category: "alpha",
params: [
{
id: "radius",
label: "Feather radius, px",
type: "slider",
min: 1,
max: 20,
step: 1,
default: 3,
},
],
run: (img, p) => featherAlpha(img, num(p, "radius")),
},
{
id: "clean-edges-png",
title: "Clean Edges PNG (defringe)",
description:
"Replaces edge-halo colors of semi-transparent pixels with the nearest fully opaque color. Alpha stays as is.",
category: "alpha",
params: [
{
id: "radius",
label: "Search radius, px",
type: "slider",
min: 1,
max: 10,
step: 1,
default: 3,
},
],
run: (img, p) => defringe(img, num(p, "radius")),
},
{
id: "harden-alpha-png",
title: "Harden edges PNG",
description:
"Binarizes the alpha channel by threshold: semi-transparent pixels become either fully transparent or fully opaque.",
category: "alpha",
params: [
{
id: "threshold",
label: "Alpha threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
run: (img, p) => hardenAlpha(img, num(p, "threshold")),
},
{
id: "despeckle-alpha-png",
title: "Despeckle PNG",
description:
"Opening: removes lone semi-transparent pixels and small specks.",
category: "alpha",
params: [
{
id: "radius",
label: "Cleanup radius, px",
type: "slider",
min: 1,
max: 3,
step: 1,
default: 1,
},
],
run: (img, p) => openingImage(img, num(p, "radius")),
},
{
id: "close-holes-png",
title: "Close holes PNG",
description: "Closing: fills lone transparent dots inside the object.",
category: "alpha",
params: [
{
id: "radius",
label: "Closing radius, px",
type: "slider",
min: 1,
max: 3,
step: 1,
default: 1,
},
],
run: (img, p) => closingImage(img, num(p, "radius")),
},
{
id: "remove-color-from-png",
title: "Remove color from PNG (make transparent)",
description:
"Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.",
category: "alpha",
params: [
{
id: "targetColor",
label: "Color to remove",
type: "color",
default: "#00ff00",
},
{
id: "tolerance",
label: "Similarity threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 10,
},
],
run: (img, p) =>
removeColorToAlpha(img, str(p, "targetColor"), num(p, "tolerance")),
preview: (img, p) =>
colorMask(img, str(p, "targetColor"), num(p, "tolerance")),
},
{
id: "watermark-image-png",
title: "Watermark Image PNG",
description:
"Overlays another PNG (logo/signature) on top: scale from canvas width, opacity, 3×3 position. The mark lives only while the page is open — after restoring a chain, pick it again.",
category: "alpha",
domOnly: true,
needsOverlaySource: true,
params: [
{
id: "scale",
label: "Mark width, % of canvas",
type: "slider",
min: 5,
max: 100,
step: 1,
default: 30,
},
{
id: "opacity",
label: "Opacity, %",
type: "slider",
min: 5,
max: 100,
step: 5,
default: 60,
},
{
id: "position",
label: "Position",
type: "select",
default: "bottom-right",
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" },
],
},
{
id: "margin",
label: "Margin, px",
type: "slider",
min: 0,
max: 200,
step: 1,
default: 24,
},
],
run: (img, p) => {
const mark = getOverlay();
if (!mark) throw new ToolError("errors.noWatermark");
return drawImageWatermark(img, {
mark,
scalePercent: num(p, "scale"),
opacityPercent: num(p, "opacity"),
position: str(p, "position") as Position9,
margin: num(p, "margin"),
});
},
},
];
}
+289
View File
@@ -0,0 +1,289 @@
import type { ToolEntry } from "../registry";
import {
extractByColor,
isGrayscaleish,
luma01,
rarityPredicate,
renderPredicateMask,
} from "../core/masks";
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
import { looksLikePng, base64ToBytes, stripDataUri } from "../core/textio";
import { clonePixelImage, type PixelImage } from "../core/types";
import { encode } from "../core/io";
import { t } from "../i18n/t";
import { num, str } from "../registry-helpers";
type MaskToolSpec = {
id: string;
title: string;
description: string;
defaultMode: "binary" | "highlight";
predicate: (
img: PixelImage,
p: Record<string, unknown>,
) => (r: number, g: number, b: number, a: number) => boolean;
extraParams?: ToolEntry["params"];
};
const MASK_TOOLS: MaskToolSpec[] = [
{
id: "show-transparent-png",
title: "Show Transparent Areas PNG",
description:
"Highlights every transparent or semi-transparent pixel with the chosen color so gaps become obvious.",
defaultMode: "highlight",
predicate: (_img, _p) => (_r, _g, _b, a) => a < 255,
},
{
id: "show-grayscale-pixels-png",
title: "Show Grayscale Pixels PNG",
description:
"Finds pixels whose channels are nearly equal and renders them as a mask. Tolerance is in channel units.",
defaultMode: "binary",
predicate: (_i, p) => (r, g, b) =>
isGrayscaleish(r, g, b, num(p, "tolerance")),
extraParams: [
{
id: "tolerance",
label: "Channel tolerance",
type: "slider",
min: 0,
max: 64,
step: 1,
default: 0,
},
],
},
{
id: "show-color-pixels-png",
title: "Show Color Pixels PNG",
description:
"Finds colored (non-gray) pixels beyond the channel tolerance and renders them as a mask.",
defaultMode: "binary",
predicate: (_i, p) => (r, g, b) =>
!isGrayscaleish(r, g, b, num(p, "tolerance")),
extraParams: [
{
id: "tolerance",
label: "Channel tolerance",
type: "slider",
min: 0,
max: 64,
step: 1,
default: 8,
},
],
},
{
id: "light-pixel-mask-png",
title: "Light Pixel Mask PNG",
description: "Selects pixels brighter than the luminance threshold.",
defaultMode: "binary",
predicate: (_i, p) => (r, g, b) =>
luma01(r, g, b) >= num(p, "threshold") / 100,
extraParams: [
{
id: "threshold",
label: "Luminance threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 70,
},
],
},
{
id: "dark-pixel-mask-png",
title: "Dark Pixel Mask PNG",
description: "Selects pixels darker than the luminance threshold.",
defaultMode: "binary",
predicate: (_i, p) => (r, g, b) =>
luma01(r, g, b) <= num(p, "threshold") / 100,
extraParams: [
{
id: "threshold",
label: "Luminance threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 30,
},
],
},
{
id: "unique-color-mask-png",
title: "Unique Color Mask PNG",
description:
"Selects colors that occur no more than the given number of times — rare and one-off pixels.",
defaultMode: "binary",
predicate: (img, p) => rarityPredicate(img, num(p, "rarity")),
extraParams: [
{
id: "rarity",
label: "Max occurrences",
type: "slider",
min: 1,
max: 50,
step: 1,
default: 1,
},
],
},
];
function maskEntries(): ToolEntry[] {
return MASK_TOOLS.map((spec) => ({
id: spec.id,
title: spec.title,
description: spec.description,
category: "analyze" as const,
params: [
...(spec.extraParams ?? []),
{
id: "mode",
label: "Mask mode",
type: "select" as const,
default: spec.defaultMode,
options: [
{ value: "binary", label: "Black & white mask" },
{ value: "highlight", label: "Color highlight" },
],
},
{
id: "color",
label: "Highlight color",
type: "color",
default: "#ff00aa",
},
{
id: "opacity",
label: "Highlight opacity, %",
type: "slider",
min: 0,
max: 100,
step: 5,
default: 70,
},
],
run: (img, p) =>
renderPredicateMask(img, spec.predicate(img, p), {
mode: str(p, "mode") === "highlight" ? "highlight" : "binary",
color: str(p, "color"),
opacityPercent: num(p, "opacity"),
}),
}));
}
export function analyzeEntries(): ToolEntry[] {
return [
...maskEntries(),
{
id: "extract-color-from-png",
title: "Extract Color from PNG",
description:
"Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.",
category: "analyze",
params: [
{
id: "color",
label: "Color to keep",
type: "color",
default: "#00ff88",
},
{
id: "tolerance",
label: "Similarity tolerance, %",
type: "slider",
min: 0,
max: 50,
step: 1,
default: 10,
},
],
run: (img, p) =>
extractByColor(img, str(p, "color"), num(p, "tolerance")),
},
{
id: "verify-is-png",
title: "Verify If Image Is a PNG",
description:
"Checks the signature of pasted base64 / data-uri content and reports whether it is a real PNG.",
category: "analyze",
sourceMode: "text",
params: [],
resultType: "text",
textToText: (text) =>
looksLikePng(base64ToBytes(stripDataUri(text)))
? "verifyYes"
: "verifyNo",
},
{
id: "png-info",
title: "PNG info",
description:
"Shows dimensions, alpha presence and the number of unique colors of the uploaded image.",
category: "analyze",
params: [],
resultType: "info",
run: (img) => clonePixelImage(img),
},
{
id: "png-is-grayscale",
title: "Check: is PNG grayscale?",
description: "Reports whether the image consists only of shades of gray.",
category: "analyze",
params: [],
resultType: "text",
toText: (img) => (isGrayscale(img) ? "grayscaleYes" : "grayscaleNo"),
},
{
id: "png-file-size",
title: "PNG File Size",
description:
"Encodes the image as PNG and reports the resulting file size.",
category: "analyze",
domOnly: true,
params: [],
resultType: "text",
toText: async (img) => {
const blob = await encode(img, "image/png");
const kb = blob.size / 1024;
const kbText = kb >= 100 ? Math.round(kb).toString() : kb.toFixed(1);
return t("tools.png-file-size.results.line", {
kb: kbText,
});
},
},
{
id: "png-is-transparent",
title: "Check: is PNG transparent?",
description:
"Reports whether the image contains transparent or semi-transparent pixels.",
category: "analyze",
params: [],
resultType: "text",
toText: (img) =>
hasTransparency(img) ? "transparentYes" : "transparentNo",
},
{
id: "png-orientation",
title: "PNG orientation",
description: "Reports whether it is portrait, landscape or square.",
category: "analyze",
params: [],
resultType: "text",
toText: (img) => {
switch (orientationOf(img)) {
case "portrait":
return "orientationPortrait";
case "landscape":
return "orientationLandscape";
default:
return "orientationSquare";
}
},
},
];
}
+501
View File
@@ -0,0 +1,501 @@
import type { ToolEntry } from "../registry";
import {
autoContrast,
brightnessContrast,
changeHue,
extractChannel,
gammaCorrection,
grayscale,
invert,
posterize,
sepia,
setOpacity,
swapChannels,
temperature,
tint,
thresholdBlackWhite,
twoColors,
type ChannelSwapPair,
type RgbChannel,
} from "../core/color";
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
import { quantizeImage, ditherImage, mapToNearest } from "../core/quantize";
import { parseHexList } from "../core/palette";
import { num, str } from "../registry-helpers";
type SpaceEntry = {
id: SpaceId;
suffix: string;
title: string;
description: string;
};
const CHANNEL_SPACES: SpaceEntry[] = [
{
id: "hsl",
suffix: "hsl",
title: "Split PNG into HSL",
description:
"Decomposes the image into Hue, Saturation and Lightness components.",
},
{
id: "hsv",
suffix: "hsv",
title: "Split PNG into HSV",
description:
"Decomposes the image into Hue, Saturation and Value (brightness) components.",
},
{
id: "hsi",
suffix: "hsi",
title: "Split PNG into HSI",
description:
"Decomposes the image into Hue, Saturation and Intensity components.",
},
{
id: "cmyk",
suffix: "cmyk",
title: "Convert PNG to CMYK Colors",
description:
"Decomposes the image into print-style Cyan, Magenta, Yellow and Key (black) components.",
},
{
id: "ycbcr",
suffix: "ycbcr",
title: "Convert PNG to YCbCr Colors",
description:
"Decomposes the image into Luma (Y) and Blue-difference / Red-difference chroma components.",
},
{
id: "lab",
suffix: "lab",
title: "Convert PNG to LAB Colors",
description:
"Decomposes the image into perceptual Lightness and green–magenta / blue–yellow opponents.",
},
];
function channelEntries(): ToolEntry[] {
return CHANNEL_SPACES.map((space) => {
const components = SPACES[space.id].components;
return {
id: `png-to-${space.suffix}`,
title: space.title,
description: space.description,
category: "color" as const,
params: [
{
id: "component",
label: "Component",
type: "select" as const,
default: components[0],
options: components.map((c) => ({
value: c,
label: c.toUpperCase(),
})),
},
{
id: "display",
label: "Display mode",
type: "select" as const,
default: "gray",
options: [
{ value: "gray", label: "Grayscale" },
{ value: "color", label: "Space as RGB" },
],
},
],
run: (img, p) =>
renderSpace(
img,
space.id,
str(p, "component"),
str(p, "display") === "color" ? "color" : "gray",
),
} satisfies ToolEntry;
});
}
export function colorEntries(): ToolEntry[] {
return [
...channelEntries(),
{
id: "grayscale-png",
title: "Grayscale PNG",
description:
"Converts the image to shades of gray using the BT.601 luminance formula. Alpha is preserved.",
category: "color",
params: [],
run: (img) => grayscale(img),
},
{
id: "invert-colors-png",
title: "Invert colors PNG",
description:
"Inverts each color channel (255 − value). Alpha is unchanged.",
category: "color",
params: [],
run: (img) => invert(img),
},
{
id: "adjust-brightness-contrast-png",
title: "Brightness & contrast PNG",
description:
"Adjusts brightness and contrast in the range from −100 to +100. Zero means no change.",
category: "color",
params: [
{
id: "brightness",
label: "Brightness",
type: "slider",
min: -100,
max: 100,
step: 1,
default: 0,
},
{
id: "contrast",
label: "Contrast",
type: "slider",
min: -100,
max: 100,
step: 1,
default: 0,
},
],
run: (img, p) =>
brightnessContrast(img, num(p, "brightness"), num(p, "contrast")),
},
{
id: "change-png-opacity",
title: "Change PNG opacity",
description:
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
category: "color",
params: [
{
id: "percent",
label: "Opacity, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 100,
},
],
run: (img, p) => setOpacity(img, num(p, "percent")),
},
{
id: "sepia-png",
title: "Sepia effect",
description:
"Tints the image into the warm brown tones of classic sepia.",
category: "color",
params: [],
run: (img) => sepia(img),
},
{
id: "change-png-hue",
title: "Change hue PNG",
description:
"Shifts the hue around the circle. Saturation and lightness are preserved.",
category: "color",
params: [
{
id: "degrees",
label: "Hue shift, °",
type: "slider",
min: -180,
max: 180,
step: 1,
default: 0,
},
],
run: (img, p) => changeHue(img, num(p, "degrees")),
},
{
id: "extract-channel-png",
title: "Extract channel PNG",
description:
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
category: "color",
params: [
{
id: "channel",
label: "Channel",
type: "select",
default: "red",
options: [
{ value: "red", label: "Red" },
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
],
},
],
run: (img, p) => extractChannel(img, str(p, "channel") as RgbChannel),
},
{
id: "swap-channels-png",
title: "Swap channels PNG",
description:
"Swaps two color channels — a quick way to get unusual coloring.",
category: "color",
params: [
{
id: "pair",
label: "Channel pair",
type: "select",
default: "r-g",
options: [
{ value: "r-g", label: "Red ↔ Green" },
{ value: "r-b", label: "Red ↔ Blue" },
{ value: "g-b", label: "Green ↔ Blue" },
],
},
],
run: (img, p) => swapChannels(img, str(p, "pair") as ChannelSwapPair),
},
{
id: "black-and-white-png",
title: "Black & white threshold PNG",
description:
"Hard binarization by luminance: every pixel becomes black or white.",
category: "color",
params: [
{
id: "threshold",
label: "Brightness threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
run: (img, p) => thresholdBlackWhite(img, num(p, "threshold")),
},
{
id: "posterize-png",
title: "Posterize PNG",
description:
"Reduces the number of levels per channel — a poster effect.",
category: "color",
params: [
{
id: "levels",
label: "Levels per channel",
type: "slider",
min: 2,
max: 16,
step: 1,
default: 4,
},
],
run: (img, p) => posterize(img, num(p, "levels")),
},
{
id: "two-colors-png",
title: "Two colors PNG",
description:
"Recolors the image into two chosen colors by luminance threshold.",
category: "color",
params: [
{
id: "lightColor",
label: "Light areas color",
type: "color",
default: "#ffffff",
},
{
id: "darkColor",
label: "Dark areas color",
type: "color",
default: "#000000",
},
{
id: "threshold",
label: "Brightness threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
run: (img, p) =>
twoColors(
img,
str(p, "lightColor"),
str(p, "darkColor"),
num(p, "threshold"),
),
},
{
id: "gamma-png",
title: "Gamma correction PNG",
description:
"Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.",
category: "color",
params: [
{
id: "value",
label: "Gamma",
type: "slider",
min: 0.1,
max: 3,
step: 0.05,
default: 1,
},
],
run: (img, p) => gammaCorrection(img, num(p, "value")),
},
{
id: "auto-contrast-png",
title: "Auto contrast PNG",
description:
"Stretches each channel's range across the full available brightness range.",
category: "color",
params: [],
run: (img) => autoContrast(img),
},
{
id: "temperature-png",
title: "Temperature PNG",
description:
"Positive values make the image warmer (more orange), negative ones cooler (more blue).",
category: "color",
params: [
{
id: "percent",
label: "Temperature",
type: "slider",
min: -100,
max: 100,
step: 1,
default: 0,
},
],
run: (img, p) => temperature(img, num(p, "percent")),
},
{
id: "tint-png",
title: "Tint PNG",
description:
"Multiplies color channels by the chosen tint with the given strength.",
category: "color",
params: [
{
id: "color",
label: "Tint color",
type: "color",
default: "#ffb060",
},
{
id: "strength",
label: "Strength, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 30,
},
],
run: (img, p) => tint(img, str(p, "color"), num(p, "strength")),
},
{
id: "quantize-png",
title: "Quantize PNG",
description:
"Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.",
category: "color",
params: [
{
id: "colors",
label: "Colors (k)",
type: "slider",
min: 2,
max: 64,
step: 1,
default: 16,
},
],
run: (img, p) => quantizeImage(img, num(p, "colors")).image,
},
{
id: "decrease-color-count-png",
title: "Decrease Color Count PNG",
description:
"Same median-cut engine with fixed power-of-two presets — quick way to drop to 2–256 colors.",
category: "color",
params: [
{
id: "maxColors",
label: "Max colors",
type: "select",
default: "16",
options: [
{ value: "2", label: "2" },
{ value: "4", label: "4" },
{ value: "8", label: "8" },
{ value: "16", label: "16" },
{ value: "32", label: "32" },
{ value: "64", label: "64" },
{ value: "128", label: "128" },
{ value: "256", label: "256" },
],
},
],
run: (img, p) => quantizeImage(img, num(p, "maxColors")).image,
},
{
id: "custom-palette-png",
title: "Custom Palette PNG",
description:
"Maps every pixel to the nearest color from your comma-separated hex list.",
category: "color",
params: [
{
id: "colors",
label: "Palette (comma-separated hex)",
type: "text",
default: "#000000,#ffffff",
},
],
run: (img, p) => mapToNearest(img, parseHexList(str(p, "colors"))),
},
{
id: "dithering-png",
title: "Dithering PNG",
description:
"Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.",
category: "color",
params: [
{
id: "colors",
label: "Colors (k)",
type: "slider",
min: 2,
max: 16,
step: 1,
default: 4,
},
{
id: "pattern",
label: "Pattern",
type: "select",
default: "floyd-steinberg",
options: [
{ value: "floyd-steinberg", label: "Floyd–Steinberg" },
{ value: "bayer", label: "Bayer 4×4" },
],
},
],
run: (img, p) =>
ditherImage(
img,
num(p, "colors"),
str(p, "pattern") === "bayer" ? "bayer" : "floyd-steinberg",
),
},
];
}
+354
View File
@@ -0,0 +1,354 @@
import type { ToolEntry } from "../registry";
import { clonePixelImage } from "../core/types";
import { flattenOntoColor } from "../core/alpha";
import { toBase64, toDataUrl, decodeSvgText, encode } from "../core/io";
import { pixelsToHex, hexToPixels } from "../core/text";
import {
imageToByteRows,
bytesToImage,
imageToRgbValues,
rgbValuesToImage,
} from "../core/textio";
import {
COMPRESSION_LEVELS,
findMaxColorsWithin,
type CompressionLevel,
} from "../core/compress";
import { quantizeImage } from "../core/quantize";
import { num, str } from "../registry-helpers";
function decodeToPng(
id: string,
title: string,
description: string,
): ToolEntry {
return {
id,
title,
description,
category: "convert",
params: [],
run: (img) => clonePixelImage(img),
};
}
export function convertEntries(): ToolEntry[] {
return [
decodeToPng(
"jpg-to-png",
"Convert JPG to PNG",
"Opens a JPEG and saves it as lossless PNG. Transparency, if present, is preserved.",
),
decodeToPng(
"webp-to-png",
"Convert WebP to PNG",
"Re-encodes a WebP image into universal PNG.",
),
decodeToPng(
"gif-to-png",
"Convert GIF to PNG",
"Extracts the first frame of a GIF animation and saves it as PNG.",
),
decodeToPng(
"bmp-to-png",
"Convert BMP to PNG",
"Re-encodes BMP into compact lossless PNG.",
),
decodeToPng(
"ico-to-png",
"Convert ICO to PNG",
"Turns an .ico icon into a regular PNG of the chosen size.",
),
{
id: "png-to-bmp",
title: "Convert PNG to BMP",
description:
"Saves the image as 24-bit BMP without an alpha channel: transparency is replaced with a black background.",
category: "convert",
params: [],
run: (img) => flattenOntoColor(img, "#000000"),
output: { mime: "image/bmp", ext: "bmp" },
},
{
id: "png-to-base64",
title: "PNG to Base64",
description:
"Encodes the image into a base64 string for embedding in code or styles.",
category: "convert",
params: [],
resultType: "text",
toText: (img) => toBase64(img),
},
{
id: "base64-to-png",
title: "Base64 to PNG",
description:
"Decodes a base64 string or data-uri back into an image. Paste the string on the left.",
category: "convert",
sourceMode: "text",
params: [],
run: (img) => clonePixelImage(img),
},
{
id: "png-to-data-uri",
title: "PNG to Data URI",
description:
"Builds a full data-uri (data:image/png;base64,…) for embedding in HTML/CSS.",
category: "convert",
params: [],
resultType: "text",
toText: (img) => toDataUrl(img),
},
{
id: "data-uri-to-png",
title: "Data URI to PNG",
description: "Decodes data:image/…;base64,… back into an image file.",
category: "convert",
sourceMode: "text",
params: [],
run: (img) => clonePixelImage(img),
},
{
id: "png-to-hex",
title: "PNG to HEX pixels",
description:
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
category: "convert",
params: [],
resultType: "text",
toText: (img) => pixelsToHex(img),
},
{
id: "hex-to-png",
title: "HEX pixels to PNG",
description:
"Assembles an image from rrggbbaa hex values (space separated). Set the width — the height is computed automatically.",
category: "convert",
sourceMode: "text",
params: [
{
id: "width",
label: "Image width",
type: "number",
min: 1,
max: 10000,
step: 1,
default: 1,
},
],
runFromText: (text, p) =>
hexToPixels(text, Math.trunc(Number(p["width"]))),
run: (img) => clonePixelImage(img),
},
{
id: "png-to-bytes",
title: "PNG to Bytes",
description:
"Lists every pixel as four decimal bytes (R G B A), one image row per line.",
category: "convert",
params: [],
resultType: "text",
toText: (img) => imageToByteRows(img),
},
{
id: "bytes-to-png",
title: "Bytes to PNG",
description:
"Assembles an image from decimal RGBA byte numbers (any separators). Set the width — height is computed automatically.",
category: "convert",
sourceMode: "text",
params: [
{
id: "width",
label: "Image width",
type: "number",
min: 1,
max: 10000,
step: 1,
default: 32,
},
],
runFromText: (text, p) =>
bytesToImage(
text,
Math.trunc(num(p as Record<string, unknown>, "width")),
),
run: (img) => clonePixelImage(img),
},
{
id: "png-to-rgb-values",
title: "PNG to RGB Values",
description:
"Lists every pixel as rgba(r, g, b, a), one image row per line.",
category: "convert",
params: [],
resultType: "text",
toText: (img) => imageToRgbValues(img),
},
{
id: "rgb-values-to-png",
title: "RGB Values to PNG",
description:
"Assembles an image from rgba(r, g, b, a) numbers. Set the width — height is computed automatically.",
category: "convert",
sourceMode: "text",
params: [
{
id: "width",
label: "Image width",
type: "number",
min: 1,
max: 10000,
step: 1,
default: 32,
},
],
runFromText: (text, p) =>
rgbValuesToImage(
text,
Math.trunc(num(p as Record<string, unknown>, "width")),
),
run: (img) => clonePixelImage(img),
},
{
id: "convert-png-to-jpg",
title: "Convert PNG to JPG",
description:
"Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.",
category: "convert",
params: [
{
id: "background",
label: "Backdrop color",
type: "color",
default: "#ffffff",
},
{
id: "quality",
label: "JPEG quality",
type: "slider",
min: 1,
max: 100,
step: 1,
default: 90,
},
],
output: {
mime: "image/jpeg",
ext: "jpg",
qualityParamId: "quality",
},
run: (img, p) => flattenOntoColor(img, str(p, "background")),
},
{
id: "convert-png-to-webp",
title: "Convert PNG to WebP",
description:
"Re-encodes the image into WebP with adjustable quality. Transparency is preserved.",
category: "convert",
params: [
{
id: "quality",
label: "WebP quality",
type: "slider",
min: 1,
max: 100,
step: 1,
default: 90,
},
],
output: {
mime: "image/webp",
ext: "webp",
qualityParamId: "quality",
},
run: (img) => clonePixelImage(img),
},
{
id: "compress-png",
title: "Compress PNG",
description:
"Shrinks the PNG by reducing its palette to a preset level. Honest trade-off: fewer colors = smaller file.",
category: "convert",
params: [
{
id: "level",
label: "Compression level",
type: "select",
default: "balanced",
options: [
{ value: "light", label: "Light (192 colors)" },
{ value: "balanced", label: "Balanced (96 colors)" },
{ value: "strong", label: "Strong (44 colors)" },
{ value: "extreme", label: "Extreme (16 colors)" },
],
},
],
run: (img, p) => {
const level = str(p, "level") as CompressionLevel;
const k = COMPRESSION_LEVELS[level] ?? COMPRESSION_LEVELS.balanced;
return quantizeImage(img, k).image;
},
},
{
id: "reduce-to-size-png",
title: "Reduce PNG to Size",
description:
"Binary-searches the palette size until the encoded PNG fits the target KB. Best effort: if even 2 colors exceed the target, returns the 2-color version.",
category: "convert",
domOnly: true,
params: [
{
id: "targetKB",
label: "Target size, KB",
type: "slider",
min: 5,
max: 2000,
step: 5,
default: 100,
},
{
id: "maxColors",
label: "Max colors to try",
type: "slider",
min: 2,
max: 256,
step: 1,
default: 256,
},
],
run: async (img, p) => {
const targetBytes = num(p, "targetKB") * 1024;
const maxK = num(p, "maxColors");
const encodeSize = async (k: number): Promise<number | null> => {
const blob = await encode(quantizeImage(img, k).image, "image/png");
return blob.size;
};
const k = await findMaxColorsWithin(targetBytes, maxK, encodeSize);
return quantizeImage(img, k).image;
},
},
{
id: "svg-to-png",
title: "SVG to PNG",
description:
"Decodes SVG markup into a raster image. Paste the SVG code on the left.",
category: "convert",
sourceMode: "text",
params: [
{
id: "width",
label: "Result width, px",
type: "number",
min: 1,
max: 10000,
step: 1,
default: 512,
},
],
runFromText: (text, p) =>
decodeSvgText(text, Math.trunc(num(p, "width"))),
run: (img) => clonePixelImage(img),
},
];
}
+209
View File
@@ -0,0 +1,209 @@
import type { ToolEntry } from "../registry";
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
import { vignette } from "../core/effects";
import {
pixelate,
shuffleBlocks,
addNoise,
silhouette,
} from "../core/pixel-fx";
import { jpegRoundtrip } from "../core/io";
import { num, str } from "../registry-helpers";
export function filterEntries(): ToolEntry[] {
return [
{
id: "blur-png",
title: "Blur PNG",
description:
"Gaussian blur: three passes of separable box blur — fast at any radius. Transparent edges do not darken.",
category: "filters",
params: [
{
id: "radius",
label: "Radius, px",
type: "slider",
min: 1,
max: 32,
step: 1,
default: 4,
},
],
run: (img, p) => gaussianBlur(img, num(p, "radius")),
},
{
id: "sharpen-png",
title: "Sharpen PNG",
description:
"Emphasizes edges with a sharpening kernel; strength sets the blend with the original. 0% means no change.",
category: "filters",
params: [
{
id: "strength",
label: "Strength, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
run: (img, p) => sharpenImage(img, num(p, "strength")),
},
{
id: "vignette-png",
title: "Vignette PNG",
description:
"Smoothly darkens the edges of the image, leaving the center untouched.",
category: "filters",
params: [
{
id: "strength",
label: "Darkening strength, %",
type: "slider",
min: 0,
max: 100,
step: 5,
default: 50,
},
],
run: (img, p) => vignette(img, num(p, "strength")),
},
{
id: "pixelate-png",
title: "Pixelate PNG",
description:
"Averages every blockSize×blockSize area into one color — classic mosaic.",
category: "filters",
params: [
{
id: "blockSize",
label: "Block size, px",
type: "slider",
min: 2,
max: 64,
step: 1,
default: 8,
},
],
run: (img, p) => pixelate(img, num(p, "blockSize")),
},
{
id: "randomize-pixels-png",
title: "Randomize Pixels PNG",
description:
"Shuffles blocks of the image between positions. Same seed gives the same arrangement.",
category: "filters",
params: [
{
id: "blockSize",
label: "Block size, px",
type: "slider",
min: 1,
max: 64,
step: 1,
default: 8,
},
{
id: "seed",
label: "Seed",
type: "number",
min: 0,
max: 999999,
step: 1,
default: 42,
},
],
run: (img, p) => shuffleBlocks(img, num(p, "blockSize"), num(p, "seed")),
},
{
id: "add-noise-png",
title: "Add Noise to PNG",
description:
"Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.",
category: "filters",
params: [
{
id: "amount",
label: "Amount, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 25,
},
{
id: "mode",
label: "Noise type",
type: "select",
default: "mono",
options: [
{ value: "mono", label: "Monochrome grain" },
{ value: "color", label: "Color noise" },
],
},
{
id: "seed",
label: "Seed",
type: "number",
min: 0,
max: 999999,
step: 1,
default: 1234,
},
],
run: (img, p) =>
addNoise(
img,
num(p, "amount"),
str(p, "mode") === "color" ? "color" : "mono",
num(p, "seed"),
),
},
{
id: "silhouette-png",
title: "Silhouette PNG",
description:
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
category: "filters",
params: [
{
id: "color",
label: "Silhouette color",
type: "color",
default: "#111318",
},
{
id: "threshold",
label: "Visibility threshold, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 10,
},
],
run: (img, p) =>
silhouette(img, str(p, "color"), num(p, "threshold") * 2.55),
},
{
id: "jpeg-artifacts-png",
title: "JPEG artifacts",
description:
"Simulates low-quality JPEG re-compression — visible blocks and smeared colors.",
category: "filters",
params: [
{
id: "quality",
label: "JPEG quality",
type: "slider",
min: 1,
max: 50,
step: 1,
default: 10,
},
],
run: (img, p) => jpegRoundtrip(img, num(p, "quality")),
},
];
}
+942
View File
@@ -0,0 +1,942 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { solidImage, noiseImage, gradientImage } from "../core/generate";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { renderTextToImage, renderEmoji, type TextFont } from "../core/domText";
import { changeCanvasSize } from "../core/geometry";
import {
renderWheel,
renderSwatches,
renderBlend,
complementarySet,
triadicSet,
tetradicSet,
analogousSet,
monochromaticSet,
shadeSet,
mixColors,
parseHexList,
stepColors,
sortPalette,
type SortKey,
} from "../core/palette";
import { num, str, bool } from "../registry-helpers";
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match) {
throw new ToolError("errors.badHex", { value: hex });
}
const d = match[1];
return [
parseInt(d.slice(0, 2), 16),
parseInt(d.slice(2, 4), 16),
parseInt(d.slice(4, 6), 16),
alpha,
];
}
function paletteParams(baseDefault: string) {
return [
{
id: "baseColor",
label: "Base color",
type: "color" as const,
default: baseDefault,
},
{
id: "width",
label: "Width",
type: "slider" as const,
min: 128,
max: 1024,
step: 16,
default: 512,
},
{
id: "layout",
label: "Layout",
type: "select" as const,
default: "grid",
options: [
{ value: "grid", label: "Grid" },
{ value: "strip", label: "Strip" },
],
},
];
}
export function generateEntries(): ToolEntry[] {
return [
{
id: "create-empty-png",
title: "Create empty PNG",
description:
"Generates a canvas of the chosen size — transparent or filled with color.",
category: "generate",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 800,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 600,
},
{
id: "transparent",
label: "Transparent",
type: "checkbox",
default: true,
},
{
id: "color",
label: "Color",
type: "color",
default: "#ffffff",
},
],
generate: (p) =>
solidImage(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
p["transparent"] === true ? [0, 0, 0, 0] : hexToRgba(str(p, "color")),
),
},
{
id: "single-color-png",
title: "Create solid color PNG",
description: "Generates a rectangle of the given size and color.",
category: "generate",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 256,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 256,
},
{
id: "color",
label: "Color",
type: "color",
default: "#ff0000",
},
],
generate: (p) =>
solidImage(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
hexToRgba(str(p, "color")),
),
},
{
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",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "seed",
label: "Seed",
type: "number",
min: 0,
max: 999999999,
step: 1,
default: 1,
},
],
generate: (p) =>
noiseImage(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
num(p, "seed"),
),
},
{
id: "text-to-png",
title: "Text to PNG",
description:
"Creates a PNG image from text: the canvas is sized to fit the label plus padding.",
category: "generate",
sourceMode: "none",
domOnly: true,
params: [
{
id: "text",
label: "Text",
type: "text",
default: "Hello!",
placeholder: "Your text",
},
{
id: "fontSize",
label: "Font size, px",
type: "slider",
min: 8,
max: 300,
step: 1,
default: 96,
},
{
id: "font",
label: "Font",
type: "select",
default: "sans",
options: [
{ value: "sans", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Monospace" },
],
},
{
id: "bold",
label: "Bold",
type: "checkbox",
default: true,
},
{
id: "color",
label: "Text color",
type: "color",
default: "#111318",
},
{
id: "transparentBg",
label: "Transparent background",
type: "checkbox",
default: false,
},
{
id: "backgroundColor",
label: "Background color",
type: "color",
default: "#ffffff",
},
{
id: "padding",
label: "Padding, px",
type: "slider",
min: 0,
max: 200,
step: 2,
default: 24,
},
],
generate: (p) =>
renderTextToImage({
text: str(p, "text"),
fontSize: num(p, "fontSize"),
font: str(p, "font") as TextFont,
bold: bool(p, "bold"),
color: str(p, "color"),
backgroundColor: str(p, "backgroundColor"),
transparentBg: bool(p, "transparentBg"),
padding: num(p, "padding"),
}),
},
{
id: "emoji-to-png",
title: "Emoji to PNG",
description:
"Renders an emoji or any Unicode symbol as a transparent PNG of the chosen size.",
category: "generate",
sourceMode: "none",
domOnly: true,
params: [
{
id: "emoji",
label: "Emoji / symbol",
type: "text",
default: "\u{1F600}",
},
{
id: "size",
label: "Size",
type: "slider",
min: 32,
max: 1024,
step: 16,
default: 256,
},
],
generate: (p) => renderEmoji(str(p, "emoji"), Math.trunc(num(p, "size"))),
},
{
id: "placeholder-png",
title: "Create Placeholder PNG",
description:
"Generates a placeholder rectangle with its dimensions printed in the center.",
category: "generate",
sourceMode: "none",
domOnly: true,
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 800,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 400,
},
{
id: "backgroundColor",
label: "Background",
type: "color",
default: "#dfe2e8",
},
{
id: "color",
label: "Text color",
type: "color",
default: "#5c6470",
},
{
id: "showText",
label: "Print dimensions",
type: "checkbox",
default: true,
},
],
generate: (p) => {
const w = Math.trunc(num(p, "width"));
const h = Math.trunc(num(p, "height"));
const label = renderTextToImage({
text: `${w} × ${h}`,
fontSize: Math.max(12, Math.round(Math.min(w, h) * 0.14)),
font: "sans",
bold: true,
color: str(p, "color"),
backgroundColor: str(p, "backgroundColor"),
transparentBg: false,
padding: 0,
});
return changeCanvasSize(label, w, h, "center");
},
},
{
id: "linear-gradient-png",
title: "Create gradient PNG",
description:
"Generates a smooth transition between two colors, horizontally or vertically.",
category: "generate",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 800,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 600,
},
{
id: "fromColor",
label: "Start color",
type: "color",
default: "#000000",
},
{
id: "toColor",
label: "End color",
type: "color",
default: "#ffffff",
},
{
id: "direction",
label: "Direction",
type: "select",
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
},
],
generate: (p) =>
gradientImage(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
hexToRgba(str(p, "fromColor")),
hexToRgba(str(p, "toColor")),
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
),
},
{
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",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 1024,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 128,
},
{
id: "direction",
label: "Direction",
type: "select",
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal" },
{ value: "vertical", label: "Vertical" },
],
},
{
id: "saturation",
label: "Saturation, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 100,
},
{
id: "lightness",
label: "Lightness, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
generate: (p) =>
colorSpectrum(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
str(p, "direction") === "vertical" ? "vertical" : "horizontal",
num(p, "saturation"),
num(p, "lightness"),
),
},
{
id: "random-colors-png",
title: "Random Color Blocks PNG",
description:
"Fills the canvas with random vivid color blocks. Deterministic by seed.",
category: "generate",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "blockSize",
label: "Block size, px",
type: "slider",
min: 4,
max: 256,
step: 2,
default: 64,
},
{
id: "seed",
label: "Seed",
type: "number",
min: 0,
max: 999999999,
step: 1,
default: 7,
},
],
generate: (p) =>
randomColorBlocks(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
num(p, "blockSize"),
num(p, "seed"),
),
},
{
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",
sourceMode: "none",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 5000,
step: 1,
default: 512,
},
{
id: "cols",
label: "Columns",
type: "slider",
min: 1,
max: 64,
step: 1,
default: 8,
},
{
id: "rows",
label: "Rows",
type: "slider",
min: 1,
max: 64,
step: 1,
default: 8,
},
{
id: "lineWidth",
label: "Line width, px",
type: "slider",
min: 1,
max: 40,
step: 1,
default: 2,
},
{
id: "color",
label: "Line color",
type: "color",
default: "#111318",
},
{
id: "transparentBg",
label: "Transparent background",
type: "checkbox",
default: true,
},
],
generate: (p) =>
drawGrid(
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
num(p, "cols"),
num(p, "rows"),
num(p, "lineWidth"),
str(p, "color"),
bool(p, "transparentBg"),
),
},
{
id: "color-wheel-png",
title: "Color Wheel PNG",
description:
"Generates an HSL color wheel: hue around the circle, saturation from center to edge, chosen lightness.",
category: "generate",
sourceMode: "none",
params: [
{
id: "width",
label: "Size",
type: "slider",
min: 128,
max: 1024,
step: 16,
default: 512,
},
{
id: "lightness",
label: "Lightness, %",
type: "slider",
min: 0,
max: 100,
step: 1,
default: 50,
},
],
generate: (p) =>
renderWheel(Math.trunc(num(p, "width")), num(p, "lightness")),
},
{
id: "complementary-png",
title: "Complementary Palette PNG",
description:
"Two opposite colors on the color wheel — the base and its complement.",
category: "generate",
sourceMode: "none",
params: paletteParams("#2563eb"),
generate: (p) =>
renderSwatches(
complementarySet(str(p, "baseColor")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "triadic-png",
title: "Triadic Palette PNG",
description: "Three colors evenly spaced 120° apart on the color wheel.",
category: "generate",
sourceMode: "none",
params: paletteParams("#ff0000"),
generate: (p) =>
renderSwatches(
triadicSet(str(p, "baseColor")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "tetradic-png",
title: "Tetradic Palette PNG",
description:
"Four colors in two complementary pairs, 90° apart on the wheel.",
category: "generate",
sourceMode: "none",
params: paletteParams("#8000ff"),
generate: (p) =>
renderSwatches(
tetradicSet(str(p, "baseColor")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "analogous-png",
title: "Analogous Palette PNG",
description:
"Neighboring hues around the base color — calm, related color scheme.",
category: "generate",
sourceMode: "none",
params: [
...paletteParams("#22c55e"),
{
id: "spread",
label: "Hue spread, °",
type: "slider",
min: 10,
max: 90,
step: 5,
default: 30,
},
{
id: "count",
label: "Colors",
type: "slider",
min: 3,
max: 9,
step: 1,
default: 5,
},
],
generate: (p) =>
renderSwatches(
analogousSet(str(p, "baseColor"), num(p, "spread"), num(p, "count")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "monochromatic-png",
title: "Monochromatic Palette PNG",
description:
"Tones of a single hue: lightness varies within the chosen range, hue and saturation stay fixed.",
category: "generate",
sourceMode: "none",
params: [
...paletteParams("#0ea5e9"),
{
id: "count",
label: "Colors",
type: "slider",
min: 2,
max: 9,
step: 1,
default: 5,
},
{
id: "range",
label: "Lightness range, %",
type: "slider",
min: 10,
max: 90,
step: 5,
default: 40,
},
],
generate: (p) =>
renderSwatches(
monochromaticSet(
str(p, "baseColor"),
num(p, "count"),
num(p, "range"),
),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "shades-png",
title: "Shade Ramp PNG",
description: "A ramp of the base color getting darker step by step.",
category: "generate",
sourceMode: "none",
params: [
...paletteParams("#f59e0b"),
{
id: "count",
label: "Colors",
type: "slider",
min: 2,
max: 9,
step: 1,
default: 5,
},
{
id: "depth",
label: "Darkening depth, %",
type: "slider",
min: 10,
max: 90,
step: 5,
default: 50,
},
],
generate: (p) =>
renderSwatches(
shadeSet(str(p, "baseColor"), num(p, "count"), num(p, "depth")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "mix-colors-png",
title: "Mix Colors PNG",
description:
"Averages several hex colors into one swatch. Enter comma-separated #hex values; invalid tokens are skipped.",
category: "generate",
sourceMode: "none",
params: [
{
id: "colors",
label: "Colors (comma-separated hex)",
type: "text",
default: "#ff0000,#00ff00,#0000ff",
},
{
id: "width",
label: "Width",
type: "slider",
min: 128,
max: 1024,
step: 16,
default: 512,
},
],
generate: (p) =>
renderSwatches(
[mixColors(parseHexList(str(p, "colors")))],
num(p, "width"),
"strip",
),
},
{
id: "blend-two-png",
title: "Blend Two Colors PNG",
description: "A continuous horizontal gradient between two colors.",
category: "generate",
sourceMode: "none",
params: [
{
id: "colorA",
label: "Color A",
type: "color",
default: "#000000",
},
{
id: "colorB",
label: "Color B",
type: "color",
default: "#ffffff",
},
{
id: "width",
label: "Width",
type: "slider",
min: 128,
max: 1024,
step: 16,
default: 512,
},
],
generate: (p) =>
renderBlend(str(p, "colorA"), str(p, "colorB"), num(p, "width")),
},
{
id: "step-colors-png",
title: "Color Steps PNG",
description: "A discrete set of evenly spaced steps between two colors.",
category: "generate",
sourceMode: "none",
params: [
{
id: "colorA",
label: "Color A",
type: "color",
default: "#000000",
},
{
id: "colorB",
label: "Color B",
type: "color",
default: "#ffffff",
},
{
id: "steps",
label: "Steps",
type: "slider",
min: 2,
max: 12,
step: 1,
default: 6,
},
...paletteParams("#808080").filter((q) => q.id !== "baseColor"),
],
generate: (p) =>
renderSwatches(
stepColors(str(p, "colorA"), str(p, "colorB"), num(p, "steps")),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
{
id: "sort-colors-png",
title: "Sort Colors PNG",
description:
"Renders your hex list as swatches sorted by hue, brightness or saturation. Invalid tokens are skipped.",
category: "generate",
sourceMode: "none",
params: [
{
id: "colors",
label: "Colors (comma-separated hex)",
type: "text",
default: "#ff0000,#ff8800,#ffff00,#00cc44,#0066ff,#8800ff",
},
{
id: "order",
label: "Sort by",
type: "select",
default: "hue",
options: [
{ value: "hue", label: "Hue" },
{ value: "luma", label: "Brightness" },
{ value: "sat", label: "Saturation" },
],
},
...paletteParams("#ffffff").filter((q) => q.id !== "baseColor"),
],
generate: (p) =>
renderSwatches(
sortPalette(
parseHexList(str(p, "colors")),
str(p, "order") as SortKey,
),
num(p, "width"),
str(p, "layout") as "strip" | "grid",
),
},
];
}
+657
View File
@@ -0,0 +1,657 @@
import type { ToolEntry } from "../registry";
import { ToolError } from "../core/errors";
import { field, toolSchema } from "../registry-schema";
import {
crop,
expandCanvas,
flip,
resize,
rotate90,
tile,
centerByAlpha,
changeCanvasSize,
cropToRatio,
forceOrientation,
padToRatio,
symmetricCopy,
trimToContent,
type Anchor9,
} from "../core/geometry";
import {
rotateFreeImage,
skewImage,
transformImage,
zoomImage,
} from "../core/affine";
import { num, str } from "../registry-helpers";
interface AddBorderParams {
thickness: number;
color: string;
}
const addBorderSchema = toolSchema<AddBorderParams>({
thickness: field.number({ min: 1, max: 500, step: 1, default: 5 }),
color: field.color({ default: "#000000" }),
});
export function geometryEntries(): ToolEntry[] {
return [
{
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",
params: [
{
id: "width",
label: "Width (0 = auto)",
type: "number",
min: 0,
max: 20000,
step: 1,
default: 0,
},
{
id: "height",
label: "Height (0 = auto)",
type: "number",
min: 0,
max: 20000,
step: 1,
default: 0,
},
{
id: "keepAspect",
label: "Keep aspect ratio",
type: "checkbox",
default: true,
},
],
run: (img, p) => {
const keepAspect = p["keepAspect"] === true;
let w = Math.trunc(num(p, "width"));
let h = Math.trunc(num(p, "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);
},
},
{
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",
params: [
{
id: "x",
label: "X (from left)",
type: "number",
min: -100000,
max: 100000,
step: 1,
default: 0,
},
{
id: "y",
label: "Y (from top)",
type: "number",
min: -100000,
max: 100000,
step: 1,
default: 0,
},
{
id: "width",
label: "Area width",
type: "number",
min: -100000,
max: 100000,
step: 1,
default: 0,
},
{
id: "height",
label: "Area height",
type: "number",
min: -100000,
max: 100000,
step: 1,
default: 0,
},
],
run: (img, p) => {
const w = Math.trunc(num(p, "width"));
const h = Math.trunc(num(p, "height"));
if (w <= 0 || h <= 0) {
throw new ToolError("errors.cropSize");
}
return crop(
img,
Math.trunc(num(p, "x")),
Math.trunc(num(p, "y")),
w,
h,
);
},
},
{
id: "rotate-png",
title: "Rotate PNG",
description:
"Rotates by 90°, 180° or 270° clockwise without quality loss.",
category: "geometry",
params: [
{
id: "angle",
label: "Rotation angle",
type: "select",
default: "90",
options: [
{ value: "90", label: "90° clockwise" },
{ value: "180", label: "180°" },
{ value: "270", label: "270° clockwise" },
],
},
],
run: (img, p) => rotate90(img, Number(str(p, "angle")) / 90),
},
{
id: "flip-png",
title: "Flip PNG",
description: "Mirrors horizontally or vertically without quality loss.",
category: "geometry",
params: [
{
id: "axis",
label: "Flip axis",
type: "select",
default: "horizontal",
options: [
{
value: "horizontal",
label: "Horizontal (left to right)",
},
{
value: "vertical",
label: "Vertical (top to bottom)",
},
],
},
],
run: (img, p) =>
flip(img, str(p, "axis") === "vertical" ? "vertical" : "horizontal"),
},
{
id: "add-padding-png",
title: "Add padding to PNG",
description:
"Expands the canvas on all sides by the chosen number of pixels.",
category: "geometry",
params: [
{
id: "padding",
label: "Padding, px",
type: "number",
min: 1,
max: 2000,
step: 1,
default: 10,
},
{
id: "transparent",
label: "Transparent padding",
type: "checkbox",
default: true,
},
{
id: "color",
label: "Padding color",
type: "color",
default: "#ffffff",
},
],
run: (img, p) =>
expandCanvas(
img,
num(p, "padding"),
num(p, "padding"),
num(p, "padding"),
num(p, "padding"),
p["transparent"] === true ? undefined : str(p, "color"),
),
},
{
id: "add-border-png",
title: "Add border to PNG",
description:
"Draws a colored frame of the chosen thickness around the image.",
category: "geometry",
schema: addBorderSchema,
params: [
{
id: "thickness",
label: "Border thickness, px",
type: "number",
min: 1,
max: 500,
step: 1,
default: 5,
},
{
id: "color",
label: "Border color",
type: "color",
default: "#000000",
},
],
run: (img, p) =>
expandCanvas(
img,
num(p, "thickness"),
num(p, "thickness"),
num(p, "thickness"),
num(p, "thickness"),
str(p, "color"),
),
},
{
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",
params: [
{
id: "width",
label: "Canvas width",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 800,
},
{
id: "height",
label: "Canvas height",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 600,
},
{
id: "transparent",
label: "Transparent background",
type: "checkbox",
default: false,
},
{
id: "color",
label: "Background color",
type: "color",
default: "#ffffff",
},
],
run: (img, p) => {
const width = Math.trunc(num(p, "width"));
const height = Math.trunc(num(p, "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"] === true ? undefined : str(p, "color"),
);
},
},
{
id: "tile-png",
title: "Tile PNG",
description:
"Repeats the image in a grid of the chosen columns and rows.",
category: "geometry",
params: [
{
id: "columns",
label: "Columns",
type: "number",
min: 1,
max: 50,
step: 1,
default: 2,
},
{
id: "rows",
label: "Rows",
type: "number",
min: 1,
max: 50,
step: 1,
default: 2,
},
],
run: (img, p) => tile(img, num(p, "columns"), num(p, "rows")),
},
{
id: "center-by-alpha-png",
title: "Center PNG by content",
description:
"Finds the opaque part of the image and centers it on the original canvas.",
category: "geometry",
params: [],
run: (img) => centerByAlpha(img),
},
{
id: "skew-png",
title: "Skew PNG",
description:
"Shifts content horizontally and vertically — a perspective effect.",
category: "geometry",
params: [
{
id: "degX",
label: "Skew X, °",
type: "slider",
min: -80,
max: 80,
step: 1,
default: 0,
},
{
id: "degY",
label: "Skew Y, °",
type: "slider",
min: -80,
max: 80,
step: 1,
default: 0,
},
],
run: (img, p) => skewImage(img, num(p, "degX"), num(p, "degY")),
},
{
id: "rotate-free-png",
title: "Rotate by custom angle",
description:
"Rotation by any angle. The canvas grows to fit the new bounds; corners stay transparent.",
category: "geometry",
params: [
{
id: "angle",
label: "Angle, °",
type: "slider",
min: -180,
max: 180,
step: 1,
default: 15,
},
],
run: (img, p) => rotateFreeImage(img, num(p, "angle")),
},
{
id: "zoom-png",
title: "Zoom PNG",
description:
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
category: "geometry",
params: [
{
id: "scale",
label: "Scale, %",
type: "slider",
min: 100,
max: 500,
step: 10,
default: 200,
},
],
run: (img, p) => zoomImage(img, num(p, "scale")),
},
{
id: "trim-empty-space-png",
title: "Trim Empty Space PNG",
description:
"Crops transparent borders around the content. Pixels with alpha above the threshold count as content.",
category: "geometry",
params: [
{
id: "threshold",
label: "Alpha threshold",
type: "slider",
min: 0,
max: 254,
step: 1,
default: 0,
},
],
run: (img, p) => trimToContent(img, num(p, "threshold")),
},
{
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",
params: [
{
id: "width",
label: "Width",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 800,
},
{
id: "height",
label: "Height",
type: "number",
min: 1,
max: 20000,
step: 1,
default: 600,
},
{
id: "anchor",
label: "Anchor",
type: "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" },
],
},
],
run: (img, p) =>
changeCanvasSize(
img,
Math.trunc(num(p, "width")),
Math.trunc(num(p, "height")),
str(p, "anchor") as Anchor9,
),
},
{
id: "change-aspect-ratio-png",
title: "Change Aspect Ratio PNG",
description:
"Fits the image into a target aspect ratio: crop the center to fill, or pad with transparency.",
category: "geometry",
params: [
{
id: "ratio",
label: "Target ratio",
type: "select",
default: "1:1",
options: [
{ value: "1:1", label: "1:1" },
{ value: "4:3", label: "4:3" },
{ value: "3:4", label: "3:4" },
{ value: "3:2", label: "3:2" },
{ value: "2:3", label: "2:3" },
{ value: "16:9", label: "16:9" },
{ value: "9:16", label: "9:16" },
],
},
{
id: "mode",
label: "Mode",
type: "select",
default: "crop",
options: [
{ value: "crop", label: "Crop to fill" },
{ value: "pad", label: "Pad to fit" },
],
},
],
run: (img, p) => {
const [rw, rh] = str(p, "ratio").split(":").map(Number);
const ratio = rw / rh;
return str(p, "mode") === "pad"
? padToRatio(img, ratio)
: cropToRatio(img, ratio);
},
},
{
id: "swap-orientation-png",
title: "Swap Orientation PNG",
description:
"Rotates the image by 90° when its orientation differs from the target — landscape becomes portrait and back. Square images are untouched.",
category: "geometry",
params: [
{
id: "target",
label: "Target orientation",
type: "select",
default: "portrait",
options: [
{ value: "portrait", label: "Portrait" },
{ value: "landscape", label: "Landscape" },
],
},
],
run: (img, p) =>
forceOrientation(
img,
str(p, "target") === "landscape" ? "landscape" : "portrait",
),
},
{
id: "symmetric-copy-png",
title: "Symmetric Copy PNG",
description:
"Doubles the canvas by mirroring the kept side onto the empty half — instant symmetric pattern.",
category: "geometry",
params: [
{
id: "axis",
label: "Mirror line",
type: "select",
default: "vertical",
options: [
{
value: "vertical",
label: "Vertical (double width)",
},
{
value: "horizontal",
label: "Horizontal (double height)",
},
],
},
{
id: "keepSide",
label: "Keep side",
type: "select",
default: "left",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
],
},
],
run: (img, p) =>
symmetricCopy(
img,
str(p, "axis") === "horizontal" ? "horizontal" : "vertical",
str(p, "keepSide") as "left" | "right" | "top" | "bottom",
),
},
{
id: "shift-png",
title: "Shift PNG",
description: "Moves content by the given X and Y offset.",
category: "geometry",
params: [
{
id: "offsetX",
label: "Offset X, px",
type: "number",
min: -5000,
max: 5000,
step: 1,
default: 0,
},
{
id: "offsetY",
label: "Offset Y, px",
type: "number",
min: -5000,
max: 5000,
step: 1,
default: 0,
},
{
id: "color",
label: "Background color",
type: "color",
default: "#ffffff",
},
],
run: (img, p) =>
transformImage(
img,
[
1,
0,
0,
1,
-Math.trunc(num(p, "offsetX")),
-Math.trunc(num(p, "offsetY")),
],
img.width,
img.height,
str(p, "color"),
),
},
];
}
+315
View File
@@ -0,0 +1,315 @@
import type { ToolEntry } from "../registry";
import { drawTextBlock, drawTextTile, type TextFont } from "../core/domText";
import { formatStamp } from "../core/datefmt";
import type { Position9 } from "../core/textdraw";
import { num, str, bool } from "../registry-helpers";
export function textEntries(): ToolEntry[] {
return [
{
id: "add-text-png",
title: "Add text to PNG",
description:
"Draws a text label on the image: font, size, color, bold, position on a 3×3 grid and an optional backing plate.",
category: "text",
domOnly: true,
params: [
{
id: "text",
label: "Text",
type: "text",
default: "Hello!",
placeholder: "Your text",
},
{
id: "fontSize",
label: "Font size, px",
type: "slider",
min: 8,
max: 200,
step: 1,
default: 48,
},
{
id: "color",
label: "Text color",
type: "color",
default: "#ffffff",
},
{
id: "font",
label: "Font",
type: "select",
default: "sans",
options: [
{ value: "sans", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Monospace" },
],
},
{ id: "bold", label: "Bold", type: "checkbox", default: true },
{
id: "position",
label: "Position",
type: "select",
default: "bottom-right",
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" },
],
},
{
id: "margin",
label: "Margin, px",
type: "slider",
min: 0,
max: 200,
step: 1,
default: 24,
},
{
id: "plate",
label: "Backing plate",
type: "checkbox",
default: false,
},
{
id: "plateColor",
label: "Plate color",
type: "color",
default: "#000000",
},
{
id: "plateOpacity",
label: "Plate opacity, %",
type: "slider",
min: 0,
max: 100,
step: 5,
default: 60,
},
],
run: (img, p) =>
drawTextBlock(img, {
text: str(p, "text"),
fontSize: num(p, "fontSize"),
font: str(p, "font") as TextFont,
bold: bool(p, "bold"),
color: str(p, "color"),
opacityPercent: 100,
position: str(p, "position") as Position9,
margin: num(p, "margin"),
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
plateOpacityPercent: num(p, "plateOpacity"),
}),
},
{
id: "date-stamp-png",
title: "Date stamp PNG",
description:
"Stamps the current date and time using a format string (YYYY MM DD hh mm ss tokens). Same styling options as Add text.",
category: "text",
domOnly: true,
params: [
{
id: "format",
label: "Format",
type: "text",
default: "YYYY-MM-DD",
placeholder: "YYYY-MM-DD hh:mm",
},
{
id: "fontSize",
label: "Font size, px",
type: "slider",
min: 8,
max: 200,
step: 1,
default: 32,
},
{
id: "color",
label: "Text color",
type: "color",
default: "#ffffff",
},
{
id: "font",
label: "Font",
type: "select",
default: "mono",
options: [
{ value: "sans", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Monospace" },
],
},
{ id: "bold", label: "Bold", type: "checkbox", default: false },
{
id: "position",
label: "Position",
type: "select",
default: "bottom-right",
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" },
],
},
{
id: "margin",
label: "Margin, px",
type: "slider",
min: 0,
max: 200,
step: 1,
default: 20,
},
{
id: "plate",
label: "Backing plate",
type: "checkbox",
default: true,
},
{
id: "plateColor",
label: "Plate color",
type: "color",
default: "#000000",
},
{
id: "plateOpacity",
label: "Plate opacity, %",
type: "slider",
min: 0,
max: 100,
step: 5,
default: 55,
},
],
run: (img, p) =>
drawTextBlock(img, {
text: formatStamp(new Date(), str(p, "format")),
fontSize: num(p, "fontSize"),
font: str(p, "font") as TextFont,
bold: bool(p, "bold"),
color: str(p, "color"),
opacityPercent: 100,
position: str(p, "position") as Position9,
margin: num(p, "margin"),
plateColor: bool(p, "plate") ? str(p, "plateColor") : undefined,
plateOpacityPercent: num(p, "plateOpacity"),
}),
},
{
id: "watermark-tile-png",
title: "Watermark Tile PNG",
description:
"Covers the image with a repeating diagonal semi-transparent text tile — a protection watermark.",
category: "text",
domOnly: true,
params: [
{
id: "text",
label: "Text",
type: "text",
default: "DRAFT",
placeholder: "Watermark text",
},
{
id: "fontSize",
label: "Font size, px",
type: "slider",
min: 12,
max: 160,
step: 1,
default: 56,
},
{
id: "color",
label: "Text color",
type: "color",
default: "#ffffff",
},
{
id: "opacity",
label: "Opacity, %",
type: "slider",
min: 5,
max: 100,
step: 5,
default: 30,
},
{
id: "angle",
label: "Angle, °",
type: "slider",
min: -90,
max: 90,
step: 1,
default: -30,
},
{
id: "stepX",
label: "Step X, px",
type: "slider",
min: 40,
max: 600,
step: 10,
default: 220,
},
{
id: "stepY",
label: "Step Y, px",
type: "slider",
min: 40,
max: 600,
step: 10,
default: 180,
},
{
id: "font",
label: "Font",
type: "select",
default: "sans",
options: [
{ value: "sans", label: "Sans-serif" },
{ value: "serif", label: "Serif" },
{ value: "mono", label: "Monospace" },
],
},
{
id: "bold",
label: "Bold",
type: "checkbox",
default: true,
},
],
run: (img, p) =>
drawTextTile(img, {
text: str(p, "text"),
fontSize: num(p, "fontSize"),
font: str(p, "font") as TextFont,
bold: bool(p, "bold"),
color: str(p, "color"),
opacityPercent: num(p, "opacity"),
stepX: num(p, "stepX"),
stepY: num(p, "stepY"),
angleDeg: num(p, "angle"),
}),
},
];
}
+6
View File
@@ -144,9 +144,15 @@
--font-size-s: 12px; --font-size-s: 12px;
--font-size-m: 14px; --font-size-m: 14px;
--font-size-l: 16px; --font-size-l: 16px;
--font-size-xl: 24px;
--font-size-2xl: 32px;
--radius-s: 0px; --radius-s: 0px;
--radius-m: 4px; --radius-m: 4px;
--bp-mobile: 640px;
--bp-tablet: 800px;
--bp-desktop: 1100px;
} }
[data-theme="dark"] { [data-theme="dark"] {
+1 -1
View File
@@ -4,7 +4,7 @@
import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte"; import SettingsFooter from "$lib/components/kit/SettingsFooter.svelte";
import ToolCard from "$lib/components/kit/ToolCard.svelte"; import ToolCard from "$lib/components/kit/ToolCard.svelte";
import Button from "$lib/components/kit/ui/Button.svelte"; import Button from "$lib/components/kit/ui/Button.svelte";
import { TOOLS } from "$lib/registry"; import { TOOLS } from "$lib/registry-new";
import { Search } from "@lucide/svelte"; import { Search } from "@lucide/svelte";
let query = $state(""); let query = $state("");
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getTool } from "$lib/registry"; import { getTool } from "$lib/registry-new";
import ToolView from "$lib/components/kit/ToolView.svelte"; import SchemaToolView from "$lib/components/kit/SchemaToolView.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte"; import EmptyState from "$lib/components/kit/EmptyState.svelte";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte"; import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
@@ -26,7 +26,7 @@
/> />
</div> </div>
{:else} {:else}
<ToolView {tool} /> <SchemaToolView {tool} />
{/if} {/if}
<style> <style>
+1 -1
View File
@@ -1,4 +1,4 @@
import { TOOLS } from "$lib/registry"; import { TOOLS } from "$lib/registry-new";
export const prerender = true; export const prerender = true;