Compare commits
26
Commits
7fa2c52bb8
...
a2dd288851
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2dd288851 | ||
|
|
cb5ccf6da8 | ||
|
|
b434bf3ef3 | ||
|
|
200e971df5 | ||
|
|
5e44a45457 | ||
|
|
76f1770286 | ||
|
|
68476c3b8c | ||
|
|
702b96261d | ||
|
|
95ee598875 | ||
|
|
46af693082 | ||
|
|
9fbb90b641 | ||
|
|
c14301130f | ||
|
|
fd5236c19b | ||
|
|
3d7371492f | ||
|
|
1b3bad46d1 | ||
|
|
474c1156aa | ||
|
|
91b3492231 | ||
|
|
3a5acfbc45 | ||
|
|
306c26a360 | ||
|
|
c93a8e493e | ||
|
|
ce7f5ff680 | ||
|
|
8532eb98c3 | ||
|
|
23c985bde9 | ||
|
|
8632e2e1cf | ||
|
|
6fae98b25c | ||
|
|
f9a4973b47 |
@@ -1,6 +1,6 @@
|
||||
# План: вторая волна EASY-инструментов
|
||||
|
||||
> Статус: план к выполнению.
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Принципы отбора
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# План: герой-поиск v2 — иконки, популярность, карточки
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Что получается
|
||||
|
||||
Выдача поиска на главной превращается из списка строк в сетку карточек: слева иконка инструмента, справа название и описание. Инструменты получают два новых поля реестра — `popularity` (приоритет в выдаче) и `icon` (идентификатор иконки). Иконки переиспользуются в каталоге `/list-tools` и в легендах шагов цепочки. Поиск работает по-прежнему: нечёткий скоринг, клавиатура ↑↓ Enter Esc.
|
||||
|
||||
## 2. Данные реестра
|
||||
|
||||
- **`popularity?: number`** (0–100, по умолчанию 50) — приоритет внутри равного скоринга запроса. Значения проставляются вручную осознанно: базовые операции (обрезка, размер, удаление фона, конвертации JPG/PNG) — 80–95; цветовые и геометрия — 40–70; узкие и генераторы — 10–30.
|
||||
- **`icon?: string`** — имя иконки из набора Lucide. Явный словарь-маппинг для всех инструментов в одном месте; отсутствие записи даёт нейтральную заглушку (Wrench).
|
||||
- Тест целостности расширяется: популярность в диапазоне, иконка — непустая строка из разрешённого набора.
|
||||
|
||||
## 3. Иконки
|
||||
|
||||
Зависимость `lucide-svelte` (MIT) — один пакет, иконки рендерятся компонентами, дерево не раздувается за счёт выборочного импорта.
|
||||
|
||||
Маппинг ведётся одним объектом `tool-icons.ts` рядом с реестром: 51 запись вида `'crop-png': Crop`. Подбор по смыслу: обрезка — Crop, размер — Scaling, поворот — RotateCw, отражение — FlipHorizontal2, оттенок — Palette, размытие — Droplets, резкость — Focus, удаление фона — Scissors, анализ — Info и так далее; для семейств конвертаций — FileInput/FileOutput варианты.
|
||||
|
||||
Иконка появляется в трёх местах: карточка выдачи, карточка каталога `/list-tools`, легенда шага цепочки («Шаг N: [icon] Название»).
|
||||
|
||||
## 4. Карточки выдачи
|
||||
|
||||
- Сетка: одна колонка на узком экране, две — начиная со среднего героя.
|
||||
- Композиция карточки: квадрат иконки слева, справа название (полужирное) и однострочное описание с обрезкой.
|
||||
- Состояния hover и активной с клавиатуры — тот же акцент подсветки, что сейчас.
|
||||
- Сортировка выдачи: скоринг запроса ↓, затем популярность ↓, затем название.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Реестр** — поля `popularity` и `icon`, словарь иконок на все 51, расширение тестов целостности. Проверка: `pnpm test/check`.
|
||||
- **B. Карточки поиска** — сетка выдачи с иконками и учётом популярности, клавиатура без изменений. Проверка: визуально, `check/build`.
|
||||
- **C. Распространение** — иконки в каталоге `/list-tools` и легендах звеньев. Проверка: визуально, `build`.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Пустой запрос на герое показывает популярные первыми после первого символа.
|
||||
2. Запрос «обрез» поднимает карточку обрезки выше прочих совпадений.
|
||||
3. У каждой карточки есть иконка; у большинства — разные, соответствующие смыслу.
|
||||
4. Наведение и стрелки подсвечивают карточку; Enter открывает инструмент.
|
||||
5. Открытый инструмент работает как раньше: параметры в колонках, результат, скачивание.
|
||||
6. Цепочка: легенда каждого шага показывает иконку выбранного инструмента.
|
||||
7. `/list-tools`: карточки категорий с иконками, ссылки работают.
|
||||
8. Регресс: старые сценарии (загрузка файла, цепочка, скачивание) не изменились.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Все инструменты имеют иконку и ненулевую популярность; тесты это гарантируют.
|
||||
- Выдача поиска — сетка карточек с корректной сортировкой и живой клавиатурой.
|
||||
- Иконки присутствуют в поиске, каталоге и легендах шагов.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Автоматический расчёт популярности из статистики использования — значения ручные.
|
||||
- Собственный набор SVG-иконок — берём готовый открытый.
|
||||
- Переводы названий и поиск на других языках — отдельная i18n-волна.
|
||||
- Изображения-превью в карточках поиска — только иконка и текст.
|
||||
@@ -0,0 +1,66 @@
|
||||
# План: MEDIUM-волна №1 — удаление фона, размытие, резкость + Web Worker
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
- Первые операции настоящего алгоритмического уровня: свёртки и заливка связной области.
|
||||
- Витринная фича каталога — удаление однотонного фона без нейросетей: порог по цвету + заливка от краёв изображения + сглаживание границы маски.
|
||||
- Эти операции реально блокируют UI-поток на больших картинках — повод включить давно подготовленный путь: исполнитель `executeStep` переезжает в Web Worker, страницы не меняются вовсе.
|
||||
|
||||
## 2. Инструменты — 3
|
||||
|
||||
- **Удалить фон PNG** (`remove-background-png`, категория «Прозрачность») — параметры: цвет фона, допуск похожести %, режим «только внешние области» (заливка от краёв вместо глобального совпадения), сглаживание границы (проходы мажоритарного фильтра маски). Даёт превью маски через существующий контракт `preview`.
|
||||
- **Размытие PNG** (`blur-png`, новая категория «Фильтры») — гауссово приближение тремя проходами разделяемого бокса; радиус слайдером 1–32 px.
|
||||
- **Резкость PNG** (`sharpen-png`, «Фильтры») — свёртка ядром резкости с регулируемой силой (смесь исходного и обработанного), сила слайдером 0–100 %.
|
||||
|
||||
## 3. Новое ядро (чистые функции, детерминированные, покрытые тестами)
|
||||
|
||||
- `core/convolution.ts`
|
||||
- универсальная свёртка с клампом краёв;
|
||||
- разделяемый бокс-блюр и сборка гауссова приближения из трёх проходов;
|
||||
- ядро резкости и смешивание с оригиналом по силе.
|
||||
- `core/background.ts`
|
||||
- построение маски удаления по цветовому расстоянию (та же метрика, что у удаления цвета);
|
||||
- заливка связной области от всех граничных пикселей (очередь, без рекурсии);
|
||||
- мажоритарное сглаживание маски заданным числом проходов;
|
||||
- сборка результата: альфа 0 у удалённых пикселей; плюс функция маски для превью.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
- Точка исполнения одна — `tools/executor.ts`; меняется только её внутренность.
|
||||
- Воркер-файл импортирует реестр и исполняет операцию по `toolId`; картинка приходит копией (структурный клон), результат возвращается передаваемым буфером.
|
||||
- Ленивая инициализация воркера при первом вызове; если воркер недоступен (старый браузер/тестовая среда) — автоматический откат на прямое исполнение в главном потоке. Обе ветви обязаны давать побайтово одинаковый результат.
|
||||
- Генераторы и текстовые представления остаются в главном потоке — они мгновенные.
|
||||
- Пипетка и лупа работают с уже готовыми картинками и не зависят от переноса.
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — свёртки и фон с тестами на малых матрицах (точные ожидания руками). Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — категория «Фильтры», три записи, превью маски. Проверка: тесты целостности, визуально.
|
||||
- **C. Web Worker** — перенос исполнителя, отккат-ветвь. Проверка: результаты до/после переключения идентичны байт-в-байт на фиксированной картинке и параметрах; `test/build` зелёные.
|
||||
- **D. Итог** — полный прогон и смоук §8.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. Размытие: радиус 1 и 32 — плавно усиливается, прозрачные края не темнеют.
|
||||
2. Резкость: сила 0 не меняет картинку; сила 100 заметно подчёркивает края без цветового мусора.
|
||||
3. Удаление фона на логотипе с однотонным фоном: фон исчез, содержимое цело.
|
||||
4. Режим «только внешние области»: дырка внутри объекта того же цвета остаётся непрозрачной.
|
||||
5. Сглаживание 0/2/4 прохода — граница от жёсткой к мягкой, результат воспроизводим.
|
||||
6. Превью маски показывает белым удаляемую область; скачивание всегда отдаёт результат.
|
||||
7. Во время размытия большой картинки интерфейс остаётся отзывчивым (чип «Пересчёт…», скролл живой).
|
||||
8. Цепочка: размытие → резкость → удаление фона работает; каждый блок скачивает свой промежуток.
|
||||
9. Регресс старых инструментов и главной-поиска без изменений поведения.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Три новых инструмента проходят смоук §6; операции ядра покрыты точными юнит-тестами.
|
||||
- Исполнение изображений идёт через воркер там, где он доступен, с гарантированно одинаковым результатом обеих ветвей.
|
||||
- `test/check/build` зелёные.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Многопроходное гауссово ядро «по-настоящему» (O(r²)) — трёхпроходного боксового приближения достаточно визуально и оно быстрее.
|
||||
- Хромакей по нескольким цветам, ИИ-сегментация — следующие волны.
|
||||
- Отмена выполняющегося прогона воркера — устаревшие результаты отбрасываются токеном, сама работа завершается фоном.
|
||||
@@ -0,0 +1,75 @@
|
||||
# План: главная страница как рабочая область с цепочкой
|
||||
|
||||
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.** Заменяет идею отдельной страницы рабочего пространства.
|
||||
|
||||
## 1. Модель
|
||||
|
||||
1. **Главная страница — это рабочая область.** Без выбранного инструмента на ней большой поиск: начните вводить название — список отфильтровывается; выбор разворачивает инструмент прямо здесь, без перехода.
|
||||
2. Развернутый инструмент выглядит как привычная страница: слева исходник, справа результат со своей кнопкой скачивания, параметры — в отдельной карточке под сценой.
|
||||
3. Под результатом рядом с кнопкой скачивания — кнопка **«⛓ Chain with…»**: добавляет пустую секцию, внутри которой такой же поиск; выбор заполняет секцию следующим инструментом. Так цепочка растёт сколько угодно.
|
||||
4. Каждая заполненная секция цепи — полноценная: свои параметры, своё превью, своя кнопка скачивания промежуточного результата. Крестик убирает секцию; кнопка «✂ Remove chain» снимает всю цепь разом (без подтверждения).
|
||||
5. Чекбокс «Показать маску» живёт в карточке параметров базового инструмента и влияет только на его превью: в цепь всегда передаётся сам результат.
|
||||
6. Сетка-каталог инструментов переезжает на отдельную страницу `/list-tools` — для SEO и визуального поиска. Основная работа происходит на главной; в шапке появляются ссылки на обе.
|
||||
7. Прямые адреса `/tools/<id>` сохраняются: тот же развернутый инструмент для глубоких ссылок.
|
||||
8. Цепочка переживает перезагрузку (автосохранение в localStorage). Экспорт/импорт файлом отложен до появления CLI/wasm-части.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
- Страница инструмента уже собрана как самодостаточный компонент, принимающий запись реестра, — он и становится встраиваемым блоком. Маршруты `/tools/[id]` и главная используют один и тот же компонент: первая — по адресу из URL, вторая — по выбору из поиска.
|
||||
- Состояние главной: выбран ли инструмент; если да — рендерится блок инструмента, целиком владеющий своим исходником, параметрами, результатом и цепочкой.
|
||||
- Смена инструмента на главной (через повторный поиск) сбрасывает блок целиком.
|
||||
- Цепочка хранится массивом шагов; у пустого звена инструмент ещё не выбран — такие звенья не сохраняются в localStorage и не участвуют в прогоне.
|
||||
- Исполнение — последовательная свёртка: выход каждого звена — вход следующего; результаты всех звеньев доступны их секциям. Общий авто-раннер даёт токен отмены и задержку ~300 мс.
|
||||
|
||||
## 3. Переиспользование и доработки существующего
|
||||
|
||||
Готовое берётся как есть: `SourceCard`, `Preview`, `ParamForm`, `DownloadButton`, `EmptyState`, модуль пайплайна, авто-раннер, `PNG_OUTPUT`.
|
||||
|
||||
Доработки:
|
||||
|
||||
- **Блок страницы инструмента** — расширяется секцией цепочки: список заполненных звеньев, каждое со своими параметрами, превью и скачиванием, плюс пустые звенья с поиском. Кнопки «⛓» и «✂ Remove chain» — рядом со скачиванием базового результата.
|
||||
- **`ParamsCard`** — опциональный блок «Показать маску» (флаг preview у инструмента + двусторонний флаг).
|
||||
- **Карточка результата** — упрощается: получает готовую картинку, маска уходит в карточку параметров.
|
||||
- **Существующая главная-каталог** — переезжает на маршрут `/list-tools` почти без изменений.
|
||||
|
||||
Удаляется: черновой маршрут рабочего пространства и его компоненты списка/селектора — вместо них новые, под вертикальную модель.
|
||||
|
||||
## 4. Новые компоненты
|
||||
|
||||
- **`ToolSearch.svelte`** — поиск с нечётким совпадением: поле ввода, отфильтрованный список (регистронезависимая подпоследовательность по названию и описанию, приоритет совпадению с начала), клавиатура ↑↓ Enter Esc; два размера — герой на главной и компактный внутри пустого звена. Предлагает только инструменты-трансформеры.
|
||||
- **`ChainStep.svelte`** — секция одного звена: пустое звено показывает компактный поиск; заполненное — заголовок с номером и крестиком, форму параметров, превью результата, кнопку скачивания.
|
||||
- **`ChainBar.svelte`** — строка под результатом базового инструмента: «⛓ Chain with…» и «✂ Remove chain» (видна только при непустой цепи).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Поиск и главная** — `ToolSearch`, герой поиска на главной, встраивание блока инструмента, перенос каталога на `/list-tools`, ссылки в шапке. Проверка: `check/build`, визуально.
|
||||
- **B. Цепочка** — состояние цепи в блоке инструмента, пустые звенья с поиском, прогон, промежуточные превью и скачивания, ошибки звеньев. Проверка: смоук §8 пп. 1–6.
|
||||
- **C. Маска и упрощение** — блок маски в `ParamsCard`, упрощение карточки результата. Проверка: смоук §8 п. 7.
|
||||
- **D. Хранение и итог** — автосохранение и восстановление цепочки, удаление чернового маршрута рабочего пространства, полный регресс каталога, `test/check/build`.
|
||||
|
||||
## 6. Смоук-чеклист
|
||||
|
||||
1. На главной — большое поле поиска; ввод фильтрует список по мере набора; Enter выбирает подсвеченный.
|
||||
2. Выбранный инструмент разворачивается на той же странице: исходник, параметры, результат.
|
||||
3. «⛓ Chain with…» добавляет пустую секцию с компактным поиском; выбор заполняет её.
|
||||
4. У каждой секции своё превью и своё скачивание промежуточного результата.
|
||||
5. Крестик убирает секцию; «✂ Remove chain» снимает все звенья разом.
|
||||
6. Ошибка звена — баннер «Шаг N (имя): …»; последние успешные результаты секций не исчезают.
|
||||
7. «Показать маску» переключает превью базового инструмента; в цепь идёт результат, скачивание базового отдаёт результат, не маску.
|
||||
8. Перезагрузка восстанавливает цепочку заполненных звеньев; пустые звенья при этом исчезают.
|
||||
9. Быстрые правки подряд — один финальный пересчёт всей вертикали.
|
||||
10. `/list-tools` открывает старый каталог по категориям; прямые адреса `/tools/<id>` работают.
|
||||
|
||||
## 7. Критерии готовности
|
||||
|
||||
- Работа начинается с главной: поиск → инструмент → цепочка звеньями, каждый со своим превью и скачиванием.
|
||||
- Отдельной страницы рабочего пространства нет; каталог доступен на `/list-tools`; прямые ссылки инструментов работают.
|
||||
- Пустые звенья не попадают в сохранение и не ломают прогон.
|
||||
- `test/check/build` зелёные, смоук §6 пройден.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Ветвления и прицепывание от середины цепи — новое звено всегда добавляется в конец.
|
||||
- Пипетка и маски внутри звеньев цепи — они остаются фичей базового инструмента.
|
||||
- Экспорт и импорт цепочки файлом — отложено до появления CLI/wasm-части.
|
||||
- Несколько параллельных инструментов на главной одновременно — один активный блок за раз.
|
||||
@@ -1 +1,21 @@
|
||||
# Backlog
|
||||
|
||||
## 1. Несколько языков — оценка: L, разбита на подзадачи
|
||||
|
||||
### 1a. Инфраструктура i18n — M
|
||||
|
||||
Модуль словарей (`locales/ru.ts`, `locales/en.ts`), функция перевода с фолбэком на базовый язык, состояние активной локали (localStorage), переключатель в шапке, обновление `<html lang>`.
|
||||
|
||||
### 1b. Перевод каркаса интерфейса — S/M
|
||||
|
||||
Шапка, кнопки, подписи полей и состояний («Результат появится здесь», тексты ошибок исполнителя), главная-герой, страница каталога.
|
||||
|
||||
### 1c. Перевод контента реестра — L по объёму, механическая
|
||||
|
||||
Вынести title/description инструментов и label/options параметров в словари локалей с фолбэком. Объём: ~51 инструмент плюс параметры — основной трудозатратный кусок волны.
|
||||
|
||||
### 1d. Поиск по любому языку — M
|
||||
|
||||
Матчинг запроса против названий/описаний во всех поддерживаемых локалях сразу (не только активной); нормализация: нижний регистр, ё→е, снятие диакритики (NFD). Скоринг существующий сохраняется, лучшая локаль даёт балл.
|
||||
|
||||
Транслитерация ru↔en наизусть — не входит, отдельно при необходимости.
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# План: MEDIUM-волна №2 — морфология прозрачности
|
||||
|
||||
> Статус: план к выполнению.
|
||||
|
||||
## 1. Зачем
|
||||
|
||||
Одна пара функций — расширение и сужение непрозрачных областей — открывает целое семейство пользовательских операций: обводка логотипов и иконок, контуры, утолщение и утончение подписей, удаление мелкого мусора и закрытие дырок. Всё это частые запросы в наборах PNG-утилит, а ядро получается компактным и легко покрывается тестами.
|
||||
|
||||
## 2. Инструменты — 7 (все в категории «Прозрачность»)
|
||||
|
||||
- **Обвести PNG** (`add-stroke-png`) — кольцо выбранного цвета вокруг непрозрачного содержимого; параметры: цвет, толщина 1–10 px.
|
||||
- **Найти контур PNG** (`find-contour-png`) — линия по краю непрозрачных областей; параметр: толщина линии.
|
||||
- **Утолщить PNG** (`make-thicker-png`) — расширение содержимого на N пикселей.
|
||||
- **Утончить PNG** (`make-thinner-png`) — сужение содержимого на N пикселей.
|
||||
- **Жёсткие края PNG** (`harden-alpha-png`) — бинаризация альфы по порогу: полупрозрачность уходит, остаётся два состояния.
|
||||
- **Убрать мусор PNG** (`despeckle-alpha-png`) — открытие: удаляет одиночные полупрозрачные пиксели и мелкие крапинки.
|
||||
- **Закрыть дыры PNG** (`close-holes-png`) — закрытие: заполняет одиночные прозрачные точки внутри объекта.
|
||||
|
||||
Все операции применяются к альфа-каналу; RGB пикселей сохраняется.
|
||||
|
||||
## 3. Ядро `core/morphology.ts`
|
||||
|
||||
Чистые функции над бинарной маской и над альфа-каналом:
|
||||
|
||||
- `buildAlphaMask(img)` — 1 там, где альфа > 0.
|
||||
- `dilateMask(mask, w, h, radius, shape)` — расширение; `shape`: квадрат или диск (для обводок диск даёт ровную круглую линию).
|
||||
- `erodeMask(...)` — зеркальная операция.
|
||||
- `dilateImage / erodeImage(img, radiusPx)` — применяют маскирование к альфе изображения, RGB сохраняется.
|
||||
- Сборщики:
|
||||
- обводка: `dilate(mask) − mask` → новое кольцо заливается цветом;
|
||||
- контур: `mask − erode(mask)` → линия;
|
||||
- открытие: `erode → dilate`; закрытие: `dilate → erode`.
|
||||
- `hardenAlpha(img, thresholdPercent)` — отдельно от морфологии, но той же волной.
|
||||
|
||||
Структурирующий элемент при радиусе r — диск радиуса r (расстояние до центра ≤ r); квадрат не используем, чтобы углы обводок не были угловатыми.
|
||||
|
||||
## 4. Web Worker
|
||||
|
||||
Ничего не требуется: новые операции попадают в реестр, исполнитель автоматически исполняет их в воркере. Тесты ядра идут мимо воркера (чистые функции).
|
||||
|
||||
## 5. Этапы и проверки
|
||||
|
||||
- **A. Ядро** — `morphology.ts` + точные тесты на матрицах 5×5: расширение диска, сужение, открытие убирает одиночный пиксель, закрытие закрывает одиночную дыру, кольцо обводки нужного цвета и толщины. Проверка: `pnpm test/check`.
|
||||
- **B. Реестр** — семь записей, тест целостности обновляется (51 → 58). Проверка: `test/build`, визуально.
|
||||
- **C. Итог** — полный прогон, смоук §7.
|
||||
|
||||
## 6. Критерии готовности
|
||||
|
||||
- Семь новых инструментов работают на прозрачных PNG: обводка, контур, толще/тоньше, жёсткие края, убрать мусор, закрыть дыры.
|
||||
- Все операции детерминированы и покрыты юнит-тестами с ручными ожиданиями.
|
||||
- Исполнение автоматически идёт в воркере.
|
||||
- `test/check/build` зелёные, смоук §7 пройден.
|
||||
|
||||
## 7. Смоук-чеклист
|
||||
|
||||
1. Обводка толщиной 3 вокруг круглого стикера — ровное кольцо выбранного цвета, без угловатости.
|
||||
2. Толщина 1 — тонкая аккуратная линия; 10 — толстая, форма содержимого не искажается сверх роста.
|
||||
3. Контур показывает только край содержимого линией.
|
||||
4. Толще/тоньше на тонкой надписи — штрихи растут/сжимаются без разрывов при умеренных значениях.
|
||||
5. Жёсткие края: полупрозрачность исчезает, силуэт сохраняется.
|
||||
6. Убрать мусор убирает одиночные точки-крапинки, не трогая крупные области.
|
||||
7. Закрыть дыры заполняет одиночные прозрачные точки внутри логотипа.
|
||||
8. Цепочка: обводка после удаления фона работает; каждое звено скачивает свой промежуток.
|
||||
9. Регресс остальных инструментов без изменений.
|
||||
|
||||
## 8. Что сознательно не делаем
|
||||
|
||||
- Морфология по яркости для непрозрачных сканов — волна фокусируется на альфе прозрачных PNG.
|
||||
- Выбор структурирующего элемента пользователем — всегда диск.
|
||||
- Feather/свечение краёв — отдельная будущая задача поверх размытия.
|
||||
- Произвольные SVG-маски и градиентные обводки.
|
||||
@@ -8,6 +8,10 @@
|
||||
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас и потом станет общим для браузера, wasm и CLI.
|
||||
- **Реестр инструментов** (id, title, description, схема параметров, `run`) — источник истины: из него генерируются страницы, формы, пайплайны. Позже — общий формат для Rust-парсера.
|
||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций, **perceptual diff** для canvas-зависимых.
|
||||
- **Единая точка исполнения инструментов.** Страницы вызывают операции только через `web/src/lib/tools/executor.ts::executeStep` — нигде напрямую `tool.run`. Контракт уже асинхронный (`Promise<PixelImage>`), поэтому:
|
||||
- когда появятся тяжёлые MEDIUM-операции (свёртки, квантование), внутренности `executeStep` переезжают в Web Worker — воркер импортирует тот же чистый core как TS-фолбэк, алгоритмы не дублируются, вызывающий код не меняется;
|
||||
- в WASM-фазе тот же воркер принимает wasm-модуль и диспетчеризует по `toolId` — свитч A/B из фазы 5 сводится к замене реализации внутри исполнителя;
|
||||
- до появления MEDIUM-операций воркер не вводится: EASY-операции быстры и не блокируют UI.
|
||||
|
||||
## 1. Структура репозитория (этап TS)
|
||||
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "pnpm --dir web dev"
|
||||
"dev": "pnpm --dir web dev",
|
||||
"build": "pnpm --dir web build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Ku6epXBOCTuK",
|
||||
|
||||
@@ -21,5 +21,8 @@
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.33.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+13
@@ -7,6 +7,10 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@lucide/svelte':
|
||||
specifier: ^1.33.0
|
||||
version: 1.33.0(svelte@5.56.9)
|
||||
devDependencies:
|
||||
'@sveltejs/adapter-static':
|
||||
specifier: ^3.0.10
|
||||
@@ -51,6 +55,11 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lucide/svelte@1.33.0':
|
||||
resolution: {integrity: sha512-b+osTYG2V4dge5Lr7tnaTaahhy/4vH7Xb8zcqVjmctjwgkpXS0NNOJPkGzHHZoxtw/OO+2YAU28omeMfYCaawg==}
|
||||
peerDependencies:
|
||||
svelte: ^5
|
||||
|
||||
'@oxc-project/types@0.144.0':
|
||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||
|
||||
@@ -627,6 +636,10 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lucide/svelte@1.33.0(svelte@5.56.9)':
|
||||
dependencies:
|
||||
svelte: 5.56.9
|
||||
|
||||
'@oxc-project/types@0.144.0': {}
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
@@ -154,3 +154,92 @@ input.control[type='number'] {
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.tool-stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tool-stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
}
|
||||
|
||||
.cell-media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 14rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chain-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.edge-legend {
|
||||
position: absolute;
|
||||
top: -0.65em;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.params-sep {
|
||||
position: relative;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 0 var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions-row > button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.tool-stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.tool-stage .cell + .cell {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export type CategoryId = 'convert' | 'alpha' | 'color' | 'geometry' | 'analyze';
|
||||
export type CategoryId =
|
||||
| 'convert'
|
||||
| 'alpha'
|
||||
| 'color'
|
||||
| 'geometry'
|
||||
| 'analyze'
|
||||
| 'generate'
|
||||
| 'filters';
|
||||
|
||||
export type Category = { id: CategoryId; label: string };
|
||||
|
||||
@@ -7,5 +14,7 @@ export const CATEGORIES: Category[] = [
|
||||
{ id: 'alpha', label: 'Прозрачность' },
|
||||
{ id: 'color', label: 'Цвет' },
|
||||
{ id: 'geometry', label: 'Геометрия' },
|
||||
{ id: 'analyze', label: 'Анализ' }
|
||||
{ id: 'filters', label: 'Фильтры' },
|
||||
{ id: 'analyze', label: 'Анализ' },
|
||||
{ id: 'generate', label: 'Генерация' }
|
||||
];
|
||||
|
||||
@@ -11,50 +11,84 @@
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
hasMask?: boolean;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
|
||||
let {
|
||||
params,
|
||||
values = $bindable(),
|
||||
pipetteTargetId = null,
|
||||
onPipetteToggle,
|
||||
hasMask = false,
|
||||
showMask = $bindable(false)
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each params as param (param.id)}
|
||||
<div class="field">
|
||||
{#if param.type === 'checkbox'}
|
||||
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
|
||||
{:else if param.type === 'number'}
|
||||
<TextField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
type="number"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'slider'}
|
||||
<SliderField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
default={param.default}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'select'}
|
||||
<SelectField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
options={param.options}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'color'}
|
||||
<ColorField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
bind:value={values[param.id]}
|
||||
pipetteActive={pipetteTargetId === param.id}
|
||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="params-grid">
|
||||
{#if hasMask}
|
||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
||||
{/if}
|
||||
{#each params as param (param.id)}
|
||||
<div class="field">
|
||||
{#if param.type === 'checkbox'}
|
||||
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
|
||||
{:else if param.type === 'number'}
|
||||
<TextField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
type="number"
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'slider'}
|
||||
<SliderField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
step={param.step}
|
||||
default={param.default}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'select'}
|
||||
<SelectField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
options={param.options}
|
||||
bind:value={values[param.id]}
|
||||
/>
|
||||
{:else if param.type === 'color'}
|
||||
<ColorField
|
||||
id={param.id}
|
||||
label={param.label}
|
||||
bind:value={values[param.id]}
|
||||
pipetteActive={pipetteTargetId === param.id}
|
||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.params-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: var(--space-5);
|
||||
}
|
||||
|
||||
@media (min-width: 75rem) {
|
||||
.params-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 50rem) {
|
||||
.params-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
||||
background-size: 16px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
}
|
||||
|
||||
canvas.pipette {
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import { loadStoredSteps, newStepId, saveSteps, type PipelineStep } from '$lib/tools/pipeline';
|
||||
import DownloadButton from './DownloadButton.svelte';
|
||||
import ParamForm from './ParamForm.svelte';
|
||||
import Preview from './Preview.svelte';
|
||||
import ToolSearch from './search/ToolSearch.svelte';
|
||||
import ChainToolBlock from './chain/ChainToolBlock.svelte';
|
||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||
import { executeStep } from '$lib/tools/executor';
|
||||
import ParamsCard from './tool/ParamsCard.svelte';
|
||||
import ResultCard from './tool/ResultCard.svelte';
|
||||
import SourceCard from './tool/SourceCard.svelte';
|
||||
import TextInputCard from './tool/TextInputCard.svelte';
|
||||
|
||||
let { tool }: { tool: ToolEntry } = $props();
|
||||
let { tool, restoreChain = false }: { tool: ToolEntry; restoreChain?: boolean } = $props();
|
||||
|
||||
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||
|
||||
@@ -21,13 +29,52 @@
|
||||
let info = $state<ImageInfo | null>(null);
|
||||
let errorText = $state('');
|
||||
let values = $state<Record<string, any>>({});
|
||||
// svelte-ignore state_referenced_locally
|
||||
let chain = $state<PipelineStep[]>(restoreChain ? loadStoredSteps() : []);
|
||||
let chainResults = $state<(PixelImage | null)[]>([]);
|
||||
let lastRunChainJson = '';
|
||||
|
||||
const isInfo = $derived(tool.resultType === 'info');
|
||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||
const sanitized = $derived(sanitizeParams(tool, values));
|
||||
const canChainBase = $derived(
|
||||
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
|
||||
);
|
||||
const hasMask = $derived(typeof tool.preview === 'function');
|
||||
const shownBase = $derived(showMask && previewResult ? previewResult : result);
|
||||
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
|
||||
|
||||
let runToken = 0;
|
||||
function addChainStep() {
|
||||
chain.push({ id: newStepId(), toolId: '', values: {} });
|
||||
}
|
||||
|
||||
function removeChainStep(index: number) {
|
||||
chain.splice(index, 1);
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function removeChain() {
|
||||
chain.length = 0;
|
||||
chainResults = [];
|
||||
}
|
||||
|
||||
function toggleChain() {
|
||||
if (chain.length > 0) {
|
||||
removeChain();
|
||||
} else {
|
||||
addChainStep();
|
||||
}
|
||||
}
|
||||
|
||||
function applyChainTool(index: number, toolId: string) {
|
||||
const stepTool = getTool(toolId);
|
||||
if (!stepTool || !chain[index]) return;
|
||||
chain[index].toolId = toolId;
|
||||
chain[index].values = defaultParams(stepTool);
|
||||
}
|
||||
|
||||
const runner = createAutoRunner();
|
||||
let hasLastRun = false;
|
||||
let lastRunSource: PixelImage | null = null;
|
||||
let lastRunValuesJson = '';
|
||||
@@ -86,10 +133,11 @@
|
||||
async function runTool() {
|
||||
if (isInfo) return;
|
||||
if (!source && !isSourceless) return;
|
||||
const token = ++runToken;
|
||||
const token = runner.next();
|
||||
hasLastRun = true;
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
lastRunChainJson = JSON.stringify(chain);
|
||||
try {
|
||||
let next: PixelImage | null = null;
|
||||
let nextText: string | null = null;
|
||||
@@ -99,9 +147,8 @@
|
||||
} else if (isSourceless) {
|
||||
next = await tool.generate!(sanitized);
|
||||
} else {
|
||||
next = await tool.run!(source!, sanitized);
|
||||
next = await executeStep(tool, source!, sanitized);
|
||||
}
|
||||
|
||||
let nextPreview: PixelImage | null = null;
|
||||
if (tool.preview && source) {
|
||||
try {
|
||||
@@ -111,24 +158,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== runToken) return;
|
||||
if (!runner.isCurrent(token)) return;
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
textResult = nextText;
|
||||
|
||||
const collected: (PixelImage | null)[] = [];
|
||||
let current: PixelImage | null = next ?? source;
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const step = chain[i];
|
||||
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
|
||||
if (!current || !stepTool?.run) {
|
||||
collected.push(null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
current = await executeStep(stepTool, current, sanitizeParams(stepTool, step.values));
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
|
||||
}
|
||||
if (!runner.isCurrent(token)) return;
|
||||
collected.push(current);
|
||||
}
|
||||
chainResults = collected;
|
||||
status = 'loaded';
|
||||
} catch (e) {
|
||||
if (token !== runToken) return;
|
||||
if (!runner.isCurrent(token)) return;
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const valuesJson = JSON.stringify(sanitized);
|
||||
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
|
||||
const chainJson = JSON.stringify(chain);
|
||||
if (
|
||||
hasLastRun &&
|
||||
source === lastRunSource &&
|
||||
valuesJson === lastRunValuesJson &&
|
||||
chainJson === lastRunChainJson
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!source && !isSourceless) return;
|
||||
if (isInfo) return;
|
||||
const timer = setTimeout(() => void runTool(), 300);
|
||||
return () => clearTimeout(timer);
|
||||
return runner.schedule(() => void runTool());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const filled = chain.filter((step) => step.toolId !== '');
|
||||
if (filled.length === 0 && !hasLastRun) return;
|
||||
saveSteps(filled);
|
||||
});
|
||||
|
||||
function showError(e: unknown) {
|
||||
@@ -178,48 +258,96 @@
|
||||
<div class="error-banner" role="alert">{errorText}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stage panel" class:single={isSourceless}>
|
||||
{#if !isSourceless}
|
||||
<div class="panel tool-block">
|
||||
<div class="tool-stage" class:single={isSourceless}>
|
||||
{#if !isSourceless}
|
||||
<span class="edge-legend source-legend" aria-hidden="true">Исходник</span>
|
||||
<div class="cell">
|
||||
{#if isTextSource && !source}
|
||||
<TextInputCard onSubmit={handleTextSubmit} />
|
||||
{:else}
|
||||
<SourceCard
|
||||
{source}
|
||||
onFile={handleFile}
|
||||
onError={(message) => (errorText = message)}
|
||||
onReset={reset}
|
||||
pipetteActive={!!pipetteTargetId}
|
||||
onPickColor={handlePickColor}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="edge-legend result-legend" aria-hidden="true">
|
||||
{isInfo ? 'Сводка' : 'Результат'}
|
||||
</span>
|
||||
<div class="cell">
|
||||
{#if isTextSource && !source}
|
||||
<TextInputCard onSubmit={handleTextSubmit} />
|
||||
{:else}
|
||||
<SourceCard
|
||||
{source}
|
||||
onFile={handleFile}
|
||||
onError={(message) => (errorText = message)}
|
||||
onReset={reset}
|
||||
pipetteActive={!!pipetteTargetId}
|
||||
onPickColor={handlePickColor}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cell">
|
||||
<ResultCard
|
||||
{tool}
|
||||
sourceLoaded={isSourceless ? true : !!source}
|
||||
{status}
|
||||
{result}
|
||||
{previewResult}
|
||||
bind:showMask
|
||||
displayImage={shownBase}
|
||||
{info}
|
||||
{isInfo}
|
||||
params={sanitized}
|
||||
{textResult}
|
||||
textResult={textResult}
|
||||
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||
hasChain={chain.length > 0}
|
||||
onDownloadError={showError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if (source || isSourceless) && !isInfo}
|
||||
{#if (source || isSourceless) && !isInfo && (tool.params.length > 0 || hasMask)}
|
||||
<div class="params-sep">
|
||||
<span class="edge-legend" aria-hidden="true">Параметры</span>
|
||||
</div>
|
||||
<ParamsCard
|
||||
params={tool.params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
onPipetteToggle={handlePipetteToggle}
|
||||
hasMask={hasMask}
|
||||
bind:showMask
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="chain-stack">
|
||||
{#each chain as step, index (step.id)}
|
||||
{#if step.toolId === ''}
|
||||
<div class="panel empty-slot">
|
||||
<header>
|
||||
<h3 class="heading-section">Шаг {index + 1}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-step"
|
||||
aria-label="Убрать шаг"
|
||||
onclick={() => removeChainStep(index)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
|
||||
</div>
|
||||
{:else if getTool(step.toolId)}
|
||||
{@const stepTool = getTool(step.toolId)!}
|
||||
<ChainToolBlock
|
||||
index={index}
|
||||
tool={stepTool}
|
||||
bind:values={step.values}
|
||||
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
|
||||
result={chainResults[index] ?? null}
|
||||
busy={status === 'processing'}
|
||||
isLast={index === chain.length - 1}
|
||||
onRemove={() => removeChainStep(index)}
|
||||
onError={showError}
|
||||
onAddStep={addChainStep}
|
||||
onRemoveChain={removeChain}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
@@ -236,37 +364,57 @@
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.stage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
.tool-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stage.single {
|
||||
grid-template-columns: 1fr;
|
||||
.source-legend {
|
||||
left: 25%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cell + .cell {
|
||||
border-left: 1px solid var(--border);
|
||||
padding-left: var(--space-4);
|
||||
.result-legend {
|
||||
left: 75%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.stage {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cell + .cell {
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-4);
|
||||
.source-legend,
|
||||
.result-legend {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.empty-slot header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-slot h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.remove-step {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.remove-step:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import ParamsCard from '../tool/ParamsCard.svelte';
|
||||
import Preview from '../Preview.svelte';
|
||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
||||
|
||||
interface Props {
|
||||
index: number;
|
||||
tool: ToolEntry;
|
||||
values: Record<string, any>;
|
||||
input: PixelImage | null;
|
||||
result: PixelImage | null;
|
||||
busy: boolean;
|
||||
isLast: boolean;
|
||||
onRemove: () => void;
|
||||
onError: (e: unknown) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveChain: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
index,
|
||||
tool,
|
||||
values = $bindable(),
|
||||
input,
|
||||
result,
|
||||
busy,
|
||||
isLast,
|
||||
onRemove,
|
||||
onError,
|
||||
onAddStep,
|
||||
onRemoveChain
|
||||
}: Props = $props();
|
||||
|
||||
const format = $derived(outputOf(tool));
|
||||
const safeParams = $derived(sanitizeParams(tool, values));
|
||||
const StepIcon = $derived(TOOL_ICONS[tool.id]);
|
||||
</script>
|
||||
|
||||
<div class="block">
|
||||
<div class="panel tool-stage">
|
||||
<span class="edge-legend step-legend">
|
||||
{#if StepIcon}
|
||||
<span class="step-icon" aria-hidden="true"><StepIcon size={14} strokeWidth={2} /></span>
|
||||
{/if}
|
||||
Шаг {index + 1}: {tool.title}
|
||||
<button
|
||||
type="button"
|
||||
class="remove"
|
||||
aria-label="Убрать шаг"
|
||||
title="Убрать шаг"
|
||||
onclick={onRemove}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div class="cell">
|
||||
<span class="edge-legend cell-legend" aria-hidden="true">Вход</span>
|
||||
<div class="cell-media">
|
||||
<Preview image={input} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell">
|
||||
<span class="edge-legend cell-legend" aria-hidden="true">Результат</span>
|
||||
{#if busy && !result}
|
||||
<div class="cell-media">
|
||||
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cell-media">
|
||||
<Preview image={result} />
|
||||
{#if busy}
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={format}
|
||||
baseName="{index + 1}-{tool.id}"
|
||||
params={safeParams}
|
||||
{onError}
|
||||
/>
|
||||
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
|
||||
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if tool.params.length > 0}
|
||||
<div class="params-sep">
|
||||
<span class="edge-legend" aria-hidden="true">Параметры</span>
|
||||
</div>
|
||||
<ParamsCard params={tool.params} bind:values />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tool-stage {
|
||||
position: relative;
|
||||
padding-top: var(--space-3);
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.step-legend {
|
||||
left: var(--space-3);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.remove {
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.remove:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cell-legend {
|
||||
top: -0.9em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.recalc {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
import { TOOL_ICONS } from '$lib/tools/tool-icons';
|
||||
|
||||
interface Props {
|
||||
toolId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
selected?: boolean;
|
||||
href?: string;
|
||||
onActivate?: () => void;
|
||||
onHover?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
toolId,
|
||||
title,
|
||||
description,
|
||||
selected = false,
|
||||
href,
|
||||
onActivate,
|
||||
onHover
|
||||
}: Props = $props();
|
||||
|
||||
const Icon = $derived(TOOL_ICONS[toolId]);
|
||||
</script>
|
||||
|
||||
{#snippet content()}
|
||||
<span class="icon-box" aria-hidden="true">
|
||||
{#if Icon}<Icon size={24} strokeWidth={1.75} />{/if}
|
||||
</span>
|
||||
<span class="text">
|
||||
<span class="title">{title}</span>
|
||||
<span class="hint text-caption text-muted">{description}</span>
|
||||
</span>
|
||||
{/snippet}
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
{href}
|
||||
class="card"
|
||||
class:selected
|
||||
onmousemove={onHover}
|
||||
>
|
||||
{@render content()}
|
||||
</a>
|
||||
{:else}
|
||||
<div
|
||||
class="card"
|
||||
class:selected
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
tabindex="-1"
|
||||
onclick={onActivate}
|
||||
onmousemove={onHover}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') onActivate?.();
|
||||
}}
|
||||
>
|
||||
{@render content()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-s);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card.selected {
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
flex: none;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-s);
|
||||
background: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { isChainable, TOOLS } from '$lib/registry';
|
||||
import ToolCard from './ToolCard.svelte';
|
||||
|
||||
interface Props {
|
||||
onSelect: (toolId: string) => void;
|
||||
}
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
const candidates = TOOLS.filter(isChainable);
|
||||
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let listOpen = $state(false);
|
||||
|
||||
type Match = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
popularity: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function score(tool: (typeof TOOLS)[number], q: string): number | null {
|
||||
if (q.length === 0) return 1;
|
||||
const title = tool.title.toLowerCase();
|
||||
const id = tool.id.toLowerCase();
|
||||
const description = tool.description.toLowerCase();
|
||||
if (title.startsWith(q)) return 100;
|
||||
const inTitle = title.indexOf(q);
|
||||
if (inTitle >= 0) return 80 - Math.min(inTitle, 40);
|
||||
const inId = id.indexOf(q);
|
||||
if (inId >= 0) return 60 - Math.min(inId, 40);
|
||||
if (description.includes(q)) return 30;
|
||||
let pos = 0;
|
||||
let subScore = 20;
|
||||
for (const ch of q) {
|
||||
pos = title.indexOf(ch, pos);
|
||||
if (pos < 0) {
|
||||
subScore = -1;
|
||||
break;
|
||||
}
|
||||
subScore -= 1;
|
||||
pos += 1;
|
||||
}
|
||||
return subScore >= 0 ? subScore : null;
|
||||
}
|
||||
|
||||
const matches = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const found: Match[] = [];
|
||||
for (const tool of candidates) {
|
||||
const s = score(tool, q);
|
||||
if (s !== null && s > 0) {
|
||||
found.push({
|
||||
id: tool.id,
|
||||
title: tool.title,
|
||||
description: tool.description,
|
||||
popularity: tool.popularity ?? 50,
|
||||
score: s
|
||||
});
|
||||
}
|
||||
}
|
||||
found.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
b.popularity - a.popularity ||
|
||||
a.title.localeCompare(b.title)
|
||||
);
|
||||
return found.slice(0, 12);
|
||||
});
|
||||
|
||||
function choose(id: string) {
|
||||
listOpen = false;
|
||||
query = '';
|
||||
activeIndex = 0;
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (!listOpen || matches.length === 0) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % matches.length;
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + matches.length) % matches.length;
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const match = matches[Math.min(activeIndex, matches.length - 1)];
|
||||
if (match) choose(match.id);
|
||||
} else if (event.key === 'Escape') {
|
||||
listOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="search">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={query}
|
||||
onfocus={() => (listOpen = true)}
|
||||
oninput={() => {
|
||||
listOpen = true;
|
||||
activeIndex = 0;
|
||||
}}
|
||||
onkeydown={onKeydown}
|
||||
placeholder="Найдите инструмент…"
|
||||
aria-label="Поиск инструмента"
|
||||
role="combobox"
|
||||
aria-expanded={listOpen}
|
||||
aria-controls="tool-search-list"
|
||||
/>
|
||||
{#if listOpen && query.trim().length > 0 && matches.length === 0}
|
||||
<p class="none text-muted">Ничего не найдено — попробуйте другое слово.</p>
|
||||
{:else if listOpen && matches.length > 0}
|
||||
<div id="tool-search-list" class="cards" role="listbox">
|
||||
{#each matches as match, index (match.id)}
|
||||
<ToolCard
|
||||
toolId={match.id}
|
||||
title={match.title}
|
||||
description={match.description}
|
||||
selected={index === activeIndex}
|
||||
onActivate={() => choose(match.id)}
|
||||
onHover={() => (activeIndex = index)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 36rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
background: var(--surface);
|
||||
font-size: var(--text-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cards {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + var(--space-1));
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: var(--space-1);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-m);
|
||||
box-shadow: var(--shadow-card);
|
||||
max-height: 26rem;
|
||||
overflow-y: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.none {
|
||||
margin: var(--space-1) 0 0;
|
||||
text-align: center;
|
||||
font-size: var(--text-s);
|
||||
}
|
||||
</style>
|
||||
@@ -7,15 +7,30 @@
|
||||
values: Record<string, any>;
|
||||
pipetteTargetId?: string | null;
|
||||
onPipetteToggle?: (id: string) => void;
|
||||
hasMask?: boolean;
|
||||
showMask?: boolean;
|
||||
}
|
||||
|
||||
let { params, values = $bindable(), pipetteTargetId = null, onPipetteToggle }: Props = $props();
|
||||
let {
|
||||
params,
|
||||
values = $bindable(),
|
||||
pipetteTargetId = null,
|
||||
onPipetteToggle,
|
||||
hasMask = false,
|
||||
showMask = $bindable(false)
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="panel root">
|
||||
<h2 class="heading-section">Параметры</h2>
|
||||
{#if params.length > 0}
|
||||
<ParamForm {params} bind:values {pipetteTargetId} {onPipetteToggle} />
|
||||
<div class="root">
|
||||
{#if params.length > 0 || hasMask}
|
||||
<ParamForm
|
||||
{params}
|
||||
bind:values
|
||||
{pipetteTargetId}
|
||||
{onPipetteToggle}
|
||||
{hasMask}
|
||||
bind:showMask
|
||||
/>
|
||||
{:else}
|
||||
<p class="hint text-caption text-muted">
|
||||
У этого инструмента нет параметров — результат уже готов.
|
||||
@@ -25,12 +40,7 @@
|
||||
|
||||
<style>
|
||||
.root {
|
||||
margin-top: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-2);
|
||||
padding: 0 var(--space-4) var(--space-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CheckboxField from '../ui/CheckboxField.svelte';
|
||||
import Button from '../ui/Button.svelte';
|
||||
import DownloadButton from '../DownloadButton.svelte';
|
||||
import EmptyState from '../ui/EmptyState.svelte';
|
||||
import InfoPanel from '../InfoPanel.svelte';
|
||||
@@ -16,12 +16,13 @@
|
||||
sourceLoaded: boolean;
|
||||
status: Status;
|
||||
result: PixelImage | null;
|
||||
previewResult: PixelImage | null;
|
||||
showMask: boolean;
|
||||
displayImage: PixelImage | null;
|
||||
info: ImageInfo | null;
|
||||
isInfo: boolean;
|
||||
params: Record<string, unknown>;
|
||||
textResult: string | null;
|
||||
onChainToggle?: () => void;
|
||||
hasChain?: boolean;
|
||||
onDownloadError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -30,21 +31,18 @@
|
||||
sourceLoaded,
|
||||
status,
|
||||
result,
|
||||
previewResult,
|
||||
showMask = $bindable(false),
|
||||
displayImage,
|
||||
info,
|
||||
isInfo,
|
||||
params,
|
||||
textResult,
|
||||
onChainToggle,
|
||||
hasChain = false,
|
||||
onDownloadError
|
||||
}: Props = $props();
|
||||
|
||||
const hasPreview = $derived(typeof tool.preview === 'function');
|
||||
const shown = $derived(showMask && previewResult ? previewResult : result);
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2 class="heading-section">{isInfo ? 'Сводка' : 'Результат'}</h2>
|
||||
{#if !sourceLoaded}
|
||||
<div class="media">
|
||||
<EmptyState
|
||||
@@ -72,22 +70,26 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasPreview}
|
||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
||||
{/if}
|
||||
<div class="media">
|
||||
<Preview image={shown} />
|
||||
<Preview image={displayImage} />
|
||||
{#if status === 'processing'}
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
</div>
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={outputOf(tool)}
|
||||
baseName={tool.id}
|
||||
{params}
|
||||
onError={onDownloadError}
|
||||
/>
|
||||
<div class="actions-row">
|
||||
<DownloadButton
|
||||
image={result}
|
||||
format={outputOf(tool)}
|
||||
baseName={tool.id}
|
||||
{params}
|
||||
onError={onDownloadError}
|
||||
/>
|
||||
{#if onChainToggle}
|
||||
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -100,10 +102,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2 class="heading-section">Исходник</h2>
|
||||
{#if !source}
|
||||
<DropZone {onFile} {onError} />
|
||||
{:else}
|
||||
@@ -40,10 +39,6 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.media {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -9,34 +9,48 @@
|
||||
let { id, label, checked = $bindable(false), hint }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="checkbox">
|
||||
<input id={id} type="checkbox" bind:checked />
|
||||
<label for={id}>{label}</label>
|
||||
<div class="checkbox-row">
|
||||
<label class="checkbox" for={id}>
|
||||
<input id={id} type="checkbox" bind:checked />
|
||||
{label}
|
||||
</label>
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: calc(var(--space-1) + 2px) 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-caption, var(--text-s));
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--text-m);
|
||||
cursor: pointer;
|
||||
label:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: calc(-1 * var(--space-3)) 0 var(--space-3);
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,17 +13,42 @@
|
||||
|
||||
<div class="field">
|
||||
<label class="text-caption text-muted" for={id}>{label}</label>
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
<div class="control-slot">
|
||||
{@render children()}
|
||||
{#if hint}
|
||||
<p class="hint text-caption text-muted">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: calc(var(--space-1) + 2px) 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
label {
|
||||
flex: none;
|
||||
max-width: 45%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.control-slot {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -63,19 +63,21 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
max-width: var(--control-max-width);
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 3rem;
|
||||
accent-color: var(--accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: none;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -84,7 +86,7 @@
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
|
||||
@@ -1,7 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { colorMask, flattenOntoColor, invertAlpha, parseHex, removeColorToAlpha } from './alpha';
|
||||
import {
|
||||
colorMask,
|
||||
extractAlphaMask,
|
||||
flattenOntoColor,
|
||||
hardenAlpha,
|
||||
invertAlpha,
|
||||
parseHex,
|
||||
removeColorToAlpha,
|
||||
roundCorners,
|
||||
setAlphaChannel
|
||||
} from './alpha';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('hardenAlpha', () => {
|
||||
it('бинаризует альфу по порогу, RGB не трогает', () => {
|
||||
const out = hardenAlpha(
|
||||
makeImage(2, 1, [
|
||||
[10, 20, 30, 100],
|
||||
[40, 50, 60, 200]
|
||||
]),
|
||||
50
|
||||
);
|
||||
expect([...out.data]).toEqual([
|
||||
10, 20, 30, 0,
|
||||
40, 50, 60, 255
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAlphaChannel', () => {
|
||||
it('задаёт константную альфу', () => {
|
||||
const out = setAlphaChannel(makeImage(2, 1, [
|
||||
[1, 2, 3, 255],
|
||||
[4, 5, 6, 0]
|
||||
]), 50);
|
||||
expect(out.data[3]).toBe(128);
|
||||
expect(out.data[7]).toBe(128);
|
||||
expect(out.data[0]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAlphaMask', () => {
|
||||
it('переводит альфу в чёрно-белую непрозрачную маску', () => {
|
||||
const out = extractAlphaMask(makeImage(2, 1, [
|
||||
[10, 20, 30, 255],
|
||||
[40, 50, 60, 0]
|
||||
]));
|
||||
expect([...out.data]).toEqual([
|
||||
255, 255, 255, 255,
|
||||
0, 0, 0, 255
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundCorners', () => {
|
||||
it('срезает углы, центр и середины сторон остаются', () => {
|
||||
const img = makeImage(11, 11, new Array(121).fill([100, 100, 100, 255]));
|
||||
const out = roundCorners(img, 40);
|
||||
expect(out.data[(0 * 11 + 0) * 4 + 3]).toBe(0);
|
||||
expect(out.data[(5 * 11 + 5) * 4 + 3]).toBe(255);
|
||||
expect(out.data[(5 * 11 + 0) * 4 + 3]).toBe(255);
|
||||
});
|
||||
|
||||
it('нулевой радиус ничего не меняет', () => {
|
||||
const img = makeImage(2, 2, new Array(4).fill([1, 2, 3, 255]));
|
||||
expect([...roundCorners(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('invertAlpha', () => {
|
||||
it('обращает альфу, RGB не трогает', () => {
|
||||
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||
|
||||
@@ -22,6 +22,46 @@ export function removeColorToAlpha(
|
||||
return out;
|
||||
}
|
||||
|
||||
export function setAlphaChannel(img: PixelImage, percent: number): PixelImage {
|
||||
const alpha = Math.round((clamp(percent, 0, 100) / 100) * 255);
|
||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
||||
for (let i = 3; i < out.data.length; i += 4) {
|
||||
out.data[i] = alpha;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractAlphaMask(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
const v = img.data[i + 3];
|
||||
out.data[i] = v;
|
||||
out.data[i + 1] = v;
|
||||
out.data[i + 2] = v;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function roundCorners(img: PixelImage, radiusPercent: number): PixelImage {
|
||||
const radius = (clamp(radiusPercent, 0, 50) / 100) * (Math.min(img.width, img.height) / 2);
|
||||
if (radius < 1) return { width: img.width, height: img.height, data: img.data.slice() };
|
||||
const out: PixelImage = { width: img.width, height: img.height, data: img.data.slice() };
|
||||
const r2 = radius * radius;
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
const cx = clamp(x, radius, out.width - 1 - radius);
|
||||
const cy = clamp(y, radius, out.height - 1 - radius);
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
if (dx * dx + dy * dy > r2) {
|
||||
out.data[(y * out.width + x) * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function invertAlpha(img: PixelImage): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
@@ -33,6 +73,18 @@ export function invertAlpha(img: PixelImage): PixelImage {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function hardenAlpha(img: PixelImage, thresholdPercent: number): PixelImage {
|
||||
const threshold = (clamp(thresholdPercent, 0, 100) / 100) * 255;
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
out.data[i] = img.data[i];
|
||||
out.data[i + 1] = img.data[i + 1];
|
||||
out.data[i + 2] = img.data[i + 2];
|
||||
out.data[i + 3] = img.data[i + 3] >= threshold ? 255 : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
|
||||
const [targetR, targetG, targetB] = parseHex(hex);
|
||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { imageInfo } from './analyze';
|
||||
import {
|
||||
hasTransparency,
|
||||
imageInfo,
|
||||
isGrayscale,
|
||||
orientationOf
|
||||
} from './analyze';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('imageInfo', () => {
|
||||
@@ -43,3 +48,29 @@ describe('imageInfo', () => {
|
||||
expect(info.height).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGrayscale', () => {
|
||||
it('серые пиксели — монохром', () => {
|
||||
expect(isGrayscale(makeImage(1, 1, [[10, 10, 10, 255]]))).toBe(true);
|
||||
});
|
||||
it('цветной пиксель ломает монохром', () => {
|
||||
expect(isGrayscale(makeImage(1, 1, [[10, 11, 10, 255]]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasTransparency', () => {
|
||||
it('альфа ниже 255 — прозрачность есть', () => {
|
||||
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 254]]))).toBe(true);
|
||||
});
|
||||
it('все пиксели непрозрачны', () => {
|
||||
expect(hasTransparency(makeImage(1, 1, [[0, 0, 0, 255]]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orientationOf', () => {
|
||||
it('определяет ориентацию', () => {
|
||||
expect(orientationOf(makeImage(2, 3, new Array(6).fill([1, 1, 1, 1])))).toBe('portrait');
|
||||
expect(orientationOf(makeImage(3, 2, new Array(6).fill([1, 1, 1, 1])))).toBe('landscape');
|
||||
expect(orientationOf(makeImage(2, 2, new Array(4).fill([1, 1, 1, 1])))).toBe('square');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,3 +24,27 @@ export function imageInfo(img: PixelImage): ImageInfo {
|
||||
}
|
||||
return { width: img.width, height: img.height, hasAlpha, colorCount: seen.size };
|
||||
}
|
||||
|
||||
export function isGrayscale(img: PixelImage): boolean {
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
if (img.data[i] !== img.data[i + 1] || img.data[i + 1] !== img.data[i + 2]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasTransparency(img: PixelImage): boolean {
|
||||
for (let i = 3; i < img.data.length; i += 4) {
|
||||
if (img.data[i] < 255) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type Orientation = 'portrait' | 'landscape' | 'square';
|
||||
|
||||
export function orientationOf(img: PixelImage): Orientation {
|
||||
if (img.height > img.width) return 'portrait';
|
||||
if (img.width > img.height) return 'landscape';
|
||||
return 'square';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
backgroundMaskPreview,
|
||||
backgroundRemovalMask,
|
||||
removeBackground
|
||||
} from './background';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const GREEN = [0, 255, 0, 255];
|
||||
const RED = [255, 0, 0, 255];
|
||||
|
||||
describe('backgroundRemovalMask', () => {
|
||||
it('глобальный режим удаляет все совпадающие пиксели', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(2, 1, [GREEN, RED]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect([...mask]).toEqual([1, 0]);
|
||||
});
|
||||
|
||||
describe('режим внешних областей', () => {
|
||||
const ringRedCenterGreen = [
|
||||
RED, RED, RED,
|
||||
RED, GREEN, RED,
|
||||
RED, RED, RED
|
||||
];
|
||||
|
||||
it('заливка от краёв не достаёт до изолированного совпадающего острова', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(3, 3, ringRedCenterGreen),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: true, smoothPasses: 0 }
|
||||
);
|
||||
expect(mask[4]).toBe(0);
|
||||
});
|
||||
|
||||
it('глобальный режим удаляет и изолированный остров', () => {
|
||||
const mask = backgroundRemovalMask(
|
||||
makeImage(3, 3, ringRedCenterGreen),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect(mask[4]).toBe(1);
|
||||
expect(mask[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('допуск расширяет захват по цветовому расстоянию', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[10, 10, 10, 255],
|
||||
[128, 128, 128, 255]
|
||||
]);
|
||||
const tight = backgroundRemovalMask(img, {
|
||||
color: '#000000', tolerancePercent: 40, outerOnly: false, smoothPasses: 0
|
||||
});
|
||||
const wide = backgroundRemovalMask(img, {
|
||||
color: '#000000', tolerancePercent: 60, outerOnly: false, smoothPasses: 0
|
||||
});
|
||||
expect(tight[1]).toBe(0);
|
||||
expect(wide[1]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smoothMask-поведение через backgroundRemovalMask', () => {
|
||||
const ringRedCenterGreen = [
|
||||
RED, RED, RED,
|
||||
RED, GREEN, RED,
|
||||
RED, RED, RED
|
||||
];
|
||||
const opts = { color: '#00ff00', tolerancePercent: 0, outerOnly: false };
|
||||
|
||||
const alphaAtCenter = (img: { data: Uint8ClampedArray }) => img.data[19];
|
||||
|
||||
it('без сглаживания центр удалён', () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 0 });
|
||||
expect(alphaAtCenter(img)).toBe(0);
|
||||
});
|
||||
|
||||
it('два прохода мажоритарного фильтра возвращают изолированный пиксель', () => {
|
||||
const img = removeBackground(makeImage(3, 3, ringRedCenterGreen), { ...opts, smoothPasses: 2 });
|
||||
expect(alphaAtCenter(img)).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBackground', () => {
|
||||
it('обнуляет альфу удалённых, сохраняет RGB остальных', () => {
|
||||
const out = removeBackground(
|
||||
makeImage(2, 1, [GREEN, [5, 6, 7, 200]]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect(out.data[3]).toBe(0);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([5, 6, 7, 200]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backgroundMaskPreview', () => {
|
||||
it('белое там, где удаление, чёрное — где остаёмся, всё непрозрачно', () => {
|
||||
const preview = backgroundMaskPreview(
|
||||
makeImage(2, 1, [
|
||||
GREEN,
|
||||
[9, 9, 9, 60]
|
||||
]),
|
||||
{ color: '#00ff00', tolerancePercent: 0, outerOnly: false, smoothPasses: 0 }
|
||||
);
|
||||
expect([...preview.data]).toEqual([
|
||||
255, 255, 255, 255,
|
||||
0, 0, 0, 255
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export type BackgroundOptions = {
|
||||
color: string;
|
||||
tolerancePercent: number;
|
||||
outerOnly: boolean;
|
||||
smoothPasses: number;
|
||||
};
|
||||
|
||||
function buildRawMask(
|
||||
img: PixelImage,
|
||||
targetR: number,
|
||||
targetG: number,
|
||||
targetB: number,
|
||||
tolerancePercent: number
|
||||
): Uint8Array {
|
||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * Math.sqrt(3 * 255 * 255);
|
||||
const thresholdSq = tolerance * tolerance;
|
||||
const mask = new Uint8Array(img.width * img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const dr = img.data[i * 4] - targetR;
|
||||
const dg = img.data[i * 4 + 1] - targetG;
|
||||
const db = img.data[i * 4 + 2] - targetB;
|
||||
mask[i] = dr * dr + dg * dg + db * db <= thresholdSq ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function floodFromBorders(mask: Uint8Array, w: number, h: number): void {
|
||||
const queue: number[] = [];
|
||||
const push = (index: number) => {
|
||||
if (mask[index] === 1) {
|
||||
mask[index] = 2;
|
||||
queue.push(index);
|
||||
}
|
||||
};
|
||||
for (let x = 0; x < w; x++) {
|
||||
push(x);
|
||||
push((h - 1) * w + x);
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
push(y * w);
|
||||
push(y * w + w - 1);
|
||||
}
|
||||
let head = 0;
|
||||
while (head < queue.length) {
|
||||
const index = queue[head++];
|
||||
const x = index % w;
|
||||
if (x > 0) push(index - 1);
|
||||
if (x < w - 1) push(index + 1);
|
||||
if (index >= w) push(index - w);
|
||||
if (index < (h - 1) * w) push(index + w);
|
||||
}
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
mask[i] = mask[i] === 2 ? 1 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function smoothMask(
|
||||
mask: Uint8Array,
|
||||
w: number,
|
||||
h: number,
|
||||
passes: number
|
||||
): Uint8Array {
|
||||
let current = mask;
|
||||
const count = clamp(Math.trunc(passes), 0, 8);
|
||||
for (let pass = 0; pass < count; pass++) {
|
||||
const next = new Uint8Array(current.length);
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
let removed = 0;
|
||||
let total = 0;
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
const sy = clamp(y + dy, 0, h - 1);
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const sx = clamp(x + dx, 0, w - 1);
|
||||
removed += current[sy * w + sx];
|
||||
total++;
|
||||
}
|
||||
}
|
||||
next[y * w + x] = removed * 2 >= total ? 1 : 0;
|
||||
}
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function backgroundRemovalMask(
|
||||
img: PixelImage,
|
||||
options: BackgroundOptions
|
||||
): Uint8Array {
|
||||
const [tr, tg, tb] = parseHex(options.color);
|
||||
const mask = buildRawMask(img, tr, tg, tb, options.tolerancePercent);
|
||||
if (options.outerOnly) {
|
||||
floodFromBorders(mask, img.width, img.height);
|
||||
}
|
||||
return smoothMask(mask, img.width, img.height, options.smoothPasses);
|
||||
}
|
||||
|
||||
export function removeBackground(img: PixelImage, options: BackgroundOptions): PixelImage {
|
||||
const mask = backgroundRemovalMask(img, options);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const di = i * 4;
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = mask[i] === 1 ? 0 : img.data[di + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function backgroundMaskPreview(img: PixelImage, options: BackgroundOptions): PixelImage {
|
||||
const mask = backgroundRemovalMask(img, options);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const v = mask[i] === 1 ? 255 : 0;
|
||||
const di = i * 4;
|
||||
out.data[di] = v;
|
||||
out.data[di + 1] = v;
|
||||
out.data[di + 2] = v;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { convolve, gaussianBlur, sharpen } from './convolution';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||
|
||||
describe('convolve', () => {
|
||||
it('крестовое ядро резкости на полоске из трёх пикселей', () => {
|
||||
const out = convolve(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
[0, 0, 0, 255]
|
||||
]),
|
||||
SHARPEN_KERNEL,
|
||||
3
|
||||
);
|
||||
expect([...out.data.slice(4, 8)]).toEqual([255, 255, 255, 255]);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 255]);
|
||||
expect([...out.data.slice(8, 12)]).toEqual([0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[2, 3],
|
||||
[3.5, 3],
|
||||
[0, 3]
|
||||
])('бросает ошибку на некорректном размере ядра %i', (size) => {
|
||||
expect(() => convolve(makeImage(1, 1, [[0, 0, 0, 255]]), [1], size as number)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharpen', () => {
|
||||
it('сила 0 возвращает копию', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[10, 20, 30, 255],
|
||||
[40, 50, 60, 128],
|
||||
[70, 80, 90, 255],
|
||||
[100, 110, 120, 200]
|
||||
]);
|
||||
expect([...sharpen(img, 0).data]).toEqual([...img.data]);
|
||||
});
|
||||
|
||||
it('сила 100 применяет чистое ядро резкости', () => {
|
||||
const out = sharpen(
|
||||
makeImage(3, 1, [
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
[0, 0, 0, 255]
|
||||
]),
|
||||
100
|
||||
);
|
||||
expect(out.data[4]).toBe(255);
|
||||
expect(out.data[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gaussianBlur', () => {
|
||||
it('постоянное изображение не меняется ни в RGB, ни в альфе', () => {
|
||||
const img = makeImage(3, 3, new Array(9).fill([40, 80, 120, 128]));
|
||||
const out = gaussianBlur(img, 16);
|
||||
for (let i = 0; i < out.data.length; i++) {
|
||||
expect(out.data[i]).toBe(img.data[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('далёкие углы остаются прозрачными, цвет центра не искажается', () => {
|
||||
const size = 61;
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
pixels.push(
|
||||
x >= 26 && x <= 34 && y >= 26 && y <= 34 ? [200, 50, 25, 255] : [0, 0, 0, 0]
|
||||
);
|
||||
}
|
||||
}
|
||||
const out = gaussianBlur(makeImage(size, size, pixels), 8);
|
||||
const corner = 0;
|
||||
expect(out.data[corner]).toBe(0);
|
||||
expect(out.data[corner + 1]).toBe(0);
|
||||
expect(out.data[corner + 2]).toBe(0);
|
||||
expect(out.data[corner + 3]).toBe(0);
|
||||
const center = (30 * size + 30) * 4;
|
||||
expect(out.data[center]).toBe(200);
|
||||
expect(out.data[center + 1]).toBe(50);
|
||||
expect(out.data[center + 2]).toBe(25);
|
||||
});
|
||||
|
||||
it('симметричный вход даёт симметричный результат', () => {
|
||||
const leftByRow = [
|
||||
[
|
||||
[255, 0, 0, 255],
|
||||
[10, 20, 30, 255],
|
||||
[64, 64, 64, 64],
|
||||
[5, 5, 5, 200]
|
||||
],
|
||||
[
|
||||
[10, 20, 30, 255],
|
||||
[200, 100, 50, 255],
|
||||
[1, 2, 3, 4],
|
||||
[90, 90, 90, 250]
|
||||
],
|
||||
[
|
||||
[64, 64, 64, 64],
|
||||
[1, 2, 3, 4],
|
||||
[128, 128, 128, 128],
|
||||
[40, 40, 40, 240]
|
||||
],
|
||||
[
|
||||
[200, 100, 50, 255],
|
||||
[90, 90, 90, 250],
|
||||
[40, 40, 40, 240],
|
||||
[7, 7, 7, 255]
|
||||
],
|
||||
[
|
||||
[10, 20, 30, 255],
|
||||
[1, 2, 3, 4],
|
||||
[64, 64, 64, 64],
|
||||
[90, 90, 90, 250]
|
||||
],
|
||||
[
|
||||
[64, 64, 64, 64],
|
||||
[200, 100, 50, 255],
|
||||
[5, 5, 5, 200],
|
||||
[1, 2, 3, 4]
|
||||
],
|
||||
[
|
||||
[5, 5, 5, 200],
|
||||
[40, 40, 40, 240],
|
||||
[90, 90, 90, 250],
|
||||
[128, 128, 128, 128]
|
||||
]
|
||||
];
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
pixels.push(leftByRow[y][Math.min(x, 6 - x)]);
|
||||
}
|
||||
}
|
||||
const img = makeImage(7, 7, pixels);
|
||||
const blurred = gaussianBlur(img, 3);
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 3; x++) {
|
||||
const li = (y * 7 + x) * 4;
|
||||
const ri = (y * 7 + (6 - x)) * 4;
|
||||
expect([...blurred.data.slice(li, li + 4)]).toEqual([
|
||||
blurred.data[ri],
|
||||
blurred.data[ri + 1],
|
||||
blurred.data[ri + 2],
|
||||
blurred.data[ri + 3]
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
type Plane = Float64Array;
|
||||
|
||||
export function convolve(
|
||||
img: PixelImage,
|
||||
kernel: readonly number[],
|
||||
size: number
|
||||
): PixelImage {
|
||||
if (!Number.isInteger(size) || size < 1 || size % 2 === 0) {
|
||||
throw new Error('Размер ядра должен быть нечётным положительным числом');
|
||||
}
|
||||
if (kernel.length !== size * size) {
|
||||
throw new Error('Длина ядра не совпадает с его размером');
|
||||
}
|
||||
const half = Math.floor(size / 2);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let y = 0; y < out.height; y++) {
|
||||
for (let x = 0; x < out.width; x++) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
let acc = 0;
|
||||
for (let ky = 0; ky < size; ky++) {
|
||||
const sy = clampInt(y + ky - half, 0, img.height - 1);
|
||||
for (let kx = 0; kx < size; kx++) {
|
||||
const sx = clampInt(x + kx - half, 0, img.width - 1);
|
||||
acc += img.data[(sy * img.width + sx) * 4 + ch] * kernel[ky * size + kx];
|
||||
}
|
||||
}
|
||||
out.data[(y * out.width + x) * 4 + ch] = acc;
|
||||
}
|
||||
out.data[(y * out.width + x) * 4 + 3] = img.data[(y * img.width + x) * 4 + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const SHARPEN_KERNEL = [0, -1, 0, -1, 5, -1, 0, -1, 0];
|
||||
|
||||
export function sharpen(img: PixelImage, strengthPercent: number): PixelImage {
|
||||
const strength = clamp(strengthPercent, 0, 100) / 100;
|
||||
if (strength === 0) return clonePixelImage(img);
|
||||
const sharp = convolve(img, SHARPEN_KERNEL, 3);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
for (let ch = 0; ch < 3; ch++) {
|
||||
out.data[i + ch] =
|
||||
img.data[i + ch] * (1 - strength) + sharp.data[i + ch] * strength;
|
||||
}
|
||||
out.data[i + 3] = img.data[i + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function gaussianBlur(img: PixelImage, radiusPx: number): PixelImage {
|
||||
const radius = clamp(radiusPx, 0, 512);
|
||||
if (radius < 1) return clonePixelImage(img);
|
||||
const sigma = Math.max(0.25, radius / 2);
|
||||
const boxes = boxesForGauss(sigma, 3);
|
||||
|
||||
const w = img.width;
|
||||
const h = img.height;
|
||||
const pixels = w * h;
|
||||
|
||||
const red = new Float64Array(pixels);
|
||||
const green = new Float64Array(pixels);
|
||||
const blue = new Float64Array(pixels);
|
||||
const alpha = new Float64Array(pixels);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const a = img.data[i * 4 + 3] / 255;
|
||||
red[i] = (img.data[i * 4] / 255) * a;
|
||||
green[i] = (img.data[i * 4 + 1] / 255) * a;
|
||||
blue[i] = (img.data[i * 4 + 2] / 255) * a;
|
||||
alpha[i] = a;
|
||||
}
|
||||
|
||||
const tmp = new Float64Array(pixels);
|
||||
for (const box of boxes) {
|
||||
const r = Math.max(0, (box - 1) / 2);
|
||||
if (r < 1) continue;
|
||||
blurPlanePass(red, tmp, w, h, r);
|
||||
blurPlanePass(green, tmp, w, h, r);
|
||||
blurPlanePass(blue, tmp, w, h, r);
|
||||
blurPlanePass(alpha, tmp, w, h, r);
|
||||
}
|
||||
|
||||
const out = createPixelImage(w, h);
|
||||
for (let i = 0; i < pixels; i++) {
|
||||
const a = alpha[i];
|
||||
const di = i * 4;
|
||||
if (a < 1 / 255) {
|
||||
out.data[di] = 0;
|
||||
out.data[di + 1] = 0;
|
||||
out.data[di + 2] = 0;
|
||||
out.data[di + 3] = 0;
|
||||
continue;
|
||||
}
|
||||
out.data[di] = (red[i] / a) * 255;
|
||||
out.data[di + 1] = (green[i] / a) * 255;
|
||||
out.data[di + 2] = (blue[i] / a) * 255;
|
||||
out.data[di + 3] = a * 255;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function blurPlanePass(plane: Plane, tmp: Plane, w: number, h: number, r: number): void {
|
||||
blurPlaneHorizontal(plane, tmp, w, h, r);
|
||||
blurPlaneVertical(tmp, plane, w, h, r);
|
||||
}
|
||||
|
||||
function blurPlaneHorizontal(
|
||||
src: Plane,
|
||||
dst: Plane,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
): void {
|
||||
const div = 2 * r + 1;
|
||||
const inv = 1 / div;
|
||||
for (let y = 0; y < h; y++) {
|
||||
const row = y * w;
|
||||
let sum = src[row] * (r + 1);
|
||||
for (let k = 1; k <= r; k++) {
|
||||
sum += src[row + clampInt(k, 0, w - 1)];
|
||||
}
|
||||
for (let x = 0; x < w; x++) {
|
||||
dst[row + x] = sum * inv;
|
||||
const addIndex = clampInt(x + r + 1, 0, w - 1);
|
||||
const removeIndex = clampInt(x - r, 0, w - 1);
|
||||
sum += src[row + addIndex] - src[row + removeIndex];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function blurPlaneVertical(
|
||||
src: Plane,
|
||||
dst: Plane,
|
||||
w: number,
|
||||
h: number,
|
||||
r: number
|
||||
): void {
|
||||
const div = 2 * r + 1;
|
||||
const inv = 1 / div;
|
||||
for (let x = 0; x < w; x++) {
|
||||
let sum = src[x] * (r + 1);
|
||||
for (let k = 1; k <= r; k++) {
|
||||
sum += src[clampInt(k, 0, h - 1) * w + x];
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
dst[y * w + x] = sum * inv;
|
||||
const addIndex = clampInt(y + r + 1, 0, h - 1);
|
||||
const removeIndex = clampInt(y - r, 0, h - 1);
|
||||
sum += src[addIndex * w + x] - src[removeIndex * w + x];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function boxesForGauss(sigma: number, boxes: number): number[] {
|
||||
const wIdeal = Math.sqrt((12 * sigma * sigma) / boxes + 1);
|
||||
let wl = Math.floor(wIdeal);
|
||||
if (wl % 2 === 0) wl--;
|
||||
const wu = wl + 2;
|
||||
const mIdeal = (12 * sigma * sigma - boxes * wl * wl - boxes * wl - boxes) / (4 * wl + 4);
|
||||
const m = Math.round(mIdeal);
|
||||
const sizes: number[] = [];
|
||||
for (let i = 0; i < boxes; i++) {
|
||||
sizes.push(i < m ? wl : wu);
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { gradientImage, noiseImage, solidImage } from './generate';
|
||||
|
||||
describe('solidImage', () => {
|
||||
it('заливает весь холст заданным цветом', () => {
|
||||
const out = solidImage(2, 2, [10, 20, 30, 255]);
|
||||
expect(out.width).toBe(2);
|
||||
expect([...out.data]).toEqual(new Array(4).fill([10, 20, 30, 255]).flat());
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0, 10],
|
||||
[10, 0],
|
||||
[2.5, 10],
|
||||
[-1, 5]
|
||||
])('бросает ошибку на размерах %i x %i', (w, h) => {
|
||||
expect(() => solidImage(w, h, [0, 0, 0, 255])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('noiseImage', () => {
|
||||
it('детерминирован: одно зерно — одни байты', () => {
|
||||
expect([...noiseImage(4, 4, 42).data]).toEqual([...noiseImage(4, 4, 42).data]);
|
||||
});
|
||||
|
||||
it('разные зерна дают разные данные', () => {
|
||||
const a = [...noiseImage(8, 8, 1).data];
|
||||
const b = [...noiseImage(8, 8, 2).data];
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('альфа всегда непрозрачная', () => {
|
||||
const data = noiseImage(3, 3, 7).data;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
expect(data[i]).toBe(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradientImage', () => {
|
||||
it('горизонтальный градиент идёт от цвета A к цвету B', () => {
|
||||
const out = gradientImage(
|
||||
3,
|
||||
1,
|
||||
[0, 0, 0, 255],
|
||||
[255, 255, 255, 255],
|
||||
'horizontal'
|
||||
);
|
||||
const px = (x: number) => [...out.data.slice(x * 4, x * 4 + 4)];
|
||||
expect(px(0)).toEqual([0, 0, 0, 255]);
|
||||
expect(px(1)).toEqual([128, 128, 128, 255]);
|
||||
expect(px(2)).toEqual([255, 255, 255, 255]);
|
||||
});
|
||||
|
||||
it('вертикальный градиент меняется по строкам', () => {
|
||||
const out = gradientImage(
|
||||
1,
|
||||
2,
|
||||
[0, 0, 0, 255],
|
||||
[100, 100, 100, 255],
|
||||
'vertical'
|
||||
);
|
||||
expect(out.data[0]).toBe(0);
|
||||
expect(out.data[4]).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export function solidImage(
|
||||
width: number,
|
||||
height: number,
|
||||
rgba: [number, number, number, number]
|
||||
): PixelImage {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
||||
}
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = rgba[0];
|
||||
data[i + 1] = rgba[1];
|
||||
data[i + 2] = rgba[2];
|
||||
data[i + 3] = rgba[3];
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function noiseImage(width: number, height: number, seed: number): PixelImage {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
||||
}
|
||||
const random = mulberry32(seed);
|
||||
const data = new Uint8ClampedArray(width * height * 4);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = Math.floor(random() * 256);
|
||||
data[i + 1] = Math.floor(random() * 256);
|
||||
data[i + 2] = Math.floor(random() * 256);
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
export function gradientImage(
|
||||
width: number,
|
||||
height: number,
|
||||
fromRgba: [number, number, number, number],
|
||||
toRgba: [number, number, number, number],
|
||||
direction: 'horizontal' | 'vertical'
|
||||
): PixelImage {
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
||||
throw new Error('Размеры должны быть целыми числами >= 1');
|
||||
}
|
||||
const out: PixelImage = {
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4)
|
||||
};
|
||||
const steps = (direction === 'horizontal' ? width : height) - 1;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const t = steps === 0 ? 0 : (direction === 'horizontal' ? x : y) / steps;
|
||||
const i = (y * width + x) * 4;
|
||||
out.data[i] = fromRgba[0] + (toRgba[0] - fromRgba[0]) * t;
|
||||
out.data[i + 1] = fromRgba[1] + (toRgba[1] - fromRgba[1]) * t;
|
||||
out.data[i + 2] = fromRgba[2] + (toRgba[2] - fromRgba[2]) * t;
|
||||
out.data[i + 3] = fromRgba[3] + (toRgba[3] - fromRgba[3]) * t;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { crop, flip, resize, rotate90 } from './geometry';
|
||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './geometry';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
const square = () =>
|
||||
@@ -126,6 +126,64 @@ describe('crop', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandCanvas', () => {
|
||||
const pixel = () => makeImage(1, 1, [[10, 20, 30, 255]]);
|
||||
|
||||
it('прозрачное расширение кладёт пиксель со смещением', () => {
|
||||
const out = expandCanvas(pixel(), 1, 2, 3, 4);
|
||||
expect(out.width).toBe(5);
|
||||
expect(out.height).toBe(7);
|
||||
expect([...out.data.slice(0, 4)]).toEqual([0, 0, 0, 0]);
|
||||
expect([...out.data.slice((2 * 5 + 1) * 4, (2 * 5 + 1) * 4 + 4)]).toEqual([10, 20, 30, 255]);
|
||||
});
|
||||
|
||||
it('цветной фон заливает всё вокруг', () => {
|
||||
const out = expandCanvas(pixel(), 1, 0, 0, 0, '#ffffff');
|
||||
expect(out.data[0]).toBe(255);
|
||||
expect(out.data[3]).toBe(255);
|
||||
expect(out.data[(0 * 2 + 1) * 4 + 3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tile', () => {
|
||||
it('повторяет изображение по сетке', () => {
|
||||
const out = tile(makeImage(1, 1, [[9, 9, 9, 255]]), 3, 2);
|
||||
expect(out.width).toBe(3);
|
||||
expect(out.height).toBe(2);
|
||||
expect([...out.data].filter((_, i) => i % 4 === 0)).toEqual([9, 9, 9, 9, 9, 9]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('centerByAlpha', () => {
|
||||
it('вырезает непрозрачный блок и центрирует на прежнем холсте', () => {
|
||||
const img = makeImage(3, 3, [
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[5, 5, 5, 255],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0]
|
||||
]);
|
||||
const out = centerByAlpha(img);
|
||||
expect(out.width).toBe(3);
|
||||
expect(out.height).toBe(3);
|
||||
const alphaAt = (x: number, y: number) => out.data[(y * 3 + x) * 4 + 3];
|
||||
expect(alphaAt(0, 0)).toBe(0);
|
||||
expect(alphaAt(1, 1)).toBe(255);
|
||||
});
|
||||
|
||||
it('полностью прозрачное изображение возвращается без изменений', () => {
|
||||
const img = makeImage(2, 1, [
|
||||
[0, 0, 0, 0],
|
||||
[0, 0, 0, 0]
|
||||
]);
|
||||
expect([...centerByAlpha(img).data]).toEqual([...img.data]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resize', () => {
|
||||
const twoByTwo = () =>
|
||||
makeImage(2, 2, [
|
||||
|
||||
@@ -1,5 +1,85 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
export function expandCanvas(
|
||||
img: PixelImage,
|
||||
left: number,
|
||||
top: number,
|
||||
right: number,
|
||||
bottom: number,
|
||||
backgroundHex?: string
|
||||
): PixelImage {
|
||||
const l = Math.max(0, Math.trunc(left));
|
||||
const t = Math.max(0, Math.trunc(top));
|
||||
const r = Math.max(0, Math.trunc(right));
|
||||
const b = Math.max(0, Math.trunc(bottom));
|
||||
const out = createPixelImage(img.width + l + r, img.height + t + b);
|
||||
if (backgroundHex !== undefined) {
|
||||
const [cr, cg, cb] = parseHex(backgroundHex);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
out.data[i] = cr;
|
||||
out.data[i + 1] = cg;
|
||||
out.data[i + 2] = cb;
|
||||
out.data[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const srcStart = y * img.width * 4;
|
||||
out.data.set(
|
||||
img.data.subarray(srcStart, srcStart + img.width * 4),
|
||||
((y + t) * out.width + l) * 4
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tile(img: PixelImage, columns: number, rows: number): PixelImage {
|
||||
const cols = Math.max(1, Math.trunc(columns));
|
||||
const rowsCount = Math.max(1, Math.trunc(rows));
|
||||
const out = createPixelImage(img.width * cols, img.height * rowsCount);
|
||||
for (let ty = 0; ty < rowsCount; ty++) {
|
||||
for (let tx = 0; tx < cols; tx++) {
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const srcStart = y * img.width * 4;
|
||||
out.data.set(
|
||||
img.data.subarray(srcStart, srcStart + img.width * 4),
|
||||
((ty * img.height + y) * out.width + tx * img.width) * 4
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function centerByAlpha(img: PixelImage): PixelImage {
|
||||
let minX = img.width;
|
||||
let minY = img.height;
|
||||
let maxX = -1;
|
||||
let maxY = -1;
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
if (img.data[(y * img.width + x) * 4 + 3] > 0) {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (maxX < 0) return clonePixelImage(img);
|
||||
const content = crop(img, minX, minY, maxX - minX + 1, maxY - minY + 1);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
const dx = Math.floor((img.width - content.width) / 2);
|
||||
const dy = Math.floor((img.height - content.height) / 2);
|
||||
for (let y = 0; y < content.height; y++) {
|
||||
out.data.set(
|
||||
content.data.subarray(y * content.width * 4, (y + 1) * content.width * 4),
|
||||
((y + dy) * out.width + dx) * 4
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type FlipAxis = 'horizontal' | 'vertical';
|
||||
|
||||
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildAlphaMask,
|
||||
closingImage,
|
||||
contourImage,
|
||||
dilateImage,
|
||||
dilateMask,
|
||||
erodeImage,
|
||||
erodeMask,
|
||||
openingImage,
|
||||
strokeImage
|
||||
} from './morphology';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
function maskFrom(rows: string[]): Uint8Array {
|
||||
const flat = rows.join('');
|
||||
const mask = new Uint8Array(flat.length);
|
||||
for (let i = 0; i < flat.length; i++) {
|
||||
mask[i] = flat[i] === '#' ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function toRows(mask: Uint8Array, w: number): string[] {
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < mask.length / w; y++) {
|
||||
let row = '';
|
||||
for (let x = 0; x < w; x++) {
|
||||
row += mask[y * w + x] === 1 ? '#' : '.';
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe('dilateMask', () => {
|
||||
it('одиночный пиксель r=1 превращается в плюс', () => {
|
||||
const out = dilateMask(
|
||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
||||
5,
|
||||
5,
|
||||
1
|
||||
);
|
||||
expect(toRows(out, 5)).toEqual(['.....', '..#..', '.###.', '..#..', '.....']);
|
||||
});
|
||||
|
||||
it('r=2 даёт ромб радиуса 2', () => {
|
||||
const out = dilateMask(
|
||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
||||
5,
|
||||
5,
|
||||
2
|
||||
);
|
||||
expect(toRows(out, 5)).toEqual(['..#..', '.###.', '#####', '.###.', '..#..']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('erodeMask', () => {
|
||||
it('сплошной объект во весь кадр не сжимается от границ', () => {
|
||||
const solid = maskFrom(['#####', '#####', '#####', '#####', '#####']);
|
||||
expect([...erodeMask(solid, 5, 5, 1)]).toEqual([...solid]);
|
||||
});
|
||||
|
||||
it('изолированный пиксель исчезает', () => {
|
||||
const out = erodeMask(
|
||||
maskFrom(['.....', '.....', '..#..', '.....', '.....']),
|
||||
5,
|
||||
5,
|
||||
1
|
||||
);
|
||||
expect([...out].every((v) => v === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('opening/closing образы', () => {
|
||||
it('opening убирает отстоящий мусорный пиксель и сохраняет блок', () => {
|
||||
const B = [10, 10, 10, 255];
|
||||
const T = [0, 0, 0, 0];
|
||||
const S = [200, 200, 200, 255];
|
||||
const out = openingImage(
|
||||
makeImage(
|
||||
6,
|
||||
3,
|
||||
[B, B, B, T, S, T, B, B, B, T, T, T, B, B, B, T, T, T]
|
||||
),
|
||||
1
|
||||
);
|
||||
const at = (x: number, y: number) => out.data[(y * 6 + x) * 4 + 3];
|
||||
expect(at(0, 1)).toBe(255);
|
||||
expect(at(2, 1)).toBe(255);
|
||||
expect(at(4, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('closing заполняет одиночную прозрачную дыру чёрным', () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 3; y++) {
|
||||
for (let x = 0; x < 3; x++) {
|
||||
pixels.push(x === 1 && y === 1 ? [0, 0, 0, 0] : [255, 255, 255, 255]);
|
||||
}
|
||||
}
|
||||
const out = closingImage(makeImage(3, 3, pixels), 1);
|
||||
const di = (1 * 3 + 1) * 4;
|
||||
expect(out.data[di + 3]).toBe(255);
|
||||
});
|
||||
});
|
||||
|
||||
describe('image-обёртки', () => {
|
||||
it('dilateImage сохраняет RGB содержимого, новые пиксели непрозрачны', () => {
|
||||
const T = [0, 0, 0, 0];
|
||||
const O = [200, 50, 25, 255];
|
||||
const img = makeImage(5, 2, [T, O, T, T, T, T, T, T, T, T]);
|
||||
const out = dilateImage(img, 1);
|
||||
const at = (x: number, y: number) => {
|
||||
const di = (y * 5 + x) * 4;
|
||||
return [out.data[di], out.data[di + 1], out.data[di + 2], out.data[di + 3]];
|
||||
};
|
||||
expect(at(1, 0)).toEqual([200, 50, 25, 255]);
|
||||
expect(at(0, 0)).toEqual([0, 0, 0, 255]);
|
||||
expect(at(4, 0)[3]).toBe(0);
|
||||
});
|
||||
|
||||
it('erodeImage не стирает объект у края кадра', () => {
|
||||
const img = makeImage(2, 2, [
|
||||
[10, 20, 30, 255],
|
||||
[10, 20, 30, 255],
|
||||
[10, 20, 30, 255],
|
||||
[10, 20, 30, 255]
|
||||
]);
|
||||
const out = erodeImage(img, 1);
|
||||
for (let i = 0; i < out.data.length; i += 4) {
|
||||
expect(out.data[i + 3]).toBe(255);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('strokeImage', () => {
|
||||
it('кольцо цвета обводки вокруг квадрата', () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
const out = strokeImage(makeImage(7, 7, pixels), 1, '#0000ff');
|
||||
const at = (x: number, y: number) =>
|
||||
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
|
||||
expect(at(2, 3)).toEqual([255, 0, 0, 255]);
|
||||
expect(at(1, 3)).toEqual([0, 0, 255, 255]);
|
||||
expect(at(0, 0)).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contourImage', () => {
|
||||
it('линия по краю квадрата, центр прозрачен', () => {
|
||||
const pixels: number[][] = [];
|
||||
for (let y = 0; y < 7; y++) {
|
||||
for (let x = 0; x < 7; x++) {
|
||||
pixels.push(x >= 2 && x <= 4 && y >= 2 && y <= 4 ? [255, 0, 0, 255] : [0, 0, 0, 0]);
|
||||
}
|
||||
}
|
||||
const out = contourImage(makeImage(7, 7, pixels), 1, '#0000ff');
|
||||
const at = (x: number, y: number) =>
|
||||
[...out.data.slice((y * 7 + x) * 4, (y * 7 + x) * 4 + 4)];
|
||||
expect(at(2, 2)).toEqual([0, 0, 255, 255]);
|
||||
expect(at(3, 3)).toEqual([0, 0, 0, 0]);
|
||||
expect(at(1, 3)).toEqual([0, 0, 0, 0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { parseHex } from './alpha';
|
||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
||||
|
||||
type Mask = Uint8Array;
|
||||
|
||||
type Offset = { dx: number; dy: number };
|
||||
|
||||
function discOffsets(radius: number): Offset[] {
|
||||
const offsets: Offset[] = [];
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
if (dx * dx + dy * dy <= radius * radius) {
|
||||
offsets.push({ dx, dy });
|
||||
}
|
||||
}
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
export function buildAlphaMask(img: PixelImage): Mask {
|
||||
const mask = new Uint8Array(img.width * img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
mask[i] = img.data[i * 4 + 3] > 0 ? 1 : 0;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function dilateMask(mask: Mask, width: number, height: number, radius: number): Mask {
|
||||
const r = Math.trunc(radius);
|
||||
if (r < 1) return mask.slice();
|
||||
const out = new Uint8Array(mask.length);
|
||||
const offsets = discOffsets(r);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let hit = 0;
|
||||
for (const { dx, dy } of offsets) {
|
||||
const sx = x + dx;
|
||||
const sy = y + dy;
|
||||
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
|
||||
if (mask[sy * width + sx] === 1) {
|
||||
hit = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out[y * width + x] = hit;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function erodeMask(mask: Mask, width: number, height: number, radius: number): Mask {
|
||||
const r = Math.trunc(radius);
|
||||
if (r < 1) return mask.slice();
|
||||
const out = new Uint8Array(mask.length);
|
||||
const offsets = discOffsets(r);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let solid = 1;
|
||||
for (const { dx, dy } of offsets) {
|
||||
const sx = x + dx;
|
||||
const sy = y + dy;
|
||||
if (sx < 0 || sy < 0 || sx >= width || sy >= height) continue;
|
||||
if (mask[sy * width + sx] !== 1) {
|
||||
solid = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
out[y * width + x] = solid;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applyMaskAlpha(img: PixelImage, mask: Mask): PixelImage {
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
const di = i * 4;
|
||||
if (mask[i] === 1) {
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = 255;
|
||||
} else {
|
||||
out.data[di + 3] = 0;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function dilateImage(img: PixelImage, radiusPx: number): PixelImage {
|
||||
if (radiusPx < 1) return clonePixelImage(img);
|
||||
return applyMaskAlpha(img, dilateMask(buildAlphaMask(img), img.width, img.height, radiusPx));
|
||||
}
|
||||
|
||||
export function erodeImage(img: PixelImage, radiusPx: number): PixelImage {
|
||||
if (radiusPx < 1) return clonePixelImage(img);
|
||||
return applyMaskAlpha(img, erodeMask(buildAlphaMask(img), img.width, img.height, radiusPx));
|
||||
}
|
||||
|
||||
export function strokeImage(
|
||||
img: PixelImage,
|
||||
radiusPx: number,
|
||||
colorHex: string
|
||||
): PixelImage {
|
||||
const r = Math.trunc(radiusPx);
|
||||
if (r < 1) return clonePixelImage(img);
|
||||
const [cr, cg, cb] = parseHex(colorHex);
|
||||
const mask = buildAlphaMask(img);
|
||||
const ring = dilateMask(mask, img.width, img.height, r);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const di = i * 4;
|
||||
if (ring[i] === 1 && mask[i] === 0) {
|
||||
out.data[di] = cr;
|
||||
out.data[di + 1] = cg;
|
||||
out.data[di + 2] = cb;
|
||||
out.data[di + 3] = 255;
|
||||
} else if (mask[i] === 1) {
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = img.data[di + 3];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function contourImage(img: PixelImage, radiusPx: number, colorHex: string): PixelImage {
|
||||
const r = Math.max(1, Math.trunc(radiusPx));
|
||||
const [cr, cg, cb] = parseHex(colorHex);
|
||||
const mask = buildAlphaMask(img);
|
||||
const inner = erodeMask(mask, img.width, img.height, r);
|
||||
const line = new Uint8Array(mask.length);
|
||||
for (let i = 0; i < mask.length; i++) {
|
||||
line[i] = mask[i] === 1 && inner[i] === 0 ? 1 : 0;
|
||||
}
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const di = i * 4;
|
||||
if (line[i] === 1) {
|
||||
out.data[di] = cr;
|
||||
out.data[di + 1] = cg;
|
||||
out.data[di + 2] = cb;
|
||||
out.data[di + 3] = 255;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function openingMask(mask: Mask, w: number, h: number, radius: number): Mask {
|
||||
return dilateMask(erodeMask(mask, w, h, radius), w, h, radius);
|
||||
}
|
||||
|
||||
export function closingMask(mask: Mask, w: number, h: number, radius: number): Mask {
|
||||
return erodeMask(dilateMask(mask, w, h, radius), w, h, radius);
|
||||
}
|
||||
|
||||
export function openingImage(img: PixelImage, radiusPx: number): PixelImage {
|
||||
if (radiusPx < 1) return clonePixelImage(img);
|
||||
const mask = buildAlphaMask(img);
|
||||
const opened = openingMask(mask, img.width, img.height, radiusPx);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < opened.length; i++) {
|
||||
const di = i * 4;
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
out.data[di + 3] = opened[i] === 1 ? img.data[di + 3] : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function closingImage(img: PixelImage, radiusPx: number): PixelImage {
|
||||
if (radiusPx < 1) return clonePixelImage(img);
|
||||
const mask = buildAlphaMask(img);
|
||||
const closed = closingMask(mask, img.width, img.height, radiusPx);
|
||||
const out = createPixelImage(img.width, img.height);
|
||||
for (let i = 0; i < closed.length; i++) {
|
||||
const di = i * 4;
|
||||
if (closed[i] === 1 && mask[i] === 0) {
|
||||
out.data[di] = 0;
|
||||
out.data[di + 1] = 0;
|
||||
out.data[di + 2] = 0;
|
||||
} else {
|
||||
out.data[di] = img.data[di];
|
||||
out.data[di + 1] = img.data[di + 1];
|
||||
out.data[di + 2] = img.data[di + 2];
|
||||
}
|
||||
out.data[di + 3] = closed[i] === 1 ? 255 : img.data[di + 3];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CATEGORIES } from './categories';
|
||||
import { TOOL_ICONS } from './tools/tool-icons';
|
||||
import {
|
||||
defaultParams,
|
||||
getTool,
|
||||
@@ -10,46 +11,7 @@ import {
|
||||
type ToolEntry
|
||||
} from './registry';
|
||||
|
||||
const PLAN_TOOL_IDS = [
|
||||
'resize-png',
|
||||
'crop-png',
|
||||
'rotate-png',
|
||||
'flip-png',
|
||||
'grayscale-png',
|
||||
'invert-colors-png',
|
||||
'adjust-brightness-contrast-png',
|
||||
'convert-png-to-jpg',
|
||||
'convert-png-to-webp',
|
||||
'remove-color-from-png',
|
||||
'png-info',
|
||||
'invert-alpha-png',
|
||||
'jpg-to-png',
|
||||
'webp-to-png',
|
||||
'gif-to-png',
|
||||
'bmp-to-png',
|
||||
'ico-to-png',
|
||||
'png-to-bmp',
|
||||
'png-to-base64',
|
||||
'base64-to-png',
|
||||
'png-to-data-uri',
|
||||
'data-uri-to-png',
|
||||
'png-to-hex',
|
||||
'hex-to-png',
|
||||
'change-png-opacity',
|
||||
'sepia-png',
|
||||
'change-png-hue',
|
||||
'extract-channel-png',
|
||||
'swap-channels-png',
|
||||
'black-and-white-png',
|
||||
'posterize-png',
|
||||
'two-colors-png'
|
||||
];
|
||||
|
||||
describe('реестр инструментов', () => {
|
||||
it('содержит ровно 32 инструмента из плана', () => {
|
||||
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
||||
});
|
||||
|
||||
it('id уникальны и в kebab-case', () => {
|
||||
const ids = TOOLS.map((t) => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
@@ -65,6 +27,8 @@ describe('реестр инструментов', () => {
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||
if (tool.resultType === 'text') {
|
||||
expect(typeof tool.toText).toBe('function');
|
||||
} else if (tool.sourceMode === 'none') {
|
||||
expect(typeof tool.generate).toBe('function');
|
||||
} else {
|
||||
expect(typeof tool.run).toBe('function');
|
||||
}
|
||||
@@ -129,6 +93,21 @@ describe('реестр инструментов', () => {
|
||||
const resize = getToolOrThrow('resize-png');
|
||||
expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true });
|
||||
});
|
||||
|
||||
it('словарь иконок покрывает все инструменты', () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(TOOL_ICONS[tool.id]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('у каждого инструмента популярность в диапазоне 0..100 и ключ иконки', () => {
|
||||
for (const tool of TOOLS) {
|
||||
expect(tool.popularity).toBeGreaterThanOrEqual(0);
|
||||
expect(tool.popularity).toBeLessThanOrEqual(100);
|
||||
expect(typeof tool.icon).toBe('string');
|
||||
expect(tool.icon!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeParams', () => {
|
||||
|
||||
+427
-3
@@ -1,5 +1,33 @@
|
||||
import type { CategoryId } from './categories';
|
||||
import { colorMask, flattenOntoColor, invertAlpha, removeColorToAlpha } from './core/alpha';
|
||||
import {
|
||||
colorMask,
|
||||
extractAlphaMask,
|
||||
flattenOntoColor,
|
||||
hardenAlpha,
|
||||
invertAlpha,
|
||||
removeColorToAlpha,
|
||||
roundCorners,
|
||||
setAlphaChannel
|
||||
} from './core/alpha';
|
||||
import {
|
||||
hasTransparency,
|
||||
isGrayscale,
|
||||
orientationOf
|
||||
} from './core/analyze';
|
||||
import {
|
||||
backgroundMaskPreview,
|
||||
removeBackground
|
||||
} from './core/background';
|
||||
import {
|
||||
closingImage,
|
||||
contourImage,
|
||||
dilateImage,
|
||||
erodeImage,
|
||||
openingImage,
|
||||
strokeImage
|
||||
} from './core/morphology';
|
||||
import { gaussianBlur, sharpen as sharpenImage } from './core/convolution';
|
||||
import { gradientImage, noiseImage, solidImage } from './core/generate';
|
||||
import {
|
||||
brightnessContrast,
|
||||
changeHue,
|
||||
@@ -15,7 +43,7 @@ import {
|
||||
type ChannelSwapPair,
|
||||
type RgbChannel
|
||||
} from './core/color';
|
||||
import { crop, flip, resize, rotate90 } from './core/geometry';
|
||||
import { centerByAlpha, crop, expandCanvas, flip, resize, rotate90, tile } from './core/geometry';
|
||||
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||
import { hexToPixels, pixelsToHex } from './core/text';
|
||||
import { clonePixelImage, type PixelImage } from './core/types';
|
||||
@@ -72,11 +100,17 @@ export type ToolEntry = {
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<PixelImage> | PixelImage;
|
||||
popularity?: number;
|
||||
icon?: string;
|
||||
resultType?: 'image' | 'info' | 'text';
|
||||
output?: OutputFormat;
|
||||
};
|
||||
|
||||
const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||
|
||||
export function isChainable(tool: ToolEntry): boolean {
|
||||
return (tool.resultType ?? 'image') === 'image' && (!tool.sourceMode || tool.sourceMode === 'file');
|
||||
}
|
||||
|
||||
function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
@@ -105,6 +139,20 @@ function decodeToPng(id: string, title: string, description: string): ToolEntry
|
||||
};
|
||||
}
|
||||
|
||||
function hexToRgba(hex: string, alpha = 255): [number, number, number, number] {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match) {
|
||||
throw new Error(`Некорректный HEX-цвет: "${hex}"`);
|
||||
}
|
||||
const d = match[1];
|
||||
return [
|
||||
parseInt(d.slice(0, 2), 16),
|
||||
parseInt(d.slice(2, 4), 16),
|
||||
parseInt(d.slice(4, 6), 16),
|
||||
alpha
|
||||
];
|
||||
}
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
decodeToPng(
|
||||
'jpg-to-png',
|
||||
@@ -293,6 +341,109 @@ export const TOOLS: ToolEntry[] = [
|
||||
],
|
||||
run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal')
|
||||
},
|
||||
{
|
||||
id: 'add-padding-png',
|
||||
title: 'Добавить поля PNG',
|
||||
description: 'Расширяет холст во все стороны на выбранное число пикселей.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'padding', label: 'Поля, px', type: 'number', min: 1, max: 2000, step: 1, default: 10 },
|
||||
{ id: 'transparent', label: 'Прозрачные поля', type: 'checkbox', default: true },
|
||||
{ id: 'color', label: 'Цвет полей', type: 'color', default: '#ffffff' }
|
||||
],
|
||||
run: (img, p) =>
|
||||
expandCanvas(
|
||||
img,
|
||||
num(p, 'padding'),
|
||||
num(p, 'padding'),
|
||||
num(p, 'padding'),
|
||||
num(p, 'padding'),
|
||||
p['transparent'] === true ? undefined : str(p, 'color')
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'add-border-png',
|
||||
title: 'Добавить рамку PNG',
|
||||
description: 'Рисует цветную рамку вокруг изображения выбранной толщины.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'thickness', label: 'Толщина рамки, px', type: 'number', min: 1, max: 500, step: 1, default: 5 },
|
||||
{ id: 'color', label: 'Цвет рамки', type: 'color', default: '#000000' }
|
||||
],
|
||||
run: (img, p) => expandCanvas(img, num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), num(p, 'thickness'), str(p, 'color'))
|
||||
},
|
||||
{
|
||||
id: 'fit-on-background-png',
|
||||
title: 'Вписать PNG на фон',
|
||||
description:
|
||||
'Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина полотна', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
|
||||
{ id: 'height', label: 'Высота полотна', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
|
||||
{ id: 'transparent', label: 'Прозрачный фон', type: 'checkbox', default: false },
|
||||
{ id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' }
|
||||
],
|
||||
run: (img, p) => {
|
||||
const width = Math.trunc(num(p, 'width'));
|
||||
const height = Math.trunc(num(p, 'height'));
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('Укажите положительные размеры полотна');
|
||||
}
|
||||
const left = Math.max(0, Math.floor((width - img.width) / 2));
|
||||
const top = Math.max(0, Math.floor((height - img.height) / 2));
|
||||
return expandCanvas(
|
||||
img,
|
||||
left,
|
||||
top,
|
||||
Math.max(0, width - img.width - left),
|
||||
Math.max(0, height - img.height - top),
|
||||
p['transparent'] === true ? undefined : str(p, 'color')
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tile-png',
|
||||
title: 'Замостить PNG',
|
||||
description: 'Повторяет изображение сеткой из выбранного числа столбцов и строк.',
|
||||
category: 'geometry',
|
||||
params: [
|
||||
{ id: 'columns', label: 'Столбцов', type: 'number', min: 1, max: 50, step: 1, default: 2 },
|
||||
{ id: 'rows', label: 'Строк', type: 'number', min: 1, max: 50, step: 1, default: 2 }
|
||||
],
|
||||
run: (img, p) => tile(img, num(p, 'columns'), num(p, 'rows'))
|
||||
},
|
||||
{
|
||||
id: 'center-by-alpha-png',
|
||||
title: 'Центрировать PNG по содержимому',
|
||||
description:
|
||||
'Находит непрозрачную часть изображения и размещает её по центру прежнего холста.',
|
||||
category: 'geometry',
|
||||
params: [],
|
||||
run: (img) => centerByAlpha(img)
|
||||
},
|
||||
{
|
||||
id: 'blur-png',
|
||||
title: 'Размытие PNG',
|
||||
description:
|
||||
'Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.',
|
||||
category: 'filters',
|
||||
params: [
|
||||
{ id: 'radius', label: 'Радиус, px', type: 'slider', min: 1, max: 32, step: 1, default: 4 }
|
||||
],
|
||||
run: (img, p) => gaussianBlur(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'sharpen-png',
|
||||
title: 'Резкость PNG',
|
||||
description:
|
||||
'Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.',
|
||||
category: 'filters',
|
||||
params: [
|
||||
{ id: 'strength', label: 'Сила, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||
],
|
||||
run: (img, p) => sharpenImage(img, num(p, 'strength'))
|
||||
},
|
||||
{
|
||||
id: 'grayscale-png',
|
||||
title: 'Чёрно-белый PNG',
|
||||
@@ -443,6 +594,43 @@ export const TOOLS: ToolEntry[] = [
|
||||
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'remove-alpha-channel-png',
|
||||
title: 'Убрать альфа-канал PNG',
|
||||
description: 'Накладывает изображение на белый фон и сохраняет без прозрачности.',
|
||||
category: 'alpha',
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, '#ffffff')
|
||||
},
|
||||
{
|
||||
id: 'set-alpha-channel-png',
|
||||
title: 'Задать альфа-канал PNG',
|
||||
description: 'Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'percent', label: 'Прозрачность, %', type: 'slider', min: 0, max: 100, step: 1, default: 100 }
|
||||
],
|
||||
run: (img, p) => setAlphaChannel(img, num(p, 'percent'))
|
||||
},
|
||||
{
|
||||
id: 'extract-alpha-mask-png',
|
||||
title: 'Извлечь маску альфы PNG',
|
||||
description: 'Превращает прозрачность в чёрно-белую непрозрачную маску.',
|
||||
category: 'alpha',
|
||||
params: [],
|
||||
run: (img) => extractAlphaMask(img)
|
||||
},
|
||||
{
|
||||
id: 'round-corners-png',
|
||||
title: 'Скруглить углы PNG',
|
||||
description:
|
||||
'Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'radius', label: 'Радиус скругления, %', type: 'slider', min: 0, max: 50, step: 1, default: 10 }
|
||||
],
|
||||
run: (img, p) => roundCorners(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'invert-alpha-png',
|
||||
title: 'Инвертировать альфа-канал PNG',
|
||||
@@ -451,6 +639,108 @@ export const TOOLS: ToolEntry[] = [
|
||||
params: [],
|
||||
run: (img) => invertAlpha(img)
|
||||
},
|
||||
{
|
||||
id: 'remove-background-png',
|
||||
title: 'Удалить фон PNG (умно)',
|
||||
description:
|
||||
'Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'color', label: 'Цвет фона', type: 'color', default: '#ffffff' },
|
||||
{ id: 'tolerance', label: 'Допуск похожести, %', type: 'slider', min: 0, max: 100, step: 1, default: 10 },
|
||||
{ id: 'outerOnly', label: 'Только внешние области', type: 'checkbox', default: true },
|
||||
{ id: 'smooth', label: 'Сглаживание границы, проходы', type: 'slider', min: 0, max: 8, step: 1, default: 1 }
|
||||
],
|
||||
run: (img, p) =>
|
||||
removeBackground(img, {
|
||||
color: str(p, 'color'),
|
||||
tolerancePercent: num(p, 'tolerance'),
|
||||
outerOnly: p['outerOnly'] === true,
|
||||
smoothPasses: num(p, 'smooth')
|
||||
}),
|
||||
preview: (img, p) =>
|
||||
backgroundMaskPreview(img, {
|
||||
color: str(p, 'color'),
|
||||
tolerancePercent: num(p, 'tolerance'),
|
||||
outerOnly: p['outerOnly'] === true,
|
||||
smoothPasses: num(p, 'smooth')
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'add-stroke-png',
|
||||
title: 'Обвести PNG',
|
||||
description:
|
||||
'Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'color', label: 'Цвет обводки', type: 'color', default: '#ff0000' },
|
||||
{ id: 'thickness', label: 'Толщина, px', type: 'slider', min: 1, max: 10, step: 1, default: 3 }
|
||||
],
|
||||
run: (img, p) => strokeImage(img, num(p, 'thickness'), str(p, 'color'))
|
||||
},
|
||||
{
|
||||
id: 'find-contour-png',
|
||||
title: 'Найти контур PNG',
|
||||
description:
|
||||
'Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'color', label: 'Цвет линии', type: 'color', default: '#000000' },
|
||||
{ id: 'thickness', label: 'Толщина линии, px', type: 'slider', min: 1, max: 5, step: 1, default: 1 }
|
||||
],
|
||||
run: (img, p) => contourImage(img, num(p, 'thickness'), str(p, 'color'))
|
||||
},
|
||||
{
|
||||
id: 'make-thicker-png',
|
||||
title: 'Утолщить PNG',
|
||||
description: 'Расширяет непрозрачные области на заданное число пикселей.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'radius', label: 'На сколько px', type: 'slider', min: 1, max: 10, step: 1, default: 2 }
|
||||
],
|
||||
run: (img, p) => dilateImage(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'make-thinner-png',
|
||||
title: 'Утончить PNG',
|
||||
description: 'Сужает непрозрачные области — утоньшает штрихи надписей и деталей.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'radius', label: 'На сколько px', type: 'slider', min: 1, max: 10, step: 1, default: 1 }
|
||||
],
|
||||
run: (img, p) => erodeImage(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'harden-alpha-png',
|
||||
title: 'Жёсткие края PNG',
|
||||
description:
|
||||
'Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'threshold', label: 'Порог альфы, %', type: 'slider', min: 0, max: 100, step: 1, default: 50 }
|
||||
],
|
||||
run: (img, p) => hardenAlpha(img, num(p, 'threshold'))
|
||||
},
|
||||
{
|
||||
id: 'despeckle-alpha-png',
|
||||
title: 'Убрать мусор PNG',
|
||||
description: 'Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'radius', label: 'Радиус очистки, px', type: 'slider', min: 1, max: 3, step: 1, default: 1 }
|
||||
],
|
||||
run: (img, p) => openingImage(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'close-holes-png',
|
||||
title: 'Закрыть дыры PNG',
|
||||
description: 'Закрытие: заполняет одиночные прозрачные точки внутри объекта.',
|
||||
category: 'alpha',
|
||||
params: [
|
||||
{ id: 'radius', label: 'Радиус закрытия, px', type: 'slider', min: 1, max: 3, step: 1, default: 1 }
|
||||
],
|
||||
run: (img, p) => closingImage(img, num(p, 'radius'))
|
||||
},
|
||||
{
|
||||
id: 'remove-color-from-png',
|
||||
title: 'Удалить цвет из PNG (прозрачность)',
|
||||
@@ -473,9 +763,143 @@ export const TOOLS: ToolEntry[] = [
|
||||
params: [],
|
||||
resultType: 'info',
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'create-empty-png',
|
||||
title: 'Создать пустой PNG',
|
||||
description: 'Генерирует холст выбранного размера — прозрачный или залитый цветом.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
|
||||
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
|
||||
{ id: 'transparent', label: 'Прозрачный', type: 'checkbox', default: true },
|
||||
{ id: 'color', label: 'Цвет', type: 'color', default: '#ffffff' }
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, 'width')),
|
||||
Math.trunc(num(p, 'height')),
|
||||
p['transparent'] === true
|
||||
? [0, 0, 0, 0]
|
||||
: hexToRgba(str(p, 'color'))
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'single-color-png',
|
||||
title: 'Создать одноцветный PNG',
|
||||
description: 'Генерирует прямоугольник заданного размера и цвета.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
|
||||
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 256 },
|
||||
{ id: 'color', label: 'Цвет', type: 'color', default: '#ff0000' }
|
||||
],
|
||||
generate: (p) =>
|
||||
solidImage(
|
||||
Math.trunc(num(p, 'width')),
|
||||
Math.trunc(num(p, 'height')),
|
||||
hexToRgba(str(p, 'color'))
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'random-noise-png',
|
||||
title: 'Создать случайный шум PNG',
|
||||
description:
|
||||
'Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 5000, step: 1, default: 512 },
|
||||
{ id: 'seed', label: 'Зерно', type: 'number', min: 0, max: 999999999, step: 1, default: 1 }
|
||||
],
|
||||
generate: (p) => noiseImage(Math.trunc(num(p, 'width')), Math.trunc(num(p, 'height')), num(p, 'seed'))
|
||||
},
|
||||
{
|
||||
id: 'linear-gradient-png',
|
||||
title: 'Создать градиент PNG',
|
||||
description: 'Генерирует плавный переход между двумя цветами по горизонтали или вертикали.',
|
||||
category: 'generate',
|
||||
sourceMode: 'none',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина', type: 'number', min: 1, max: 20000, step: 1, default: 800 },
|
||||
{ id: 'height', label: 'Высота', type: 'number', min: 1, max: 20000, step: 1, default: 600 },
|
||||
{ id: 'fromColor', label: 'Цвет начала', type: 'color', default: '#000000' },
|
||||
{ id: 'toColor', label: 'Цвет конца', type: 'color', default: '#ffffff' },
|
||||
{
|
||||
id: 'direction',
|
||||
label: 'Направление',
|
||||
type: 'select',
|
||||
default: 'horizontal',
|
||||
options: [
|
||||
{ value: 'horizontal', label: 'По горизонтали' },
|
||||
{ value: 'vertical', label: 'По вертикали' }
|
||||
]
|
||||
}
|
||||
],
|
||||
generate: (p) =>
|
||||
gradientImage(
|
||||
Math.trunc(num(p, 'width')),
|
||||
Math.trunc(num(p, 'height')),
|
||||
hexToRgba(str(p, 'fromColor')),
|
||||
hexToRgba(str(p, 'toColor')),
|
||||
str(p, 'direction') === 'vertical' ? 'vertical' : 'horizontal'
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'png-is-grayscale',
|
||||
title: 'Проверить: PNG монохромный?',
|
||||
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
|
||||
category: 'analyze',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) =>
|
||||
isGrayscale(img)
|
||||
? 'Да — все пиксели являются оттенками серого.'
|
||||
: 'Нет — найдены цветные пиксели.'
|
||||
},
|
||||
{
|
||||
id: 'png-is-transparent',
|
||||
title: 'Проверить: PNG прозрачный?',
|
||||
description: 'Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.',
|
||||
category: 'analyze',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) =>
|
||||
hasTransparency(img)
|
||||
? 'Да — есть прозрачные или полупрозрачные пиксели.'
|
||||
: 'Нет — все пиксели полностью непрозрачны.'
|
||||
},
|
||||
{
|
||||
id: 'png-orientation',
|
||||
title: 'Ориентация PNG',
|
||||
description: 'Сообщает, портрет это, ландшафт или квадрат.',
|
||||
category: 'analyze',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => {
|
||||
switch (orientationOf(img)) {
|
||||
case 'portrait':
|
||||
return 'Портрет — высота больше ширины.';
|
||||
case 'landscape':
|
||||
return 'Ландшафт — ширина больше высоты.';
|
||||
default:
|
||||
return 'Квадрат — стороны равны.';
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
import { TOOL_ICONS } from './tools/tool-icons';
|
||||
import { TOOL_POPULARITY } from './tools/tool-popularity';
|
||||
|
||||
for (const entry of TOOLS) {
|
||||
entry.icon = entry.id;
|
||||
entry.popularity = TOOL_POPULARITY[entry.id] ?? 50;
|
||||
}
|
||||
|
||||
export function getTool(id: string): ToolEntry | undefined {
|
||||
return TOOLS.find((tool) => tool.id === id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function createAutoRunner(delayMs = 300) {
|
||||
let token = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return {
|
||||
next(): number {
|
||||
cancelTimer();
|
||||
return ++token;
|
||||
},
|
||||
isCurrent(t: number): boolean {
|
||||
return t === token;
|
||||
},
|
||||
schedule(run: () => void): () => void {
|
||||
cancelTimer();
|
||||
timer = setTimeout(run, delayMs);
|
||||
return cancelTimer;
|
||||
}
|
||||
};
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { executeStep } from './executor';
|
||||
import { makeImage } from '../core/test-helpers';
|
||||
import type { PixelImage } from '../core/types';
|
||||
|
||||
function cloneOf(img: PixelImage): PixelImage {
|
||||
return { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) };
|
||||
}
|
||||
|
||||
describe('executeStep: прямой путь (среда без Worker)', () => {
|
||||
it('возвращает результат инструмента', async () => {
|
||||
const img = makeImage(1, 1, [
|
||||
[1, 2, 3, 4]
|
||||
]);
|
||||
const out = await executeStep(
|
||||
{ id: 'stub', run: (i) => cloneOf(i) },
|
||||
img,
|
||||
{}
|
||||
);
|
||||
expect(out).toEqual(img);
|
||||
});
|
||||
|
||||
it('пробрасывает ошибку инструмента как есть', async () => {
|
||||
const boom = () => {
|
||||
throw new Error('бум');
|
||||
};
|
||||
await expect(executeStep({ id: 'stub', run: boom }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
|
||||
'бум'
|
||||
);
|
||||
});
|
||||
|
||||
it('инструмент без run даёт понятную ошибку', async () => {
|
||||
await expect(executeStep({ id: 'stub' }, makeImage(1, 1, [[0, 0, 0, 255]]), {})).rejects.toThrow(
|
||||
'не обрабатывает изображения'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { PixelImage } from '../core/types';
|
||||
|
||||
type MaybeRunnable = {
|
||||
id: string;
|
||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
};
|
||||
|
||||
export async function executeStep(
|
||||
tool: MaybeRunnable,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
): Promise<PixelImage> {
|
||||
if (!tool.run) {
|
||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
||||
}
|
||||
if (typeof Worker === 'undefined') {
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
const worker = ensureWorker();
|
||||
if (worker === null) {
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
try {
|
||||
return await runInWorker(worker, tool.id, img, params);
|
||||
} catch (workerError) {
|
||||
disableWorker();
|
||||
void workerError;
|
||||
return await runDirect(tool, img, params);
|
||||
}
|
||||
}
|
||||
|
||||
async function runDirect(
|
||||
tool: MaybeRunnable,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
): Promise<PixelImage> {
|
||||
if (!tool.run) {
|
||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
||||
}
|
||||
return await tool.run(img, params);
|
||||
}
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let workerTried = false;
|
||||
let nextRequestId = 1;
|
||||
const pending = new Map<
|
||||
number,
|
||||
{ resolve: (img: PixelImage) => void; reject: (e: Error) => void }
|
||||
>();
|
||||
|
||||
function ensureWorker(): Worker | null {
|
||||
if (workerTried) return worker;
|
||||
workerTried = true;
|
||||
try {
|
||||
const candidate = new Worker(new URL('./executor.worker.ts', import.meta.url), {
|
||||
type: 'module'
|
||||
});
|
||||
candidate.onmessage = (event: MessageEvent) => {
|
||||
const payload = event.data as {
|
||||
id: number;
|
||||
ok: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
data?: Uint8ClampedArray;
|
||||
error?: string;
|
||||
};
|
||||
const entry = pending.get(payload.id);
|
||||
if (!entry) return;
|
||||
pending.delete(payload.id);
|
||||
if (payload.ok && payload.data && payload.width && payload.height) {
|
||||
entry.resolve({
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
data: new Uint8ClampedArray(payload.data)
|
||||
});
|
||||
} else {
|
||||
entry.reject(new Error(payload.error ?? 'Ошибка исполнения в воркере'));
|
||||
}
|
||||
};
|
||||
candidate.onerror = () => {
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error('Воркер недоступен'));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
worker = candidate;
|
||||
} catch {
|
||||
worker = null;
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
function disableWorker(): void {
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
}
|
||||
worker = null;
|
||||
}
|
||||
|
||||
function runInWorker(
|
||||
workerInstance: Worker,
|
||||
toolId: string,
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
): Promise<PixelImage> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = nextRequestId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
workerInstance.postMessage({
|
||||
id,
|
||||
toolId,
|
||||
image: { width: img.width, height: img.height, data: new Uint8ClampedArray(img.data) },
|
||||
params
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/// <reference lib="webworker" />
|
||||
import type { PixelImage } from '../core/types';
|
||||
import { getTool, sanitizeParams } from '../registry';
|
||||
|
||||
type WorkerRequest = {
|
||||
id: number;
|
||||
toolId: string;
|
||||
image: { width: number; height: number; data: Uint8ClampedArray };
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
void handle(event.data);
|
||||
};
|
||||
|
||||
async function handle(request: WorkerRequest): Promise<void> {
|
||||
try {
|
||||
const tool = getTool(request.toolId);
|
||||
if (!tool?.run) {
|
||||
throw new Error('Этот инструмент не обрабатывает изображения');
|
||||
}
|
||||
const image: PixelImage = {
|
||||
width: request.image.width,
|
||||
height: request.image.height,
|
||||
data: new Uint8ClampedArray(request.image.data)
|
||||
};
|
||||
const output = await tool.run(image, sanitizeParams(tool, request.params));
|
||||
const payload = {
|
||||
id: request.id,
|
||||
ok: true,
|
||||
width: output.width,
|
||||
height: output.height,
|
||||
data: output.data
|
||||
};
|
||||
(self as unknown as Worker).postMessage(payload, [output.data.buffer]);
|
||||
} catch (e) {
|
||||
(self as unknown as Worker).postMessage({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createStep, parseDocument } from './pipeline';
|
||||
|
||||
describe('createStep', () => {
|
||||
it('создаёт шаг с дефолтными значениями инструмента', () => {
|
||||
const step = createStep('grayscale-png');
|
||||
expect(step.toolId).toBe('grayscale-png');
|
||||
expect(step.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('бросает ошибку для неизвестного инструмента', () => {
|
||||
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDocument', () => {
|
||||
const valid = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: 'a', toolId: 'grayscale-png', values: {} },
|
||||
{
|
||||
id: 'b',
|
||||
toolId: 'adjust-brightness-contrast-png',
|
||||
values: { brightness: 5000, contrast: 'мусор' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
it('разбирает корректный документ и санитизирует значения', () => {
|
||||
const steps = parseDocument(valid);
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].id).toBe('a');
|
||||
expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 });
|
||||
});
|
||||
|
||||
it.each(['не json', '{}', JSON.stringify({ version: 99, steps: [] }), JSON.stringify({ version: 1 })])(
|
||||
'отклоняет мусор: %s',
|
||||
(raw) => {
|
||||
expect(() => parseDocument(raw as string)).toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it('пропускает шаги с неизвестным инструментом', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: 'x', toolId: 'nope', values: {} },
|
||||
{ id: 'y', toolId: 'flip-png', values: { axis: 'vertical' } }
|
||||
]
|
||||
});
|
||||
const steps = parseDocument(raw);
|
||||
expect(steps.map((s) => s.id)).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { getTool, sanitizeParams } from '../registry';
|
||||
|
||||
export type PipelineStep = {
|
||||
id: string;
|
||||
toolId: string;
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PipelineDocument = { version: number; steps: PipelineStep[] };
|
||||
|
||||
export const PIPELINE_VERSION = 1;
|
||||
const STORAGE_KEY = 'workspace-pipeline';
|
||||
|
||||
export function createStep(toolId: string): PipelineStep {
|
||||
const tool = getTool(toolId);
|
||||
if (!tool) {
|
||||
throw new Error(`Инструмент "${toolId}" не найден`);
|
||||
}
|
||||
return { id: newStepId(), toolId, values: {} };
|
||||
}
|
||||
|
||||
export function newStepId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
||||
}
|
||||
|
||||
export function parseDocument(raw: string): PipelineStep[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('Файл не является корректным JSON');
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('Структура файла не похожа на цепочку шагов');
|
||||
}
|
||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
||||
if (doc.version !== PIPELINE_VERSION) {
|
||||
throw new Error(`Неподдерживаемая версия цепочки: ${String(doc.version)}`);
|
||||
}
|
||||
if (!Array.isArray(doc.steps)) {
|
||||
throw new Error('В файле нет списка шагов');
|
||||
}
|
||||
const steps: PipelineStep[] = [];
|
||||
for (const rawStep of doc.steps) {
|
||||
const step = validateStep(rawStep);
|
||||
if (step) {
|
||||
steps.push(step);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function validateStep(raw: unknown): PipelineStep | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown };
|
||||
if (typeof candidate.toolId !== 'string') return null;
|
||||
const tool = getTool(candidate.toolId);
|
||||
if (!tool || typeof candidate.values !== 'object' || candidate.values === null) return null;
|
||||
const values = candidate.values as Record<string, unknown>;
|
||||
return {
|
||||
id: typeof candidate.id === 'string' && candidate.id.length > 0 ? candidate.id : newStepId(),
|
||||
toolId: candidate.toolId,
|
||||
values: sanitizeParams(tool, values)
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSteps(steps: PipelineStep[]): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ version: PIPELINE_VERSION, steps })
|
||||
);
|
||||
} catch {
|
||||
// переполнение квоты или приватный режим — сохранение необязательно для работы
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredSteps(): PipelineStep[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return parseDocument(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
AppWindow,
|
||||
Binary,
|
||||
Blend,
|
||||
ClipboardPaste,
|
||||
Contrast,
|
||||
Crop,
|
||||
Crosshair,
|
||||
Dices,
|
||||
Droplet,
|
||||
Droplets,
|
||||
Eye,
|
||||
FileImage,
|
||||
FileOutput,
|
||||
FilePlus2,
|
||||
Film,
|
||||
FlipHorizontal2,
|
||||
Focus,
|
||||
Frame,
|
||||
Grid3x3,
|
||||
Hammer,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
Info,
|
||||
Layers,
|
||||
Link2,
|
||||
Maximize2,
|
||||
PaintBucket,
|
||||
Palette,
|
||||
Rainbow,
|
||||
Repeat2,
|
||||
RotateCw,
|
||||
Ruler,
|
||||
Scaling,
|
||||
Scan,
|
||||
Scissors,
|
||||
SearchCheck,
|
||||
Square,
|
||||
Sun,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export const TOOL_ICONS: Record<string, typeof AppWindow> = {
|
||||
'resize-png': Scaling,
|
||||
'crop-png': Crop,
|
||||
'rotate-png': RotateCw,
|
||||
'flip-png': FlipHorizontal2,
|
||||
'grayscale-png': Contrast,
|
||||
'invert-colors-png': Blend,
|
||||
'adjust-brightness-contrast-png': Sun,
|
||||
'convert-png-to-jpg': FileImage,
|
||||
'convert-png-to-webp': FileImage,
|
||||
'remove-color-from-png': Scissors,
|
||||
'png-info': Info,
|
||||
'jpg-to-png': FileImage,
|
||||
'webp-to-png': FileImage,
|
||||
'gif-to-png': Film,
|
||||
'bmp-to-png': FileImage,
|
||||
'ico-to-png': AppWindow,
|
||||
'png-to-bmp': FileOutput,
|
||||
'png-to-base64': Binary,
|
||||
'base64-to-png': ClipboardPaste,
|
||||
'png-to-data-uri': Link2,
|
||||
'data-uri-to-png': ClipboardPaste,
|
||||
'png-to-hex': Hash,
|
||||
'hex-to-png': Hash,
|
||||
'change-png-opacity': Droplet,
|
||||
'sepia-png': Palette,
|
||||
'change-png-hue': Rainbow,
|
||||
'extract-channel-png': Layers,
|
||||
'swap-channels-png': Repeat2,
|
||||
'black-and-white-png': Contrast,
|
||||
'posterize-png': Layers,
|
||||
'two-colors-png': Blend,
|
||||
'invert-alpha-png': Droplets,
|
||||
'remove-alpha-channel-png': Square,
|
||||
'set-alpha-channel-png': Droplet,
|
||||
'extract-alpha-mask-png': Layers,
|
||||
'round-corners-png': Frame,
|
||||
'add-padding-png': Maximize2,
|
||||
'add-border-png': Frame,
|
||||
'fit-on-background-png': ImageIcon,
|
||||
'tile-png': Grid3x3,
|
||||
'center-by-alpha-png': Crosshair,
|
||||
'create-empty-png': FilePlus2,
|
||||
'single-color-png': PaintBucket,
|
||||
'random-noise-png': Dices,
|
||||
'linear-gradient-png': Sun,
|
||||
'png-is-grayscale': SearchCheck,
|
||||
'png-is-transparent': Eye,
|
||||
'png-orientation': Ruler,
|
||||
'blur-png': Droplets,
|
||||
'sharpen-png': Focus,
|
||||
'remove-background-png': Scissors,
|
||||
'add-stroke-png': Square,
|
||||
'find-contour-png': Scan,
|
||||
'make-thicker-png': ZoomIn,
|
||||
'make-thinner-png': ZoomOut,
|
||||
'harden-alpha-png': Sun,
|
||||
'despeckle-alpha-png': SearchCheck,
|
||||
'close-holes-png': Hammer
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
export const TOOL_POPULARITY: Record<string, number> = {
|
||||
'crop-png': 92,
|
||||
'resize-png': 90,
|
||||
'remove-background-png': 95,
|
||||
'blur-png': 85,
|
||||
'sharpen-png': 80,
|
||||
'convert-png-to-jpg': 82,
|
||||
'jpg-to-png': 78,
|
||||
'rotate-png': 75,
|
||||
'flip-png': 70,
|
||||
'convert-png-to-webp': 68,
|
||||
'webp-to-png': 62,
|
||||
'grayscale-png': 60,
|
||||
'adjust-brightness-contrast-png': 58,
|
||||
'invert-colors-png': 52,
|
||||
'remove-color-from-png': 65,
|
||||
'change-png-opacity': 45,
|
||||
'sepia-png': 40,
|
||||
'change-png-hue': 38,
|
||||
'add-padding-png': 42,
|
||||
'add-border-png': 38,
|
||||
'two-colors-png': 35,
|
||||
'black-and-white-png': 42,
|
||||
'posterize-png': 30,
|
||||
'round-corners-png': 36,
|
||||
'fit-on-background-png': 34,
|
||||
'extract-channel-png': 24,
|
||||
'swap-channels-png': 22,
|
||||
'invert-alpha-png': 26,
|
||||
'remove-alpha-channel-png': 30,
|
||||
'set-alpha-channel-png': 24,
|
||||
'extract-alpha-mask-png': 20,
|
||||
'tile-png': 28,
|
||||
'center-by-alpha-png': 26,
|
||||
'png-info': 44,
|
||||
'png-is-transparent': 18,
|
||||
'png-is-grayscale': 14,
|
||||
'png-orientation': 16,
|
||||
'png-to-base64': 40,
|
||||
'base64-to-png': 32,
|
||||
'png-to-data-uri': 34,
|
||||
'data-uri-to-png': 26,
|
||||
'png-to-hex': 10,
|
||||
'hex-to-png': 8,
|
||||
'gif-to-png': 30,
|
||||
'bmp-to-png': 18,
|
||||
'ico-to-png': 20,
|
||||
'png-to-bmp': 12,
|
||||
'create-empty-png': 25,
|
||||
'single-color-png': 20,
|
||||
'random-noise-png': 12,
|
||||
'linear-gradient-png': 22
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
@@ -13,10 +12,9 @@
|
||||
<div class="app">
|
||||
<header>
|
||||
<a href="/" class="brand">easy-png-tools</a>
|
||||
<nav aria-label="Категории инструментов">
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
<a class="nav-link" href="/#{category.id}">{category.label}</a>
|
||||
{/each}
|
||||
<nav aria-label="Разделы">
|
||||
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
||||
<a class="nav-link" href="/list-tools">Каталог</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -79,6 +77,11 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-link {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
|
||||
+82
-74
@@ -1,97 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
import { TOOLS } from '$lib/registry';
|
||||
import { getTool } from '$lib/registry';
|
||||
import ToolPage from '$lib/components/ToolPage.svelte';
|
||||
import ToolSearch from '$lib/components/search/ToolSearch.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
const LAST_TOOL_KEY = 'last-tool-id';
|
||||
|
||||
function loadLastToolId(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
try {
|
||||
const id = localStorage.getItem(LAST_TOOL_KEY);
|
||||
return id !== null && getTool(id) ? id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let selectedId = $state<string | null>(null);
|
||||
let restoreOnOpen = $state(false);
|
||||
let lastToolId = $state<string | null>(loadLastToolId());
|
||||
|
||||
function openTool(id: string, restore = false) {
|
||||
if (!getTool(id)) return;
|
||||
selectedId = id;
|
||||
restoreOnOpen = restore;
|
||||
lastToolId = id;
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(LAST_TOOL_KEY, id);
|
||||
} catch {
|
||||
// приватный режим — просто не запоминаем
|
||||
}
|
||||
}
|
||||
|
||||
function closeTool() {
|
||||
selectedId = null;
|
||||
}
|
||||
|
||||
let selected = $derived(selectedId !== null ? (getTool(selectedId) ?? null) : null);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
|
||||
/>
|
||||
<title>
|
||||
{selected ? `${selected.title} — easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>easy-png-tools</h1>
|
||||
<p class="lead text-muted">
|
||||
Набор утилит для работы с PNG. Все операции выполняются локально в браузере — файлы никуда не
|
||||
отправляются.
|
||||
</p>
|
||||
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||
{#if categoryTools.length > 0}
|
||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
||||
<div class="grid">
|
||||
{#each categoryTools as tool (tool.id)}
|
||||
<a class="card panel" href="/tools/{tool.id}">
|
||||
<span class="card-title">{tool.title}</span>
|
||||
<span class="card-desc text-caption text-muted">{tool.description}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if !selected}
|
||||
<section class="hero">
|
||||
<h1>Что делаем с изображением?</h1>
|
||||
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||
<ToolSearch onSelect={(id) => openTool(id)} />
|
||||
{#if lastToolId !== null && getTool(lastToolId)}
|
||||
{@const restoreId = lastToolId}
|
||||
<div class="restore-row">
|
||||
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
||||
↩ Вернуть последний: {getTool(restoreId)?.title}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</section>
|
||||
{:else}
|
||||
<section class="workbench">
|
||||
<button type="button" class="back text-caption text-muted" onclick={closeTool}>
|
||||
← Сменить инструмент
|
||||
</button>
|
||||
{#key selectedId}
|
||||
<ToolPage tool={selected} restoreChain={restoreOnOpen} />
|
||||
{/key}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding-top: var(--space-5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-5);
|
||||
max-width: 40rem;
|
||||
margin: 0 auto var(--space-4);
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: var(--space-5);
|
||||
scroll-margin-top: 5rem;
|
||||
.restore-row {
|
||||
max-width: 36rem;
|
||||
margin: var(--space-2) auto 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
.back {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-3);
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-3) var(--space-4);
|
||||
color: inherit;
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
text-decoration: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--text-m);
|
||||
}
|
||||
|
||||
.card:hover .card-title {
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { CATEGORIES } from '$lib/categories';
|
||||
import ToolCard from '$lib/components/search/ToolCard.svelte';
|
||||
import { TOOLS } from '$lib/registry';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Все инструменты — easy-png-tools</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<h1>Каталог инструментов</h1>
|
||||
<p class="lead text-muted">
|
||||
{TOOLS.length} утилит для работы с PNG. Все операции выполняются локально в браузере.
|
||||
</p>
|
||||
|
||||
{#each CATEGORIES as category (category.id)}
|
||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
||||
{#if categoryTools.length > 0}
|
||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
||||
<div class="grid">
|
||||
{#each categoryTools as tool (tool.id)}
|
||||
<div class="panel">
|
||||
<ToolCard
|
||||
toolId={tool.id}
|
||||
title={tool.title}
|
||||
description={tool.description}
|
||||
href="/tools/{tool.id}"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
h1 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 48rem;
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: var(--space-5);
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.grid > .panel {
|
||||
padding: var(--space-2) var(--space-2) var(--space-3);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
box-shadow var(--transition-fast),
|
||||
transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.grid > .panel:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-card);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,9 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: ['@lucide/svelte']
|
||||
},
|
||||
plugins: [
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
|
||||
Reference in New Issue
Block a user