From 1e9b35406d8e3fd4071ba72d1dc6a043a0bf318f Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Fri, 4 Sep 2026 19:09:47 +0500 Subject: [PATCH] feat: add registry tools for new preview flow --- docs/backlog.md | 8 ++ docs/plan-composite-params.md | 65 ++++++++----- web/src/lib/preview/catalog.ts | 4 +- web/src/lib/preview/categories.ts | 26 ++++++ web/src/lib/registry-new/alpha.ts | 46 +++++++++- web/src/lib/registry-new/analyze.ts | 25 +++++ web/src/lib/registry-new/color.ts | 136 ++++++++++++++++++++++++++++ web/src/lib/registry-new/convert.ts | 44 +++++++++ web/src/lib/registry-new/filters.ts | 115 +++++++++++++++++++++++ web/src/lib/registry-new/index.ts | 8 ++ web/src/lib/registry-new/types.ts | 2 +- 11 files changed, 450 insertions(+), 29 deletions(-) create mode 100644 web/src/lib/preview/categories.ts create mode 100644 web/src/lib/registry-new/analyze.ts create mode 100644 web/src/lib/registry-new/color.ts create mode 100644 web/src/lib/registry-new/convert.ts create mode 100644 web/src/lib/registry-new/filters.ts diff --git a/docs/backlog.md b/docs/backlog.md index 430bbda..d9c605d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -20,3 +20,11 @@ 6. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на карточках каталога, результатах поиска и в шапке страницы инструмента; список id в localStorage. Избранное показывается отдельной секцией сверху каталога и поднимается в выдаче поиска (бонус к popularity при скоринге). + +7. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download** — оценка M/L. + Инструменты `convert-png-to-jpg` / `convert-png-to-webp` (и др. форматы) не должны жить как самостоятельные инструменты цепочки. Вместо этого — бесшовное внедрение выбора формата/качества прямо в кнопку/диалог **Download**: юзер скачивает результат в нужном формате (mime/ext/quality), а pipeline при этом не усложняется лишним звеном. + Связано с планом типизации: в новом `ToolEntry

` под `run` в `registry-new` нет поля `output` (mime/ext/qualityParamId), которое есть у старых конвертеров — см. `plan-composite-params.md`, замечание по Фазе 2. Формат-метаданные должны переехать в понятие «формат результата» на уровне скачивания, а не отдельного инструмента. + **Решение «не сейчас» (исследование, 2026-09):** + - В старом UI этот паттерн уже работает: `convert-*`-инструменты не конвертируют в `run`, а только настраивают `output` (mime/ext/qualityParamId), который читает `DownloadButton` → `encode(img, mime, quality)`. То есть формат уже фактически «поле скачивания» в старом pipeline. + - В новом preview `registry-new` поля `output` нет, `executor.worker` возвращает только пиксели, а `SchemaToolView.download()` захардкожен на `image/png` + имя `with-border.png`. Делать ретработу сейчас = параллельный спец-проект. + - **Когда делать:** вместе с достройкой нового download/сохранения в новом UI (в рамках `plan-composite-params`, Фаза 4/UI). Тогда выбор формата встраивается в download-flow естественно, без выдёргивания отдельным проектом. Не начинать, пока не закрыта типизация pipeline. diff --git a/docs/plan-composite-params.md b/docs/plan-composite-params.md index c360fb0..c0a66ef 100644 --- a/docs/plan-composite-params.md +++ b/docs/plan-composite-params.md @@ -1,12 +1,15 @@ # План: составные типы параметров и полная типизация pipeline > Статус: **в реализации.** Фаза 0 (фундамент) ✔, Фаза 1 (рабочий инструмент -> в preview) ✔, Фаза 2 — начата: построен отдельный **новый registry** -> (`web/src/lib/registry-new/`) с первыми инструментами (`add-border-png`, -> `add-stroke-png`), preview переключён на него. Следующее: продолжить Фазу 2 — -> следующий инструмент `find-contour-png` в новый registry. +> в preview) ✔, Фаза 2 (простые инструменты без составных типов) — переведены +> все одиночные инструменты (18 шт): `add-border-png`, `add-stroke-png`, +> `find-contour-png` и весь блок 8-22 (convert/noise/pixelate/color/filters...). +> Новый registry `web/src/lib/registry-new/` разбит по категориям +> (`geometry`, `alpha`, `convert`, `analyze`, `filters`, `color`), preview +> переключён на него. Следующее: Фаза 3 — инструменты с составными типами. > -> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,geometry,alpha,index}.ts`, +> Ключевые файлы нового registry: `web/src/lib/registry-new/{types,index,*}.ts` +> (по файлу на категорию) + `web/src/lib/preview/categories.ts`, > `web/src/lib/registry-schema.ts`. Старый `web/src/lib/registry.ts` разбит по > категориям в `web/src/lib/registry/` (см. `registry.ts` — тонкий баррель). @@ -21,6 +24,16 @@ сборка, изоляция от старого, затем новый становится основным и старый удаляется вместе со старым дизайном. +**Правило копий (важно!):** если для нового UI/registry нужно внести изменения +в файл, который **прямо или косвенно** уже используется `(old)` веткой, — этот +файл **НЕ трогаем**. Вместо этого делаем **копию** в новом месте (например, +в `lib/preview/`) и правим копию. Это приводит к дублированию, но +**гарантированно не задевает старую ветку сайта**. Пример: категории — новый +`lib/preview/categories.ts` (object as const) копирует и заменяет собой +`../categories` для нового кода, старый `categories.ts` обслуживает `(old)` и +остаётся без изменений. После перехода (Фаза 5) копия становится основной, +а исходник удаляется вместе со старым UI. + **Разделение схем: две независимые схемы.** | Схема | Источник | Использование | @@ -286,28 +299,32 @@ Typed field builders + `Field` + `toolSchema

