Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c28925673 | ||
|
|
743ae817ee | ||
|
|
19f2e13b88 | ||
|
|
4a13bfbd16 | ||
|
|
f286044ab9 | ||
|
|
2601aa5b5c | ||
|
|
1e9b35406d | ||
|
|
520a1e1d37 | ||
|
|
a19d3db929 | ||
|
|
58e75f2334 | ||
|
|
1c746299c9 |
@@ -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
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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[];
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { executeGenerate, executeStep } from "./executor";
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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];
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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];
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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")),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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"] {
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
import { TOOLS } from "$lib/registry";
|
import { TOOLS } from "$lib/registry-new";
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user