refactor: separate logic from schema tool view component

This commit is contained in:
2026-10-11 03:46:01 +05:00
parent dd9cb6d574
commit b7613e8c0f
6 changed files with 512 additions and 324 deletions
@@ -1,53 +1,23 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import Button from "$lib/components/ui/Button.svelte"; import Button from "$lib/components/ui/Button.svelte";
import { ButtonVariantDefine } from "$lib/components/ui/define"; import { ButtonVariantDefine } from "$lib/components/ui/define";
import { isFav, toggleFav } from "$lib/favorites.svelte"; import { isFav, toggleFav } from "$lib/favorites.svelte";
import { Star } from "@lucide/svelte"; import { Star } from "@lucide/svelte";
import { DragDropProvider } from "@dnd-kit/svelte"; import { DragDropProvider } from "@dnd-kit/svelte";
import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { ToolError } from "$lib/core/errors"; import { toDataUrl } from "$lib/core/io";
import { decodeFile, toDataUrl, type OutputMime } from "$lib/core/io";
import { outputFormatByMime } from "$lib/output-formats";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { baseName, uniqueName, type ChainWarning } from "$lib/run-chain";
import { import {
chainStepTitle,
chainWarningText,
pageDescription, pageDescription,
pageTitle, pageTitle,
chainStepTitle,
} from "$lib/i18n/schema-tool-strings"; } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t"; import { t } from "$lib/i18n/t";
import { import { moveStep, type ChainStep } from "$lib/pipeline.svelte";
autoChainName, import { getTool, type Page, type Tool } from "$lib/registry";
createNamedChain,
getChain,
stashHandoff,
takeHandoff,
updateChainSteps,
} from "$lib/chains.svelte";
import {
createChain,
isStructuralDefault,
moveStep,
type ChainStep,
} from "$lib/pipeline.svelte";
import {
getTool,
type Page,
type Tool,
type ToolImageFile,
} from "$lib/registry";
import {
applySourceDefaults,
clampSourceAwareMaxes,
sanitizeSchemaParams,
} from "$lib/registry-schema";
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import ChipButton from "$lib/components/ui/ChipButton.svelte";
import Toggle from "$lib/components/ui/Toggle.svelte"; import Toggle from "$lib/components/ui/Toggle.svelte";
import { onMount } from "svelte";
import { SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte"; import AddStepButton from "./AddStepButton.svelte";
import PartsGrid from "./PartsGrid.svelte"; import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -58,13 +28,17 @@
import SortableStepCard from "./SortableStepCard.svelte"; import SortableStepCard from "./SortableStepCard.svelte";
import StepReorderOverlay from "./StepReorderOverlay.svelte"; import StepReorderOverlay from "./StepReorderOverlay.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte"; import ToolPickerButton from "./ToolPickerButton.svelte";
import { createChainHost } from "./chain-host.svelte";
import { createOutputControls } from "./output-controls.svelte";
import { hasPreviewResult } from "./schema-preview-model"; import { hasPreviewResult } from "./schema-preview-model";
import { createSchemaToolRunner } from "./schema-tool-runner.svelte"; import { createSchemaToolRunner } from "./schema-tool-runner.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import { import {
createSchemaToolState, createSchemaToolState,
toolSchemaOf, toolSchemaOf,
} from "./schema-tool-state.svelte"; } from "./schema-tool-state.svelte";
import { createSourceUpload } from "./source-upload.svelte";
import { createStepDnd } from "./step-dnd.svelte";
import { createStepMasks } from "./step-mask.svelte";
interface Props { interface Props {
page: Page; page: Page;
@@ -79,48 +53,21 @@
const inputMode = $derived(tool.input); const inputMode = $derived(tool.input);
let aligned = $state(false); let aligned = $state(false);
let source = $state<PixelImage | null>(null);
// The full named input set (multi-upload); `source` above is the first // Composition root: feature state lives in sibling composables
// image for preview/defaults. Single upload = one element. // (source-upload, output-controls, step-mask, chain-host); execution in
let sourceFiles = $state<ToolImageFile[]>([]); // schema-tool-runner, step state in schema-tool-state.
let textSource = $state(""); const upload = createSourceUpload({
let sourceWarnings = $state<ChainWarning[]>([]); inputMode: () => inputMode,
let initedFor = $state(""); steps: () => stepState.steps,
let format = $state<OutputMime>("image/png"); setSteps: (v) => (stepState.steps = v),
// Lossy-format quality for tools without a quality param in the schema. touchedFor: (key) => stepState.touchedFor(key),
let formatQuality = $state<Record<string, number>>({}); reportError: (e) => runner.reportError(e),
// Optional download size limit (KB); undefined = no limit. clearError: () => (runner.displayError = null),
let limitKb = $state<number | undefined>(undefined);
// Mask preview per step (keyed by step.key): the toggle lives on every
// step result tile, not just the final one.
const maskOnKeys = new SvelteSet<string>();
// Mask preview: recomputed on the main thread per toggled step from that
// step's input; a mask never enters the chain itself.
const maskResults = $derived.by(() => {
const next: Record<string, PixelImage | null> = {};
steps.forEach((step, i) => {
if (!maskOnKeys.has(step.key)) return;
const stepTool = getTool(step.id);
if (!stepTool?.runMask) return;
const input = i === 0 ? source : 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;
}); });
const stepState = createSchemaToolState({ const stepState = createSchemaToolState({
source: () => source, source: () => upload.source,
stepDims: () => runner.stepDims, stepDims: () => runner.stepDims,
clearResults: () => runner.clearResults(), clearResults: () => runner.clearResults(),
}); });
@@ -139,16 +86,28 @@
}, },
}); });
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const output = createOutputControls({
lastTool: () => lastTool,
steps: () => steps,
result: () => runner.result,
setStepValue,
});
const runner = createSchemaToolRunner({ const runner = createSchemaToolRunner({
pageSlug: () => page.slug, pageSlug: () => page.slug,
schema: () => schema, schema: () => schema,
steps: () => steps, steps: () => steps,
source: () => source, source: () => upload.source,
sourceFiles: () => sourceFiles, sourceFiles: () => upload.sourceFiles,
textSource: () => textSource, textSource: () => upload.textSource,
format: () => format, format: () => output.format,
quality: () => currentQuality, quality: () => output.quality,
limitKb: () => limitKb, limitKb: () => output.limitKb,
lastTool: () => lastTool, lastTool: () => lastTool,
}); });
const { run, download, copyText, downloadText } = runner; const { run, download, copyText, downloadText } = runner;
@@ -164,12 +123,27 @@
const stepFileSets = $derived(runner.stepFileSets); const stepFileSets = $derived(runner.stepFileSets);
const runWarnings = $derived(runner.runWarnings); const runWarnings = $derived(runner.runWarnings);
const displayError = $derived(runner.displayError); const displayError = $derived(runner.displayError);
const allWarnings = $derived([...sourceWarnings, ...runWarnings]); const allWarnings = $derived([...upload.sourceWarnings, ...runWarnings]);
const masks = createStepMasks({
steps: () => steps,
source: () => upload.source,
stepResults: () => runner.stepResults,
});
const host = createChainHost({
page: () => page,
chainId: () => chainId,
schema: () => schema,
lastTool: () => lastTool,
steps: () => steps,
stepState,
source: upload,
output,
resetRun: () => runner.resetForHost(),
clearMasks: () => masks.clear(),
});
const lastTool = $derived(
(steps.length > 0 ? getTool(steps[steps.length - 1].id) : undefined) ??
tool,
);
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Display kind follows the actual output: a batch run of an image tool // Display kind follows the actual output: a batch run of an image tool
// produces a file set, not a single image. // produces a file set, not a single image.
@@ -191,35 +165,12 @@
}), }),
); );
const alphaLoss = $derived(
resultKind === "image" &&
result !== null &&
!outputFormatByMime(format).supportsAlpha &&
hasTransparency(result),
);
// 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(lastTool.output?.qualityParamId);
const lastParams = $derived(steps.at(-1)?.params); const lastParams = $derived(steps.at(-1)?.params);
const resultNote = $derived( const resultNote = $derived(
result && lastTool.resultNote result && lastTool.resultNote
? lastTool.resultNote(lastParams ?? {}, result) ? lastTool.resultNote(lastParams ?? {}, result)
: null, : null,
); );
const currentQuality = $derived.by(() => {
const setting = outputFormatByMime(format).settings?.quality;
if (!setting) return undefined;
if (qualityParamId && lastParams) return Number(lastParams[qualityParamId]);
return formatQuality[format] ?? setting.default;
});
function setFormatQuality(q: number) {
if (qualityParamId) {
setStepValue(steps.length - 1, qualityParamId, q);
} else {
formatQuality = { ...formatQuality, [format]: q };
}
}
const errorText = $derived.by(() => { const errorText = $derived.by(() => {
const current = displayError; const current = displayError;
@@ -229,200 +180,20 @@
: current.text; : current.text;
}); });
const stepMaskable = $derived(steps.map((s) => !!getTool(s.id)?.runMask));
const stepMaskOn = $derived(steps.map((s) => maskOnKeys.has(s.key)));
const stepMasks = $derived(steps.map((s) => maskResults[s.key] ?? null));
const debouncedRun = debounce(() => run(), 200); const debouncedRun = debounce(() => run(), 200);
function stepTitle(toolId: string): string {
return chainStepTitle(toolId);
}
function init() {
if (!schema) return;
const host = chainId ? `chain:${chainId}` : page.slug;
if (host === initedFor) return;
initedFor = host;
// Invalidate any in-flight run from the previous host.
runner.resetForHost();
if (chainId) {
const handoff = takeHandoff(chainId);
stepState.steps = getChain(chainId)?.steps ?? createChain(page);
source = handoff?.source ?? null;
sourceFiles =
handoff?.sourceFiles ??
(handoff?.source
? [{ name: `${baseName(page.slug)}.png`, image: handoff.source }]
: []);
textSource = handoff?.textSource ?? "";
if (handoff?.format) format = handoff.format;
else format = lastTool.output?.mime ?? "image/png";
limitKb = handoff?.limitKb;
} else {
stepState.steps = createChain(page);
format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null;
sourceFiles = [];
textSource = "";
}
sourceWarnings = [];
maskOnKeys.clear();
stepState.clearMarks();
}
async function handleFiles(files: File[]) {
runner.displayError = null;
sourceWarnings = [];
const decoded: ToolImageFile[] = [];
const usedNames = new SvelteSet<string>();
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) {
runner.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 = steps[0];
const firstSchema = first ? toolSchemaOf(first) : undefined;
if (first && firstSchema) {
stepState.steps = steps.with(0, {
...first,
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: firstImage },
stepState.touchedFor(first.key),
),
firstImage,
),
});
}
}
async function handleFile(file: File) {
await handleFiles([file]);
}
$effect(() => { $effect(() => {
init(); if (!host.inited) return;
}); if (inputMode === "image" && !upload.source) return;
if (inputMode === "text" && !upload.textSource.trim()) return;
// Named-chain mode autosaves into the chains store; a plain tool page
// persists nothing -- it becomes a named chain on structural change below.
$effect(() => {
if (!initedFor || !chainId) return;
void steps; void steps;
updateChainSteps(chainId, steps); void upload.source;
});
// 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 || chainId) return;
void steps;
if (isStructuralDefault(page, steps)) return;
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
});
onMount(() => {
if (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);
});
$effect(() => {
if (!initedFor) return;
if (inputMode === "image" && !source) return;
if (inputMode === "text" && !textSource.trim()) return;
void steps;
void source;
debouncedRun(); debouncedRun();
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
}); });
function toggleStepMask(key: string) { function stepTitle(toolId: string): string {
if (maskOnKeys.has(key)) maskOnKeys.delete(key); return chainStepTitle(toolId);
else maskOnKeys.add(key);
}
function toggleStepMaskByIndex(i: number) {
const step = steps[i];
if (step) toggleStepMask(step.key);
}
function warningText(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,
});
}
}
// Explicit save for a structurally default chain (single tuned step): the
// auto-create effect only fires on structural change.
function saveAsChain() {
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
noScroll: true,
});
} }
</script> </script>
@@ -457,7 +228,7 @@
label={t("savedChains.saveAs")} label={t("savedChains.saveAs")}
variant={ButtonVariantDefine.OUTLINE} variant={ButtonVariantDefine.OUTLINE}
size="s" size="s"
onclick={saveAsChain} onclick={host.saveAsChain}
/> />
{/if} {/if}
</header> </header>
@@ -484,7 +255,9 @@
schema={stepSchema} schema={stepSchema}
values={step.params} values={step.params}
toolId={step.id} 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)} onchange={(id, v, axis) => setStepValue(i, id, v, axis)}
onreset={() => resetStep(i)} onreset={() => resetStep(i)}
/> />
@@ -493,11 +266,11 @@
{/snippet} {/snippet}
{#snippet maskChip(i: number)} {#snippet maskChip(i: number)}
{#if stepMaskable[i]} {#if masks.maskable[i]}
<ChipButton <ChipButton
label={t("resultCard.maskToggle")} label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false} active={masks.maskOn[i] ?? false}
onclick={() => toggleStepMaskByIndex(i)} onclick={() => masks.toggleByIndex(i)}
/> />
{/if} {/if}
{/snippet} {/snippet}
@@ -506,15 +279,15 @@
{#if i === 0} {#if i === 0}
<SchemaSourceTile <SchemaSourceTile
mode={inputMode} mode={inputMode}
{source} source={upload.source}
sources={sourceFiles} sources={upload.sourceFiles}
{textSource} textSource={upload.textSource}
running={busy} running={busy}
fileCount={sourceFiles.length} fileCount={upload.sourceFiles.length}
ontextinput={(v) => (textSource = v)} ontextinput={(v) => (upload.textSource = v)}
onrendertext={run} onrendertext={run}
onupload={handleFile} onupload={upload.handleFile}
onuploadmany={handleFiles} onuploadmany={upload.handleFiles}
/> />
{:else} {:else}
{@const input = stepResults[i - 1]} {@const input = stepResults[i - 1]}
@@ -548,10 +321,10 @@
textVars={verdictVars} textVars={verdictVars}
toolId={lastTool.id} toolId={lastTool.id}
running={busy} running={busy}
mask={stepMasks[i] ?? null} mask={masks.masks[i] ?? null}
maskOn={stepMaskOn[i] ?? false} maskOn={masks.maskOn[i] ?? false}
ontogglemask={stepMaskable[i] ontogglemask={masks.maskable[i]
? () => toggleStepMaskByIndex(i) ? () => masks.toggleByIndex(i)
: undefined} : undefined}
oncopy={copyText} oncopy={copyText}
ondownloadtxt={downloadText} ondownloadtxt={downloadText}
@@ -567,13 +340,13 @@
{#snippet actions()} {#snippet actions()}
{@render maskChip(i)} {@render maskChip(i)}
{/snippet} {/snippet}
{#if outSet.length > 1 && !stepMaskOn[i]} {#if outSet.length > 1 && !masks.maskOn[i]}
<PartsGrid files={outSet} /> <PartsGrid files={outSet} />
{:else if out} {:else if out}
<img <img
src={toDataUrl( src={toDataUrl(
stepMaskOn[i] && stepMasks[i] masks.maskOn[i] && masks.masks[i]
? (stepMasks[i] as PixelImage) ? (masks.masks[i] as PixelImage)
: out, : out,
)} )}
alt="" alt=""
@@ -599,15 +372,15 @@
resultKind={displayResultKind} resultKind={displayResultKind}
canDownload={hasResult} canDownload={hasResult}
running={busy} running={busy}
{format} format={output.format}
quality={currentQuality} quality={output.quality}
{limitKb} limitKb={output.limitKb}
{alphaLoss} alphaLoss={output.alphaLoss}
onupload={handleFile} onupload={upload.handleFile}
ondownload={download} ondownload={download}
onformat={(v) => (format = v)} onformat={(v) => (output.format = v)}
onquality={setFormatQuality} onquality={output.setQuality}
onlimit={(v) => (limitKb = v)} onlimit={(v) => (output.limitKb = v)}
/> />
</div> </div>
{/snippet} {/snippet}
@@ -667,8 +440,8 @@
{#if errorText} {#if errorText}
<p class="error" role="alert">{errorText}</p> <p class="error" role="alert">{errorText}</p>
{/if} {/if}
{#each allWarnings as w (warningText(w))} {#each allWarnings as w (chainWarningText(w))}
<p class="warn" role="status">{warningText(w)}</p> <p class="warn" role="status">{chainWarningText(w)}</p>
{/each} {/each}
<StepReorderOverlay {steps} dnd={stepDnd} /> <StepReorderOverlay {steps} dnd={stepDnd} />
</DragDropProvider> </DragDropProvider>
@@ -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<Record<string, unknown>> | 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()),
};
}
@@ -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<OutputMime>("image/png");
// Lossy-format quality for tools without a quality param in the schema.
let formatQuality = $state<Record<string, number>>({});
// Optional download size limit (KB); undefined = no limit.
let limitKb = $state<number | undefined>(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 };
}
},
};
}
@@ -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<string>;
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<PixelImage | null>(null);
let sourceFiles = $state<ToolImageFile[]>([]);
let textSource = $state("");
let sourceWarnings = $state<ChainWarning[]>([]);
async function handleFiles(files: File[]) {
ctx.clearError();
sourceWarnings = [];
const decoded: ToolImageFile[] = [];
const usedNames = new Set<string>();
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,
};
}
@@ -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<string>();
// Recomputed on the main thread per toggled step from that step's input.
const results = $derived.by(() => {
const next: Record<string, PixelImage | null> = {};
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(),
};
}
+16
View File
@@ -1,5 +1,6 @@
import type { Page } from "$lib/registry"; import type { Page } from "$lib/registry";
import { PAGES } from "../registry"; import { PAGES } from "../registry";
import type { ChainWarning } from "$lib/run-chain";
import type { Field } from "$lib/registry-schema"; import type { Field } from "$lib/registry-schema";
import { getMergedDict } from "./locale.svelte"; import { getMergedDict } from "./locale.svelte";
import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching"; import { normalizeForSearch, scoreDoc, type SearchDoc } from "./matching";
@@ -67,6 +68,21 @@ export function verdictTone(key: string): "success" | "danger" | "info" {
return "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[] { function dedupe(values: string[]): string[] {
return [...new Set(values.filter((v) => v.length > 0))]; return [...new Set(values.filter((v) => v.length > 0))];
} }