()` + `ToolSchema

` — составных типов. Каждый — отдельный маленький diff (~15-30 строк), тем самым проверенным в Фазе 1 паттерном. **Переезжают в `registry-new`.** -Переведено: `add-border-png` (Фаза 1), `add-stroke-png`. Следующий — -`find-contour-png`. +Переведено: `add-border-png` (Фаза 1), `add-stroke-png`, `find-contour-png`, плюс +весь блок простых инструментов ниже (пункты 8-22). Следующее — Фаза 3. 6. ~~add-stroke-png (color + slider)~~ → переведён в `registry-new` ✔ -7. find-contour-png (color + slider) — **следующий** -8. convert-png-to-jpg (color + slider) -9. convert-png-to-webp (slider) -10. remove-color-from-png (color + slider) -11. extract-color-from-png (color + slider) -12. add-noise-png (slider + select + number) -13. randomize-pixels-png (slider + number) -14. pixelate-png (slider) -15. vignette-png (slider) -16. gamma-png (slider) -17. temperature-png (slider) -18. tint-png (color + slider) -19. quantize-png (slider) -20. custom-palette-png (text) -21. dithering-png (slider + select) -22. jpeg-artifacts-png (slider) +7. ~~find-contour-png (color + slider)~~ → переведён в `registry-new` ✔ +8. ~~convert-png-to-jpg (color + slider)~~ → переведён в `registry-new` ✔ +9. ~~convert-png-to-webp (slider)~~ → переведён в `registry-new` ✔ +10. ~~remove-color-from-png (color + slider)~~ → переведён в `registry-new` ✔ +11. ~~extract-color-from-png (color + slider)~~ → переведён в `registry-new` ✔ +12. ~~add-noise-png (slider + select + number)~~ → переведён в `registry-new` ✔ +13. ~~randomize-pixels-png (slider + number)~~ → переведён в `registry-new` ✔ +14. ~~pixelate-png (slider)~~ → переведён в `registry-new` ✔ +15. ~~vignette-png (slider)~~ → переведён в `registry-new` ✔ +16. ~~gamma-png (slider)~~ → переведён в `registry-new` ✔ +17. ~~temperature-png (slider)~~ → переведён в `registry-new` ✔ +18. ~~tint-png (color + slider)~~ → переведён в `registry-new` ✔ +19. ~~quantize-png (slider)~~ → переведён в `registry-new` ✔ +20. ~~custom-palette-png (text)~~ → переведён в `registry-new` ✔ +21. ~~dithering-png (slider + select)~~ → переведён в `registry-new` ✔ +22. ~~jpeg-artifacts-png (slider)~~ → переведён в `registry-new` ✔ -(и т.п. — все одиночные инструменты в этом же ключе) +> **Замечание (конвертеры):** старые `convert-png-to-jpg`/`convert-png-to-webp` +> несли `output`-метаданные (`mime`/`ext`/`qualityParamId`) для выбора формата +> вывода. В новом `ToolEntry

