feat: add show mask workflow
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
let status = $state<Status>('idle');
|
||||
let source = $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 errorText = $state('');
|
||||
let values = $state<Record<string, any>>({});
|
||||
@@ -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}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<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 { PixelImage } from '$lib/core/types';
|
||||
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';
|
||||
|
||||
@@ -14,6 +15,8 @@
|
||||
sourceLoaded: boolean;
|
||||
status: Status;
|
||||
result: PixelImage | null;
|
||||
previewResult: PixelImage | null;
|
||||
showMask: boolean;
|
||||
info: ImageInfo | null;
|
||||
isInfo: boolean;
|
||||
params: Record<string, unknown>;
|
||||
@@ -25,11 +28,16 @@
|
||||
sourceLoaded,
|
||||
status,
|
||||
result,
|
||||
previewResult,
|
||||
showMask = $bindable(false),
|
||||
info,
|
||||
isInfo,
|
||||
params,
|
||||
onDownloadError
|
||||
}: Props = $props();
|
||||
|
||||
const hasPreview = $derived(typeof tool.preview === 'function');
|
||||
const shown = $derived(showMask && previewResult ? previewResult : result);
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
@@ -55,8 +63,11 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasPreview}
|
||||
<CheckboxField id="show-mask" label="Показать маску" bind:checked={showMask} />
|
||||
{/if}
|
||||
<div class="media">
|
||||
<Preview image={result} />
|
||||
<Preview image={shown} />
|
||||
{#if status === 'processing'}
|
||||
<span class="recalc" aria-live="polite">Пересчёт…</span>
|
||||
{/if}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
preview?: (
|
||||
img: PixelImage,
|
||||
params: Record<string, unknown>
|
||||
) => Promise<PixelImage> | 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',
|
||||
|
||||
Reference in New Issue
Block a user