From 23c985bde90cdbc24af5f1c137c0153e98658408 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 23 Aug 2026 15:45:29 +0500 Subject: [PATCH] feat: add pipeline for multi instrument flow --- web/src/lib/components/ToolPage.svelte | 12 ++-- web/src/lib/registry.ts | 2 +- web/src/lib/tools/auto-run.ts | 26 ++++++++ web/src/lib/tools/pipeline.test.ts | 54 +++++++++++++++ web/src/lib/tools/pipeline.ts | 91 ++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 7 deletions(-) create mode 100644 web/src/lib/tools/auto-run.ts create mode 100644 web/src/lib/tools/pipeline.test.ts create mode 100644 web/src/lib/tools/pipeline.ts diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index a4d1648..b102b06 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -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) { diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index f5830ac..17e3640 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -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, id: string): number { const v = params[id]; diff --git a/web/src/lib/tools/auto-run.ts b/web/src/lib/tools/auto-run.ts new file mode 100644 index 0000000..4496ffc --- /dev/null +++ b/web/src/lib/tools/auto-run.ts @@ -0,0 +1,26 @@ +export function createAutoRunner(delayMs = 300) { + let token = 0; + let timer: ReturnType | 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; + } + } +} diff --git a/web/src/lib/tools/pipeline.test.ts b/web/src/lib/tools/pipeline.test.ts new file mode 100644 index 0000000..b29eab7 --- /dev/null +++ b/web/src/lib/tools/pipeline.test.ts @@ -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']); + }); +}); diff --git a/web/src/lib/tools/pipeline.ts b/web/src/lib/tools/pipeline.ts new file mode 100644 index 0000000..2360f65 --- /dev/null +++ b/web/src/lib/tools/pipeline.ts @@ -0,0 +1,91 @@ +import { getTool, sanitizeParams } from '../registry'; + +export type PipelineStep = { + id: string; + toolId: string; + values: Record; +}; + +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; + 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 []; + } +}