From 551da338f38a96b2519e783d1f5d38e17d8d8c9e Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sat, 10 Oct 2026 04:29:26 +0500 Subject: [PATCH] refactor: separate schema tool runner --- docs/architecture-audit-2026-10.md | 8 +- .../components/schema/SchemaToolView.svelte | 205 +++----------- .../schema/schema-tool-runner.svelte.ts | 267 ++++++++++++++++++ 3 files changed, 305 insertions(+), 175 deletions(-) create mode 100644 web/src/lib/components/schema/schema-tool-runner.svelte.ts diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index ca38b0b..a061a0a 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -29,8 +29,12 @@ `SchemaAlignedLayout.svelte` (view-only: разметка + scoped-стили, сниппет `stepCard` остался в родителе и передаётся пропсом). Родитель уменьшился примерно на 200 строк. -- **H1b (далее):** runner (run/download/clipboard, `runGen`/`running`, - `assignResult`, `toDisplayError`) → `schema-tool-runner.svelte.ts`. +- **H1b — СДЕЛАНО (2026-10):** runner вынесен в `schema-tool-runner.svelte.ts` + (`createSchemaToolRunner(ctx)`: состояние в `$state`-объекте `RunnerState`, + действия — модульные функции run/download/ + copyText/downloadText/assignResult/toDisplayError, геттеры наружу; лимит + `max-lines-per-function` не даёт держать всё в одной фабрике). Компонент + читает состояние через `$derived`-алиасы. - **H1c (далее):** стейт шагов (мутации `steps`, `touchedByStep`, `lastAxis`) → `schema-tool-state.svelte.ts`. diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 6075183..101eeec 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -8,29 +8,18 @@ import { Star } from "@lucide/svelte"; import { hasTransparency } from "$lib/core/analyze"; import { debounce } from "$lib/core/debounce"; - import { ToolError, type ErrorVars } from "$lib/core/errors"; + import { ToolError } from "$lib/core/errors"; import { decodeFile, - downloadBlob, - encode, - fitWithinBytes, outputFormatByMime, type OutputMime, } from "$lib/core/io"; import type { PixelImage } from "$lib/core/types"; - import { execute } from "$lib/executor"; - import { - baseName, - ChainStepError, - runChain, - uniqueName, - type ChainWarning, - } from "$lib/run-chain"; + import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain"; import { pageDescription, pageTitle, chainStepTitle, - verdictText, } from "$lib/i18n/schema-tool-strings"; import { t } from "$lib/i18n/t"; import { @@ -52,11 +41,9 @@ } from "$lib/pipeline.svelte"; import { getTool, - type FileResult, type Page, type Tool, type ToolImageFile, - type ToolResult, } from "$lib/registry"; import { applySourceDefaults, @@ -66,7 +53,6 @@ type Dimension, type ToolSchema, } from "$lib/registry-schema"; - import { downloadZip } from "$lib/zip"; import { onMount } from "svelte"; import { SvelteMap, SvelteSet } from "svelte/reactivity"; import AddStepButton from "./AddStepButton.svelte"; @@ -74,6 +60,7 @@ import SchemaFields from "./SchemaFields.svelte"; import SchemaPreview from "./SchemaPreview.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte"; + import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; interface Props { page: Page; @@ -81,9 +68,6 @@ /** Named-chain mode: the chain id and display name; page is virtual. */ chainId?: string; } - type DisplayError = - | { kind: "i18n"; key: string; vars?: ErrorVars } - | { kind: "plain"; text: string }; let { page, tool, chainId = undefined }: Props = $props(); const schema = $derived( @@ -98,30 +82,13 @@ const touchedByStep = new SvelteMap>(); // Last edited axis of a dimension field; leads under lockAspect. const lastAxis: Record = {}; - let stepDims = $state<(Dimension | undefined)[]>([]); - let stepResults = $state<(PixelImage | null)[]>([]); - // Full named set per step (batch/fan-out); intermediate tiles show a grid. - let stepFileSets = $state([]); let aligned = $state(false); let source = $state(null); // The full named input set (multi-upload); `source` above is the first // image for preview/defaults. Single upload = one element. let sourceFiles = $state([]); - let result = $state(null); - let fileResult = $state(null); let textSource = $state(""); - let textResult = $state(null); - let verdictVars = $state | undefined>( - undefined, - ); - let running = $state(false); - // Generation of the latest run(): a stale in-flight chain (params changed - // while the worker was busy) must not overwrite newer results. - let runGen = 0; - let displayError = $state(null); let sourceWarnings = $state([]); - let runWarnings = $state([]); - const allWarnings = $derived([...sourceWarnings, ...runWarnings]); let initedFor = $state(""); let format = $state("image/png"); // Lossy-format quality for tools without a quality param in the schema. @@ -154,6 +121,31 @@ }); let dragFrom = $state(null); + const runner = createSchemaToolRunner({ + pageSlug: () => page.slug, + schema: () => schema, + steps: () => steps, + source: () => source, + sourceFiles: () => sourceFiles, + textSource: () => textSource, + format: () => format, + quality: () => currentQuality, + limitKb: () => limitKb, + lastTool: () => lastTool, + }); + const { run, download, copyText, downloadText } = runner; + const running = $derived(runner.running); + const result = $derived(runner.result); + const fileResult = $derived(runner.fileResult); + const textResult = $derived(runner.textResult); + const verdictVars = $derived(runner.verdictVars); + const stepDims = $derived(runner.stepDims); + const stepResults = $derived(runner.stepResults); + const stepFileSets = $derived(runner.stepFileSets); + const runWarnings = $derived(runner.runWarnings); + const displayError = $derived(runner.displayError); + const allWarnings = $derived([...sourceWarnings, ...runWarnings]); + const lastTool = $derived( (steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ?? tool, @@ -237,7 +229,7 @@ if (host === initedFor) return; initedFor = host; // Invalidate any in-flight run from the previous host. - runGen++; + runner.resetForHost(); if (chainId) { const handoff = takeHandoff(chainId); steps = getChain(chainId)?.steps ?? createChain(page); @@ -260,13 +252,6 @@ textSource = ""; } sourceWarnings = []; - runWarnings = []; - stepFileSets = []; - result = null; - fileResult = null; - textResult = null; - verdictVars = undefined; - displayError = null; maskOnKeys.clear(); // Marks are keyed by step.key from the previous host; keep them and // stale keys leak into the new chain's fields. @@ -332,11 +317,7 @@ ? defaultSchemaParams(stepSchema, { source: dims }) : {}; steps = steps.with(index, { ...step, params }); - result = null; - fileResult = null; - textResult = null; - verdictVars = undefined; - displayError = null; + runner.clearResults(); } function addStepAt(index: number, toolId: string) { @@ -374,7 +355,7 @@ } async function handleFiles(files: File[]) { - displayError = null; + runner.displayError = null; sourceWarnings = []; const decoded: ToolImageFile[] = []; const usedNames = new SvelteSet(); @@ -389,7 +370,7 @@ } } if (decoded.length === 0) { - displayError = toDisplayError(new ToolError("errors.imageDecode")); + runner.reportError(new ToolError("errors.imageDecode")); return; } if (skipped > 0) { @@ -421,120 +402,6 @@ await handleFiles([file]); } - function assignResult(out: ToolResult) { - if (typeof out === "object" && out !== null && "files" in out) { - fileResult = out; - result = null; - textResult = null; - verdictVars = undefined; - } else if (typeof out === "object" && out !== null && "data" in out) { - result = out as PixelImage; - fileResult = null; - textResult = null; - verdictVars = undefined; - } else { - if (typeof out === "string") { - textResult = out; - verdictVars = undefined; - } else if (out && "key" in out) { - textResult = out.key; - verdictVars = out.vars; - } else { - textResult = null; - verdictVars = undefined; - } - result = null; - fileResult = null; - } - } - - async function run() { - if (!schema || steps.length === 0) return; - const gen = ++runGen; - displayError = null; - running = true; - try { - const input = - sourceFiles.length > 0 - ? sourceFiles - : source - ? [{ name: `${baseName(page.slug)}.png`, image: source }] - : []; - const r = await runChain(steps, input, textSource || undefined, execute); - if (gen !== runGen) return; - stepDims = r.stepDims; - stepResults = r.stepResults; - stepFileSets = r.stepFileSets; - runWarnings = r.warnings; - assignResult(r.out); - } catch (e) { - if (gen !== runGen) return; - if (e instanceof ChainStepError) { - const base = toDisplayError(e.cause); - const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text; - displayError = { - kind: "plain", - text: t("toolPage.stepError", { - n: e.stepIndex + 1, - title: stepTitle(e.toolId), - msg, - }), - }; - } else { - displayError = toDisplayError(e); - } - } finally { - if (gen === runGen) running = false; - } - } - - async function download() { - if (!schema) return; - running = true; - displayError = null; - try { - if (fileResult) { - await downloadZip(fileResult.files, `${page.slug}.zip`); - return; - } - if (!result) return; - const out = outputFormatByMime(format); - const quality = - currentQuality !== undefined ? currentQuality / 100 : undefined; - const blob = limitKb - ? await fitWithinBytes(result, out.mime, limitKb * 1024, quality) - : await encode(result, out.mime, quality); - downloadBlob(blob, `${page.slug}.${out.ext}`); - } catch (e) { - displayError = toDisplayError(e); - } finally { - running = false; - } - } - - async function copyText() { - if (!textResult) return; - const copyValue = - resultKind === "verdict" - ? verdictText(lastTool.id, textResult, verdictVars) - : textResult; - try { - await navigator.clipboard.writeText(copyValue); - } catch (e) { - displayError = toDisplayError(e); - } - } - - async function downloadText() { - if (!textResult) return; - try { - const blob = new Blob([textResult], { type: "text/plain" }); - downloadBlob(blob, `${page.slug}.txt`); - } catch (e) { - displayError = toDisplayError(e); - } - } - $effect(() => { init(); }); @@ -608,14 +475,6 @@ if (step) toggleStepMask(step.key); } - function toDisplayError(e: unknown): DisplayError { - if (e instanceof ToolError) { - return { kind: "i18n", key: e.key, vars: e.vars }; - } - if (e instanceof Error) return { kind: "plain", text: e.message }; - return { kind: "plain", text: String(e) }; - } - function warningText(w: ChainWarning): string { switch (w.kind) { case "partial": diff --git a/web/src/lib/components/schema/schema-tool-runner.svelte.ts b/web/src/lib/components/schema/schema-tool-runner.svelte.ts new file mode 100644 index 0000000..65578bf --- /dev/null +++ b/web/src/lib/components/schema/schema-tool-runner.svelte.ts @@ -0,0 +1,267 @@ +import { ToolError, type ErrorVars } from "$lib/core/errors"; +import { + downloadBlob, + encode, + fitWithinBytes, + outputFormatByMime, + type OutputMime, +} from "$lib/core/io"; +import type { PixelImage } from "$lib/core/types"; +import { execute } from "$lib/executor"; +import { chainStepTitle, verdictText } from "$lib/i18n/schema-tool-strings"; +import { t } from "$lib/i18n/t"; +import type { ChainStep } from "$lib/pipeline.svelte"; +import type { + FileResult, + Tool, + ToolImageFile, + ToolResult, +} from "$lib/registry"; +import type { Dimension, ToolSchema } from "$lib/registry-schema"; +import { + baseName, + ChainStepError, + runChain, + type ChainWarning, +} from "$lib/run-chain"; +import { downloadZip } from "$lib/zip"; + +export type DisplayError = + | { kind: "i18n"; key: string; vars?: ErrorVars } + | { kind: "plain"; text: string }; + +export interface SchemaToolRunnerCtx { + pageSlug: () => string; + schema: () => ToolSchema> | undefined; + steps: () => ChainStep[]; + source: () => PixelImage | null; + sourceFiles: () => ToolImageFile[]; + textSource: () => string; + format: () => OutputMime; + quality: () => number | undefined; + limitKb: () => number | undefined; + lastTool: () => Tool; +} + +interface RunnerState { + running: boolean; + // Generation of the latest run(): a stale in-flight chain (params changed + // while the worker was busy) must not overwrite newer results. + runGen: number; + result: PixelImage | null; + fileResult: FileResult | null; + textResult: string | null; + verdictVars: Record | undefined; + stepDims: (Dimension | undefined)[]; + stepResults: (PixelImage | null)[]; + // Full named set per step (batch/fan-out); intermediate tiles show a grid. + stepFileSets: ToolImageFile[][]; + runWarnings: ChainWarning[]; + displayError: DisplayError | null; +} + +function toDisplayError(e: unknown): DisplayError { + if (e instanceof ToolError) { + return { kind: "i18n", key: e.key, vars: e.vars }; + } + if (e instanceof Error) return { kind: "plain", text: e.message }; + return { kind: "plain", text: String(e) }; +} + +function assignResult(s: RunnerState, out: ToolResult) { + if (typeof out === "object" && out !== null && "files" in out) { + s.fileResult = out; + s.result = null; + s.textResult = null; + s.verdictVars = undefined; + } else if (typeof out === "object" && out !== null && "data" in out) { + s.result = out as PixelImage; + s.fileResult = null; + s.textResult = null; + s.verdictVars = undefined; + } else { + if (typeof out === "string") { + s.textResult = out; + s.verdictVars = undefined; + } else if (out && "key" in out) { + s.textResult = out.key; + s.verdictVars = out.vars; + } else { + s.textResult = null; + s.verdictVars = undefined; + } + s.result = null; + s.fileResult = null; + } +} + +async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) { + const schema = ctx.schema(); + const steps = ctx.steps(); + if (!schema || steps.length === 0) return; + const gen = ++s.runGen; + s.displayError = null; + s.running = true; + try { + const source = ctx.source(); + const sourceFiles = ctx.sourceFiles(); + const textSource = ctx.textSource(); + const input = + sourceFiles.length > 0 + ? sourceFiles + : source + ? [{ name: `${baseName(ctx.pageSlug())}.png`, image: source }] + : []; + const r = await runChain(steps, input, textSource || undefined, execute); + if (gen !== s.runGen) return; + s.stepDims = r.stepDims; + s.stepResults = r.stepResults; + s.stepFileSets = r.stepFileSets; + s.runWarnings = r.warnings; + assignResult(s, r.out); + } catch (e) { + if (gen !== s.runGen) return; + if (e instanceof ChainStepError) { + const base = toDisplayError(e.cause); + const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text; + s.displayError = { + kind: "plain", + text: t("toolPage.stepError", { + n: e.stepIndex + 1, + title: chainStepTitle(e.toolId), + msg, + }), + }; + } else { + s.displayError = toDisplayError(e); + } + } finally { + if (gen === s.runGen) s.running = false; + } +} + +async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) { + if (!ctx.schema()) return; + s.running = true; + s.displayError = null; + try { + if (s.fileResult) { + await downloadZip(s.fileResult.files, `${ctx.pageSlug()}.zip`); + return; + } + if (!s.result) return; + const out = outputFormatByMime(ctx.format()); + const q = ctx.quality(); + const limit = ctx.limitKb(); + const quality = q !== undefined ? q / 100 : undefined; + const blob = limit + ? await fitWithinBytes(s.result, out.mime, limit * 1024, quality) + : await encode(s.result, out.mime, quality); + downloadBlob(blob, `${ctx.pageSlug()}.${out.ext}`); + } catch (e) { + s.displayError = toDisplayError(e); + } finally { + s.running = false; + } +} + +async function copyText(s: RunnerState, ctx: SchemaToolRunnerCtx) { + if (!s.textResult) return; + const tool = ctx.lastTool(); + const copyValue = + (tool.result ?? "image") === "verdict" + ? verdictText(tool.id, s.textResult, s.verdictVars) + : s.textResult; + try { + await navigator.clipboard.writeText(copyValue); + } catch (e) { + s.displayError = toDisplayError(e); + } +} + +async function downloadText(s: RunnerState, ctx: SchemaToolRunnerCtx) { + if (!s.textResult) return; + try { + const blob = new Blob([s.textResult], { type: "text/plain" }); + downloadBlob(blob, `${ctx.pageSlug()}.txt`); + } catch (e) { + s.displayError = toDisplayError(e); + } +} + +function clearResults(s: RunnerState) { + s.result = null; + s.fileResult = null; + s.textResult = null; + s.verdictVars = undefined; + s.displayError = null; +} + +// Execution state and actions of SchemaToolView: run/download/clipboard and +// all results. Owns the generation counter (see RunnerState.runGen). +export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) { + const s = $state({ + running: false, + runGen: 0, + result: null, + fileResult: null, + textResult: null, + verdictVars: undefined, + stepDims: [], + stepResults: [], + stepFileSets: [], + runWarnings: [], + displayError: null, + }); + return { + get running() { + return s.running; + }, + get result() { + return s.result; + }, + get fileResult() { + return s.fileResult; + }, + get textResult() { + return s.textResult; + }, + get verdictVars() { + return s.verdictVars; + }, + get stepDims() { + return s.stepDims; + }, + get stepResults() { + return s.stepResults; + }, + get stepFileSets() { + return s.stepFileSets; + }, + get runWarnings() { + return s.runWarnings; + }, + get displayError() { + return s.displayError; + }, + set displayError(v: DisplayError | null) { + s.displayError = v; + }, + run: () => run(s, ctx), + download: () => download(s, ctx), + copyText: () => copyText(s, ctx), + downloadText: () => downloadText(s, ctx), + // Host changed (another page/chain): drop run state and invalidate any + // in-flight run from the previous host. + resetForHost: () => { + s.runGen++; + s.runWarnings = []; + s.stepFileSets = []; + clearResults(s); + }, + clearResults: () => clearResults(s), + reportError: (e: unknown) => { + s.displayError = toDisplayError(e); + }, + }; +}