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-инструментов
|
# План: вторая волна EASY-инструментов
|
||||||
|
|
||||||
> Статус: план к выполнению.
|
> **СТАТУС: ВЫПОЛНЕН 2026-08-23.**
|
||||||
|
|
||||||
## 1. Принципы отбора
|
## 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
|
# 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.
|
- **Пайплайн = JSON-список шагов** `{tool, params}`. Формат закладывается сейчас и потом станет общим для браузера, wasm и CLI.
|
||||||
- **Реестр инструментов** (id, title, description, схема параметров, `run`) — источник истины: из него генерируются страницы, формы, пайплайны. Позже — общий формат для Rust-парсера.
|
- **Реестр инструментов** (id, title, description, схема параметров, `run`) — источник истины: из него генерируются страницы, формы, пайплайны. Позже — общий формат для Rust-парсера.
|
||||||
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций, **perceptual diff** для canvas-зависимых.
|
- Валидация будущего 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)
|
## 1. Структура репозитория (этап TS)
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,8 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"dev": "pnpm --dir web dev"
|
"dev": "pnpm --dir web dev",
|
||||||
|
"build": "pnpm --dir web build"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Ku6epXBOCTuK",
|
"author": "Ku6epXBOCTuK",
|
||||||
|
|||||||
@@ -21,5 +21,8 @@
|
|||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.11"
|
"vitest": "^4.1.11"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/svelte": "^1.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
@@ -7,6 +7,10 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
'@lucide/svelte':
|
||||||
|
specifier: ^1.33.0
|
||||||
|
version: 1.33.0(svelte@5.56.9)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/adapter-static':
|
'@sveltejs/adapter-static':
|
||||||
specifier: ^3.0.10
|
specifier: ^3.0.10
|
||||||
@@ -51,6 +55,11 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
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':
|
'@oxc-project/types@0.144.0':
|
||||||
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
|
||||||
|
|
||||||
@@ -627,6 +636,10 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@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': {}
|
'@oxc-project/types@0.144.0': {}
|
||||||
|
|
||||||
'@polka/url@1.0.0-next.29': {}
|
'@polka/url@1.0.0-next.29': {}
|
||||||
|
|||||||
@@ -154,3 +154,92 @@ input.control[type='number'] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
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 };
|
export type Category = { id: CategoryId; label: string };
|
||||||
|
|
||||||
@@ -7,5 +14,7 @@ export const CATEGORIES: Category[] = [
|
|||||||
{ id: 'alpha', label: 'Прозрачность' },
|
{ id: 'alpha', label: 'Прозрачность' },
|
||||||
{ id: 'color', label: 'Цвет' },
|
{ id: 'color', label: 'Цвет' },
|
||||||
{ id: 'geometry', label: 'Геометрия' },
|
{ id: 'geometry', label: 'Геометрия' },
|
||||||
{ id: 'analyze', label: 'Анализ' }
|
{ id: 'filters', label: 'Фильтры' },
|
||||||
|
{ id: 'analyze', label: 'Анализ' },
|
||||||
|
{ id: 'generate', label: 'Генерация' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,50 +11,84 @@
|
|||||||
values: Record<string, any>;
|
values: Record<string, any>;
|
||||||
pipetteTargetId?: string | null;
|
pipetteTargetId?: string | null;
|
||||||
onPipetteToggle?: (id: string) => void;
|
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>
|
</script>
|
||||||
|
|
||||||
{#each params as param (param.id)}
|
<div class="params-grid">
|
||||||
<div class="field">
|
{#if hasMask}
|
||||||
{#if param.type === 'checkbox'}
|
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
||||||
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
|
{/if}
|
||||||
{:else if param.type === 'number'}
|
{#each params as param (param.id)}
|
||||||
<TextField
|
<div class="field">
|
||||||
id={param.id}
|
{#if param.type === 'checkbox'}
|
||||||
label={param.label}
|
<CheckboxField id={param.id} label={param.label} bind:checked={values[param.id]} />
|
||||||
type="number"
|
{:else if param.type === 'number'}
|
||||||
min={param.min}
|
<TextField
|
||||||
max={param.max}
|
id={param.id}
|
||||||
step={param.step}
|
label={param.label}
|
||||||
bind:value={values[param.id]}
|
type="number"
|
||||||
/>
|
min={param.min}
|
||||||
{:else if param.type === 'slider'}
|
max={param.max}
|
||||||
<SliderField
|
step={param.step}
|
||||||
id={param.id}
|
bind:value={values[param.id]}
|
||||||
label={param.label}
|
/>
|
||||||
min={param.min}
|
{:else if param.type === 'slider'}
|
||||||
max={param.max}
|
<SliderField
|
||||||
step={param.step}
|
id={param.id}
|
||||||
default={param.default}
|
label={param.label}
|
||||||
bind:value={values[param.id]}
|
min={param.min}
|
||||||
/>
|
max={param.max}
|
||||||
{:else if param.type === 'select'}
|
step={param.step}
|
||||||
<SelectField
|
default={param.default}
|
||||||
id={param.id}
|
bind:value={values[param.id]}
|
||||||
label={param.label}
|
/>
|
||||||
options={param.options}
|
{:else if param.type === 'select'}
|
||||||
bind:value={values[param.id]}
|
<SelectField
|
||||||
/>
|
id={param.id}
|
||||||
{:else if param.type === 'color'}
|
label={param.label}
|
||||||
<ColorField
|
options={param.options}
|
||||||
id={param.id}
|
bind:value={values[param.id]}
|
||||||
label={param.label}
|
/>
|
||||||
bind:value={values[param.id]}
|
{:else if param.type === 'color'}
|
||||||
pipetteActive={pipetteTargetId === param.id}
|
<ColorField
|
||||||
onPipetteToggle={() => onPipetteToggle?.(param.id)}
|
id={param.id}
|
||||||
/>
|
label={param.label}
|
||||||
{/if}
|
bind:value={values[param.id]}
|
||||||
</div>
|
pipetteActive={pipetteTargetId === param.id}
|
||||||
{/each}
|
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%);
|
repeating-conic-gradient(var(--check-a) 0% 25%, var(--check-b) 0% 50%);
|
||||||
background-size: 16px 16px;
|
background-size: 16px 16px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-m);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.pipette {
|
canvas.pipette {
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
import { imageInfo, type ImageInfo } from '$lib/core/analyze';
|
||||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||||
import type { PixelImage } from '$lib/core/types';
|
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 ParamsCard from './tool/ParamsCard.svelte';
|
||||||
import ResultCard from './tool/ResultCard.svelte';
|
import ResultCard from './tool/ResultCard.svelte';
|
||||||
import SourceCard from './tool/SourceCard.svelte';
|
import SourceCard from './tool/SourceCard.svelte';
|
||||||
import TextInputCard from './tool/TextInputCard.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';
|
type Status = 'idle' | 'loaded' | 'processing' | 'error';
|
||||||
|
|
||||||
@@ -21,13 +29,52 @@
|
|||||||
let info = $state<ImageInfo | null>(null);
|
let info = $state<ImageInfo | null>(null);
|
||||||
let errorText = $state('');
|
let errorText = $state('');
|
||||||
let values = $state<Record<string, any>>({});
|
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 isInfo = $derived(tool.resultType === 'info');
|
||||||
const isSourceless = $derived(tool.sourceMode === 'none');
|
const isSourceless = $derived(tool.sourceMode === 'none');
|
||||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||||
const sanitized = $derived(sanitizeParams(tool, values));
|
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 hasLastRun = false;
|
||||||
let lastRunSource: PixelImage | null = null;
|
let lastRunSource: PixelImage | null = null;
|
||||||
let lastRunValuesJson = '';
|
let lastRunValuesJson = '';
|
||||||
@@ -86,10 +133,11 @@
|
|||||||
async function runTool() {
|
async function runTool() {
|
||||||
if (isInfo) return;
|
if (isInfo) return;
|
||||||
if (!source && !isSourceless) return;
|
if (!source && !isSourceless) return;
|
||||||
const token = ++runToken;
|
const token = runner.next();
|
||||||
hasLastRun = true;
|
hasLastRun = true;
|
||||||
lastRunSource = source;
|
lastRunSource = source;
|
||||||
lastRunValuesJson = JSON.stringify(sanitized);
|
lastRunValuesJson = JSON.stringify(sanitized);
|
||||||
|
lastRunChainJson = JSON.stringify(chain);
|
||||||
try {
|
try {
|
||||||
let next: PixelImage | null = null;
|
let next: PixelImage | null = null;
|
||||||
let nextText: string | null = null;
|
let nextText: string | null = null;
|
||||||
@@ -99,9 +147,8 @@
|
|||||||
} else if (isSourceless) {
|
} else if (isSourceless) {
|
||||||
next = await tool.generate!(sanitized);
|
next = await tool.generate!(sanitized);
|
||||||
} else {
|
} else {
|
||||||
next = await tool.run!(source!, sanitized);
|
next = await executeStep(tool, source!, sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextPreview: PixelImage | null = null;
|
let nextPreview: PixelImage | null = null;
|
||||||
if (tool.preview && source) {
|
if (tool.preview && source) {
|
||||||
try {
|
try {
|
||||||
@@ -111,24 +158,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token !== runToken) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
result = next;
|
result = next;
|
||||||
previewResult = nextPreview;
|
previewResult = nextPreview;
|
||||||
textResult = nextText;
|
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';
|
status = 'loaded';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (token !== runToken) return;
|
if (!runner.isCurrent(token)) return;
|
||||||
showError(e);
|
showError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const valuesJson = JSON.stringify(sanitized);
|
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 (!source && !isSourceless) return;
|
||||||
if (isInfo) return;
|
if (isInfo) return;
|
||||||
const timer = setTimeout(() => void runTool(), 300);
|
return runner.schedule(() => void runTool());
|
||||||
return () => clearTimeout(timer);
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const filled = chain.filter((step) => step.toolId !== '');
|
||||||
|
if (filled.length === 0 && !hasLastRun) return;
|
||||||
|
saveSteps(filled);
|
||||||
});
|
});
|
||||||
|
|
||||||
function showError(e: unknown) {
|
function showError(e: unknown) {
|
||||||
@@ -178,48 +258,96 @@
|
|||||||
<div class="error-banner" role="alert">{errorText}</div>
|
<div class="error-banner" role="alert">{errorText}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="stage panel" class:single={isSourceless}>
|
<div class="panel tool-block">
|
||||||
{#if !isSourceless}
|
<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">
|
<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
|
<ResultCard
|
||||||
{tool}
|
{tool}
|
||||||
sourceLoaded={isSourceless ? true : !!source}
|
sourceLoaded={isSourceless ? true : !!source}
|
||||||
{status}
|
{status}
|
||||||
{result}
|
{result}
|
||||||
{previewResult}
|
displayImage={shownBase}
|
||||||
bind:showMask
|
|
||||||
{info}
|
{info}
|
||||||
{isInfo}
|
{isInfo}
|
||||||
params={sanitized}
|
params={sanitized}
|
||||||
{textResult}
|
textResult={textResult}
|
||||||
|
onChainToggle={canChainBase ? toggleChain : undefined}
|
||||||
|
hasChain={chain.length > 0}
|
||||||
onDownloadError={showError}
|
onDownloadError={showError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ParamsCard
|
||||||
params={tool.params}
|
params={tool.params}
|
||||||
bind:values
|
bind:values
|
||||||
{pipetteTargetId}
|
{pipetteTargetId}
|
||||||
onPipetteToggle={handlePipetteToggle}
|
onPipetteToggle={handlePipetteToggle}
|
||||||
|
hasMask={hasMask}
|
||||||
|
bind:showMask
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -236,37 +364,57 @@
|
|||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage {
|
.tool-stage {
|
||||||
display: grid;
|
position: relative;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: var(--space-4);
|
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage.single {
|
.source-legend {
|
||||||
grid-template-columns: 1fr;
|
left: 25%;
|
||||||
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
.result-legend {
|
||||||
min-width: 0;
|
left: 75%;
|
||||||
display: flex;
|
transform: translateX(-50%);
|
||||||
}
|
|
||||||
|
|
||||||
.cell + .cell {
|
|
||||||
border-left: 1px solid var(--border);
|
|
||||||
padding-left: var(--space-4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 48rem) {
|
@media (max-width: 48rem) {
|
||||||
.stage {
|
.source-legend,
|
||||||
grid-template-columns: 1fr;
|
.result-legend {
|
||||||
}
|
display: none;
|
||||||
|
|
||||||
.cell + .cell {
|
|
||||||
border-left: none;
|
|
||||||
padding-left: 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
padding-top: var(--space-4);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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>
|
</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>;
|
values: Record<string, any>;
|
||||||
pipetteTargetId?: string | null;
|
pipetteTargetId?: string | null;
|
||||||
onPipetteToggle?: (id: string) => void;
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="panel root">
|
<div class="root">
|
||||||
<h2 class="heading-section">Параметры</h2>
|
{#if params.length > 0 || hasMask}
|
||||||
{#if params.length > 0}
|
<ParamForm
|
||||||
<ParamForm {params} bind:values {pipetteTargetId} {onPipetteToggle} />
|
{params}
|
||||||
|
bind:values
|
||||||
|
{pipetteTargetId}
|
||||||
|
{onPipetteToggle}
|
||||||
|
{hasMask}
|
||||||
|
bind:showMask
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="hint text-caption text-muted">
|
<p class="hint text-caption text-muted">
|
||||||
У этого инструмента нет параметров — результат уже готов.
|
У этого инструмента нет параметров — результат уже готов.
|
||||||
@@ -25,12 +40,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.root {
|
.root {
|
||||||
margin-top: var(--space-4);
|
padding: 0 var(--space-4) var(--space-3);
|
||||||
padding: var(--space-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CheckboxField from '../ui/CheckboxField.svelte';
|
import Button from '../ui/Button.svelte';
|
||||||
import DownloadButton from '../DownloadButton.svelte';
|
import DownloadButton from '../DownloadButton.svelte';
|
||||||
import EmptyState from '../ui/EmptyState.svelte';
|
import EmptyState from '../ui/EmptyState.svelte';
|
||||||
import InfoPanel from '../InfoPanel.svelte';
|
import InfoPanel from '../InfoPanel.svelte';
|
||||||
@@ -16,12 +16,13 @@
|
|||||||
sourceLoaded: boolean;
|
sourceLoaded: boolean;
|
||||||
status: Status;
|
status: Status;
|
||||||
result: PixelImage | null;
|
result: PixelImage | null;
|
||||||
previewResult: PixelImage | null;
|
displayImage: PixelImage | null;
|
||||||
showMask: boolean;
|
|
||||||
info: ImageInfo | null;
|
info: ImageInfo | null;
|
||||||
isInfo: boolean;
|
isInfo: boolean;
|
||||||
params: Record<string, unknown>;
|
params: Record<string, unknown>;
|
||||||
textResult: string | null;
|
textResult: string | null;
|
||||||
|
onChainToggle?: () => void;
|
||||||
|
hasChain?: boolean;
|
||||||
onDownloadError: (e: unknown) => void;
|
onDownloadError: (e: unknown) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,21 +31,18 @@
|
|||||||
sourceLoaded,
|
sourceLoaded,
|
||||||
status,
|
status,
|
||||||
result,
|
result,
|
||||||
previewResult,
|
displayImage,
|
||||||
showMask = $bindable(false),
|
|
||||||
info,
|
info,
|
||||||
isInfo,
|
isInfo,
|
||||||
params,
|
params,
|
||||||
textResult,
|
textResult,
|
||||||
|
onChainToggle,
|
||||||
|
hasChain = false,
|
||||||
onDownloadError
|
onDownloadError
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const hasPreview = $derived(typeof tool.preview === 'function');
|
|
||||||
const shown = $derived(showMask && previewResult ? previewResult : result);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="heading-section">{isInfo ? 'Сводка' : 'Результат'}</h2>
|
|
||||||
{#if !sourceLoaded}
|
{#if !sourceLoaded}
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -72,22 +70,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if hasPreview}
|
|
||||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
|
||||||
{/if}
|
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<Preview image={shown} />
|
<Preview image={displayImage} />
|
||||||
{#if status === 'processing'}
|
{#if status === 'processing'}
|
||||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<DownloadButton
|
<div class="actions-row">
|
||||||
image={result}
|
<DownloadButton
|
||||||
format={outputOf(tool)}
|
image={result}
|
||||||
baseName={tool.id}
|
format={outputOf(tool)}
|
||||||
{params}
|
baseName={tool.id}
|
||||||
onError={onDownloadError}
|
{params}
|
||||||
/>
|
onError={onDownloadError}
|
||||||
|
/>
|
||||||
|
{#if onChainToggle}
|
||||||
|
<Button variant="secondary" fullWidth onclick={onChainToggle}>
|
||||||
|
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -100,10 +102,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
.media {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="heading-section">Исходник</h2>
|
|
||||||
{#if !source}
|
{#if !source}
|
||||||
<DropZone {onFile} {onError} />
|
<DropZone {onFile} {onError} />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -40,10 +39,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
.media {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -9,34 +9,48 @@
|
|||||||
let { id, label, checked = $bindable(false), hint }: Props = $props();
|
let { id, label, checked = $bindable(false), hint }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="checkbox">
|
<div class="checkbox-row">
|
||||||
<input id={id} type="checkbox" bind:checked />
|
<label class="checkbox" for={id}>
|
||||||
<label for={id}>{label}</label>
|
<input id={id} type="checkbox" bind:checked />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{#if hint}
|
||||||
|
<p class="hint text-caption text-muted">{hint}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if hint}
|
|
||||||
<p class="hint text-caption text-muted">{hint}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<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 {
|
.checkbox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
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'] {
|
input[type='checkbox'] {
|
||||||
width: 1rem;
|
width: 1rem;
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label:hover {
|
||||||
font-size: var(--text-m);
|
color: var(--text);
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
margin: calc(-1 * var(--space-3)) 0 var(--space-3);
|
margin: 0;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,17 +13,42 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="text-caption text-muted" for={id}>{label}</label>
|
<label class="text-caption text-muted" for={id}>{label}</label>
|
||||||
{@render children()}
|
<div class="control-slot">
|
||||||
{#if hint}
|
{@render children()}
|
||||||
<p class="hint text-caption text-muted">{hint}</p>
|
{#if hint}
|
||||||
{/if}
|
<p class="hint text-caption text-muted">{hint}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.field {
|
.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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
margin-bottom: var(--space-3);
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
align-self: flex-end;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -63,19 +63,21 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
max-width: var(--control-max-width);
|
width: 100%;
|
||||||
|
max-width: 22rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='range'] {
|
input[type='range'] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 3rem;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step {
|
.step {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 1.7rem;
|
width: 1.5rem;
|
||||||
height: 1.7rem;
|
height: 1.5rem;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -84,7 +86,7 @@
|
|||||||
border-radius: var(--radius-s);
|
border-radius: var(--radius-s);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition:
|
transition:
|
||||||
|
|||||||
@@ -1,7 +1,73 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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';
|
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', () => {
|
describe('invertAlpha', () => {
|
||||||
it('обращает альфу, RGB не трогает', () => {
|
it('обращает альфу, RGB не трогает', () => {
|
||||||
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
const out = invertAlpha(makeImage(1, 1, [[10, 20, 30, 128]]));
|
||||||
|
|||||||
@@ -22,6 +22,46 @@ export function removeColorToAlpha(
|
|||||||
return out;
|
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 {
|
export function invertAlpha(img: PixelImage): PixelImage {
|
||||||
const out = createPixelImage(img.width, img.height);
|
const out = createPixelImage(img.width, img.height);
|
||||||
for (let i = 0; i < out.data.length; i += 4) {
|
for (let i = 0; i < out.data.length; i += 4) {
|
||||||
@@ -33,6 +73,18 @@ export function invertAlpha(img: PixelImage): PixelImage {
|
|||||||
return out;
|
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 {
|
export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage {
|
||||||
const [targetR, targetG, targetB] = parseHex(hex);
|
const [targetR, targetG, targetB] = parseHex(hex);
|
||||||
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { imageInfo } from './analyze';
|
import {
|
||||||
|
hasTransparency,
|
||||||
|
imageInfo,
|
||||||
|
isGrayscale,
|
||||||
|
orientationOf
|
||||||
|
} from './analyze';
|
||||||
import { makeImage } from './test-helpers';
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
describe('imageInfo', () => {
|
describe('imageInfo', () => {
|
||||||
@@ -43,3 +48,29 @@ describe('imageInfo', () => {
|
|||||||
expect(info.height).toBe(3);
|
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 };
|
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 { 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';
|
import { makeImage } from './test-helpers';
|
||||||
|
|
||||||
const square = () =>
|
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', () => {
|
describe('resize', () => {
|
||||||
const twoByTwo = () =>
|
const twoByTwo = () =>
|
||||||
makeImage(2, 2, [
|
makeImage(2, 2, [
|
||||||
|
|||||||
@@ -1,5 +1,85 @@
|
|||||||
|
import { parseHex } from './alpha';
|
||||||
import { clonePixelImage, createPixelImage, type PixelImage } from './types';
|
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 type FlipAxis = 'horizontal' | 'vertical';
|
||||||
|
|
||||||
export function flip(img: PixelImage, axis: FlipAxis): PixelImage {
|
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 { describe, expect, it } from 'vitest';
|
||||||
import { CATEGORIES } from './categories';
|
import { CATEGORIES } from './categories';
|
||||||
|
import { TOOL_ICONS } from './tools/tool-icons';
|
||||||
import {
|
import {
|
||||||
defaultParams,
|
defaultParams,
|
||||||
getTool,
|
getTool,
|
||||||
@@ -10,46 +11,7 @@ import {
|
|||||||
type ToolEntry
|
type ToolEntry
|
||||||
} from './registry';
|
} 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('реестр инструментов', () => {
|
describe('реестр инструментов', () => {
|
||||||
it('содержит ровно 32 инструмента из плана', () => {
|
|
||||||
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('id уникальны и в kebab-case', () => {
|
it('id уникальны и в kebab-case', () => {
|
||||||
const ids = TOOLS.map((t) => t.id);
|
const ids = TOOLS.map((t) => t.id);
|
||||||
expect(new Set(ids).size).toBe(ids.length);
|
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) => {
|
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||||
if (tool.resultType === 'text') {
|
if (tool.resultType === 'text') {
|
||||||
expect(typeof tool.toText).toBe('function');
|
expect(typeof tool.toText).toBe('function');
|
||||||
|
} else if (tool.sourceMode === 'none') {
|
||||||
|
expect(typeof tool.generate).toBe('function');
|
||||||
} else {
|
} else {
|
||||||
expect(typeof tool.run).toBe('function');
|
expect(typeof tool.run).toBe('function');
|
||||||
}
|
}
|
||||||
@@ -129,6 +93,21 @@ describe('реестр инструментов', () => {
|
|||||||
const resize = getToolOrThrow('resize-png');
|
const resize = getToolOrThrow('resize-png');
|
||||||
expect(defaultParams(resize)).toEqual({ width: 0, height: 0, keepAspect: true });
|
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', () => {
|
describe('sanitizeParams', () => {
|
||||||
|
|||||||
+427
-3
@@ -1,5 +1,33 @@
|
|||||||
import type { CategoryId } from './categories';
|
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 {
|
import {
|
||||||
brightnessContrast,
|
brightnessContrast,
|
||||||
changeHue,
|
changeHue,
|
||||||
@@ -15,7 +43,7 @@ import {
|
|||||||
type ChannelSwapPair,
|
type ChannelSwapPair,
|
||||||
type RgbChannel
|
type RgbChannel
|
||||||
} from './core/color';
|
} 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 { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||||
import { hexToPixels, pixelsToHex } from './core/text';
|
import { hexToPixels, pixelsToHex } from './core/text';
|
||||||
import { clonePixelImage, type PixelImage } from './core/types';
|
import { clonePixelImage, type PixelImage } from './core/types';
|
||||||
@@ -72,11 +100,17 @@ export type ToolEntry = {
|
|||||||
img: PixelImage,
|
img: PixelImage,
|
||||||
params: Record<string, unknown>
|
params: Record<string, unknown>
|
||||||
) => Promise<PixelImage> | PixelImage;
|
) => Promise<PixelImage> | PixelImage;
|
||||||
|
popularity?: number;
|
||||||
|
icon?: string;
|
||||||
resultType?: 'image' | 'info' | 'text';
|
resultType?: 'image' | 'info' | 'text';
|
||||||
output?: OutputFormat;
|
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 {
|
function num(params: Record<string, unknown>, id: string): number {
|
||||||
const v = params[id];
|
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[] = [
|
export const TOOLS: ToolEntry[] = [
|
||||||
decodeToPng(
|
decodeToPng(
|
||||||
'jpg-to-png',
|
'jpg-to-png',
|
||||||
@@ -293,6 +341,109 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
],
|
],
|
||||||
run: (img, p) => flip(img, str(p, 'axis') === 'vertical' ? 'vertical' : 'horizontal')
|
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',
|
id: 'grayscale-png',
|
||||||
title: 'Чёрно-белый PNG',
|
title: 'Чёрно-белый PNG',
|
||||||
@@ -443,6 +594,43 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
output: { mime: 'image/webp', ext: 'webp', qualityParamId: 'quality' },
|
||||||
run: (img) => clonePixelImage(img)
|
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',
|
id: 'invert-alpha-png',
|
||||||
title: 'Инвертировать альфа-канал PNG',
|
title: 'Инвертировать альфа-канал PNG',
|
||||||
@@ -451,6 +639,108 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
params: [],
|
params: [],
|
||||||
run: (img) => invertAlpha(img)
|
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',
|
id: 'remove-color-from-png',
|
||||||
title: 'Удалить цвет из PNG (прозрачность)',
|
title: 'Удалить цвет из PNG (прозрачность)',
|
||||||
@@ -473,9 +763,143 @@ export const TOOLS: ToolEntry[] = [
|
|||||||
params: [],
|
params: [],
|
||||||
resultType: 'info',
|
resultType: 'info',
|
||||||
run: (img) => clonePixelImage(img)
|
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 {
|
export function getTool(id: string): ToolEntry | undefined {
|
||||||
return TOOLS.find((tool) => tool.id === id);
|
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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { CATEGORIES } from '$lib/categories';
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,10 +12,9 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<a href="/" class="brand">easy-png-tools</a>
|
<a href="/" class="brand">easy-png-tools</a>
|
||||||
<nav aria-label="Категории инструментов">
|
<nav aria-label="Разделы">
|
||||||
{#each CATEGORIES as category (category.id)}
|
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
||||||
<a class="nav-link" href="/#{category.id}">{category.label}</a>
|
<a class="nav-link" href="/list-tools">Каталог</a>
|
||||||
{/each}
|
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -79,6 +77,11 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.workspace-link {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+82
-74
@@ -1,97 +1,105 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CATEGORIES } from '$lib/categories';
|
import { getTool } from '$lib/registry';
|
||||||
import { TOOLS } 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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>easy-png-tools — PNG-утилиты прямо в браузере</title>
|
<title>
|
||||||
<meta
|
{selected ? `${selected.title} — easy-png-tools` : 'easy-png-tools — PNG-утилиты прямо в браузере'}
|
||||||
name="description"
|
</title>
|
||||||
content="Набор PNG-утилит, которые работают полностью локально в вашем браузере: конвертация, прозрачность, цвет, геометрия и анализ изображений."
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<h1>easy-png-tools</h1>
|
{#if !selected}
|
||||||
<p class="lead text-muted">
|
<section class="hero">
|
||||||
Набор утилит для работы с PNG. Все операции выполняются локально в браузере — файлы никуда не
|
<h1>Что делаем с изображением?</h1>
|
||||||
отправляются.
|
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
|
||||||
</p>
|
<ToolSearch onSelect={(id) => openTool(id)} />
|
||||||
|
{#if lastToolId !== null && getTool(lastToolId)}
|
||||||
{#each CATEGORIES as category (category.id)}
|
{@const restoreId = lastToolId}
|
||||||
{@const categoryTools = TOOLS.filter((tool) => tool.category === category.id)}
|
<div class="restore-row">
|
||||||
{#if categoryTools.length > 0}
|
<Button variant="secondary" fullWidth onclick={() => openTool(restoreId, true)}>
|
||||||
<section id={category.id} class="category" aria-labelledby="{category.id}-heading">
|
↩ Вернуть последний: {getTool(restoreId)?.title}
|
||||||
<h2 id="{category.id}-heading" class="heading-section">{category.label}</h2>
|
</Button>
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
{/if}
|
||||||
{/if}
|
</section>
|
||||||
{/each}
|
{: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>
|
<style>
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin-bottom: var(--space-1);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
max-width: 48rem;
|
max-width: 40rem;
|
||||||
margin-bottom: var(--space-5);
|
margin: 0 auto var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category {
|
.restore-row {
|
||||||
margin-bottom: var(--space-5);
|
max-width: 36rem;
|
||||||
scroll-margin-top: 5rem;
|
margin: var(--space-2) auto 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.back {
|
||||||
|
display: inline-block;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.back:hover {
|
||||||
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 {
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
</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';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['@lucide/svelte']
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
sveltekit({
|
sveltekit({
|
||||||
compilerOptions: {
|
compilerOptions: {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts'],
|
||||||
environment: 'node'
|
environment: 'node'
|
||||||
|
|||||||
Reference in New Issue
Block a user