feat: add show mask workflow
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user