feat: add show mask workflow

This commit is contained in:
2026-08-23 04:06:02 +05:00
parent d0ee4ec77f
commit aa5372b771
6 changed files with 90 additions and 8 deletions
+17
View File
@@ -14,6 +14,8 @@
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
let previewResult = $state<PixelImage | null>(null);
let showMask = $state(false);
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>>({});
@@ -32,6 +34,8 @@
source = await decodeFile(file); source = await decodeFile(file);
values = defaultParams(tool); values = defaultParams(tool);
result = null; result = null;
previewResult = null;
showMask = false;
info = isInfo ? imageInfo(source) : null; info = isInfo ? imageInfo(source) : null;
if (isInfo) { if (isInfo) {
status = 'loaded'; status = 'loaded';
@@ -50,8 +54,17 @@
lastRunValuesJson = JSON.stringify(sanitized); lastRunValuesJson = JSON.stringify(sanitized);
try { try {
const next = await tool.run(source, sanitized); 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; if (token !== runToken) return;
result = next; result = next;
previewResult = nextPreview;
status = 'loaded'; status = 'loaded';
} catch (e) { } catch (e) {
if (token !== runToken) return; if (token !== runToken) return;
@@ -75,6 +88,8 @@
function reset() { function reset() {
source = null; source = null;
result = null; result = null;
previewResult = null;
showMask = false;
info = null; info = null;
errorText = ''; errorText = '';
status = 'idle'; status = 'idle';
@@ -125,6 +140,8 @@
sourceLoaded={!!source} sourceLoaded={!!source}
{status} {status}
{result} {result}
{previewResult}
bind:showMask
{info} {info}
{isInfo} {isInfo}
params={sanitized} params={sanitized}
+16 -5
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import CheckboxField from '../ui/CheckboxField.svelte';
import DownloadButton from '../DownloadButton.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import InfoPanel from '../InfoPanel.svelte';
import Preview from '../Preview.svelte';
import type { ImageInfo } from '$lib/core/analyze'; import type { ImageInfo } from '$lib/core/analyze';
import type { PixelImage } from '$lib/core/types'; import type { PixelImage } from '$lib/core/types';
import { outputOf, type ToolEntry } from '$lib/registry'; import { outputOf, type ToolEntry } from '$lib/registry';
import DownloadButton from '../DownloadButton.svelte';
import InfoPanel from '../InfoPanel.svelte';
import Preview from '../Preview.svelte';
import EmptyState from '../ui/EmptyState.svelte';
type Status = 'idle' | 'loaded' | 'processing' | 'error'; type Status = 'idle' | 'loaded' | 'processing' | 'error';
@@ -14,6 +15,8 @@
sourceLoaded: boolean; sourceLoaded: boolean;
status: Status; status: Status;
result: PixelImage | null; result: PixelImage | null;
previewResult: PixelImage | null;
showMask: boolean;
info: ImageInfo | null; info: ImageInfo | null;
isInfo: boolean; isInfo: boolean;
params: Record<string, unknown>; params: Record<string, unknown>;
@@ -25,11 +28,16 @@
sourceLoaded, sourceLoaded,
status, status,
result, result,
previewResult,
showMask = $bindable(false),
info, info,
isInfo, isInfo,
params, params,
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">
@@ -55,8 +63,11 @@
{/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={result} /> <Preview image={shown} />
{#if status === 'processing'} {#if status === 'processing'}
<span class="recalc" aria-live="polite">Пересчёт…</span> <span class="recalc" aria-live="polite">Пересчёт…</span>
{/if} {/if}
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { flattenOntoColor, parseHex, removeColorToAlpha } from './alpha'; import { colorMask, flattenOntoColor, parseHex, removeColorToAlpha } from './alpha';
import { makeImage } from './test-helpers'; import { makeImage } from './test-helpers';
describe('removeColorToAlpha', () => { 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', () => { describe('flattenOntoColor', () => {
it('непрозрачный пиксель не меняется, альфа становится 255', () => { it('непрозрачный пиксель не меняется, альфа становится 255', () => {
const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff'); const out = flattenOntoColor(makeImage(1, 1, [[10, 20, 30, 255]]), '#ffffff');
+18
View File
@@ -22,6 +22,24 @@ export function removeColorToAlpha(
return out; 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 { export function flattenOntoColor(img: PixelImage, hex: string): PixelImage {
const [bgR, bgG, bgB] = parseHex(hex); const [bgR, bgG, bgB] = parseHex(hex);
const out = createPixelImage(img.width, img.height); const out = createPixelImage(img.width, img.height);
+3
View File
@@ -43,6 +43,9 @@ describe('реестр инструментов', () => {
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => { it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => {
expect(typeof tool.run).toBe('function'); expect(typeof tool.run).toBe('function');
if (tool.preview) {
expect(typeof tool.preview).toBe('function');
}
expect(tool.title.length).toBeGreaterThan(0); expect(tool.title.length).toBeGreaterThan(0);
expect(tool.description.length).toBeGreaterThan(0); expect(tool.description.length).toBeGreaterThan(0);
}); });
+7 -2
View File
@@ -1,5 +1,5 @@
import type { CategoryId } from './categories'; 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 { brightnessContrast, grayscale, invert } from './core/color';
import { crop, flip, resize, rotate90 } from './core/geometry'; import { crop, flip, resize, rotate90 } from './core/geometry';
import type { OutputMime } from './core/io'; import type { OutputMime } from './core/io';
@@ -38,6 +38,10 @@ export type ToolEntry = {
category: CategoryId; category: CategoryId;
params: ParamDef[]; params: ParamDef[];
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage; run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
preview?: (
img: PixelImage,
params: Record<string, unknown>
) => Promise<PixelImage> | PixelImage;
resultType?: 'image' | 'info'; resultType?: 'image' | 'info';
output?: OutputFormat; output?: OutputFormat;
}; };
@@ -212,7 +216,8 @@ export const TOOLS: ToolEntry[] = [
{ id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' }, { id: 'targetColor', label: 'Цвет для удаления', type: 'color', default: '#00ff00' },
{ id: 'tolerance', label: 'Порог похожести, %', type: 'number', min: 0, max: 100, step: 1, default: 10 } { 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', id: 'png-info',