Compare commits

..
13 Commits
23 changed files with 2821 additions and 253 deletions
+67 -97
View File
@@ -1,8 +1,9 @@
# План: дожать перевод инструментов в preview и переезд ветки `old/`
> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 15). Текущее
> состояние: **40 из 125 инструментов переведены в `registry-new`**, старый UI
> уже живёт на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте
> Статус: **переведено 121 из 125; остальные 4 — отложены/закрыты решениями.**
> Текущее
> состояние: **121 инструмент переведён в `registry-new`**, старый UI уже живёт
> на `(old)/`-маршрутах, линтер-изоляция old↔preview на месте
> (плагин `isolation/no-mixed-imports`). Здесь — что сделать, чтобы:
>
> 1. в preview был **весь** каталог инструментов (плюс закрыты известные долги);
@@ -16,13 +17,16 @@
## Текущая картина (факты)
- `registry.ts` + `registry/`**125 инструментов**; `registry-new/`**40**;
остаток — **85**. (Ранний подсчёт «104» был занижен: извлечение суффиксом
`-png` теряло каналы `png-to-hsl/…`, текстовые вердикты `png-info` и т.п. и
обратные конвертеры `png-to-base64/…`.)
- Preview (`/preview`) показывает только переведённые 40; старый UI работает
- `registry.ts` + `registry/`**125 инструментов**; `registry-new/`**121**.
Не переведены только: `png-info` (отложен, хвост-фича с exif), `compress-png`
и `reduce-to-size-png` (закрыто решениями, см. ниже), `watermark-image-png`
(overlay — единственный реальный остаток).
- Preview (`/preview`) показывает переведённые; старый UI работает
на `/`-маршрутах (группа `(old)/`): `+page`, `demo`, `list-tools`,
`tools/[id]`.
`tools/[id]`. Переведено всё кроме четырёх хвостов: color 18, filters 3,
geometry 13, alpha 13 (+watermark-image в остатке), generate 10 (mix/sort),
analyze 12 (6 масок + verify + 5 вердиктов), convert 14 (jpg/webp/bmp +
5 png→texт + 6 text→png), text `watermark-tile-png`.
- Плагин `isolation/no-mixed-imports` (`web/eslint-plugins/isolation/`)
полностью разделяет ветки: старый `lib/registry.ts|/registry/**`,
`lib/registry-helpers.ts`, `lib/categories.ts`, `lib/tools/**`,
@@ -39,72 +43,41 @@
`ColorField.svelte`, пустой ruleset в `Toggle.svelte`, неиспользуемые токены
в `preview.css`.
## Скоуп перевода: 85 инструментов осталось
## Скоуп перевода: переведено всё переводимое (отложено 4 хвоста)
По категориям (в скобках — сколько из категории уже переведено; подсчёт по
`registry/` vs `registry-new/`, без учёта генерируемых `SpaceId` и params):
Каталог дожат до предела текущих решений: переведено 121 из 125. Оставшиеся
4 инструмента **отложены осознанно** (не делаем сейчас):
- **color — 18** (из 25, переведено 7): grayscale-png, invert-colors-png,
adjust-brightness-contrast-png, auto-contrast-png, sepia-png, posterize-png,
black-and-white-png, decrease-color-count-png, extract-channel-png,
swap-channels-png, change-png-opacity, change-png-hue, плюс 6 канальных
`png-to-{hsl,hsv,hsi,cmyk,ycbcr,lab}` (генерируются из `SPACES`);
`compress-png`/`reduce-to-size-png` — нет, они в convert. Простое; канальные —
на `core/channels.ts` (`renderSpace`). Нюанс: `change-png-opacity`/`hue`
похожи на переведённые, но это отдельные инструменты, не дубли.
- **geometry — 13** (из 18): rotate, flip, skew, zoom, shift, add-padding, tile,
trim-empty-space, change-aspect-ratio, swap-orientation, symmetric-copy,
center-by-alpha, rotate-free. В основном простое (slider/select/color/checkbox).
Нюансы: `rotate-free` — угол 0..360 (посмотреть reuse `AngleControl` или
slider); `tile` — cols/rows (два числа, можно `dimension` или два slider);
`shift` — offsetX/Y в px + цвет (не путать с `offset`-процентами).
- **alpha — 14** (из 21): remove-alpha, set-alpha, extract-alpha-mask,
round-corners, invert-alpha, remove-background, make-thicker, make-thinner,
feather-edges, clean-edges, harden-alpha, despeckle-alpha, close-holes +
**`watermark-image-png`** (см. особый случай). Остальное простое.
- **generate — 10** (из 21): emoji-to-png (emoji+size); палитровые генераторы
color-wheel, complementary, analogous, triadic, tetradic, monochromatic,
shades (baseColor + count/spread — простое); **`mix-colors`** и
**`sort-colors`** — особые случаи (списки).
- **convert — 14** (из 16): text-source-инструменты
base64/data-uri/hex/bytes/rgb-values-to-png + обратные
png-to-base64/data-uri/hex/bytes/rgb-values/bmp — **особый случай** (нужен
text-источник в preview); `compress-png`/`reduce-to-size-png` — **особый
случай** (output/формат, связан с backlog №7); `svg-to-png` — width.
- **analyze — 12** (из 13): show-transparent, show-grayscale, show-color,
light/dark-pixel-mask, unique-color-mask (color+opacity+tolerance+mode) —
простое, частично переиспользуемый предикат; **`verify-is-png`** — текстовый
вердикт, особый случай; плюс 5 text-вердиктов `png-info`, `png-is-grayscale`,
`png-file-size`, `png-is-transparent`, `png-orientation` — тоже текстовый
вывод (см. text-source/вердикт-механику).
- **filters — 3** (из 8): blur (radius), sharpen (strength), silhouette
(color+threshold). Простое.
- **text — 1** (из 3): **`watermark-tile-png`** — text+font-style+opacity+angle+
stepX/stepY. Переиспользует font-style; угол — AngleControl-подобный.
- **`watermark-image-png` (alpha)** — **отложен**. Нужна overlay-механика
(второе изображение-знак через `getOverlay`/store + параметры
scale/opacity/position/margin/aspect); решается отдельно, вместе с вопросом,
как второй источник изображения вписывается в schema-driven preview.
- **`png-info`** — **отложен осознанно**: будет серьёзно дорабатываться
отдельным райзом (exif-теги, редактирование, структурированный вывод),
для него нужен свой отдельный случай в UI, не «ещё один text/verdict».
- **`compress-png`** — **закрыт как дубликат** `decrease-color-count-png`:
пресеты сжатия 192/96/44 добавлены прямо в схему `decrease-color-count`.
- **`reduce-to-size-png`** — **отложен** до достройки кнопки Download
(backlog №7, «экспорт с лимитом размера»).
### Особые случаи (требуют достройки фундамента, не просто перевод)
### Переведено в этом проходе (text-механика)
1. **`watermark-image-png`** — нужна overlay-механика: второе изображение-знак
(`getOverlay`/store), которое загружается на странице инструмента и
применяется поверх. В новом preview это единственный непереведённый
инструмент с источником-картинкой помимо основного входа. Учесть связь с
`overlay-store` (упомянут в `plan-redesign.md`).
2. **Text-source / text-вердикты** (base64/data-uri/hex/bytes/rgb-values-to-png
и обратные png-to-base64/…; аналитические `png-info`, `png-is-grayscale`,
`png-file-size`, `png-is-transparent`, `png-orientation`, `verify-is-png`) —
в старом UI это `text`-параметры, из которых собирается изображение, либо
текстовый результат вместо картинки. В новом preview нужно решить, как
подаётся текст-вход (текстовое поле/паста на странице инструмента) и как
показывается результат для вердиктных инструментов (в конверсиях — не
картинка, а текст/бейдж).
3. **Output/формат** (`compress-png`, `reduce-to-size-png`; пересекается с
`convert-png-to-jpg`/`webp`) — по `backlog.md` №7 формат переезжает в
кнопку Download, а не в отдельный инструмент. Перед переводом решить:
переводим как инструменты с quality-параметром или дожидаемся достройки
download-формата.
4. **`mix-colors` / `sort-colors`** — нужен список значений (цвета/веса или
источник). В схеме нет list-kind: либо новый kind, либо фиксированное
число слотов, либо отложить до отдельной задачи про списки.
- **Типы:** `ToolEntry.input: "file" | "text" | "none"`,
`ToolEntry.result: "image" | "text" | "verdict"`, `OutputFormat` (mime/ext/
qualityParamId) для download.
- **Executor:** `executeFromText` / `executeToText` / `executeTextToText`
(прямые, без worker), экспорт в `preview/executor/index.ts`.
- **UI:** `SchemaTextSource` (textarea + Render), `SchemaTextResult`
(кол-блок с Copy/Download .txt или бейдж-вердикт по префиксу Yes/No),
интеграция в `SchemaPreview`/`SchemaToolView` (режимы file/text/generate,
рендер image/text/verdict, скрытие image-download для text-результата).
- **Инструменты:** convert 5×png→text (base64/data-uri/hex/bytes/rgb-values),
6×text→png (base64/data-uri/hex/bytes/rgb-values/svg, +width), analyze
verify-is-png (text→text) и 5 вердиктов (grayscale/file-size/transparent/
orientation). Доработка: `base64ToBytes` сужен до `Uint8Array<ArrayBuffer>`
(совместимость с `decodeBytes`).
- **Гейты:** eslint 0, prettier чист, svelte-check — только предсуществующий
ToolCard, тесты — только предсуществующий фейл `palette.test.ts`.
## Известные долги перед переездом (завести tasks и закрыть)
@@ -119,20 +92,18 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
## Шаги перевода остатка (порядок)
1. **Простые пачки** (основной объём, 1 инструмент = 1 диф, паттерн Фазы 2):
color (18) → filters (3) → geometry простое → alpha (кроме watermark-image).
2. **Analyze-маски и вердикты** (12): переиспользование предикатов
`core/masks` в новом `registry-new/analyze.ts`; `show-color-pixels` — аналог
переведённого `extract-color-from-png` (уже в registry-new); text-вердикты
(png-info, png-is-grayscale, png-file-size, png-is-transparent,
png-orientation, verify-is-png) — вместе с text-source-механикой (п. 5).
3. **Generate-палитры** (8 без mix/sort): генераторы с палитрой (как
`color-spectrum`/`step-colors`, кнопка Generate).
4. **Raw-пакеты** (watermark-tile): font-style + угол + dimension(+),
сверить с `add-text` (переведён).
5. **Особые случаи** — отдельным переговоренным решением (overlay для
watermark-image, text-source, output-формат, списки для mix/sort).
6. После категории — `schema.layout` для сгруппированных инструментов
1. **Простые пачки** **выполнено**: color (18) → filters (3) → geometry (13)
→ alpha (13/14, кроме watermark-image) → generate (10, включая mix/sort
через kind `colors`) → analyze-маски (6) → text `watermark-tile-png`.
2. **Text-механика****выполнено**: типы `input`/`result` + output-формат,
executor `executeFromText`/`toText`/`textToText`, UI
`SchemaTextSource`/`SchemaTextResult` + интеграция в preview, перевод
конвертеров convert (5 png→text + 6 text→png) и analyze (verify + 5
вердиктов). `png-info` отложен отдельным райзом (exif).
3. **Шаг про overlay не делается**`watermark-image-png` **отложен решением**
(см. «Скоуп перевода» выше), отдельным райзом вместе с вопросом второго
источника изображения в schema-driven preview.
4. После категории — `schema.layout` для сгруппированных инструментов
(шаг 32-33 паттерн) и ручная проверка в `/preview`.
## Переезд в `old/` (шаг 37 основного плана)
@@ -163,7 +134,8 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
дефолты совпадают со старыми, валидация/клампы поведения не меняют,
результат эквивалентен старому UI (сверить на одинаковых входах).
2. **Тяжёлые кейсы руками:** генераторы, маски, font-style/plate/gradient на
всем наборе, watermark-image (после overlay), text-source, output-формат.
всем наборе, text-source, output-формат. (`watermark-image` — позже, после
overlay-райза.)
3. **Сбор фидбека** — отдельные задачи на каждый найденный фикс; чеклист
зафиксировать в этом документе по мере находок (или ссылкой на tasks).
4. Сверка каталога с `tools-map.md` (покрытие/дедупликация
@@ -186,17 +158,15 @@ a11y `ColorField`, пустой ruleset `Toggle`. Плюс два, мешающ
## Оценка трудозатрат
| Часть | Сложность | Оценка |
| ---------------------------------------------------------------- | --------------- | --------------------- |
| Простые инструменты (color/filters/geometry/alpha без watermark) | Низкая-Средняя | ~5-8ч (≈47 шт) |
| Analyze-маски (12, часть в п.5 с text-source) | Средняя | ~2ч |
| Generate-палитры (8) | Средняя | ~2-3ч |
| watermark-tile (text) | Средняя | ~ |
| Особые случаи (overlay/text-source/format/списки) | Средняя-Высокая | ~4-6ч + ревью решения |
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
| Проверка/фидбек + правки | Зависит | ~3-6ч |
| **Итого** | | **~21-34ч** по фазам |
| Часть | Сложность | Оценка |
| ---------------------------------------------------------------------- | -------------- | ------------------------- |
| Простые инструменты (color/filters/geometry/alpha/generate/маски/text) | Низкая-Средняя | **выполнено** (62 шт) |
| Text-механика + конвертеры/вердикты | Средняя | **выполнено** (16 шт) |
| watermark-image / png-info / reduce-to-size (отложены решениями) | Высокая | отложено, отдельные райзы |
| Долги (ToolCard, palette.test.ts, lint:all остаток) | Средняя | ~2-4ч |
| Переезд в `old/` + конфиг плагина + гейты | Средняя-Низкая | ~2-4ч |
| Проверка/фидбек + правки | Зависит | ~3-6ч |
| **Итого** | | **~9-18ч** по фазам |
> Оценки ориентировочные; особые случаи — с ревью решения, объём финальных
> правок по фидбеку не предсказуем.
@@ -0,0 +1,77 @@
<script lang="ts">
import DownloadButton from "./ui/DownloadButton.svelte";
import { Sparkles, Upload } from "@lucide/svelte";
interface Props {
inputMode: "file" | "text" | "none";
canDownload: boolean;
running: boolean;
onupload: (file: File) => void;
ongenerate: () => void;
ondownload: () => void;
}
let {
inputMode,
canDownload,
running,
onupload,
ongenerate,
ondownload,
}: Props = $props();
</script>
<div class="actions">
{#if inputMode === "none"}
<button class="btn generate" onclick={ongenerate} disabled={running}>
<Sparkles size={14} />
{running ? "Generating…" : "Generate"}
</button>
{:else if inputMode === "file"}
<label class="btn 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}
{#if canDownload}
<DownloadButton label="Download result" onclick={ondownload} />
{/if}
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: var(--space-l);
flex-wrap: wrap;
}
.btn {
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 {
background: var(--color-main);
border-color: var(--color-main);
color: var(--color-background);
}
.generate:disabled {
opacity: 0.6;
cursor: default;
}
.upload input {
display: none;
}
</style>
@@ -10,6 +10,7 @@
import CheckboxControl from "./fields/schema/CheckboxControl.svelte";
import ColorControl from "./fields/schema/ColorControl.svelte";
import ColorPairControl from "./fields/schema/ColorPairControl.svelte";
import ColorsControl from "./fields/schema/ColorsControl.svelte";
import RangeControl from "./fields/schema/RangeControl.svelte";
import SelectControl from "./fields/schema/SelectControl.svelte";
import TextControl from "./fields/schema/TextControl.svelte";
@@ -31,6 +32,7 @@
slider: RangeControl,
color: ColorControl,
"color-pair": ColorPairControl,
colors: ColorsControl,
select: SelectControl,
checkbox: CheckboxControl,
text: TextControl,
+69 -135
View File
@@ -1,64 +1,78 @@
<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 SchemaActions from "$lib/components/kit/SchemaActions.svelte";
import SchemaResultTile from "$lib/components/kit/SchemaResultTile.svelte";
import SchemaSourceTile from "$lib/components/kit/SchemaSourceTile.svelte";
import type { PixelImage } from "$lib/core/types";
import { Check, Sparkles, Upload } from "@lucide/svelte";
interface Props {
inputMode?: "file" | "text" | "none";
resultKind?: "image" | "text" | "verdict";
source: PixelImage | null;
result: PixelImage | null;
running: boolean;
error: string;
isGenerator?: boolean;
textSource?: string;
textResult?: string | null;
running?: boolean;
error?: string;
onupload: (file: File) => void;
ongenerate?: () => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
ondownloadtxt?: () => void;
ondownload: () => void;
}
let {
inputMode = "file",
resultKind = "image",
source,
result,
running,
error,
isGenerator = false,
textSource = "",
textResult = null,
running = false,
error = "",
onupload,
ongenerate,
ontextsource,
onrendertext,
oncopytext,
ondownloadtxt,
ondownload,
}: Props = $props();
let sourceUrl = $derived(source ? toDataUrl(source) : null);
let resultUrl = $derived(result ? toDataUrl(result) : null);
const isGenerator = $derived(inputMode === "none");
const sourceValue = $derived(
isGenerator
? "—"
: inputMode === "text"
? "text"
: source
? `${source.width} × ${source.height} px`
: "—",
);
const resultValue = $derived(
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: textResult
? "text"
: "—",
);
</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>
<SchemaActions
{inputMode}
canDownload={resultKind === "image" && Boolean(result)}
{running}
{onupload}
ongenerate={ongenerate ?? (() => {})}
{ondownload}
/>
</div>
{#if error}
@@ -67,44 +81,31 @@
<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>
<SchemaSourceTile
mode={inputMode === "text" ? "text" : "file"}
{source}
{textSource}
{running}
ontextinput={ontextsource}
{onrendertext}
/>
{/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>
<SchemaResultTile
{resultKind}
{result}
{textResult}
wide={isGenerator || resultKind !== "image"}
{running}
oncopy={oncopytext}
{ondownloadtxt}
/>
</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: "SOURCE", value: sourceValue },
{ caption: "RESULT", value: resultValue },
{ caption: "FORMAT", value: "PNG" },
]}
/>
@@ -125,35 +126,6 @@
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);
@@ -164,44 +136,6 @@
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);
}
@@ -0,0 +1,101 @@
<script lang="ts">
import SchemaTextResult from "./SchemaTextResult.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { Check } from "@lucide/svelte";
interface Props {
resultKind?: "image" | "text" | "verdict";
result: PixelImage | null;
textResult?: string | null;
wide?: boolean;
running?: boolean;
oncopy?: () => void;
ondownloadtxt?: () => void;
}
let {
resultKind = "image",
result,
textResult = null,
wide = false,
running = false,
oncopy,
ondownloadtxt,
}: Props = $props();
const resultUrl = $derived(result ? toDataUrl(result) : null);
</script>
<figure class="tile" style:grid-column={wide ? "1 / -1" : undefined}>
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
<div class="canvas" class:checker={resultKind === "image"}>
{#if resultKind === "text" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="text"
oncopy={oncopy ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "verdict" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="verdict"
oncopy={oncopy ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "image" && resultUrl}
<img src={resultUrl} alt="result" />
{:else if running}
<Check size={22} />
{:else}
<span class="empty">no result yet</span>
{/if}
</div>
</figure>
<style>
.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);
}
.text-result-wrap {
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
}
</style>
@@ -0,0 +1,80 @@
<script lang="ts">
import SchemaTextSource from "./SchemaTextSource.svelte";
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
interface Props {
mode: "file" | "text";
source: PixelImage | null;
textSource?: string;
running?: boolean;
ontextinput?: (text: string) => void;
onrendertext?: () => void;
}
let {
mode,
source,
textSource = "",
running = false,
ontextinput,
onrendertext,
}: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null);
</script>
<figure class="tile">
<figcaption><span>SOURCE</span></figcaption>
<div class="canvas">
{#if mode === "text"}
<div class="text-source-wrap">
<SchemaTextSource
value={textSource}
disabled={running}
oninput={ontextinput ?? (() => {})}
onrender={onrendertext ?? (() => {})}
/>
</div>
{:else if sourceUrl}
<img src={sourceUrl} alt="source" />
{:else}
<span class="empty">choose an image</span>
{/if}
</div>
</figure>
<style>
.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;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.text-source-wrap {
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
}
</style>
@@ -0,0 +1,129 @@
<script lang="ts">
import { Copy, Download, FileText } from "@lucide/svelte";
import IconButton from "./ui/IconButton.svelte";
interface Props {
value: string;
kind: "text" | "verdict";
fileName?: string;
oncopy: () => void;
ondownload: () => void;
}
let {
value,
kind,
fileName = "result.txt",
oncopy,
ondownload,
}: Props = $props();
const tone = $derived(
/^(yes|true)/i.test(value.trim())
? "success"
: /^(no|false)/i.test(value.trim())
? "danger"
: "info",
);
</script>
{#if kind === "verdict"}
<div class="verdict">
<span class="verdict-label">RESULT</span>
<div class="badge badge-tone--{tone}" role="status">
<span class="verdict-text">{value}</span>
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
</div>
</div>
{:else}
<div class="text-result">
<div class="result-head">
<span class="result-label"><FileText size={14} /> {fileName}</span>
<div class="result-actions">
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
<IconButton
icon={Download}
label="Download .txt"
onclick={ondownload}
/>
</div>
</div>
<pre class="result-pre"><code>{value}</code></pre>
</div>
{/if}
<style>
.text-result {
display: grid;
gap: var(--space-m);
min-width: 0;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
}
.result-label {
display: inline-flex;
align-items: center;
gap: var(--space-m);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
}
.result-actions {
display: flex;
gap: var(--space-s);
}
.result-pre {
margin: 0;
box-sizing: border-box;
max-height: var(--space-xxxl);
overflow: auto;
padding: var(--space-l) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
white-space: pre;
}
.verdict {
display: grid;
gap: var(--space-m);
justify-items: start;
}
.verdict-label {
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-l);
padding: var(--space-m) var(--space-xl);
border: var(--size-border-thick) solid transparent;
border-radius: var(--radius-m);
font: var(--font-size-m) var(--font-mono);
}
.badge-tone--success {
background: var(--color-success-tint);
border-color: var(--color-success);
color: var(--color-success);
}
.badge-tone--danger {
background: var(--color-danger-tint);
border-color: var(--color-danger);
color: var(--color-danger);
}
.badge-tone--info {
background: var(--color-info-tint);
border-color: var(--color-info);
color: var(--color-info);
}
.verdict-text {
line-height: normal;
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
import { ArrowUp, Upload } from "@lucide/svelte";
interface Props {
value: string;
placeholder?: string;
disabled?: boolean;
oninput: (text: string) => void;
onrender: () => void;
onsample?: () => void;
}
let {
value,
placeholder = "Paste base64 / hex / bytes…",
disabled = false,
oninput,
onrender,
onsample,
}: Props = $props();
</script>
<div class="text-source">
<textarea
spellcheck="false"
{placeholder}
rows="6"
{value}
oninput={(e) => oninput((e.target as HTMLTextAreaElement).value)}
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onrender();
}
}}></textarea>
<div class="actions">
{#if onsample}
<button type="button" class="secondary" onclick={onsample}>
<Upload size={14} /> Try sample
</button>
{/if}
<button type="button" class="render" onclick={onrender} {disabled}>
<ArrowUp size={14} /> Render text
</button>
</div>
</div>
<style>
.text-source {
display: grid;
gap: var(--space-m);
}
textarea {
width: 100%;
min-height: var(--space-brand);
max-height: var(--space-xxxl);
resize: vertical;
box-sizing: border-box;
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
}
textarea:focus {
outline: none;
border-color: var(--color-main);
}
.actions {
display: flex;
gap: var(--space-m);
justify-content: flex-end;
}
.render {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.render:disabled {
opacity: 0.6;
cursor: default;
}
.secondary {
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);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.secondary:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
</style>
@@ -4,7 +4,13 @@
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 {
executeFromText,
executeGenerate,
executeStep,
executeTextToText,
executeToText,
} from "$lib/preview/executor";
import type { ToolEntry } from "$lib/registry-new";
import {
defaultSchemaParams,
@@ -22,10 +28,14 @@
);
const isGenerator = $derived(Boolean(tool.generate && !tool.run));
const inputMode = $derived(tool.input ?? "file");
const resultKind = $derived(tool.result ?? "image");
let values = $state<Record<string, unknown>>({});
let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null);
let textSource = $state("");
let textResult = $state<string | null>(null);
let running = $state(false);
let error = $state("");
let started = $state(false);
@@ -45,6 +55,7 @@
function reset() {
values = schema ? defaultSchemaParams(schema) : {};
result = null;
textResult = null;
error = "";
}
@@ -64,12 +75,29 @@
await runGenerate();
return;
}
if (!source) return;
error = "";
running = true;
const params = sanitizeSchemaParams(schema, values);
running = true;
try {
result = await executeStep(tool, source, params);
if (inputMode === "text") {
if (!textSource.trim()) return;
if (tool.textToText) {
textResult = await executeTextToText(tool, textSource);
result = null;
} else {
result = await executeFromText(tool, textSource, params);
textResult = null;
}
} else {
if (!source) return;
if (resultKind !== "image" && tool.toText) {
textResult = await executeToText(tool, source, params);
result = null;
} else {
result = await executeStep(tool, source, params);
textResult = null;
}
}
} catch (e) {
error = errorText(e);
} finally {
@@ -92,12 +120,36 @@
}
async function download() {
if (!result) return;
const blob = await encode(result, "image/png");
if (!result || !schema) return;
const out = tool.output;
const quality = out?.qualityParamId
? Number(values[out.qualityParamId]) / 100
: undefined;
const blob = await encode(result, out?.mime ?? "image/png", quality);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "with-border.png";
a.download = `${tool.id}.${out?.ext ?? "png"}`;
a.click();
URL.revokeObjectURL(url);
}
async function copyText() {
if (!textResult) return;
try {
await navigator.clipboard.writeText(textResult);
} catch (e) {
error = errorText(e);
}
}
async function downloadText() {
if (!textResult) return;
const blob = new Blob([textResult], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.id}.txt`;
a.click();
URL.revokeObjectURL(url);
}
@@ -107,7 +159,7 @@
});
$effect(() => {
if (isGenerator || !source || !started) return;
if (inputMode === "text" || isGenerator || !source || !started) return;
void values;
debouncedRun();
return () => debouncedRun.cancel();
@@ -142,11 +194,20 @@
<SchemaPreview
{source}
{result}
{textSource}
{textResult}
{inputMode}
{resultKind}
{running}
{error}
{isGenerator}
onupload={handleFile}
ongenerate={runGenerate}
ontextsource={(t) => {
textSource = t;
}}
onrendertext={run}
oncopytext={copyText}
ondownloadtxt={downloadText}
ondownload={download}
/>
</section>
@@ -0,0 +1,156 @@
<script lang="ts">
import type {
ColorList,
ColorListSpec,
FieldSpec,
} from "$lib/registry-schema";
interface Props {
label: string;
value: unknown;
spec: FieldSpec;
onchange?: (value: ColorList) => void;
}
let { label, value, spec, onchange }: Props = $props();
const sp = $derived(spec as ColorListSpec);
const current = $derived.by((): ColorList => {
if (Array.isArray(value)) {
const valid = value.filter(
(c): c is string => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c),
);
if (valid.length > 0) return valid;
}
return [...sp.default];
});
function setColor(index: number, hex: string) {
const next = [...current];
next[index] = hex;
onchange?.(next);
}
function addColor() {
onchange?.([...current, "#000000"]);
}
function removeColor(index: number) {
if (current.length <= 1) return;
onchange?.(current.filter((_, i) => i !== index));
}
</script>
<div class="control colors-control">
<span class="ctrl-label">{label}</span>
<div class="chips">
{#each current as hex, i (i)}
<div class="chip">
<label class="swatch-wrap">
<span class="swatch" style="background:{hex}"></span>
<input
type="color"
value={hex}
oninput={(e) => setColor(i, (e.target as HTMLInputElement).value)}
/>
</label>
<span class="hex">{hex}</span>
<button
type="button"
class="remove"
onclick={() => removeColor(i)}
disabled={current.length <= 1}
aria-label="Remove color"
>
×
</button>
</div>
{/each}
</div>
<button type="button" class="add" onclick={addColor}>+ Add color</button>
</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);
}
.ctrl-label {
color: var(--color-text);
font-size: var(--font-size-s);
letter-spacing: var(--space-text-l);
text-transform: uppercase;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
}
.chip {
display: grid;
grid-template-columns: var(--space-xxl) minmax(0, auto) auto;
align-items: center;
gap: var(--space-m);
padding: var(--space-s) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
}
.swatch-wrap {
display: grid;
position: relative;
}
.swatch {
width: var(--space-xxl);
height: var(--space-xxl);
border-radius: var(--radius-s);
border: var(--size-border) solid var(--color-border);
}
.swatch-wrap input[type="color"] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.hex {
color: var(--color-text-muted);
font-size: var(--font-size-s);
}
.remove {
display: flex;
align-items: center;
justify-content: center;
width: var(--space-xl);
height: var(--space-xl);
border: none;
border-radius: var(--radius-s);
background: none;
color: var(--color-text-muted);
font-size: var(--font-size-s);
cursor: pointer;
}
.remove:hover:not(:disabled) {
color: var(--color-text);
background: var(--color-background-muted);
}
.remove:disabled {
opacity: 0.4;
cursor: default;
}
.add {
justify-self: start;
padding: var(--space-s) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
</style>
+1 -1
View File
@@ -85,7 +85,7 @@ export function stripDataUri(text: string): string {
return m ? text.slice(m[0].length) : text.trim();
}
export function base64ToBytes(text: string): Uint8Array {
export function base64ToBytes(text: string): Uint8Array<ArrayBuffer> {
const clean = text.replace(/\s+/g, "");
const binary = atob(clean);
const bytes = new Uint8Array(binary.length);
+47
View File
@@ -55,6 +55,53 @@ export async function executeGenerate(
return await tool.generate(params);
}
type MaybeTextRunnable = MaybeRunnable & {
runFromText?: (
text: string,
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
toText?: (
img: PixelImage,
params: Record<string, unknown>,
) => Promise<string> | string;
textToText?: (text: string) => Promise<string> | string;
};
/** Текст → изображение (text-source конвертеры). Выполняется напрямую. */
export async function executeFromText(
tool: MaybeTextRunnable,
text: string,
params: Record<string, unknown>,
): Promise<PixelImage> {
if (!tool.runFromText) {
throw new Error("errors.noTextInput");
}
return await tool.runFromText(text, params);
}
/** Изображение → текст (текстовые конвертеры и вердикты). */
export async function executeToText(
tool: MaybeTextRunnable,
img: PixelImage,
params: Record<string, unknown>,
): Promise<string> {
if (!tool.toText) {
throw new Error("errors.noTextResult");
}
return await tool.toText(img, params);
}
/** Текст → текст (напр. verify-is-png). */
export async function executeTextToText(
tool: MaybeTextRunnable,
text: string,
): Promise<string> {
if (!tool.textToText) {
throw new Error("errors.noTextResult");
}
return await tool.textToText(text);
}
async function runDirect(
tool: MaybeRunnable,
img: PixelImage,
+7 -1
View File
@@ -1 +1,7 @@
export { executeGenerate, executeStep } from "./executor";
export {
executeFromText,
executeGenerate,
executeStep,
executeTextToText,
executeToText,
} from "./executor";
+278 -2
View File
@@ -1,8 +1,26 @@
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 {
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 { defringe, featherAlpha } from "../core/pixel-fx";
import {
boxTest,
circleTest,
@@ -242,6 +260,251 @@ const wavyMask: ToolEntry<WavyMaskParams> = {
),
};
interface EmptyParams {}
export const removeAlphaChannelSchema = toolSchema<EmptyParams>({});
const removeAlphaChannel: ToolEntry<EmptyParams> = {
id: "remove-alpha-channel-png",
title: "Remove alpha channel PNG",
description:
"Composites the image over a white background and saves without transparency.",
category: "alpha",
schema: removeAlphaChannelSchema,
run: (img) => flattenOntoColor(img, "#ffffff"),
};
interface SetAlphaChannelParams {
percent: number;
}
export const setAlphaChannelSchema = toolSchema<SetAlphaChannelParams>({
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
});
const setAlphaChannelTool: ToolEntry<SetAlphaChannelParams> = {
id: "set-alpha-channel-png",
title: "Set alpha channel PNG",
description: "Assigns the same opacity to all pixels; colors stay unchanged.",
category: "alpha",
schema: setAlphaChannelSchema,
run: (img, p) => setAlphaChannel(img, p.percent),
};
export const extractAlphaMaskSchema = toolSchema<EmptyParams>({});
const extractAlphaMaskTool: ToolEntry<EmptyParams> = {
id: "extract-alpha-mask-png",
title: "Extract alpha mask PNG",
description: "Turns transparency into a black-and-white opaque mask.",
category: "alpha",
schema: extractAlphaMaskSchema,
run: (img) => extractAlphaMask(img),
};
interface RoundCornersParams {
radius: number;
}
export const roundCornersSchema = toolSchema<RoundCornersParams>({
radius: field.slider({ min: 0, max: 50, step: 1, default: 10 }),
});
const roundCornersTool: ToolEntry<RoundCornersParams> = {
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",
schema: roundCornersSchema,
run: (img, p) => roundCorners(img, p.radius),
};
export const invertAlphaSchema = toolSchema<EmptyParams>({});
const invertAlphaTool: ToolEntry<EmptyParams> = {
id: "invert-alpha-png",
title: "Invert alpha PNG",
description: "Opaque areas become transparent and vice versa.",
category: "alpha",
schema: invertAlphaSchema,
run: (img) => invertAlpha(img),
};
interface RemoveBackgroundParams {
color: string;
tolerance: number;
outerOnly: boolean;
smooth: number;
}
export const removeBackgroundSchema = toolSchema<RemoveBackgroundParams>(
{
color: field.color({ default: "#ffffff" }),
tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
outerOnly: field.checkbox({ default: true }),
smooth: field.slider({ min: 0, max: 8, step: 1, default: 1 }),
},
{
layout: {
groups: [
{
title: "Background",
cols: 2,
fields: ["color", "tolerance"],
},
{ title: "Options", fields: ["outerOnly", "smooth"] },
],
},
},
);
const removeBackgroundTool: ToolEntry<RemoveBackgroundParams> = {
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",
schema: removeBackgroundSchema,
run: (img, p) =>
removeBackground(img, {
color: p.color,
tolerancePercent: p.tolerance,
outerOnly: p.outerOnly,
smoothPasses: p.smooth,
}),
preview: (img, p) =>
backgroundMaskPreview(img, {
color: p.color,
tolerancePercent: p.tolerance,
outerOnly: p.outerOnly,
smoothPasses: p.smooth,
}),
};
interface MakeThickerParams {
radius: number;
}
export const makeThickerSchema = toolSchema<MakeThickerParams>({
radius: field.slider({ min: 1, max: 10, step: 1, default: 2 }),
});
const makeThickerTool: ToolEntry<MakeThickerParams> = {
id: "make-thicker-png",
title: "Thicken PNG",
description: "Expands opaque areas by the given number of pixels.",
category: "alpha",
schema: makeThickerSchema,
run: (img, p) => dilateImage(img, p.radius),
};
interface MakeThinnerParams {
radius: number;
}
export const makeThinnerSchema = toolSchema<MakeThinnerParams>({
radius: field.slider({ min: 1, max: 10, step: 1, default: 1 }),
});
const makeThinnerTool: ToolEntry<MakeThinnerParams> = {
id: "make-thinner-png",
title: "Thin PNG",
description: "Shrinks opaque areas — thins the strokes of text and details.",
category: "alpha",
schema: makeThinnerSchema,
run: (img, p) => erodeImage(img, p.radius),
};
interface FeatherEdgesParams {
radius: number;
}
export const featherEdgesSchema = toolSchema<FeatherEdgesParams>({
radius: field.slider({ min: 1, max: 20, step: 1, default: 3 }),
});
const featherEdgesTool: ToolEntry<FeatherEdgesParams> = {
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",
schema: featherEdgesSchema,
run: (img, p) => featherAlpha(img, p.radius),
};
interface CleanEdgesParams {
radius: number;
}
export const cleanEdgesSchema = toolSchema<CleanEdgesParams>({
radius: field.slider({ min: 1, max: 10, step: 1, default: 3 }),
});
const cleanEdgesTool: ToolEntry<CleanEdgesParams> = {
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",
schema: cleanEdgesSchema,
run: (img, p) => defringe(img, p.radius),
};
interface HardenAlphaParams {
threshold: number;
}
export const hardenAlphaSchema = toolSchema<HardenAlphaParams>({
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const hardenAlphaTool: ToolEntry<HardenAlphaParams> = {
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",
schema: hardenAlphaSchema,
run: (img, p) => hardenAlpha(img, p.threshold),
};
interface DespeckleAlphaParams {
radius: number;
}
export const despeckleAlphaSchema = toolSchema<DespeckleAlphaParams>({
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
});
const despeckleAlphaTool: ToolEntry<DespeckleAlphaParams> = {
id: "despeckle-alpha-png",
title: "Despeckle PNG",
description:
"Opening: removes lone semi-transparent pixels and small specks.",
category: "alpha",
schema: despeckleAlphaSchema,
run: (img, p) => openingImage(img, p.radius),
};
interface CloseHolesParams {
radius: number;
}
export const closeHolesSchema = toolSchema<CloseHolesParams>({
radius: field.slider({ min: 1, max: 3, step: 1, default: 1 }),
});
const closeHolesTool: ToolEntry<CloseHolesParams> = {
id: "close-holes-png",
title: "Close holes PNG",
description: "Closing: fills lone transparent dots inside the object.",
category: "alpha",
schema: closeHolesSchema,
run: (img, p) => closingImage(img, p.radius),
};
export const alphaEntries = [
addStroke,
findContour,
@@ -250,4 +513,17 @@ export const alphaEntries = [
squareMask,
starMask,
wavyMask,
removeAlphaChannel,
setAlphaChannelTool,
extractAlphaMaskTool,
roundCornersTool,
invertAlphaTool,
removeBackgroundTool,
makeThickerTool,
makeThinnerTool,
featherEdgesTool,
cleanEdgesTool,
hardenAlphaTool,
despeckleAlphaTool,
closeHolesTool,
];
+249 -2
View File
@@ -1,6 +1,15 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { extractByColor } from "../core/masks";
import {
extractByColor,
isGrayscaleish,
luma01,
rarityPredicate,
renderPredicateMask,
} from "../core/masks";
import { hasTransparency, isGrayscale, orientationOf } from "../core/analyze";
import { encode } from "../core/io";
import { base64ToBytes, looksLikePng, stripDataUri } from "../core/textio";
interface ExtractColorParams {
color: string;
@@ -22,4 +31,242 @@ const extractColor: ToolEntry<ExtractColorParams> = {
run: (img, p) => extractByColor(img, p.color, p.tolerance),
};
export const analyzeEntries = [extractColor];
interface MaskParams {
mode: "binary" | "highlight";
color: string;
opacity: number;
}
const maskBaseFields = {
mode: field.select({
default: "binary",
options: [
{ value: "binary", label: "Black & white mask" },
{ value: "highlight", label: "Color highlight" },
],
}),
color: field.color({ default: "#ff00aa" }),
opacity: field.slider({ min: 0, max: 100, step: 5, default: 70 }),
};
function renderMask(
img: Parameters<typeof renderPredicateMask>[0],
p: MaskParams,
predicate: (r: number, g: number, b: number, a: number) => boolean,
) {
return renderPredicateMask(img, predicate, {
mode: p.mode,
color: p.color,
opacityPercent: p.opacity,
});
}
export const showTransparentSchema = toolSchema<MaskParams>({
...maskBaseFields,
mode: field.select({
default: "highlight",
options: [
{ value: "binary", label: "Black & white mask" },
{ value: "highlight", label: "Color highlight" },
],
}),
});
const showTransparent: ToolEntry<MaskParams> = {
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.",
category: "analyze",
schema: showTransparentSchema,
run: (img, p) => renderMask(img, p, (_r, _g, _b, a) => a < 255),
};
interface GrayscalePixelsParams extends MaskParams {
tolerance: number;
}
export const showGrayscalePixelsSchema = toolSchema<GrayscalePixelsParams>({
...maskBaseFields,
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 0 }),
});
const showGrayscalePixels: ToolEntry<GrayscalePixelsParams> = {
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.",
category: "analyze",
schema: showGrayscalePixelsSchema,
run: (img, p) =>
renderMask(img, p, (r, g, b) => isGrayscaleish(r, g, b, p.tolerance)),
};
interface ColorPixelsParams extends MaskParams {
tolerance: number;
}
export const showColorPixelsSchema = toolSchema<ColorPixelsParams>({
...maskBaseFields,
tolerance: field.slider({ min: 0, max: 64, step: 1, default: 8 }),
});
const showColorPixels: ToolEntry<ColorPixelsParams> = {
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.",
category: "analyze",
schema: showColorPixelsSchema,
run: (img, p) =>
renderMask(img, p, (r, g, b) => !isGrayscaleish(r, g, b, p.tolerance)),
};
interface LightPixelParams extends MaskParams {
threshold: number;
}
export const lightPixelMaskSchema = toolSchema<LightPixelParams>({
...maskBaseFields,
threshold: field.slider({ min: 0, max: 100, step: 1, default: 70 }),
});
const lightPixelMask: ToolEntry<LightPixelParams> = {
id: "light-pixel-mask-png",
title: "Light Pixel Mask PNG",
description: "Selects pixels brighter than the luminance threshold.",
category: "analyze",
schema: lightPixelMaskSchema,
run: (img, p) =>
renderMask(img, p, (r, g, b) => luma01(r, g, b) >= p.threshold / 100),
};
interface DarkPixelParams extends MaskParams {
threshold: number;
}
export const darkPixelMaskSchema = toolSchema<DarkPixelParams>({
...maskBaseFields,
threshold: field.slider({ min: 0, max: 100, step: 1, default: 30 }),
});
const darkPixelMask: ToolEntry<DarkPixelParams> = {
id: "dark-pixel-mask-png",
title: "Dark Pixel Mask PNG",
description: "Selects pixels darker than the luminance threshold.",
category: "analyze",
schema: darkPixelMaskSchema,
run: (img, p) =>
renderMask(img, p, (r, g, b) => luma01(r, g, b) <= p.threshold / 100),
};
interface UniqueColorParams extends MaskParams {
rarity: number;
}
export const uniqueColorMaskSchema = toolSchema<UniqueColorParams>({
...maskBaseFields,
rarity: field.slider({ min: 1, max: 50, step: 1, default: 1 }),
});
const uniqueColorMask: ToolEntry<UniqueColorParams> = {
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.",
category: "analyze",
schema: uniqueColorMaskSchema,
run: (img, p) => renderMask(img, p, rarityPredicate(img, p.rarity)),
};
interface NoParams {}
const emptySchema = toolSchema<NoParams>({});
const verifyIsPng: ToolEntry<NoParams> = {
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",
schema: emptySchema,
input: "text",
result: "verdict",
textToText: (text) =>
looksLikePng(base64ToBytes(stripDataUri(text)))
? "Yes — valid PNG signature."
: "No — the content is not a PNG.",
};
const pngIsGrayscale: ToolEntry<NoParams> = {
id: "png-is-grayscale",
title: "Check: is PNG grayscale?",
description: "Reports whether the image consists only of shades of gray.",
category: "analyze",
schema: emptySchema,
result: "verdict",
toText: (img) =>
isGrayscale(img) ? "Yes — grayscale." : "No — contains colors.",
};
const pngFileSize: ToolEntry<NoParams> = {
id: "png-file-size",
title: "PNG File Size",
description: "Encodes the image as PNG and reports the resulting file size.",
category: "analyze",
schema: emptySchema,
domOnly: true,
result: "verdict",
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 `${kbText} KB`;
},
};
const pngIsTransparent: ToolEntry<NoParams> = {
id: "png-is-transparent",
title: "Check: is PNG transparent?",
description:
"Reports whether the image contains transparent or semi-transparent pixels.",
category: "analyze",
schema: emptySchema,
result: "verdict",
toText: (img) =>
hasTransparency(img) ? "Yes — has transparency." : "No — fully opaque.",
};
const pngOrientation: ToolEntry<NoParams> = {
id: "png-orientation",
title: "PNG orientation",
description: "Reports whether it is portrait, landscape or square.",
category: "analyze",
schema: emptySchema,
result: "verdict",
toText: (img) => {
switch (orientationOf(img)) {
case "portrait":
return "Portrait";
case "landscape":
return "Landscape";
default:
return "Square";
}
},
};
export const analyzeEntries = [
extractColor,
showTransparent,
showGrayscalePixels,
showColorPixels,
lightPixelMask,
darkPixelMask,
uniqueColorMask,
verifyIsPng,
pngIsGrayscale,
pngFileSize,
pngIsTransparent,
pngOrientation,
];
+351 -1
View File
@@ -1,4 +1,23 @@
import { gammaCorrection, temperature, twoColors, tint } from "../core/color";
import {
autoContrast,
brightnessContrast,
changeHue,
extractChannel,
gammaCorrection,
grayscale,
invert,
posterize,
sepia,
setOpacity,
swapChannels,
temperature,
thresholdBlackWhite,
tint,
twoColors,
type ChannelSwapPair,
type RgbChannel,
} from "../core/color";
import { renderSpace, SPACES, type SpaceId } from "../core/channels";
import { parseHexList } from "../core/palette";
import { ditherImage, mapToNearest, quantizeImage } from "../core/quantize";
import { field, toolSchema, type ColorPair } from "../registry-schema";
@@ -142,6 +161,324 @@ const ditheringTool: ToolEntry<DitheringParams> = {
run: (img, p) => ditherImage(img, p.colors, p.pattern),
};
interface EmptyParams {}
export const grayscaleSchema = toolSchema<EmptyParams>({});
const grayscaleTool: ToolEntry<EmptyParams> = {
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",
schema: grayscaleSchema,
run: (img) => grayscale(img),
};
export const invertColorsSchema = toolSchema<EmptyParams>({});
const invertColorsTool: ToolEntry<EmptyParams> = {
id: "invert-colors-png",
title: "Invert colors PNG",
description: "Inverts each color channel (255 value). Alpha is unchanged.",
category: "color",
schema: invertColorsSchema,
run: (img) => invert(img),
};
interface BrightnessContrastParams {
brightness: number;
contrast: number;
}
export const brightnessContrastSchema = toolSchema<BrightnessContrastParams>(
{
brightness: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
contrast: field.slider({ min: -100, max: 100, step: 1, default: 0 }),
},
{
layout: {
groups: [
{ title: "Adjust", cols: 2, fields: ["brightness", "contrast"] },
],
},
},
);
const brightnessContrastTool: ToolEntry<BrightnessContrastParams> = {
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",
schema: brightnessContrastSchema,
run: (img, p) => brightnessContrast(img, p.brightness, p.contrast),
};
interface OpacityParams {
percent: number;
}
export const opacitySchema = toolSchema<OpacityParams>({
percent: field.slider({ min: 0, max: 100, step: 1, default: 100 }),
});
const opacityTool: ToolEntry<OpacityParams> = {
id: "change-png-opacity",
title: "Change PNG opacity",
description:
"Multiplies the alpha channel by a percentage: 0% — fully transparent, 100% — unchanged.",
category: "color",
schema: opacitySchema,
run: (img, p) => setOpacity(img, p.percent),
};
export const sepiaSchema = toolSchema<EmptyParams>({});
const sepiaTool: ToolEntry<EmptyParams> = {
id: "sepia-png",
title: "Sepia effect",
description: "Tints the image into the warm brown tones of classic sepia.",
category: "color",
schema: sepiaSchema,
run: (img) => sepia(img),
};
interface HueShiftParams {
degrees: number;
}
export const hueShiftSchema = toolSchema<HueShiftParams>({
degrees: field.slider({ min: -180, max: 180, step: 1, default: 0 }),
});
const hueShiftTool: ToolEntry<HueShiftParams> = {
id: "change-png-hue",
title: "Change hue PNG",
description:
"Shifts the hue around the circle. Saturation and lightness are preserved.",
category: "color",
schema: hueShiftSchema,
run: (img, p) => changeHue(img, p.degrees),
};
interface ExtractChannelParams {
channel: RgbChannel;
}
export const extractChannelSchema = toolSchema<ExtractChannelParams>({
channel: field.select({
default: "red",
options: [
{ value: "red", label: "Red" },
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
],
}),
});
const extractChannelTool: ToolEntry<ExtractChannelParams> = {
id: "extract-channel-png",
title: "Extract channel PNG",
description:
"Keeps only the chosen channel — red, green or blue — as shades of gray.",
category: "color",
schema: extractChannelSchema,
run: (img, p) => extractChannel(img, p.channel),
};
interface SwapChannelsParams {
pair: ChannelSwapPair;
}
export const swapChannelsSchema = toolSchema<SwapChannelsParams>({
pair: field.select({
default: "r-g",
options: [
{ value: "r-g", label: "Red ↔ Green" },
{ value: "r-b", label: "Red ↔ Blue" },
{ value: "g-b", label: "Green ↔ Blue" },
],
}),
});
const swapChannelsTool: ToolEntry<SwapChannelsParams> = {
id: "swap-channels-png",
title: "Swap channels PNG",
description:
"Swaps two color channels — a quick way to get unusual coloring.",
category: "color",
schema: swapChannelsSchema,
run: (img, p) => swapChannels(img, p.pair),
};
interface BlackAndWhiteParams {
threshold: number;
}
export const blackAndWhiteSchema = toolSchema<BlackAndWhiteParams>({
threshold: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const blackAndWhiteTool: ToolEntry<BlackAndWhiteParams> = {
id: "black-and-white-png",
title: "Black & white threshold PNG",
description:
"Hard binarization by luminance: every pixel becomes black or white.",
category: "color",
schema: blackAndWhiteSchema,
run: (img, p) => thresholdBlackWhite(img, p.threshold),
};
interface PosterizeParams {
levels: number;
}
export const posterizeSchema = toolSchema<PosterizeParams>({
levels: field.slider({ min: 2, max: 16, step: 1, default: 4 }),
});
const posterizeTool: ToolEntry<PosterizeParams> = {
id: "posterize-png",
title: "Posterize PNG",
description: "Reduces the number of levels per channel — a poster effect.",
category: "color",
schema: posterizeSchema,
run: (img, p) => posterize(img, p.levels),
};
export const autoContrastSchema = toolSchema<EmptyParams>({});
const autoContrastTool: ToolEntry<EmptyParams> = {
id: "auto-contrast-png",
title: "Auto contrast PNG",
description:
"Stretches each channel's range across the full available brightness range.",
category: "color",
schema: autoContrastSchema,
run: (img) => autoContrast(img),
};
interface DecreaseColorCountParams {
maxColors:
"2" | "4" | "8" | "16" | "32" | "44" | "64" | "96" | "128" | "192" | "256";
}
export const decreaseColorCountSchema = toolSchema<DecreaseColorCountParams>({
maxColors: field.select({
default: "16",
options: [
{ value: "2", label: "2" },
{ value: "4", label: "4" },
{ value: "8", label: "8" },
{ value: "16", label: "16 (extreme)" },
{ value: "32", label: "32" },
{ value: "44", label: "44 (strong)" },
{ value: "64", label: "64" },
{ value: "96", label: "96 (balanced)" },
{ value: "128", label: "128" },
{ value: "192", label: "192 (light)" },
{ value: "256", label: "256" },
],
}),
});
const decreaseColorCountTool: ToolEntry<DecreaseColorCountParams> = {
id: "decrease-color-count-png",
title: "Decrease Color Count PNG",
description:
"Median-cut engine as a quick way to drop to 2256 colors. Presets marked (extreme/strong/balanced/light) match the classic compression levels.",
category: "color",
schema: decreaseColorCountSchema,
run: (img, p) => quantizeImage(img, Number(p.maxColors)).image,
};
interface ChannelParams {
component: string;
display: "gray" | "color";
}
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 greenmagenta / blueyellow opponents.",
},
];
function channelEntries(): ToolEntry<ChannelParams>[] {
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",
schema: toolSchema<ChannelParams>({
component: field.select({
default: components[0],
options: components.map((c) => ({
value: c,
label: c.toUpperCase(),
})),
}),
display: field.select({
default: "gray",
options: [
{ value: "gray", label: "Grayscale" },
{ value: "color", label: "Space as RGB" },
],
}),
}),
run: (img, p) => renderSpace(img, space.id, p.component, p.display),
} satisfies ToolEntry<ChannelParams>;
});
}
export const colorEntries = [
twoColorsTool,
gammaTool,
@@ -150,4 +487,17 @@ export const colorEntries = [
quantizeTool,
customPalette,
ditheringTool,
grayscaleTool,
invertColorsTool,
brightnessContrastTool,
opacityTool,
sepiaTool,
hueShiftTool,
extractChannelTool,
swapChannelsTool,
blackAndWhiteTool,
posterizeTool,
autoContrastTool,
decreaseColorCountTool,
...channelEntries(),
];
+202 -1
View File
@@ -1,7 +1,20 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { flattenOntoColor } from "../core/alpha";
import {
base64ToBytes,
bytesToImage,
imageToByteRows,
imageToRgbValues,
rgbValuesToImage,
stripDataUri,
} from "../core/textio";
import { hexToPixels, pixelsToHex } from "../core/text";
import { clonePixelImage } from "../core/types";
import { decodeBytes, decodeSvgText, toBase64, toDataUrl } from "../core/io";
const widthProps = (defaultValue: number) =>
field.number({ min: 1, max: 10000, step: 1, default: defaultValue });
interface ConvertToJpgParams {
background: string;
@@ -21,6 +34,7 @@ const convertToJpg: ToolEntry<ConvertToJpgParams> = {
category: "convert",
schema: convertToJpgSchema,
run: (img, p) => flattenOntoColor(img, p.background),
output: { mime: "image/jpeg", ext: "jpg", qualityParamId: "quality" },
};
interface ConvertToWebpParams {
@@ -39,6 +53,193 @@ const convertToWebp: ToolEntry<ConvertToWebpParams> = {
category: "convert",
schema: convertToWebpSchema,
run: (img) => clonePixelImage(img),
output: { mime: "image/webp", ext: "webp", qualityParamId: "quality" },
};
export const convertEntries = [convertToJpg, convertToWebp];
interface ConvertToBmpParams {}
export const convertToBmpSchema = toolSchema<ConvertToBmpParams>({});
const convertToBmp: ToolEntry<ConvertToBmpParams> = {
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",
schema: convertToBmpSchema,
run: (img) => flattenOntoColor(img, "#000000"),
output: { mime: "image/bmp", ext: "bmp" },
};
interface NoParams {}
const emptySchema = toolSchema<NoParams>({});
const pngToBase64: ToolEntry<NoParams> = {
id: "png-to-base64",
title: "PNG to Base64",
description:
"Encodes the image into a base64 string for embedding in code or styles.",
category: "convert",
schema: emptySchema,
result: "text",
toText: (img) => toBase64(img),
};
const pngToDataUri: ToolEntry<NoParams> = {
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",
schema: emptySchema,
result: "text",
toText: (img) => toDataUrl(img),
};
const pngToHex: ToolEntry<NoParams> = {
id: "png-to-hex",
title: "PNG to HEX pixels",
description:
"Shows all pixels as rrggbbaa hex values — row by row, space separated.",
category: "convert",
schema: emptySchema,
result: "text",
toText: (img) => pixelsToHex(img),
};
const pngToBytes: ToolEntry<NoParams> = {
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",
schema: emptySchema,
result: "text",
toText: (img) => imageToByteRows(img),
};
const pngToRgbValues: ToolEntry<NoParams> = {
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",
schema: emptySchema,
result: "text",
toText: (img) => imageToRgbValues(img),
};
const base64ToPng: ToolEntry<NoParams> = {
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",
schema: emptySchema,
input: "text",
runFromText: async (text) => decodeBytes(base64ToBytes(stripDataUri(text))),
};
const dataUriToPng: ToolEntry<NoParams> = {
id: "data-uri-to-png",
title: "Data URI to PNG",
description: "Decodes data:image/…;base64,… back into an image file.",
category: "convert",
schema: emptySchema,
input: "text",
runFromText: async (text) => decodeBytes(base64ToBytes(stripDataUri(text))),
};
interface HexToPngParams {
width: number;
}
export const hexToPngSchema = toolSchema<HexToPngParams>({
width: widthProps(1),
});
const hexToPng: ToolEntry<HexToPngParams> = {
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",
schema: hexToPngSchema,
input: "text",
runFromText: (text, p) => hexToPixels(text, Math.trunc(p.width)),
};
interface BytesToPngParams {
width: number;
}
export const bytesToPngSchema = toolSchema<BytesToPngParams>({
width: widthProps(32),
});
const bytesToPng: ToolEntry<BytesToPngParams> = {
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",
schema: bytesToPngSchema,
input: "text",
runFromText: (text, p) => bytesToImage(text, Math.trunc(p.width)),
};
interface RgbValuesToPngParams {
width: number;
}
export const rgbValuesToPngSchema = toolSchema<RgbValuesToPngParams>({
width: widthProps(32),
});
const rgbValuesToPng: ToolEntry<RgbValuesToPngParams> = {
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",
schema: rgbValuesToPngSchema,
input: "text",
runFromText: (text, p) => rgbValuesToImage(text, Math.trunc(p.width)),
};
interface SvgToPngParams {
width: number;
}
export const svgToPngSchema = toolSchema<SvgToPngParams>({
width: widthProps(512),
});
const svgToPng: ToolEntry<SvgToPngParams> = {
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",
schema: svgToPngSchema,
input: "text",
domOnly: true,
runFromText: (text, p) => decodeSvgText(text, Math.trunc(p.width)),
};
export const convertEntries = [
convertToJpg,
convertToWebp,
convertToBmp,
pngToBase64,
pngToDataUri,
pngToHex,
pngToBytes,
pngToRgbValues,
base64ToPng,
dataUriToPng,
hexToPng,
bytesToPng,
rgbValuesToPng,
svgToPng,
];
+66 -1
View File
@@ -1,9 +1,71 @@
import type { ToolEntry } from "./types";
import { field, toolSchema } from "../registry-schema";
import { addNoise, pixelate, shuffleBlocks } from "../core/pixel-fx";
import {
addNoise,
pixelate,
shuffleBlocks,
silhouette,
} from "../core/pixel-fx";
import { gaussianBlur, sharpen as sharpenImage } from "../core/convolution";
import { vignette } from "../core/effects";
import { jpegRoundtrip } from "../core/io";
interface BlurParams {
radius: number;
}
export const blurSchema = toolSchema<BlurParams>({
radius: field.slider({ min: 1, max: 32, step: 1, default: 4 }),
});
const blurTool: ToolEntry<BlurParams> = {
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",
schema: blurSchema,
run: (img, p) => gaussianBlur(img, p.radius),
};
interface SharpenParams {
strength: number;
}
export const sharpenSchema = toolSchema<SharpenParams>({
strength: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const sharpenTool: ToolEntry<SharpenParams> = {
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",
schema: sharpenSchema,
run: (img, p) => sharpenImage(img, p.strength),
};
interface SilhouetteParams {
color: string;
threshold: number;
}
export const silhouetteSchema = toolSchema<SilhouetteParams>({
color: field.color({ default: "#111318" }),
threshold: field.slider({ min: 0, max: 100, step: 1, default: 10 }),
});
const silhouetteTool: ToolEntry<SilhouetteParams> = {
id: "silhouette-png",
title: "Silhouette PNG",
description:
"Turns all visible pixels into a single solid color while keeping their transparency — instant silhouette.",
category: "filters",
schema: silhouetteSchema,
run: (img, p) => silhouette(img, p.color, p.threshold * 2.55),
};
interface VignetteParams {
strength: number;
}
@@ -129,4 +191,7 @@ export const filtersEntries = [
randomizePixels,
addNoiseTool,
jpegArtifacts,
blurTool,
sharpenTool,
silhouetteTool,
];
+307 -1
View File
@@ -1,13 +1,22 @@
import { renderTextToImage } from "../core/domText";
import { renderEmoji, renderTextToImage } from "../core/domText";
import { colorSpectrum, drawGrid, randomColorBlocks } from "../core/gen-tools";
import { noiseImage, solidImage } from "../core/generate";
import { changeCanvasSize } from "../core/geometry";
import type { PixelImage } from "../core/types";
import {
analogousSet,
complementarySet,
hexToRgb,
mixColors,
monochromaticSet,
renderBlend,
renderSwatches,
renderWheel,
shadeSet,
sortPalette,
stepColors,
tetradicSet,
triadicSet,
} from "../core/palette";
import {
field,
@@ -439,6 +448,293 @@ const stepColorsTool: ToolEntry<StepColorsParams> = {
),
};
interface EmojiToPngParams {
emoji: string;
size: number;
}
export const emojiToPngSchema = toolSchema<EmojiToPngParams>({
emoji: field.text({ default: "😀" }),
size: field.slider({ min: 32, max: 1024, step: 16, default: 256 }),
});
const emojiToPng: ToolEntry<EmojiToPngParams> = {
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",
domOnly: true,
schema: emojiToPngSchema,
generate: (p) => renderEmoji(p.emoji, Math.round(p.size)),
};
interface ColorWheelParams {
size: number;
lightness: number;
}
export const colorWheelSchema = toolSchema<ColorWheelParams>({
size: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
lightness: field.slider({ min: 0, max: 100, step: 1, default: 50 }),
});
const colorWheelTool: ToolEntry<ColorWheelParams> = {
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",
schema: colorWheelSchema,
generate: (p) => renderWheel(p.size, p.lightness),
};
interface PaletteBaseParams {
baseColor: string;
width: number;
layout: "strip" | "grid";
}
const paletteBaseSchema = {
baseColor: field.color({ default: "#2563eb" }),
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 paletteLayoutGroup = {
layout: {
groups: [
{ title: "Base color", fields: ["baseColor"] },
{
title: "Output",
cols: 2,
fields: ["width", "layout"],
},
],
},
};
const complementaryTool: ToolEntry<PaletteBaseParams> = {
id: "complementary-png",
title: "Complementary Palette PNG",
description:
"Two opposite colors on the color wheel — the base and its complement.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{ ...paletteBaseSchema },
{ ...paletteLayoutGroup },
),
generate: (p) =>
renderSwatches(complementarySet(p.baseColor), p.width, p.layout),
};
const triadicTool: ToolEntry<PaletteBaseParams> = {
id: "triadic-png",
title: "Triadic Palette PNG",
description: "Three colors evenly spaced 120° apart on the color wheel.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{
...paletteBaseSchema,
baseColor: field.color({ default: "#ff0000" }),
},
{ ...paletteLayoutGroup },
),
generate: (p) => renderSwatches(triadicSet(p.baseColor), p.width, p.layout),
};
const tetradicTool: ToolEntry<PaletteBaseParams> = {
id: "tetradic-png",
title: "Tetradic Palette PNG",
description:
"Four colors in two complementary pairs, 90° apart on the wheel.",
category: "generate",
schema: toolSchema<PaletteBaseParams>(
{
...paletteBaseSchema,
baseColor: field.color({ default: "#8000ff" }),
},
{ ...paletteLayoutGroup },
),
generate: (p) => renderSwatches(tetradicSet(p.baseColor), p.width, p.layout),
};
interface AnalogousParams extends PaletteBaseParams {
spread: number;
count: number;
}
const analogousTool: ToolEntry<AnalogousParams> = {
id: "analogous-png",
title: "Analogous Palette PNG",
description:
"Neighboring hues around the base color — calm, related color scheme.",
category: "generate",
schema: toolSchema<AnalogousParams>(
{
...paletteBaseSchema,
baseColor: field.color({ default: "#22c55e" }),
spread: field.slider({ min: 10, max: 90, step: 5, default: 30 }),
count: field.slider({ min: 3, max: 9, step: 1, default: 5 }),
},
{ ...paletteLayoutGroup },
),
generate: (p) =>
renderSwatches(
analogousSet(p.baseColor, p.spread, p.count),
p.width,
p.layout,
),
};
interface MonochromaticParams extends PaletteBaseParams {
count: number;
range: number;
}
const monochromaticTool: ToolEntry<MonochromaticParams> = {
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",
schema: toolSchema<MonochromaticParams>(
{
...paletteBaseSchema,
baseColor: field.color({ default: "#0ea5e9" }),
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
range: field.slider({ min: 10, max: 90, step: 5, default: 40 }),
},
{ ...paletteLayoutGroup },
),
generate: (p) =>
renderSwatches(
monochromaticSet(p.baseColor, p.count, p.range),
p.width,
p.layout,
),
};
interface ShadesParams extends PaletteBaseParams {
count: number;
depth: number;
}
const shadesTool: ToolEntry<ShadesParams> = {
id: "shades-png",
title: "Shade Ramp PNG",
description: "A ramp of the base color getting darker step by step.",
category: "generate",
schema: toolSchema<ShadesParams>(
{
...paletteBaseSchema,
baseColor: field.color({ default: "#f59e0b" }),
count: field.slider({ min: 2, max: 9, step: 1, default: 5 }),
depth: field.slider({ min: 10, max: 90, step: 5, default: 50 }),
},
{ ...paletteLayoutGroup },
),
generate: (p) =>
renderSwatches(shadeSet(p.baseColor, p.count, p.depth), p.width, p.layout),
};
interface MixColorsParams {
colors: string[];
width: number;
}
export const mixColorsSchema = toolSchema<MixColorsParams>(
{
colors: field.colors({ default: ["#ff0000", "#00ff00", "#0000ff"] }),
width: field.slider({ min: 128, max: 1024, step: 16, default: 512 }),
},
{
layout: {
groups: [
{ title: "Colors", fields: ["colors"] },
{ title: "Output", fields: ["width"] },
],
},
},
);
const mixColorsTool: ToolEntry<MixColorsParams> = {
id: "mix-colors-png",
title: "Mix Colors PNG",
description:
"Averages the selected colors into one swatch. Colors become one uniform fill.",
category: "generate",
schema: mixColorsSchema,
generate: (p) => renderSwatches([mixColors(p.colors)], p.width, "strip"),
};
interface SortColorsParams {
colors: string[];
order: "hue" | "luma" | "sat";
width: number;
layout: "strip" | "grid";
}
export const sortColorsSchema = toolSchema<SortColorsParams>(
{
colors: field.colors({
default: [
"#ff0000",
"#ff8800",
"#ffff00",
"#00cc44",
"#0066ff",
"#8800ff",
],
}),
order: field.select({
default: "hue",
options: [
{ value: "hue", label: "Hue" },
{ value: "luma", label: "Brightness" },
{ value: "sat", label: "Saturation" },
],
}),
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" },
],
}),
},
{
layout: {
groups: [
{ title: "Colors", fields: ["colors"] },
{
title: "Output",
cols: 2,
fields: ["order", "width", "layout"],
},
],
},
},
);
const sortColorsTool: ToolEntry<SortColorsParams> = {
id: "sort-colors-png",
title: "Sort Colors PNG",
description:
"Renders the chosen colors as swatches sorted by hue, brightness or saturation.",
category: "generate",
schema: sortColorsSchema,
generate: (p) =>
renderSwatches(sortPalette(p.colors, p.order), p.width, p.layout),
};
interface TextToPngParams {
text: string;
style: FontStyle;
@@ -505,5 +801,15 @@ export const generateEntries = [
placeholder,
blendTwo,
stepColorsTool,
emojiToPng,
colorWheelTool,
complementaryTool,
triadicTool,
tetradicTool,
analogousTool,
monochromaticTool,
shadesTool,
mixColorsTool,
sortColorsTool,
textToPng,
];
+355
View File
@@ -1,10 +1,26 @@
import { ToolError } from "../core/errors";
import {
rotateFreeImage,
skewImage,
transformImage,
zoomImage,
} from "../core/affine";
import {
centerByAlpha,
changeCanvasSize,
crop,
cropToRatio,
expandCanvas,
flip,
forceOrientation,
padToRatio,
resize,
rotate90,
symmetricCopy,
tile,
trimToContent,
type Anchor9,
type FlipAxis,
} from "../core/geometry";
import { field, toolSchema, type Dimension } from "../registry-schema";
import type { ToolEntry } from "./types";
@@ -222,10 +238,349 @@ const cropTool: ToolEntry<CropParams> = {
},
};
interface RotateParams {
angle: "90" | "180" | "270";
}
export const rotateSchema = toolSchema<RotateParams>({
angle: field.select({
default: "90",
options: [
{ value: "90", label: "90° clockwise" },
{ value: "180", label: "180°" },
{ value: "270", label: "270° clockwise" },
],
}),
});
const rotateTool: ToolEntry<RotateParams> = {
id: "rotate-png",
title: "Rotate PNG",
description: "Rotates by 90°, 180° or 270° clockwise without quality loss.",
category: "geometry",
schema: rotateSchema,
run: (img, p) => rotate90(img, Number(p.angle) / 90),
};
interface FlipParams {
axis: FlipAxis;
}
export const flipSchema = toolSchema<FlipParams>({
axis: field.select({
default: "horizontal",
options: [
{ value: "horizontal", label: "Horizontal (left to right)" },
{ value: "vertical", label: "Vertical (top to bottom)" },
],
}),
});
const flipTool: ToolEntry<FlipParams> = {
id: "flip-png",
title: "Flip PNG",
description: "Mirrors horizontally or vertically without quality loss.",
category: "geometry",
schema: flipSchema,
run: (img, p) => flip(img, p.axis),
};
interface AddPaddingParams {
padding: number;
transparent: boolean;
color: string;
}
export const addPaddingSchema = toolSchema<AddPaddingParams>(
{
padding: field.number({ min: 1, max: 2000, step: 1, default: 10 }),
transparent: field.checkbox({ default: true }),
color: field.color({ default: "#ffffff" }),
},
{
layout: {
groups: [
{ title: "Padding", fields: ["padding"] },
{ title: "Fill", fields: ["transparent", "color"] },
],
},
},
);
const addPaddingTool: ToolEntry<AddPaddingParams> = {
id: "add-padding-png",
title: "Add padding to PNG",
description:
"Expands the canvas on all sides by the chosen number of pixels.",
category: "geometry",
schema: addPaddingSchema,
run: (img, p) =>
expandCanvas(
img,
p.padding,
p.padding,
p.padding,
p.padding,
p.transparent ? undefined : p.color,
),
};
interface TileParams {
columns: number;
rows: number;
}
export const tileSchema = toolSchema<TileParams>({
columns: field.number({ min: 1, max: 50, step: 1, default: 2 }),
rows: field.number({ min: 1, max: 50, step: 1, default: 2 }),
});
const tileTool: ToolEntry<TileParams> = {
id: "tile-png",
title: "Tile PNG",
description: "Repeats the image in a grid of the chosen columns and rows.",
category: "geometry",
schema: tileSchema,
run: (img, p) => tile(img, p.columns, p.rows),
};
interface EmptyParams {}
export const centerByAlphaSchema = toolSchema<EmptyParams>({});
const centerByAlphaTool: ToolEntry<EmptyParams> = {
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",
schema: centerByAlphaSchema,
run: (img) => centerByAlpha(img),
};
interface SkewParams {
degX: number;
degY: number;
}
export const skewSchema = toolSchema<SkewParams>({
degX: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
degY: field.slider({ min: -80, max: 80, step: 1, default: 0 }),
});
const skewTool: ToolEntry<SkewParams> = {
id: "skew-png",
title: "Skew PNG",
description:
"Shifts content horizontally and vertically — a perspective effect.",
category: "geometry",
schema: skewSchema,
run: (img, p) => skewImage(img, p.degX, p.degY),
};
interface RotateFreeParams {
angle: number;
}
export const rotateFreeSchema = toolSchema<RotateFreeParams>({
angle: field.slider({ min: -180, max: 180, step: 1, default: 15 }),
});
const rotateFreeTool: ToolEntry<RotateFreeParams> = {
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",
schema: rotateFreeSchema,
run: (img, p) => rotateFreeImage(img, p.angle),
};
interface ZoomParams {
scale: number;
}
export const zoomSchema = toolSchema<ZoomParams>({
scale: field.slider({ min: 100, max: 500, step: 10, default: 200 }),
});
const zoomTool: ToolEntry<ZoomParams> = {
id: "zoom-png",
title: "Zoom PNG",
description:
"Magnifies content toward the center. The canvas keeps its size — edges are cropped.",
category: "geometry",
schema: zoomSchema,
run: (img, p) => zoomImage(img, p.scale),
};
interface TrimEmptySpaceParams {
threshold: number;
}
export const trimEmptySpaceSchema = toolSchema<TrimEmptySpaceParams>({
threshold: field.slider({ min: 0, max: 254, step: 1, default: 0 }),
});
const trimEmptySpaceTool: ToolEntry<TrimEmptySpaceParams> = {
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",
schema: trimEmptySpaceSchema,
run: (img, p) => trimToContent(img, p.threshold),
};
type AspectRatio = "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "16:9" | "9:16";
interface ChangeAspectRatioParams {
ratio: AspectRatio;
mode: "crop" | "pad";
}
export const changeAspectRatioSchema = toolSchema<ChangeAspectRatioParams>({
ratio: field.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" },
],
}),
mode: field.select({
default: "crop",
options: [
{ value: "crop", label: "Crop to fill" },
{ value: "pad", label: "Pad to fit" },
],
}),
});
const changeAspectRatioTool: ToolEntry<ChangeAspectRatioParams> = {
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",
schema: changeAspectRatioSchema,
run: (img, p) => {
const [rw, rh] = p.ratio.split(":").map(Number);
const ratio = rw / rh;
return p.mode === "pad" ? padToRatio(img, ratio) : cropToRatio(img, ratio);
},
};
interface SwapOrientationParams {
target: "portrait" | "landscape";
}
export const swapOrientationSchema = toolSchema<SwapOrientationParams>({
target: field.select({
default: "portrait",
options: [
{ value: "portrait", label: "Portrait" },
{ value: "landscape", label: "Landscape" },
],
}),
});
const swapOrientationTool: ToolEntry<SwapOrientationParams> = {
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",
schema: swapOrientationSchema,
run: (img, p) => forceOrientation(img, p.target),
};
type SymmetricAxis = "horizontal" | "vertical";
type KeepSide = "left" | "right" | "top" | "bottom";
interface SymmetricCopyParams {
axis: SymmetricAxis;
keepSide: KeepSide;
}
export const symmetricCopySchema = toolSchema<SymmetricCopyParams>({
axis: field.select({
default: "vertical",
options: [
{ value: "vertical", label: "Vertical (double width)" },
{ value: "horizontal", label: "Horizontal (double height)" },
],
}),
keepSide: field.select({
default: "left",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
],
}),
});
const symmetricCopyTool: ToolEntry<SymmetricCopyParams> = {
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",
schema: symmetricCopySchema,
run: (img, p) => symmetricCopy(img, p.axis, p.keepSide),
};
interface ShiftParams {
offsetX: number;
offsetY: number;
color: string;
}
export const shiftSchema = toolSchema<ShiftParams>({
offsetX: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
offsetY: field.number({ min: -5000, max: 5000, step: 1, default: 0 }),
color: field.color({ default: "#ffffff" }),
});
const shiftTool: ToolEntry<ShiftParams> = {
id: "shift-png",
title: "Shift PNG",
description: "Moves content by the given X and Y offset.",
category: "geometry",
schema: shiftSchema,
run: (img, p) =>
transformImage(
img,
[1, 0, 0, 1, -Math.trunc(p.offsetX), -Math.trunc(p.offsetY)],
img.width,
img.height,
p.color,
),
};
export const geometryEntries = [
addBorder,
fitOnBackground,
changeCanvasSizeTool,
resizeTool,
cropTool,
rotateTool,
flipTool,
addPaddingTool,
tileTool,
centerByAlphaTool,
skewTool,
rotateFreeTool,
zoomTool,
trimEmptySpaceTool,
changeAspectRatioTool,
swapOrientationTool,
symmetricCopyTool,
shiftTool,
];
+63 -2
View File
@@ -1,5 +1,5 @@
import { formatStamp } from "../core/datefmt";
import { drawTextBlock } from "../core/domText";
import { drawTextBlock, drawTextTile } from "../core/domText";
import type { Position9 } from "../core/textdraw";
import type { FontStyle, Plate } from "../registry-schema";
import { field, toolSchema } from "../registry-schema";
@@ -122,4 +122,65 @@ const dateStamp: ToolEntry<DateStampParams> = {
}),
};
export const textEntries = [addText, dateStamp];
interface WatermarkTileParams {
text: string;
style: FontStyle;
opacity: number;
angle: number;
stepX: number;
stepY: number;
}
const watermarkTileSchema = toolSchema<WatermarkTileParams>(
{
text: field.text({ default: "DRAFT", placeholder: "Watermark text" }),
style: field.fontStyle({
min: 12,
max: 160,
size: 56,
font: "sans",
bold: true,
color: "#ffffff",
}),
opacity: field.slider({ min: 5, max: 100, step: 5, default: 30 }),
angle: field.slider({ min: -90, max: 90, step: 1, default: -30 }),
stepX: field.slider({ min: 40, max: 600, step: 10, default: 220 }),
stepY: field.slider({ min: 40, max: 600, step: 10, default: 180 }),
},
{
layout: {
groups: [
{ title: "Watermark", fields: ["text", "style", "opacity"] },
{
title: "Tile",
cols: 2,
fields: ["angle", "stepX", "stepY"],
},
],
},
},
);
const watermarkTile: ToolEntry<WatermarkTileParams> = {
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,
schema: watermarkTileSchema,
run: (img, p) =>
drawTextTile(img, {
text: p.text,
fontSize: p.style.size,
font: p.style.font,
bold: p.style.bold,
color: p.style.color,
opacityPercent: p.opacity,
stepX: p.stepX,
stepY: p.stepY,
angleDeg: p.angle,
}),
};
export const textEntries = [addText, dateStamp, watermarkTile];
+14
View File
@@ -1,6 +1,14 @@
import type { CategoryId } from "../preview/categories";
import type { ToolSchema } from "../registry-schema";
import type { PixelImage } from "../core/types";
import type { OutputMime } from "../core/io";
/** Формат скачивания, отличный от PNG (bmp/jpeg/webp). */
export interface OutputFormat {
mime: OutputMime;
ext: string;
qualityParamId?: string;
}
/**
* Инструмент нового registry: полностью типизирован на `Params`, схема
@@ -15,6 +23,10 @@ export type ToolEntry<P = Record<string, unknown>> = {
schema: ToolSchema<P>;
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
domOnly?: boolean;
/** Чем управляется инструмент: файлом (по умолчанию), текстом или ничем (генератор). */
input?: "file" | "text" | "none";
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
result?: "image" | "text" | "verdict";
/** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage;
@@ -22,5 +34,7 @@ export type ToolEntry<P = Record<string, unknown>> = {
runFromText?(text: string, params: P): Promise<PixelImage> | PixelImage;
textToText?(text: string): Promise<string> | string;
preview?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
/** Формат/качество скачивания результата; по умолчанию — PNG. */
output?: OutputFormat;
icon?: string;
};
+23
View File
@@ -79,6 +79,14 @@ export interface ColorPairSpec {
to: string;
}
/** Список цветов (палитра): массив hex-строк. */
export type ColorList = string[];
export interface ColorListSpec {
kind: "colors";
default: string[];
}
/** Смещение фигуры/объекта по центру: x + y в процентах. */
export interface Offset {
x: number;
@@ -187,6 +195,7 @@ export const fieldSpecs = {
checkbox: {} as CheckboxSpec,
dimension: {} as DimensionSpec,
"color-pair": {} as ColorPairSpec,
colors: {} as ColorListSpec,
offset: {} as OffsetSpec,
position9: {} as Position9Spec,
"font-style": {} as FontStyleSpec,
@@ -262,6 +271,9 @@ export const field = {
colorPair: (s: { from: string; to: string }): Field<ColorPair> => ({
spec: { kind: "color-pair", ...s },
}),
colors: (s: { default: string[] }): Field<ColorList> => ({
spec: { kind: "colors", ...s },
}),
offset: (s: {
min: number;
max: number;
@@ -327,6 +339,8 @@ export function defaultSchemaParams<P>(
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 === "colors") {
out[key] = [...spec.default];
} else if (spec.kind === "offset") {
out[key] = { x: spec.x, y: spec.y };
} else if (spec.kind === "font-style") {
@@ -431,6 +445,15 @@ export function sanitizeSchemaParams<P>(
out[key] = { from, to };
break;
}
case "colors": {
const r =
Array.isArray(raw) &&
raw.every((c) => typeof c === "string" && /^#[0-9a-f]{6}$/i.test(c))
? (raw as string[])
: undefined;
out[key] = r && r.length > 0 ? [...r] : [...spec.default];
break;
}
case "position9":
out[key] =
typeof raw === "string" &&