From b7613e8c0ff82469de2585b1572fbae8f5a796fa Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Sun, 11 Oct 2026 03:46:01 +0500 Subject: [PATCH] refactor: separate logic from schema tool view component --- .../components/schema/SchemaToolView.svelte | 421 ++++-------------- .../components/schema/chain-host.svelte.ts | 139 ++++++ .../schema/output-controls.svelte.ts | 71 +++ .../components/schema/source-upload.svelte.ts | 130 ++++++ .../lib/components/schema/step-mask.svelte.ts | 59 +++ web/src/lib/i18n/schema-tool-strings.ts | 16 + 6 files changed, 512 insertions(+), 324 deletions(-) create mode 100644 web/src/lib/components/schema/chain-host.svelte.ts create mode 100644 web/src/lib/components/schema/output-controls.svelte.ts create mode 100644 web/src/lib/components/schema/source-upload.svelte.ts create mode 100644 web/src/lib/components/schema/step-mask.svelte.ts diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte index 3529e83..2f9feaf 100644 --- a/web/src/lib/components/schema/SchemaToolView.svelte +++ b/web/src/lib/components/schema/SchemaToolView.svelte @@ -1,53 +1,23 @@ @@ -457,7 +228,7 @@ label={t("savedChains.saveAs")} variant={ButtonVariantDefine.OUTLINE} size="s" - onclick={saveAsChain} + onclick={host.saveAsChain} /> {/if} @@ -484,7 +255,9 @@ schema={stepSchema} values={step.params} toolId={step.id} - sourceDims={i === 0 ? (source ?? undefined) : stepDims[i - 1]} + sourceDims={i === 0 + ? (upload.source ?? undefined) + : stepDims[i - 1]} onchange={(id, v, axis) => setStepValue(i, id, v, axis)} onreset={() => resetStep(i)} /> @@ -493,11 +266,11 @@ {/snippet} {#snippet maskChip(i: number)} - {#if stepMaskable[i]} + {#if masks.maskable[i]} toggleStepMaskByIndex(i)} + active={masks.maskOn[i] ?? false} + onclick={() => masks.toggleByIndex(i)} /> {/if} {/snippet} @@ -506,15 +279,15 @@ {#if i === 0} (textSource = v)} + fileCount={upload.sourceFiles.length} + ontextinput={(v) => (upload.textSource = v)} onrendertext={run} - onupload={handleFile} - onuploadmany={handleFiles} + onupload={upload.handleFile} + onuploadmany={upload.handleFiles} /> {:else} {@const input = stepResults[i - 1]} @@ -548,10 +321,10 @@ textVars={verdictVars} toolId={lastTool.id} running={busy} - mask={stepMasks[i] ?? null} - maskOn={stepMaskOn[i] ?? false} - ontogglemask={stepMaskable[i] - ? () => toggleStepMaskByIndex(i) + mask={masks.masks[i] ?? null} + maskOn={masks.maskOn[i] ?? false} + ontogglemask={masks.maskable[i] + ? () => masks.toggleByIndex(i) : undefined} oncopy={copyText} ondownloadtxt={downloadText} @@ -567,13 +340,13 @@ {#snippet actions()} {@render maskChip(i)} {/snippet} - {#if outSet.length > 1 && !stepMaskOn[i]} + {#if outSet.length > 1 && !masks.maskOn[i]} {:else if out} (format = v)} - onquality={setFormatQuality} - onlimit={(v) => (limitKb = v)} + onformat={(v) => (output.format = v)} + onquality={output.setQuality} + onlimit={(v) => (output.limitKb = v)} /> {/snippet} @@ -667,8 +440,8 @@ {#if errorText} {/if} - {#each allWarnings as w (warningText(w))} -

{warningText(w)}

+ {#each allWarnings as w (chainWarningText(w))} +

{chainWarningText(w)}

{/each} diff --git a/web/src/lib/components/schema/chain-host.svelte.ts b/web/src/lib/components/schema/chain-host.svelte.ts new file mode 100644 index 0000000..f674be9 --- /dev/null +++ b/web/src/lib/components/schema/chain-host.svelte.ts @@ -0,0 +1,139 @@ +import { goto } from "$app/navigation"; +import { resolve } from "$app/paths"; +import { + autoChainName, + createNamedChain, + getChain, + stashHandoff, + takeHandoff, + updateChainSteps, +} from "$lib/chains.svelte"; +import type { OutputMime } from "$lib/core/io"; +import type { PixelImage } from "$lib/core/types"; +import { + createChain, + isStructuralDefault, + type ChainStep, +} from "$lib/pipeline.svelte"; +import type { Page, Tool, ToolImageFile } from "$lib/registry"; +import type { ToolSchema } from "$lib/registry-schema"; +import { baseName } from "$lib/run-chain"; + +interface SourceLike { + source: PixelImage | null; + sourceFiles: ToolImageFile[]; + textSource: string; + clearWarnings: () => void; +} + +interface OutputLike { + format: OutputMime; + limitKb: number | undefined; +} + +export interface ChainHostCtx { + page: () => Page; + chainId: () => string | undefined; + schema: () => ToolSchema> | undefined; + lastTool: () => Tool; + steps: () => ChainStep[]; + stepState: { steps: ChainStep[]; clearMarks: () => void }; + source: SourceLike; + output: OutputLike; + // Drops run state and invalidates any in-flight run from the previous host. + resetRun: () => void; + clearMasks: () => void; +} + +// Host lifecycle of SchemaToolView: per-host init (plain tool page vs named +// chain), handoff across the tool -> pipeline route swap, autosave into the +// chains store and the explicit "save as chain" action. +export function createChainHost(ctx: ChainHostCtx) { + let initedFor = $state(""); + + function init() { + if (!ctx.schema()) return; + const chainId = ctx.chainId(); + const host = chainId ? `chain:${chainId}` : ctx.page().slug; + if (host === initedFor) return; + initedFor = host; + ctx.resetRun(); + if (chainId) { + const handoff = takeHandoff(chainId); + ctx.stepState.steps = getChain(chainId)?.steps ?? createChain(ctx.page()); + ctx.source.source = handoff?.source ?? null; + ctx.source.sourceFiles = + handoff?.sourceFiles ?? + (handoff?.source + ? [ + { + name: `${baseName(ctx.page().slug)}.png`, + image: handoff.source, + }, + ] + : []); + ctx.source.textSource = handoff?.textSource ?? ""; + ctx.output.format = + handoff?.format ?? ctx.lastTool().output?.mime ?? "image/png"; + ctx.output.limitKb = handoff?.limitKb; + } else { + ctx.stepState.steps = createChain(ctx.page()); + ctx.output.format = ctx.lastTool().output?.mime ?? "image/png"; + ctx.output.limitKb = undefined; + ctx.source.source = null; + ctx.source.sourceFiles = []; + ctx.source.textSource = ""; + } + ctx.source.clearWarnings(); + ctx.clearMasks(); + ctx.stepState.clearMarks(); + } + + function openChain(steps: ChainStep[]) { + const chain = createNamedChain(autoChainName(), steps); + stashHandoff(chain.id, { + source: ctx.source.source, + sourceFiles: ctx.source.sourceFiles, + textSource: ctx.source.textSource, + format: ctx.output.format, + limitKb: ctx.output.limitKb, + }); + goto(resolve(`/pipeline?id=${chain.id}`), { + replaceState: true, + keepFocus: true, + noScroll: true, + }); + } + + $effect(() => { + init(); + }); + + // Named-chain mode autosaves into the chains store; a plain tool page + // persists nothing -- it becomes a named chain on structural change below. + $effect(() => { + const chainId = ctx.chainId(); + if (!initedFor || !chainId) return; + void ctx.steps(); + updateChainSteps(chainId, ctx.steps()); + }); + + // A tool page turns into a pipeline on the first structural change (step + // added, first tool swapped): create the named chain and swap routes + // seamlessly -- steps persist via the store, the source via the handoff. + $effect(() => { + if (!initedFor || ctx.chainId()) return; + void ctx.steps(); + if (isStructuralDefault(ctx.page(), ctx.steps())) return; + openChain(ctx.steps()); + }); + + return { + get inited() { + return initedFor !== ""; + }, + // Explicit save for a structurally default chain (single tuned step): + // the auto-create effect only fires on structural change. + saveAsChain: () => openChain(ctx.steps()), + }; +} diff --git a/web/src/lib/components/schema/output-controls.svelte.ts b/web/src/lib/components/schema/output-controls.svelte.ts new file mode 100644 index 0000000..b0d2779 --- /dev/null +++ b/web/src/lib/components/schema/output-controls.svelte.ts @@ -0,0 +1,71 @@ +import { hasTransparency } from "$lib/core/analyze"; +import type { OutputMime } from "$lib/core/io"; +import type { PixelImage } from "$lib/core/types"; +import { outputFormatByMime } from "$lib/output-formats"; +import type { ChainStep } from "$lib/pipeline.svelte"; +import type { Tool } from "$lib/registry"; + +export interface OutputControlsCtx { + lastTool: () => Tool; + steps: () => ChainStep[]; + result: () => PixelImage | null; + setStepValue: (index: number, id: string, value: unknown) => void; +} + +// Download controls of SchemaToolView: output format, lossy quality and the +// optional size limit, plus the alpha-loss warning for the current format. +export function createOutputControls(ctx: OutputControlsCtx) { + let format = $state("image/png"); + // Lossy-format quality for tools without a quality param in the schema. + let formatQuality = $state>({}); + // Optional download size limit (KB); undefined = no limit. + let limitKb = $state(undefined); + + // If the last step's schema has a quality param (convert tools), it is the + // single source of quality; the dropdown edits the same value. + const qualityParamId = $derived(ctx.lastTool().output?.qualityParamId); + const lastParams = $derived(ctx.steps().at(-1)?.params); + const quality = $derived.by(() => { + const setting = outputFormatByMime(format).settings?.quality; + if (!setting) return undefined; + if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]); + return formatQuality[format] ?? setting.default; + }); + const alphaLoss = $derived.by(() => { + const result = ctx.result(); + return ( + (ctx.lastTool().result ?? "image") === "image" && + result !== null && + !outputFormatByMime(format).supportsAlpha && + hasTransparency(result) + ); + }); + + return { + get format() { + return format; + }, + set format(v: OutputMime) { + format = v; + }, + get quality() { + return quality; + }, + get limitKb() { + return limitKb; + }, + set limitKb(v: number | undefined) { + limitKb = v; + }, + get alphaLoss() { + return alphaLoss; + }, + setQuality: (q: number) => { + if (qualityParamId) { + ctx.setStepValue(ctx.steps().length - 1, qualityParamId, q); + } else { + formatQuality = { ...formatQuality, [format]: q }; + } + }, + }; +} diff --git a/web/src/lib/components/schema/source-upload.svelte.ts b/web/src/lib/components/schema/source-upload.svelte.ts new file mode 100644 index 0000000..dfe5487 --- /dev/null +++ b/web/src/lib/components/schema/source-upload.svelte.ts @@ -0,0 +1,130 @@ +import { ToolError } from "$lib/core/errors"; +import { decodeFile } from "$lib/core/io"; +import type { PixelImage } from "$lib/core/types"; +import type { ChainStep } from "$lib/pipeline.svelte"; +import type { InputMode, ToolImageFile } from "$lib/registry"; +import { + applySourceDefaults, + clampSourceAwareMaxes, +} from "$lib/registry-schema"; +import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain"; +import { onMount } from "svelte"; +import type { SvelteSet } from "svelte/reactivity"; +import { toolSchemaOf } from "./schema-tool-state.svelte"; + +export interface SourceUploadCtx { + inputMode: () => InputMode; + steps: () => ChainStep[]; + setSteps: (steps: ChainStep[]) => void; + touchedFor: (key: string) => SvelteSet; + reportError: (e: unknown) => void; + clearError: () => void; +} + +// Source state of SchemaToolView: image files / text input, decode warnings, +// paste-to-load. `source` is the first image for preview/defaults; +// `sourceFiles` is the full named input set (single upload = one element). +export function createSourceUpload(ctx: SourceUploadCtx) { + let source = $state(null); + let sourceFiles = $state([]); + let textSource = $state(""); + let sourceWarnings = $state([]); + + async function handleFiles(files: File[]) { + ctx.clearError(); + sourceWarnings = []; + const decoded: ToolImageFile[] = []; + const usedNames = new Set(); + let skipped = 0; + for (const file of files) { + try { + const name = uniqueName(`${baseName(file.name)}.png`, usedNames); + usedNames.add(name); + decoded.push({ name, image: await decodeFile(file) }); + } catch { + skipped++; + } + } + if (decoded.length === 0) { + ctx.reportError(new ToolError("errors.imageDecode")); + return; + } + if (skipped > 0) { + sourceWarnings = [{ kind: "sourceSkip", skipped, total: files.length }]; + } + const firstImage = decoded[0].image; + source = firstImage; + sourceFiles = decoded; + const first = ctx.steps()[0]; + const firstSchema = first ? toolSchemaOf(first) : undefined; + if (first && firstSchema) { + ctx.setSteps( + ctx.steps().with(0, { + ...first, + params: clampSourceAwareMaxes( + firstSchema, + applySourceDefaults( + firstSchema, + first.params, + { source: firstImage }, + ctx.touchedFor(first.key), + ), + firstImage, + ), + }), + ); + } + } + + async function handleFile(file: File) { + await handleFiles([file]); + } + + onMount(() => { + if (ctx.inputMode() !== "image") return; + function onPaste(e: ClipboardEvent) { + const target = e.target as HTMLElement | null; + if (target?.closest("input, textarea, [contenteditable]")) return; + for (const item of e.clipboardData?.items ?? []) { + if (item.kind !== "file" || !item.type.startsWith("image/")) continue; + const file = item.getAsFile(); + if (file) { + e.preventDefault(); + void handleFile(file); + } + return; + } + } + window.addEventListener("paste", onPaste); + return () => window.removeEventListener("paste", onPaste); + }); + + return { + get source() { + return source; + }, + set source(v: PixelImage | null) { + source = v; + }, + get sourceFiles() { + return sourceFiles; + }, + set sourceFiles(v: ToolImageFile[]) { + sourceFiles = v; + }, + get textSource() { + return textSource; + }, + set textSource(v: string) { + textSource = v; + }, + get sourceWarnings() { + return sourceWarnings; + }, + clearWarnings: () => { + sourceWarnings = []; + }, + handleFile, + handleFiles, + }; +} diff --git a/web/src/lib/components/schema/step-mask.svelte.ts b/web/src/lib/components/schema/step-mask.svelte.ts new file mode 100644 index 0000000..6586655 --- /dev/null +++ b/web/src/lib/components/schema/step-mask.svelte.ts @@ -0,0 +1,59 @@ +import type { PixelImage } from "$lib/core/types"; +import type { ChainStep } from "$lib/pipeline.svelte"; +import { getTool } from "$lib/registry"; +import { sanitizeSchemaParams } from "$lib/registry-schema"; +import { SvelteSet } from "svelte/reactivity"; +import { toolSchemaOf } from "./schema-tool-state.svelte"; + +export interface StepMasksCtx { + steps: () => ChainStep[]; + source: () => PixelImage | null; + stepResults: () => (PixelImage | null)[]; +} + +// Per-step mask preview of SchemaToolView: the toggle lives on every step +// result tile (keyed by step.key), the mask never enters the chain itself. +export function createStepMasks(ctx: StepMasksCtx) { + const onKeys = new SvelteSet(); + // Recomputed on the main thread per toggled step from that step's input. + const results = $derived.by(() => { + const next: Record = {}; + ctx.steps().forEach((step, i) => { + if (!onKeys.has(step.key)) return; + const stepTool = getTool(step.id); + if (!stepTool?.runMask) return; + const input = i === 0 ? ctx.source() : ctx.stepResults()[i - 1]; + if (!input) return; + // Same contract as executor.ts: params are sanitized against the + // schema with the step input as the source context. + const stepSchema = toolSchemaOf(step); + const params = stepSchema + ? sanitizeSchemaParams(stepSchema, step.params, { source: input }) + : step.params; + try { + next[step.key] = stepTool.runMask({ params, source: input }); + } catch { + next[step.key] = null; + } + }); + return next; + }); + return { + get maskable() { + return ctx.steps().map((s) => !!getTool(s.id)?.runMask); + }, + get maskOn() { + return ctx.steps().map((s) => onKeys.has(s.key)); + }, + get masks() { + return ctx.steps().map((s) => results[s.key] ?? null); + }, + toggleByIndex: (i: number) => { + const step = ctx.steps()[i]; + if (!step) return; + if (onKeys.has(step.key)) onKeys.delete(step.key); + else onKeys.add(step.key); + }, + clear: () => onKeys.clear(), + }; +} diff --git a/web/src/lib/i18n/schema-tool-strings.ts b/web/src/lib/i18n/schema-tool-strings.ts index 0715b04..b4b3906 100644 --- a/web/src/lib/i18n/schema-tool-strings.ts +++ b/web/src/lib/i18n/schema-tool-strings.ts @@ -1,5 +1,6 @@ import type { Page } from "$lib/registry"; import { PAGES } from "../registry"; +import type { ChainWarning } from "$lib/run-chain"; import type { Field } from "$lib/registry-schema"; import { getMergedDict } from "./locale.svelte"; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; @@ -67,6 +68,21 @@ export function verdictTone(key: string): "success" | "danger" | "info" { return "info"; } +/** Human-readable text for a chain run/source warning. */ +export function chainWarningText(w: ChainWarning): string { + switch (w.kind) { + case "partial": + return t("chain.warnPartial", { n: w.step, ok: w.ok, total: w.total }); + case "firstOnly": + return t("chain.warnFirstOnly", { n: w.step, total: w.total }); + case "sourceSkip": + return t("chain.warnSourceSkip", { + skipped: w.skipped, + total: w.total, + }); + } +} + function dedupe(values: string[]): string[] { return [...new Set(values.filter((v) => v.length > 0))]; }