` поля `output` пока нет, поэтому при переводе +> эти метаданные не перенесены — preview пока отдаёт результат как PNG. +> Механика выбора формата/качества в новом UI — отдельный шаг (не блокирует Фазу 2). ### Фаза 3 — инструменты с составными типами (по типу, затем по инструментам) diff --git a/web/src/lib/preview/catalog.ts b/web/src/lib/preview/catalog.ts index ffd3760..c9aefec 100644 --- a/web/src/lib/preview/catalog.ts +++ b/web/src/lib/preview/catalog.ts @@ -1,4 +1,4 @@ -import { CATEGORIES } from "../categories"; +import { CATEGORY_IDS } from "./categories"; import { TOOLS, type ToolEntry } from "../registry-new"; export type PreviewGroup = { @@ -23,7 +23,7 @@ const GROUP_LABELS: Record = { * Preview-каталог: полный реестр инструментов, сгруппированный по категориям. * Больше не урезаем до референс-набора — показываем все инструменты из TOOLS. */ -export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORIES.map((category) => ({ +export const PREVIEW_GROUPS: PreviewGroup[] = CATEGORY_IDS.map((category) => ({ id: category, label: GROUP_LABELS[category] ?? category.toUpperCase(), tools: TOOLS.filter((tool) => tool.category === category), diff --git a/web/src/lib/preview/categories.ts b/web/src/lib/preview/categories.ts new file mode 100644 index 0000000..893f7ac --- /dev/null +++ b/web/src/lib/preview/categories.ts @@ -0,0 +1,26 @@ +/** + * Категории инструментов для нового registry/preview. + * + * Единая точка правды: тип `CategoryId` и порядок отображения в каталоге + * выводятся из одного `as const`-объекта, чтобы не рассинхронизироваться. + * Этот файл — локальная копия подхода для нового UI; старый `../categories` + * (массив + union вручную) не трогаем — он обслуживает старый UI в `(old)/`. + * + * Человекочитаемые названия живут в словарях i18n: секция categories, + * ключ = CategoryId. + */ +export const CATEGORIES = { + convert: "convert", + alpha: "alpha", + color: "color", + geometry: "geometry", + filters: "filters", + text: "text", + analyze: "analyze", + generate: "generate", +} as const; + +export type CategoryId = (typeof CATEGORIES)[keyof typeof CATEGORIES]; + +/** Порядок категорий в каталоге (как задано в `CATEGORIES`). */ +export const CATEGORY_IDS = Object.keys(CATEGORIES) as CategoryId[]; diff --git a/web/src/lib/registry-new/alpha.ts b/web/src/lib/registry-new/alpha.ts index 3637e75..ebc9b79 100644 --- a/web/src/lib/registry-new/alpha.ts +++ b/web/src/lib/registry-new/alpha.ts @@ -1,6 +1,7 @@ import type { ToolEntry } from "./types"; import { field, toolSchema } from "../registry-schema"; -import { strokeImage } from "../core/morphology"; +import { colorMask, removeColorToAlpha } from "../core/alpha"; +import { contourImage, strokeImage } from "../core/morphology"; interface AddStrokeParams { color: string; @@ -22,4 +23,45 @@ const addStroke: ToolEntry = { run: (img, p) => strokeImage(img, p.thickness, p.color), }; -export const alphaEntries = [addStroke]; +interface FindContourParams { + color: string; + thickness: number; +} + +export const findContourSchema = toolSchema({ + color: field.color({ default: "#000000" }), + thickness: field.slider({ min: 1, max: 5, step: 1, default: 1 }), +}); + +const findContour: ToolEntry = { + id: "find-contour-png", + title: "Find contour PNG", + description: + "Leaves only a line along the boundary of opaque regions in the chosen color and thickness.", + category: "alpha", + schema: findContourSchema, + run: (img, p) => contourImage(img, p.thickness, p.color), +}; + +interface RemoveColorParams { + targetColor: string; + tolerance: number; +} + +export const removeColorSchema = toolSchema({ + targetColor: field.color({ default: "#00ff00" }), + tolerance: field.slider({ min: 0, max: 100, step: 1, default: 10 }), +}); + +const removeColor: ToolEntry = { + id: "remove-color-from-png", + title: "Remove color from PNG (make transparent)", + description: + "Makes all pixels close to the chosen color transparent. The tolerance sets the allowed deviation as a percentage of the maximum color distance.", + category: "alpha", + schema: removeColorSchema, + run: (img, p) => removeColorToAlpha(img, p.targetColor, p.tolerance), + preview: (img, p) => colorMask(img, p.targetColor, p.tolerance), +}; + +export const alphaEntries = [addStroke, findContour, removeColor]; diff --git a/web/src/lib/registry-new/analyze.ts b/web/src/lib/registry-new/analyze.ts new file mode 100644 index 0000000..657bb07 --- /dev/null +++ b/web/src/lib/registry-new/analyze.ts @@ -0,0 +1,25 @@ +import type { ToolEntry } from "./types"; +import { field, toolSchema } from "../registry-schema"; +import { extractByColor } from "../core/masks"; + +interface ExtractColorParams { + color: string; + tolerance: number; +} + +export const extractColorSchema = toolSchema({ + color: field.color({ default: "#00ff88" }), + tolerance: field.slider({ min: 0, max: 50, step: 1, default: 10 }), +}); + +const extractColor: ToolEntry = { + id: "extract-color-from-png", + title: "Extract Color from PNG", + description: + "Keeps only pixels close to the chosen color and makes everything else transparent — the inverse of Remove Color.", + category: "analyze", + schema: extractColorSchema, + run: (img, p) => extractByColor(img, p.color, p.tolerance), +}; + +export const analyzeEntries = [extractColor]; diff --git a/web/src/lib/registry-new/color.ts b/web/src/lib/registry-new/color.ts new file mode 100644 index 0000000..0c7ee47 --- /dev/null +++ b/web/src/lib/registry-new/color.ts @@ -0,0 +1,136 @@ +import type { ToolEntry } from "./types"; +import { field, toolSchema } from "../registry-schema"; +import { + ditherImage, + mapToNearest, + quantizeImage, +} from "../core/quantize"; +import { gammaCorrection, temperature, tint } from "../core/color"; +import { parseHexList } from "../core/palette"; + +interface GammaParams { + value: number; +} + +export const gammaSchema = toolSchema({ + value: field.slider({ min: 0.1, max: 3, step: 0.05, default: 1 }), +}); + +const gammaTool: ToolEntry = { + id: "gamma-png", + title: "Gamma correction PNG", + description: + "Corrects midtone brightness. <1 darker, >1 lighter, 1 — unchanged.", + category: "color", + schema: gammaSchema, + run: (img, p) => gammaCorrection(img, p.value), +}; + +interface TemperatureParams { + percent: number; +} + +export const temperatureSchema = toolSchema({ + percent: field.slider({ min: -100, max: 100, step: 1, default: 0 }), +}); + +const temperatureTool: ToolEntry = { + id: "temperature-png", + title: "Temperature PNG", + description: + "Positive values make the image warmer (more orange), negative ones cooler (more blue).", + category: "color", + schema: temperatureSchema, + run: (img, p) => temperature(img, p.percent), +}; + +interface TintParams { + color: string; + strength: number; +} + +export const tintSchema = toolSchema({ + color: field.color({ default: "#ffb060" }), + strength: field.slider({ min: 0, max: 100, step: 1, default: 30 }), +}); + +const tintTool: ToolEntry = { + id: "tint-png", + title: "Tint PNG", + description: + "Multiplies color channels by the chosen tint with the given strength.", + category: "color", + schema: tintSchema, + run: (img, p) => tint(img, p.color, p.strength), +}; + +interface QuantizeParams { + colors: number; +} + +export const quantizeSchema = toolSchema({ + colors: field.slider({ min: 2, max: 64, step: 1, default: 16 }), +}); + +const quantizeTool: ToolEntry = { + id: "quantize-png", + title: "Quantize PNG", + description: + "Reduces the image to k colors via median-cut palette. Transparent pixels are preserved.", + category: "color", + schema: quantizeSchema, + run: (img, p) => quantizeImage(img, p.colors).image, +}; + +interface CustomPaletteParams { + colors: string; +} + +export const customPaletteSchema = toolSchema({ + colors: field.text({ default: "#000000,#ffffff" }), +}); + +const customPalette: ToolEntry = { + id: "custom-palette-png", + title: "Custom Palette PNG", + description: + "Maps every pixel to the nearest color from your comma-separated hex list.", + category: "color", + schema: customPaletteSchema, + run: (img, p) => mapToNearest(img, parseHexList(p.colors)), +}; + +interface DitheringParams { + colors: number; + pattern: "floyd-steinberg" | "bayer"; +} + +export const ditheringSchema = toolSchema({ + colors: field.slider({ min: 2, max: 16, step: 1, default: 4 }), + pattern: field.select({ + default: "floyd-steinberg", + options: [ + { value: "floyd-steinberg", label: "Floyd–Steinberg" }, + { value: "bayer", label: "Bayer 4x4" }, + ], + }), +}); + +const ditheringTool: ToolEntry = { + id: "dithering-png", + title: "Dithering PNG", + description: + "Applies Floyd–Steinberg error diffusion or ordered Bayer dithering while reducing to k colors.", + category: "color", + schema: ditheringSchema, + run: (img, p) => ditherImage(img, p.colors, p.pattern), +}; + +export const colorEntries = [ + gammaTool, + temperatureTool, + tintTool, + quantizeTool, + customPalette, + ditheringTool, +]; diff --git a/web/src/lib/registry-new/convert.ts b/web/src/lib/registry-new/convert.ts new file mode 100644 index 0000000..4246831 --- /dev/null +++ b/web/src/lib/registry-new/convert.ts @@ -0,0 +1,44 @@ +import type { ToolEntry } from "./types"; +import { field, toolSchema } from "../registry-schema"; +import { flattenOntoColor } from "../core/alpha"; +import { clonePixelImage } from "../core/types"; + +interface ConvertToJpgParams { + background: string; + quality: number; +} + +export const convertToJpgSchema = toolSchema({ + background: field.color({ default: "#ffffff" }), + quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }), +}); + +const convertToJpg: ToolEntry = { + id: "convert-png-to-jpg", + title: "Convert PNG to JPG", + description: + "Transparency is composited over the chosen backdrop color (white by default) and saved as JPEG.", + category: "convert", + schema: convertToJpgSchema, + run: (img, p) => flattenOntoColor(img, p.background), +}; + +interface ConvertToWebpParams { + quality: number; +} + +export const convertToWebpSchema = toolSchema({ + quality: field.slider({ min: 1, max: 100, step: 1, default: 90 }), +}); + +const convertToWebp: ToolEntry = { + id: "convert-png-to-webp", + title: "Convert PNG to WebP", + description: + "Re-encodes the image into WebP with adjustable quality. Transparency is preserved.", + category: "convert", + schema: convertToWebpSchema, + run: (img) => clonePixelImage(img), +}; + +export const convertEntries = [convertToJpg, convertToWebp]; diff --git a/web/src/lib/registry-new/filters.ts b/web/src/lib/registry-new/filters.ts new file mode 100644 index 0000000..30b529c --- /dev/null +++ b/web/src/lib/registry-new/filters.ts @@ -0,0 +1,115 @@ +import type { ToolEntry } from "./types"; +import { field, toolSchema } from "../registry-schema"; +import { addNoise, pixelate, shuffleBlocks } from "../core/pixel-fx"; +import { vignette } from "../core/effects"; +import { jpegRoundtrip } from "../core/io"; + +interface VignetteParams { + strength: number; +} + +export const vignetteSchema = toolSchema({ + strength: field.slider({ min: 0, max: 100, step: 5, default: 50 }), +}); + +const vignetteTool: ToolEntry = { + id: "vignette-png", + title: "Vignette PNG", + description: + "Smoothly darkens the edges of the image, leaving the center untouched.", + category: "filters", + schema: vignetteSchema, + run: (img, p) => vignette(img, p.strength), +}; + +interface PixelateParams { + blockSize: number; +} + +export const pixelateSchema = toolSchema({ + blockSize: field.slider({ min: 2, max: 64, step: 1, default: 8 }), +}); + +const pixelateTool: ToolEntry = { + id: "pixelate-png", + title: "Pixelate PNG", + description: + "Averages every blockSize×blockSize area into one color — classic mosaic.", + category: "filters", + schema: pixelateSchema, + run: (img, p) => pixelate(img, p.blockSize), +}; + +interface RandomizePixelsParams { + blockSize: number; + seed: number; +} + +export const randomizePixelsSchema = toolSchema({ + blockSize: field.slider({ min: 1, max: 64, step: 1, default: 8 }), + seed: field.number({ min: 0, max: 999999, step: 1, default: 42 }), +}); + +const randomizePixels: ToolEntry = { + id: "randomize-pixels-png", + title: "Randomize Pixels PNG", + description: + "Shuffles blocks of the image between positions. Same seed gives the same arrangement.", + category: "filters", + schema: randomizePixelsSchema, + run: (img, p) => shuffleBlocks(img, p.blockSize, p.seed), +}; + +interface AddNoiseParams { + amount: number; + mode: "mono" | "color"; + seed: number; +} + +export const addNoiseSchema = toolSchema({ + amount: field.slider({ min: 0, max: 100, step: 1, default: 25 }), + mode: field.select({ + default: "mono", + options: [ + { value: "mono", label: "Monochrome grain" }, + { value: "color", label: "Color noise" }, + ], + }), + seed: field.number({ min: 0, max: 999999, step: 1, default: 1234 }), +}); + +const addNoiseTool: ToolEntry = { + id: "add-noise-png", + title: "Add Noise to PNG", + description: + "Adds film-grain style noise. Deterministic by seed; monochrome keeps original hue balance.", + category: "filters", + schema: addNoiseSchema, + run: (img, p) => addNoise(img, p.amount, p.mode, p.seed), +}; + +interface JpegArtifactsParams { + quality: number; +} + +export const jpegArtifactsSchema = toolSchema({ + quality: field.slider({ min: 1, max: 50, step: 1, default: 10 }), +}); + +const jpegArtifacts: ToolEntry = { + id: "jpeg-artifacts-png", + title: "JPEG artifacts", + description: + "Simulates low-quality JPEG re-compression — visible blocks and smeared colors.", + category: "filters", + schema: jpegArtifactsSchema, + run: (img, p) => jpegRoundtrip(img, p.quality), +}; + +export const filtersEntries = [ + vignetteTool, + pixelateTool, + randomizePixels, + addNoiseTool, + jpegArtifacts, +]; diff --git a/web/src/lib/registry-new/index.ts b/web/src/lib/registry-new/index.ts index 1ee46eb..cfaec75 100644 --- a/web/src/lib/registry-new/index.ts +++ b/web/src/lib/registry-new/index.ts @@ -1,12 +1,20 @@ import type { ToolEntry } from "./types"; import { geometryEntries } from "./geometry"; import { alphaEntries } from "./alpha"; +import { convertEntries } from "./convert"; +import { analyzeEntries } from "./analyze"; +import { filtersEntries } from "./filters"; +import { colorEntries } from "./color"; export type { ToolEntry } from "./types"; export const TOOLS: ToolEntry[] = [ ...geometryEntries, ...alphaEntries, + ...convertEntries, + ...analyzeEntries, + ...filtersEntries, + ...colorEntries, ] as unknown as ToolEntry[]; export function getTool(id: string): ToolEntry | undefined { diff --git a/web/src/lib/registry-new/types.ts b/web/src/lib/registry-new/types.ts index a5299bf..d8d2fca 100644 --- a/web/src/lib/registry-new/types.ts +++ b/web/src/lib/registry-new/types.ts @@ -1,4 +1,4 @@ -import type { CategoryId } from "../categories"; +import type { CategoryId } from "../preview/categories"; import type { ToolSchema } from "../registry-schema"; import type { PixelImage } from "../core/types";