feat: flow for multiple files - pipeline, tools

This commit is contained in:
2026-10-09 20:23:14 +05:00
parent 0cb94d06c2
commit bac82f8442
15 changed files with 648 additions and 193 deletions
@@ -5,15 +5,18 @@
interface Props {
accept?: string;
onfile?: (file: File) => void;
onfiles?: (files: File[]) => void;
}
let { accept = "image/*", onfile }: Props = $props();
let { accept = "image/*", onfile, onfiles }: Props = $props();
let dragging = $state(false);
let input = $state<HTMLInputElement | null>(null);
function handleFiles(files: FileList | null | undefined) {
const file = files?.[0];
if (file) onfile?.(file);
const list = Array.from(files ?? []);
if (list.length === 0) return;
if (list.length > 1 && onfiles) onfiles(list);
else if (list[0]) onfile?.(list[0]);
}
function onDrop(e: DragEvent) {
@@ -50,6 +53,7 @@
bind:this={input}
type="file"
{accept}
multiple
hidden
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)}
/>
@@ -0,0 +1,70 @@
<script lang="ts">
import { toDataUrl } from "$lib/core/io";
import type { ToolImageFile } from "$lib/registry";
interface Props {
files: ToolImageFile[];
}
let { files }: Props = $props();
const parts = $derived(
files.map((file) => ({ name: file.name, url: toDataUrl(file.image) })),
);
</script>
<div class="parts-wrap">
<div class="parts-grid">
{#each parts as part (part.name)}
<figure class="part">
<img src={part.url} alt={part.name} loading="lazy" />
<figcaption>{part.name}</figcaption>
</figure>
{/each}
</div>
</div>
<style>
/* Own scrollbox: usable inside any tile without extra canvas classes. */
.parts-wrap {
width: 100%;
max-height: 60vh;
overflow: auto;
align-self: flex-start;
}
.parts-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(var(--size-parts-grid-min), 1fr)
);
gap: var(--space-m);
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
align-content: start;
}
.part {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.part img {
width: 100%;
height: auto;
display: block;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 16px 16px;
}
.part figcaption {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
text-align: center;
overflow-wrap: anywhere;
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts">
import { hasPreviewResult } from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte";
@@ -15,6 +16,7 @@
InputMode,
ResultKind,
ResultNote,
ToolImageFile,
} from "$lib/registry";
interface Props {
@@ -22,6 +24,8 @@
resultKind?: ResultKind;
toolId: string;
source: PixelImage | null;
sources?: ToolImageFile[];
fileCount?: number;
result: PixelImage | null;
resultNote?: ResultNote | null;
fileResult?: FileResult | null;
@@ -34,6 +38,7 @@
running?: boolean;
error?: string;
stepResults?: (PixelImage | null)[];
stepFileSets?: ToolImageFile[][];
stepMaskable?: boolean[];
stepMaskOn?: boolean[];
stepMasks?: (PixelImage | null)[];
@@ -45,6 +50,7 @@
limitKb?: number;
alphaLoss?: boolean;
onupload: (file: File) => void;
onuploadmany?: (files: File[]) => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
@@ -59,6 +65,8 @@
resultKind = "image",
toolId,
source,
sources = [],
fileCount = 0,
result,
resultNote = null,
fileResult = null,
@@ -71,6 +79,7 @@
running = false,
error = "",
stepResults = [],
stepFileSets = [],
stepMaskable = [],
stepMaskOn = [],
stepMasks = [],
@@ -82,6 +91,7 @@
limitKb = undefined,
alphaLoss = false,
onupload,
onuploadmany,
ontextsource,
onrendertext,
oncopytext,
@@ -134,14 +144,36 @@
<SchemaSourceTile
mode={inputMode}
{source}
{sources}
{textSource}
{running}
{fileCount}
ontextinput={ontextsource}
{onrendertext}
{onupload}
{onuploadmany}
/>
{#each stepResults.slice(0, -1) as img, i (i)}
{#if img}
{@const set = stepFileSets[i] ?? []}
{#if set.length > 1 && !stepMaskOn[i]}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
parts={set.length}
partsDims={`${set[0].image.width} × ${set[0].image.height}`}
>
{#snippet actions()}
{#if stepMaskable[i]}
<ChipButton
label={t("resultCard.maskToggle")}
active={stepMaskOn[i] ?? false}
onclick={() => ontogglestepmask?.(i)}
/>
{/if}
{/snippet}
<PartsGrid files={set} />
</PreviewTile>
{:else if img}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
@@ -5,6 +5,7 @@
import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte";
@@ -46,14 +47,6 @@
? `${result.width} × ${result.height}`
: undefined,
);
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
name: file.name,
url: toDataUrl(file.image),
}))
: [],
);
let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined,
@@ -85,11 +78,7 @@
/>
{/if}
{/snippet}
<div
class="canvas"
class:checker={resultKind === "image"}
class:files={resultKind === "files"}
>
<div class="canvas" class:checker={resultKind === "image"}>
{#if resultKind === "text" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
@@ -110,15 +99,8 @@
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "files" && partUrls.length > 0}
<div class="parts-grid">
{#each partUrls as part (part.name)}
<figure class="part">
<img src={part.url} alt={part.name} loading="lazy" />
<figcaption>{part.name}</figcaption>
</figure>
{/each}
</div>
{:else if resultKind === "files" && fileResult && fileResult.files.length > 0}
<PartsGrid files={fileResult.files} />
{:else if resultKind === "image" && resultUrl}
<img
data-testid="result-image"
@@ -166,51 +148,9 @@
)
50% / 28px 28px;
}
/* Сетка частей: без потолка 1024 плитки растягивали страницу бесконечно. */
.canvas.files {
max-height: 60vh;
align-items: flex-start;
}
.empty {
font: var(--font-size-s) var(--font-mono);
}
.parts-grid {
display: grid;
grid-template-columns: repeat(
auto-fill,
minmax(var(--size-parts-grid-min), 1fr)
);
gap: var(--space-m);
width: 100%;
height: 100%;
padding: var(--space-l);
box-sizing: border-box;
align-content: start;
}
.part {
margin: 0;
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.part img {
width: 100%;
height: auto;
display: block;
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
background: repeating-conic-gradient(
var(--color-checker-main) 0 25%,
var(--color-checker-alt) 0 50%
)
50% / 16px 16px;
}
.part figcaption {
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
text-align: center;
overflow-wrap: anywhere;
}
.text-result-wrap {
width: 100%;
padding: var(--space-l);
@@ -2,33 +2,45 @@
import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t";
import type { InputMode } from "$lib/registry";
import type { InputMode, ToolImageFile } from "$lib/registry";
import Dropzone from "./Dropzone.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props {
mode: InputMode;
source: PixelImage | null;
sources?: ToolImageFile[];
textSource?: string;
running?: boolean;
fileCount?: number;
ontextinput?: (text: string) => void;
onrendertext?: () => void;
onupload?: (file: File) => void;
onuploadmany?: (files: File[]) => void;
}
let {
mode,
source,
sources = [],
textSource = "",
running = false,
fileCount = 0,
ontextinput,
onrendertext,
onupload,
onuploadmany,
}: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null);
const sourceDims = $derived(
source ? `${source.width} × ${source.height}` : undefined,
source
? `${source.width} × ${source.height}` +
(fileCount > 1
? ` · ${t("sourceCard.countFiles", { count: fileCount })}`
: "")
: undefined,
);
let dragging = $state(false);
@@ -57,8 +69,13 @@
function onDrop(e: DragEvent) {
e.preventDefault();
dragging = false;
const file = e.dataTransfer?.files?.[0];
if (file) onupload?.(file);
pickFiles(Array.from(e.dataTransfer?.files ?? []));
}
function pickFiles(files: File[]) {
if (files.length === 0) return;
if (files.length > 1 && onuploadmany) onuploadmany(files);
else if (files[0]) onupload?.(files[0]);
}
function open() {
@@ -106,17 +123,21 @@
bind:this={input}
type="file"
accept="image/*"
multiple
hidden
onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) onupload?.(file);
pickFiles(Array.from((e.target as HTMLInputElement).files ?? []));
}}
/>
<img
data-testid="source-image"
src={sourceUrl}
alt={t("sourceCard.alt")}
/>
{#if sources.length > 1}
<PartsGrid files={sources} />
{:else}
<img
data-testid="source-image"
src={sourceUrl}
alt={t("sourceCard.alt")}
/>
{/if}
{#if dragging}
<span class="drop-hint">{t("dropZone.overlayDefault")}</span>
{:else}
@@ -124,7 +145,7 @@
{/if}
</div>
{:else if mode === "image"}
<Dropzone onfile={onupload} />
<Dropzone onfile={onupload} onfiles={onuploadmany} />
{:else}
<span class="empty">{t("sourceCard.noSource")}</span>
{/if}
@@ -22,6 +22,13 @@
} 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 {
pageDescription,
pageTitle,
@@ -51,6 +58,7 @@
type FileResult,
type Page,
type Tool,
type ToolImageFile,
type ToolResult,
} from "$lib/registry";
import {
@@ -66,6 +74,7 @@
import { SvelteMap, SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte";
import PreviewTile from "./PreviewTile.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
@@ -98,8 +107,13 @@
const lastAxis: Record<string, "width" | "height"> = {};
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<ToolImageFile[][]>([]);
let aligned = $state(false);
let source = $state<PixelImage | null>(null);
// The full named input set (multi-upload); `source` above is the first
// image for preview/defaults. Single upload = one element.
let sourceFiles = $state<ToolImageFile[]>([]);
let result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null);
let textSource = $state("");
@@ -109,6 +123,9 @@
);
let running = $state(false);
let displayError = $state<DisplayError | null>(null);
let sourceWarnings = $state<ChainWarning[]>([]);
let runWarnings = $state<ChainWarning[]>([]);
const allWarnings = $derived([...sourceWarnings, ...runWarnings]);
let initedFor = $state("");
let format = $state<OutputMime>("image/png");
// Lossy-format quality for tools without a quality param in the schema.
@@ -126,8 +143,13 @@
tool,
);
const resultKind = $derived(lastTool.result ?? "image");
// Chain can be extended only if the last step outputs an image.
const canExtend = $derived((lastTool.result ?? "image") === "image");
// Display kind follows the actual output: a batch run of an image tool
// produces a file set, not a single image.
const displayResultKind = $derived(fileResult ? "files" : resultKind);
// Chain can be extended while the last step outputs images (one or many).
const canExtend = $derived(
["image", "files"].includes(lastTool.result ?? "image"),
);
const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1,
);
@@ -202,6 +224,11 @@
const handoff = takeHandoff(chainId);
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";
@@ -211,8 +238,12 @@
format = lastTool.output?.mime ?? "image/png";
limitKb = undefined;
source = null;
sourceFiles = [];
textSource = "";
}
sourceWarnings = [];
runWarnings = [];
stepFileSets = [];
result = null;
fileResult = null;
textResult = null;
@@ -321,42 +352,65 @@
dragFrom = null;
}
async function handleFile(file: File) {
async function handleFiles(files: File[]) {
displayError = null;
try {
const decoded = await decodeFile(file);
source = decoded;
const first = steps[0];
const firstSchema = first ? toolSchemaOf(first) : undefined;
if (first && firstSchema) {
steps = steps.with(0, {
...first,
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: decoded },
touchedFor(first.key),
),
decoded,
),
});
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++;
}
} catch (e) {
displayError = toDisplayError(e);
}
if (decoded.length === 0) {
displayError = toDisplayError(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) {
steps = steps.with(0, {
...first,
params: clampSourceAwareMaxes(
firstSchema,
applySourceDefaults(
firstSchema,
first.params,
{ source: firstImage },
touchedFor(first.key),
),
firstImage,
),
});
}
}
async function handleFile(file: File) {
await handleFiles([file]);
}
function assignResult(out: ToolResult) {
if (resultKind === "image") {
result = out as PixelImage;
textResult = null;
fileResult = null;
} else if (resultKind === "files") {
fileResult = out as FileResult;
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;
@@ -378,46 +432,33 @@
displayError = null;
running = true;
try {
let current: PixelImage | undefined = source ?? undefined;
const dims: (Dimension | undefined)[] = [];
const results: (PixelImage | null)[] = [];
let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) {
const stepTool = getTool(steps[i].id);
if (!stepTool) continue;
try {
out = await execute(stepTool, {
params: steps[i].params,
source: stepTool.input === "image" ? current : undefined,
text:
stepTool.input === "text" ? textSource || undefined : undefined,
});
} catch (e) {
const base = toDisplayError(e);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error(
t("toolPage.stepError", {
n: i + 1,
title: stepTitle(stepTool.id),
msg,
}),
{ cause: e },
);
}
if (out && typeof out === "object" && "data" in out) {
current = out as PixelImage;
dims[i] = { width: current.width, height: current.height };
results[i] = current;
} else {
current = undefined;
results[i] = null;
}
}
stepDims = dims;
stepResults = results;
assignResult(out);
const input =
sourceFiles.length > 0
? sourceFiles
: source
? [{ name: `${baseName(page.slug)}.png`, image: source }]
: [];
const r = await runChain(steps, input, textSource || undefined, execute);
stepDims = r.stepDims;
stepResults = r.stepResults;
stepFileSets = r.stepFileSets;
runWarnings = r.warnings;
assignResult(r.out);
} catch (e) {
displayError = toDisplayError(e);
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 {
running = false;
}
@@ -428,8 +469,7 @@
running = true;
displayError = null;
try {
if (resultKind === "files") {
if (!fileResult) return;
if (fileResult) {
await downloadZip(fileResult.files, `${page.slug}.zip`);
return;
}
@@ -491,7 +531,13 @@
void steps;
if (isStructuralDefault(page, steps)) return;
const chain = createNamedChain(autoChainName(), steps);
stashHandoff(chain.id, { source, textSource, format, limitKb });
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
@@ -571,11 +617,31 @@
return { kind: "plain", text: String(e) };
}
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, textSource, format, limitKb });
stashHandoff(chain.id, {
source,
sourceFiles,
textSource,
format,
limitKb,
});
goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true,
keepFocus: true,
@@ -688,6 +754,8 @@
<SchemaPreview
toolId={lastTool.id}
{source}
sources={sourceFiles}
fileCount={sourceFiles.length}
{result}
{resultNote}
{fileResult}
@@ -695,7 +763,7 @@
{textResult}
textVars={verdictVars}
{inputMode}
{resultKind}
resultKind={displayResultKind}
{running}
error={errorText}
{format}
@@ -703,6 +771,7 @@
{limitKb}
{alphaLoss}
{stepResults}
{stepFileSets}
{aligned}
{stepMaskable}
{stepMaskOn}
@@ -718,6 +787,7 @@
onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)}
onupload={handleFile}
onuploadmany={handleFiles}
ontextsource={(textValue) => {
textSource = textValue;
}}
@@ -728,6 +798,9 @@
/>
</section>
</div>
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{:else}
<div class="aligned">
<div class="aligned-row">
@@ -740,7 +813,7 @@
</label>
<SchemaActions
{inputMode}
{resultKind}
resultKind={displayResultKind}
canDownload={result !== null}
{running}
{format}
@@ -758,6 +831,9 @@
{#if errorText}
<p class="error" role="alert">{errorText}</p>
{/if}
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{#if canExtend}
<div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(0, id)} />
@@ -782,19 +858,26 @@
<SchemaSourceTile
mode={inputMode}
{source}
sources={sourceFiles}
{textSource}
{running}
fileCount={sourceFiles.length}
ontextinput={(v) => (textSource = v)}
onrendertext={run}
onupload={handleFile}
onuploadmany={handleFiles}
/>
{:else}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile
label={t("chain.inputLegend")}
viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined}
parts={inSet.length > 1 ? inSet.length : undefined}
>
{#if input}
{#if inSet.length > 1}
<PartsGrid files={inSet} />
{:else if input}
<img src={toDataUrl(input)} alt="" />
{:else}
<span class="empty">{t("resultCard.noResult")}</span>
@@ -803,7 +886,7 @@
{/if}
{#if i === steps.length - 1}
<SchemaResultTile
{resultKind}
resultKind={displayResultKind}
result={out}
{resultNote}
{fileResult}
@@ -820,15 +903,19 @@
ondownloadtxt={downloadText}
/>
{:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile
label={t("chain.stepResult", { n: i + 1 })}
viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
>
{#snippet actions()}
{@render maskChip(i)}
{/snippet}
{#if out}
{#if outSet.length > 1 && !stepMaskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img
src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
@@ -1003,6 +1090,11 @@
color: var(--color-danger);
font: var(--font-size-s) var(--font-mono);
}
.warn {
margin: 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
.empty {
font: var(--font-size-s) var(--font-mono);
}