mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 13:36:36 +00:00
feat: add pipeline for multi instrument flow
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
|
||||
import type { PixelImage } from '$lib/core/types';
|
||||
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry';
|
||||
import { createAutoRunner } from '$lib/tools/auto-run';
|
||||
import ParamsCard from './tool/ParamsCard.svelte';
|
||||
import ResultCard from './tool/ResultCard.svelte';
|
||||
import SourceCard from './tool/SourceCard.svelte';
|
||||
@@ -27,7 +28,7 @@
|
||||
const isTextSource = $derived(tool.sourceMode === 'text');
|
||||
const sanitized = $derived(sanitizeParams(tool, values));
|
||||
|
||||
let runToken = 0;
|
||||
const runner = createAutoRunner();
|
||||
let hasLastRun = false;
|
||||
let lastRunSource: PixelImage | null = null;
|
||||
let lastRunValuesJson = '';
|
||||
@@ -86,7 +87,7 @@
|
||||
async function runTool() {
|
||||
if (isInfo) return;
|
||||
if (!source && !isSourceless) return;
|
||||
const token = ++runToken;
|
||||
const token = runner.next();
|
||||
hasLastRun = true;
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
@@ -111,13 +112,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (token !== runToken) return;
|
||||
if (!runner.isCurrent(token)) return;
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
textResult = nextText;
|
||||
status = 'loaded';
|
||||
} catch (e) {
|
||||
if (token !== runToken) return;
|
||||
if (!runner.isCurrent(token)) return;
|
||||
showError(e);
|
||||
}
|
||||
}
|
||||
@@ -127,8 +128,7 @@
|
||||
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return;
|
||||
if (!source && !isSourceless) return;
|
||||
if (isInfo) return;
|
||||
const timer = setTimeout(() => void runTool(), 300);
|
||||
return () => clearTimeout(timer);
|
||||
return runner.schedule(() => void runTool());
|
||||
});
|
||||
|
||||
function showError(e: unknown) {
|
||||
|
||||
@@ -90,7 +90,7 @@ export type ToolEntry = {
|
||||
output?: OutputFormat;
|
||||
};
|
||||
|
||||
const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||
export const PNG_OUTPUT: OutputFormat = { mime: 'image/png', ext: 'png' };
|
||||
|
||||
function num(params: Record<string, unknown>, id: string): number {
|
||||
const v = params[id];
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export function createAutoRunner(delayMs = 300) {
|
||||
let token = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
return {
|
||||
next(): number {
|
||||
cancelTimer();
|
||||
return ++token;
|
||||
},
|
||||
isCurrent(t: number): boolean {
|
||||
return t === token;
|
||||
},
|
||||
schedule(run: () => void): () => void {
|
||||
cancelTimer();
|
||||
timer = setTimeout(run, delayMs);
|
||||
return cancelTimer;
|
||||
}
|
||||
};
|
||||
|
||||
function cancelTimer() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createStep, parseDocument } from './pipeline';
|
||||
|
||||
describe('createStep', () => {
|
||||
it('создаёт шаг с дефолтными значениями инструмента', () => {
|
||||
const step = createStep('grayscale-png');
|
||||
expect(step.toolId).toBe('grayscale-png');
|
||||
expect(step.id.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('бросает ошибку для неизвестного инструмента', () => {
|
||||
expect(() => createStep('no-such-tool')).toThrow(/не найден/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDocument', () => {
|
||||
const valid = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: 'a', toolId: 'grayscale-png', values: {} },
|
||||
{
|
||||
id: 'b',
|
||||
toolId: 'adjust-brightness-contrast-png',
|
||||
values: { brightness: 5000, contrast: 'мусор' }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
it('разбирает корректный документ и санитизирует значения', () => {
|
||||
const steps = parseDocument(valid);
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps[0].id).toBe('a');
|
||||
expect(steps[1].values).toEqual({ brightness: 100, contrast: 0 });
|
||||
});
|
||||
|
||||
it.each(['не json', '{}', JSON.stringify({ version: 99, steps: [] }), JSON.stringify({ version: 1 })])(
|
||||
'отклоняет мусор: %s',
|
||||
(raw) => {
|
||||
expect(() => parseDocument(raw as string)).toThrow();
|
||||
}
|
||||
);
|
||||
|
||||
it('пропускает шаги с неизвестным инструментом', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
steps: [
|
||||
{ id: 'x', toolId: 'nope', values: {} },
|
||||
{ id: 'y', toolId: 'flip-png', values: { axis: 'vertical' } }
|
||||
]
|
||||
});
|
||||
const steps = parseDocument(raw);
|
||||
expect(steps.map((s) => s.id)).toEqual(['y']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { getTool, sanitizeParams } from '../registry';
|
||||
|
||||
export type PipelineStep = {
|
||||
id: string;
|
||||
toolId: string;
|
||||
values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PipelineDocument = { version: number; steps: PipelineStep[] };
|
||||
|
||||
export const PIPELINE_VERSION = 1;
|
||||
const STORAGE_KEY = 'workspace-pipeline';
|
||||
|
||||
export function createStep(toolId: string): PipelineStep {
|
||||
const tool = getTool(toolId);
|
||||
if (!tool) {
|
||||
throw new Error(`Инструмент "${toolId}" не найден`);
|
||||
}
|
||||
return { id: newStepId(), toolId, values: {} };
|
||||
}
|
||||
|
||||
export function newStepId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `step-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
||||
}
|
||||
|
||||
export function parseDocument(raw: string): PipelineStep[] {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error('Файл не является корректным JSON');
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error('Структура файла не похожа на цепочку шагов');
|
||||
}
|
||||
const doc = parsed as { version?: unknown; steps?: unknown };
|
||||
if (doc.version !== PIPELINE_VERSION) {
|
||||
throw new Error(`Неподдерживаемая версия цепочки: ${String(doc.version)}`);
|
||||
}
|
||||
if (!Array.isArray(doc.steps)) {
|
||||
throw new Error('В файле нет списка шагов');
|
||||
}
|
||||
const steps: PipelineStep[] = [];
|
||||
for (const rawStep of doc.steps) {
|
||||
const step = validateStep(rawStep);
|
||||
if (step) {
|
||||
steps.push(step);
|
||||
}
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function validateStep(raw: unknown): PipelineStep | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const candidate = raw as { id?: unknown; toolId?: unknown; values?: unknown };
|
||||
if (typeof candidate.toolId !== 'string') return null;
|
||||
const tool = getTool(candidate.toolId);
|
||||
if (!tool || typeof candidate.values !== 'object' || candidate.values === null) return null;
|
||||
const values = candidate.values as Record<string, unknown>;
|
||||
return {
|
||||
id: typeof candidate.id === 'string' && candidate.id.length > 0 ? candidate.id : newStepId(),
|
||||
toolId: candidate.toolId,
|
||||
values: sanitizeParams(tool, values)
|
||||
};
|
||||
}
|
||||
|
||||
export function saveSteps(steps: PipelineStep[]): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ version: PIPELINE_VERSION, steps })
|
||||
);
|
||||
} catch {
|
||||
// переполнение квоты или приватный режим — сохранение необязательно для работы
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredSteps(): PipelineStep[] {
|
||||
if (typeof localStorage === 'undefined') return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return parseDocument(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user