Compare commits
13
Commits
6cbb9f95f0
...
a1438e6f7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1438e6f7f | ||
|
|
5884f4bd82 | ||
|
|
e5b76ca3e1 | ||
|
|
a49785764b | ||
|
|
c0cebdc81f | ||
|
|
37a4a0576c | ||
|
|
b3d460a951 | ||
|
|
6be9d10c42 | ||
|
|
cc70750727 | ||
|
|
e2735c52ab | ||
|
|
d383cf29e3 | ||
|
|
bfad0c7d02 | ||
|
|
6750d5a373 |
@@ -1,8 +1,9 @@
|
||||
# План: дожать перевод инструментов в preview и переезд ветки `old/`
|
||||
|
||||
> Статус: **план.** Основа — `plan-composite-params.md` (Фазы 1–5). Текущее
|
||||
> состояние: **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) | Средняя | ~1ч |
|
||||
| Особые случаи (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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export { executeGenerate, executeStep } from "./executor";
|
||||
export {
|
||||
executeFromText,
|
||||
executeGenerate,
|
||||
executeStep,
|
||||
executeTextToText,
|
||||
executeToText,
|
||||
} from "./executor";
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 2–256 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 green–magenta / blue–yellow 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(),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
Reference in New Issue
Block a user