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}
{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))];
}