From aa5372b771ef0834c2221f045c27cdd4570a1c41 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 23 Aug 2026 04:06:02 +0500 Subject: [PATCH] feat: add show mask workflow --- web/src/lib/components/ToolPage.svelte | 17 +++++++++++ web/src/lib/components/tool/ResultCard.svelte | 21 +++++++++---- web/src/lib/core/alpha.test.ts | 30 ++++++++++++++++++- web/src/lib/core/alpha.ts | 18 +++++++++++ web/src/lib/registry.test.ts | 3 ++ web/src/lib/registry.ts | 9 ++++-- 6 files changed, 90 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index 0f73902..118c162 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -14,6 +14,8 @@ let status = $state('idle'); let source = $state(null); let result = $state(null); + let previewResult = $state(null); + let showMask = $state(false); let info = $state(null); let errorText = $state(''); let values = $state>({}); @@ -32,6 +34,8 @@ source = await decodeFile(file); values = defaultParams(tool); result = null; + previewResult = null; + showMask = false; info = isInfo ? imageInfo(source) : null; if (isInfo) { status = 'loaded'; @@ -50,8 +54,17 @@ lastRunValuesJson = JSON.stringify(sanitized); try { const next = await tool.run(source, sanitized); + let nextPreview: PixelImage | null = null; + if (tool.preview) { + try { + nextPreview = await tool.preview(source, sanitized); + } catch { + nextPreview = null; + } + } if (token !== runToken) return; result = next; + previewResult = nextPreview; status = 'loaded'; } catch (e) { if (token !== runToken) return; @@ -75,6 +88,8 @@ function reset() { source = null; result = null; + previewResult = null; + showMask = false; info = null; errorText = ''; status = 'idle'; @@ -125,6 +140,8 @@ sourceLoaded={!!source} {status} {result} + {previewResult} + bind:showMask {info} {isInfo} params={sanitized} diff --git a/web/src/lib/components/tool/ResultCard.svelte b/web/src/lib/components/tool/ResultCard.svelte index e091271..2a5f8a5 100644 --- a/web/src/lib/components/tool/ResultCard.svelte +++ b/web/src/lib/components/tool/ResultCard.svelte @@ -1,11 +1,12 @@
@@ -55,8 +63,11 @@ {/if}
{:else} + {#if hasPreview} + + {/if}
- + {#if status === 'processing'} Пересчёт… {/if} diff --git a/web/src/lib/core/alpha.test.ts b/web/src/lib/core/alpha.test.ts index e304a2d..34c5e9f 100644 --- a/web/src/lib/core/alpha.test.ts +++ b/web/src/lib/core/alpha.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { flattenOntoColor, parseHex, removeColorToAlpha } from './alpha'; +import { colorMask, flattenOntoColor, parseHex, removeColorToAlpha } from './alpha'; import { makeImage } from './test-helpers'; describe('removeColorToAlpha', () => { @@ -55,6 +55,34 @@ describe('removeColorToAlpha', () => { }); }); +describe('colorMask', () => { + it('удаляемые пиксели белые, остальные чёрные, маска непрозрачная', () => { + const out = colorMask( + makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255] + ]), + '#ff0000', + 0 + ); + expect([...out.data]).toEqual([ + 255, 255, 255, 255, + 0, 0, 0, 255 + ]); + }); + + it('порог совпадает с removeColorToAlpha', () => { + const img = makeImage(2, 1, [ + [0, 0, 0, 255], + [128, 128, 128, 255] + ]); + const kept = colorMask(img, '#000000', 40); + const removed = colorMask(img, '#000000', 60); + expect(kept.data[4]).toBe(0); + expect(removed.data[4]).toBe(255); + }); +}); + describe('flattenOntoColor', () => { it('непрозрачный пиксель не меняется, альфа становится 255', () => { const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff'); diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index 10eb9e3..5f6fe6f 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -22,6 +22,24 @@ export function removeColorToAlpha( return out; } +export function colorMask(img: PixelImage, hex: string, tolerancePercent = 0): PixelImage { + const [targetR, targetG, targetB] = parseHex(hex); + const tolerance = (clamp(tolerancePercent, 0, 100) / 100) * MAX_COLOR_DISTANCE; + const thresholdSq = tolerance * tolerance; + const out = createPixelImage(img.width, img.height); + for (let i = 0; i < out.data.length; i += 4) { + const dr = img.data[i] - targetR; + const dg = img.data[i + 1] - targetG; + const db = img.data[i + 2] - targetB; + const matched = dr * dr + dg * dg + db * db <= thresholdSq; + out.data[i] = matched ? 255 : 0; + out.data[i + 1] = matched ? 255 : 0; + out.data[i + 2] = matched ? 255 : 0; + out.data[i + 3] = 255; + } + return out; +} + export function flattenOntoColor(img: PixelImage, hex: string): PixelImage { const [bgR, bgG, bgB] = parseHex(hex); const out = createPixelImage(img.width, img.height); diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index 9d59707..7eea275 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -43,6 +43,9 @@ describe('реестр инструментов', () => { it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => { expect(typeof tool.run).toBe('function'); + if (tool.preview) { + expect(typeof tool.preview).toBe('function'); + } expect(tool.title.length).toBeGreaterThan(0); expect(tool.description.length).toBeGreaterThan(0); }); diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 9a0e555..76648ac 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -1,5 +1,5 @@ import type { CategoryId } from './categories'; -import { flattenOntoColor, removeColorToAlpha } from './core/alpha'; +import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha'; import { brightnessContrast, grayscale, invert } from './core/color'; import { crop, flip, resize, rotate90 } from './core/geometry'; import type { OutputMime } from './core/io'; @@ -38,6 +38,10 @@ export type ToolEntry = { category: CategoryId; params: ParamDef[]; run: (img: PixelImage, params: Record) => Promise | PixelImage; + preview?: ( + img: PixelImage, + params: Record + ) => Promise | PixelImage; resultType?: 'image' | 'info'; output?: OutputFormat; }; @@ -212,7 +216,8 @@ export const TOOLS: ToolEntry[] = [ { id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' }, { id: 'tolerance', label: 'Порог похожести, %', type: 'number', min: 0, max: 100, step: 1, default: 10 } ], - run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')) + run: (img, p) => removeColorToAlpha(img, str(p, 'targetColor'), num(p, 'tolerance')), + preview: (img, p) => colorMask(img, str(p, 'targetColor'), num(p, 'tolerance')) }, { id: 'png-info',