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
+7
View File
@@ -107,6 +107,13 @@ registry/{tools,pages}/*.ts
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы. `registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая - **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод. функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
- **Цепочка работает над набором файлов (fan-out).** `run-chain.ts`: «ток» между
шагами — массив именованных изображений (одиночная загрузка = набор из
одного); image-шаг применяется к каждому файлу, files-шаг (split) склеивает
имена частей (`<вход>-<часть>`), analyze-шаг (text/verdict) при batch-входе
видит первый файл. Шаг роняет цепочку, только если упали все файлы
(`ChainStepError`), частичные сбои — warnings. Мультивыбор файлов в drop-зоне;
набор на выходе скачивается zip через `zip.ts`.
## 3. Инструмент и страница ## 3. Инструмент и страница
+13 -27
View File
@@ -18,33 +18,20 @@
- [ ] посмотреть реализацию flip animation svelte для анимации переключения - [ ] посмотреть реализацию flip animation svelte для анимации переключения
режима aligned\normal режима aligned\normal
- [ ] переделать визуал toggle компонента (которым режим переключается) - [ ] переделать визуал toggle компонента (которым режим переключается)
- [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Вход — - [ ] **Идея 4. Batch-обработка архивов выбранным chain** — оценка L. Fan-out и
zip-архив картинок (распаковка в браузере), прогон текущего или выбранного мультивыбор уже есть (идея 15: `web/src/lib/run-chain.ts`, zip-выгрузка
сохранённого chain по каждому файлу, сборка результата обратно в zip для набора). Осталось: zip на вход (распаковка в браузере, порядок файлов,
скачивания. Зависит от идеи 3 (выбор цепочки) и от экспортного формата поведение many → 1 → many), прогресс и отмена пофайлово, запуск по
pipeline JSON; прогресс и ошибки — пофайлово. сохранённой цепочке из `/chains`.
- [ ] **Идея 15. Набор файлов в пайплайне: 1 → many не должен быть - [ ] **Идея 15. Остатки fan-out** — сделано: 1 → many не терминален,
терминальным** — оценка L. **Сейчас** `execute` принимает один мультизагрузка, склейка имён, per-file ошибки с warning. Отложено:
`source: PixelImage | undefined` и возвращает один `ToolResult`, а parts-grid на промежуточных тайлах (сейчас первая картинка набора),
`SchemaToolView` раскладывает вывод в `result` / `fileResult` / прогресс/отмена, analyze-шаги пофайлово (сейчас firstOnly-warning).
`textResult` по `resultKind` — поэтому шаг с `result: "files"` упирается в
конец цепочки. **Направление:** следующий инструмент применяется **ко всем
картинкам** предыдущего результата (fan-out), а many → 1 (вход = архив или
несколько файлов) — обратная сторона того же контракта. Терминальными
остаются только результаты, которые не картинка (text, verdict). Что
обдумать: - где проходит граница: один вызов `run(ctx)` на файл (имя части
склеивается в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена; - частичные ошибки: один битый файл не должен ронять
весь шаг — нужен per-file отчёт, а не один `ToolError` на шаг; - имена
частей на каждом шаге и Download для набора (сейчас zip по `page.slug`,
`SchemaToolView.download()`); - обратная сторона: распаковка zip в
браузере, порядок файлов, поведение many → 1 → many в одной цепочке.
Зависит от идеи 10 (остальные потребители 1 → many) и идеи 4 (batch по
архиву), `docs/roadmap.md` фаза 8 «batch-страница».
## Инструменты ## Инструменты
- [ ] split-to-parts, загружаем картинку, после этого переключаемся на режим by
tile - интерфейс начинает тормозить. уже пора переходить на worker\wasm?
- [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с - [ ] remove-alpha-channel - дубль инструмента с выбором цвета на замену, что с
такими делать вообще? оставить как seo? можно ли под капотом использовать такими делать вообще? оставить как seo? можно ли под капотом использовать
другой инструмент с хардкодед настройками? другой инструмент с хардкодед настройками?
@@ -54,9 +41,6 @@
выделенной области выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор - [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер - 112\56\28px или 72\36\18) какой размер - 112\56\28px или 72\36\18)
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в
[twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать - [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
@@ -134,6 +118,8 @@
## UI и качество ## UI и качество
- [ ] при загрузке нескольких файлов, показывает source не той же высоты, как
result
- [ ] split to parts - посмотреть вариант с toggle component - может будет - [ ] split to parts - посмотреть вариант с toggle component - может будет
удобнее чем sectioned buttons удобнее чем sectioned buttons
- [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать - [ ] чип «Маска» (`resultCard.maskToggle` на тайлах результата) — попробовать
+2
View File
@@ -8,6 +8,7 @@ import {
serializeSteps, serializeSteps,
type ChainStep, type ChainStep,
} from "./pipeline.svelte"; } from "./pipeline.svelte";
import type { ToolImageFile } from "./registry";
export interface NamedChain { export interface NamedChain {
id: string; id: string;
@@ -135,6 +136,7 @@ export function deleteChain(id: string): void {
/** State that cannot persist across the tool -> pipeline route swap. */ /** State that cannot persist across the tool -> pipeline route swap. */
export interface ChainHandoff { export interface ChainHandoff {
source: PixelImage | null; source: PixelImage | null;
sourceFiles?: ToolImageFile[];
textSource: string; textSource: string;
format: OutputMime; format: OutputMime;
limitKb: number | undefined; limitKb: number | undefined;
@@ -5,15 +5,18 @@
interface Props { interface Props {
accept?: string; accept?: string;
onfile?: (file: File) => void; 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 dragging = $state(false);
let input = $state<HTMLInputElement | null>(null); let input = $state<HTMLInputElement | null>(null);
function handleFiles(files: FileList | null | undefined) { function handleFiles(files: FileList | null | undefined) {
const file = files?.[0]; const list = Array.from(files ?? []);
if (file) onfile?.(file); if (list.length === 0) return;
if (list.length > 1 && onfiles) onfiles(list);
else if (list[0]) onfile?.(list[0]);
} }
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
@@ -50,6 +53,7 @@
bind:this={input} bind:this={input}
type="file" type="file"
{accept} {accept}
multiple
hidden hidden
onchange={(e) => handleFiles((e.target as HTMLInputElement).files)} 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"> <script lang="ts">
import { hasPreviewResult } from "./schema-preview-model"; import { hasPreviewResult } from "./schema-preview-model";
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaResultTile from "./SchemaResultTile.svelte"; import SchemaResultTile from "./SchemaResultTile.svelte";
import SchemaSourceTile from "./SchemaSourceTile.svelte"; import SchemaSourceTile from "./SchemaSourceTile.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -15,6 +16,7 @@
InputMode, InputMode,
ResultKind, ResultKind,
ResultNote, ResultNote,
ToolImageFile,
} from "$lib/registry"; } from "$lib/registry";
interface Props { interface Props {
@@ -22,6 +24,8 @@
resultKind?: ResultKind; resultKind?: ResultKind;
toolId: string; toolId: string;
source: PixelImage | null; source: PixelImage | null;
sources?: ToolImageFile[];
fileCount?: number;
result: PixelImage | null; result: PixelImage | null;
resultNote?: ResultNote | null; resultNote?: ResultNote | null;
fileResult?: FileResult | null; fileResult?: FileResult | null;
@@ -34,6 +38,7 @@
running?: boolean; running?: boolean;
error?: string; error?: string;
stepResults?: (PixelImage | null)[]; stepResults?: (PixelImage | null)[];
stepFileSets?: ToolImageFile[][];
stepMaskable?: boolean[]; stepMaskable?: boolean[];
stepMaskOn?: boolean[]; stepMaskOn?: boolean[];
stepMasks?: (PixelImage | null)[]; stepMasks?: (PixelImage | null)[];
@@ -45,6 +50,7 @@
limitKb?: number; limitKb?: number;
alphaLoss?: boolean; alphaLoss?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
onuploadmany?: (files: File[]) => void;
ontextsource?: (text: string) => void; ontextsource?: (text: string) => void;
onrendertext?: () => void; onrendertext?: () => void;
oncopytext?: () => void; oncopytext?: () => void;
@@ -59,6 +65,8 @@
resultKind = "image", resultKind = "image",
toolId, toolId,
source, source,
sources = [],
fileCount = 0,
result, result,
resultNote = null, resultNote = null,
fileResult = null, fileResult = null,
@@ -71,6 +79,7 @@
running = false, running = false,
error = "", error = "",
stepResults = [], stepResults = [],
stepFileSets = [],
stepMaskable = [], stepMaskable = [],
stepMaskOn = [], stepMaskOn = [],
stepMasks = [], stepMasks = [],
@@ -82,6 +91,7 @@
limitKb = undefined, limitKb = undefined,
alphaLoss = false, alphaLoss = false,
onupload, onupload,
onuploadmany,
ontextsource, ontextsource,
onrendertext, onrendertext,
oncopytext, oncopytext,
@@ -134,14 +144,36 @@
<SchemaSourceTile <SchemaSourceTile
mode={inputMode} mode={inputMode}
{source} {source}
{sources}
{textSource} {textSource}
{running} {running}
{fileCount}
ontextinput={ontextsource} ontextinput={ontextsource}
{onrendertext} {onrendertext}
{onupload} {onupload}
{onuploadmany}
/> />
{#each stepResults.slice(0, -1) as img, i (i)} {#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 <PreviewTile
label={t("chain.stepResult", { n: i + 1 })} label={t("chain.stepResult", { n: i + 1 })}
viewMode="image" viewMode="image"
@@ -5,6 +5,7 @@
import type { FileResult, ResultKind, ResultNote } from "$lib/registry"; import type { FileResult, ResultKind, ResultNote } from "$lib/registry";
import { Check } from "@lucide/svelte"; import { Check } from "@lucide/svelte";
import ChipButton from "$lib/components/ui/ChipButton.svelte"; import ChipButton from "$lib/components/ui/ChipButton.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaTextResult from "./SchemaTextResult.svelte"; import SchemaTextResult from "./SchemaTextResult.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
@@ -46,14 +47,6 @@
? `${result.width} × ${result.height}` ? `${result.width} × ${result.height}`
: undefined, : undefined,
); );
const partUrls = $derived(
resultKind === "files" && fileResult
? fileResult.files.map((file) => ({
name: file.name,
url: toDataUrl(file.image),
}))
: [],
);
let parts = $derived( let parts = $derived(
resultKind === "files" && fileResult ? fileResult.files.length : undefined, resultKind === "files" && fileResult ? fileResult.files.length : undefined,
@@ -85,11 +78,7 @@
/> />
{/if} {/if}
{/snippet} {/snippet}
<div <div class="canvas" class:checker={resultKind === "image"}>
class="canvas"
class:checker={resultKind === "image"}
class:files={resultKind === "files"}
>
{#if resultKind === "text" && textResult} {#if resultKind === "text" && textResult}
<div class="text-result-wrap"> <div class="text-result-wrap">
<SchemaTextResult <SchemaTextResult
@@ -110,15 +99,8 @@
ondownload={ondownloadtxt ?? (() => {})} ondownload={ondownloadtxt ?? (() => {})}
/> />
</div> </div>
{:else if resultKind === "files" && partUrls.length > 0} {:else if resultKind === "files" && fileResult && fileResult.files.length > 0}
<div class="parts-grid"> <PartsGrid files={fileResult.files} />
{#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 === "image" && resultUrl} {:else if resultKind === "image" && resultUrl}
<img <img
data-testid="result-image" data-testid="result-image"
@@ -166,51 +148,9 @@
) )
50% / 28px 28px; 50% / 28px 28px;
} }
/* Сетка частей: без потолка 1024 плитки растягивали страницу бесконечно. */
.canvas.files {
max-height: 60vh;
align-items: flex-start;
}
.empty { .empty {
font: var(--font-size-s) var(--font-mono); 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 { .text-result-wrap {
width: 100%; width: 100%;
padding: var(--space-l); padding: var(--space-l);
@@ -2,33 +2,45 @@
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { t } from "$lib/i18n/t"; 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 Dropzone from "./Dropzone.svelte";
import PartsGrid from "./PartsGrid.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
import SchemaTextSource from "./SchemaTextSource.svelte"; import SchemaTextSource from "./SchemaTextSource.svelte";
interface Props { interface Props {
mode: InputMode; mode: InputMode;
source: PixelImage | null; source: PixelImage | null;
sources?: ToolImageFile[];
textSource?: string; textSource?: string;
running?: boolean; running?: boolean;
fileCount?: number;
ontextinput?: (text: string) => void; ontextinput?: (text: string) => void;
onrendertext?: () => void; onrendertext?: () => void;
onupload?: (file: File) => void; onupload?: (file: File) => void;
onuploadmany?: (files: File[]) => void;
} }
let { let {
mode, mode,
source, source,
sources = [],
textSource = "", textSource = "",
running = false, running = false,
fileCount = 0,
ontextinput, ontextinput,
onrendertext, onrendertext,
onupload, onupload,
onuploadmany,
}: Props = $props(); }: Props = $props();
const sourceUrl = $derived(source ? toDataUrl(source) : null); const sourceUrl = $derived(source ? toDataUrl(source) : null);
const sourceDims = $derived( 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); let dragging = $state(false);
@@ -57,8 +69,13 @@
function onDrop(e: DragEvent) { function onDrop(e: DragEvent) {
e.preventDefault(); e.preventDefault();
dragging = false; dragging = false;
const file = e.dataTransfer?.files?.[0]; pickFiles(Array.from(e.dataTransfer?.files ?? []));
if (file) onupload?.(file); }
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() { function open() {
@@ -106,17 +123,21 @@
bind:this={input} bind:this={input}
type="file" type="file"
accept="image/*" accept="image/*"
multiple
hidden hidden
onchange={(e) => { onchange={(e) => {
const file = (e.target as HTMLInputElement).files?.[0]; pickFiles(Array.from((e.target as HTMLInputElement).files ?? []));
if (file) onupload?.(file);
}} }}
/> />
{#if sources.length > 1}
<PartsGrid files={sources} />
{:else}
<img <img
data-testid="source-image" data-testid="source-image"
src={sourceUrl} src={sourceUrl}
alt={t("sourceCard.alt")} alt={t("sourceCard.alt")}
/> />
{/if}
{#if dragging} {#if dragging}
<span class="drop-hint">{t("dropZone.overlayDefault")}</span> <span class="drop-hint">{t("dropZone.overlayDefault")}</span>
{:else} {:else}
@@ -124,7 +145,7 @@
{/if} {/if}
</div> </div>
{:else if mode === "image"} {:else if mode === "image"}
<Dropzone onfile={onupload} /> <Dropzone onfile={onupload} onfiles={onuploadmany} />
{:else} {:else}
<span class="empty">{t("sourceCard.noSource")}</span> <span class="empty">{t("sourceCard.noSource")}</span>
{/if} {/if}
@@ -22,6 +22,13 @@
} from "$lib/core/io"; } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { execute } from "$lib/executor"; import { execute } from "$lib/executor";
import {
baseName,
ChainStepError,
runChain,
uniqueName,
type ChainWarning,
} from "$lib/run-chain";
import { import {
pageDescription, pageDescription,
pageTitle, pageTitle,
@@ -51,6 +58,7 @@
type FileResult, type FileResult,
type Page, type Page,
type Tool, type Tool,
type ToolImageFile,
type ToolResult, type ToolResult,
} from "$lib/registry"; } from "$lib/registry";
import { import {
@@ -66,6 +74,7 @@
import { SvelteMap, SvelteSet } from "svelte/reactivity"; import { SvelteMap, SvelteSet } from "svelte/reactivity";
import AddStepButton from "./AddStepButton.svelte"; import AddStepButton from "./AddStepButton.svelte";
import PreviewTile from "./PreviewTile.svelte"; import PreviewTile from "./PreviewTile.svelte";
import PartsGrid from "./PartsGrid.svelte";
import SchemaActions from "./SchemaActions.svelte"; import SchemaActions from "./SchemaActions.svelte";
import SchemaFields from "./SchemaFields.svelte"; import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte"; import SchemaPreview from "./SchemaPreview.svelte";
@@ -98,8 +107,13 @@
const lastAxis: Record<string, "width" | "height"> = {}; const lastAxis: Record<string, "width" | "height"> = {};
let stepDims = $state<(Dimension | undefined)[]>([]); let stepDims = $state<(Dimension | undefined)[]>([]);
let stepResults = $state<(PixelImage | null)[]>([]); 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 aligned = $state(false);
let source = $state<PixelImage | null>(null); 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 result = $state<PixelImage | null>(null);
let fileResult = $state<FileResult | null>(null); let fileResult = $state<FileResult | null>(null);
let textSource = $state(""); let textSource = $state("");
@@ -109,6 +123,9 @@
); );
let running = $state(false); let running = $state(false);
let displayError = $state<DisplayError | null>(null); let displayError = $state<DisplayError | null>(null);
let sourceWarnings = $state<ChainWarning[]>([]);
let runWarnings = $state<ChainWarning[]>([]);
const allWarnings = $derived([...sourceWarnings, ...runWarnings]);
let initedFor = $state(""); let initedFor = $state("");
let format = $state<OutputMime>("image/png"); let format = $state<OutputMime>("image/png");
// Lossy-format quality for tools without a quality param in the schema. // Lossy-format quality for tools without a quality param in the schema.
@@ -126,8 +143,13 @@
tool, tool,
); );
const resultKind = $derived(lastTool.result ?? "image"); const resultKind = $derived(lastTool.result ?? "image");
// Chain can be extended only if the last step outputs an image. // Display kind follows the actual output: a batch run of an image tool
const canExtend = $derived((lastTool.result ?? "image") === "image"); // 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( const alignedMode = $derived(
aligned && resultKind === "image" && steps.length > 1, aligned && resultKind === "image" && steps.length > 1,
); );
@@ -202,6 +224,11 @@
const handoff = takeHandoff(chainId); const handoff = takeHandoff(chainId);
steps = getChain(chainId)?.steps ?? createChain(page); steps = getChain(chainId)?.steps ?? createChain(page);
source = handoff?.source ?? null; source = handoff?.source ?? null;
sourceFiles =
handoff?.sourceFiles ??
(handoff?.source
? [{ name: `${baseName(page.slug)}.png`, image: handoff.source }]
: []);
textSource = handoff?.textSource ?? ""; textSource = handoff?.textSource ?? "";
if (handoff?.format) format = handoff.format; if (handoff?.format) format = handoff.format;
else format = lastTool.output?.mime ?? "image/png"; else format = lastTool.output?.mime ?? "image/png";
@@ -211,8 +238,12 @@
format = lastTool.output?.mime ?? "image/png"; format = lastTool.output?.mime ?? "image/png";
limitKb = undefined; limitKb = undefined;
source = null; source = null;
sourceFiles = [];
textSource = ""; textSource = "";
} }
sourceWarnings = [];
runWarnings = [];
stepFileSets = [];
result = null; result = null;
fileResult = null; fileResult = null;
textResult = null; textResult = null;
@@ -321,11 +352,31 @@
dragFrom = null; dragFrom = null;
} }
async function handleFile(file: File) { async function handleFiles(files: File[]) {
displayError = null; displayError = null;
sourceWarnings = [];
const decoded: ToolImageFile[] = [];
const usedNames = new SvelteSet<string>();
let skipped = 0;
for (const file of files) {
try { try {
const decoded = await decodeFile(file); const name = uniqueName(`${baseName(file.name)}.png`, usedNames);
source = decoded; usedNames.add(name);
decoded.push({ name, image: await decodeFile(file) });
} catch {
skipped++;
}
}
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 first = steps[0];
const firstSchema = first ? toolSchemaOf(first) : undefined; const firstSchema = first ? toolSchemaOf(first) : undefined;
if (first && firstSchema) { if (first && firstSchema) {
@@ -336,27 +387,30 @@
applySourceDefaults( applySourceDefaults(
firstSchema, firstSchema,
first.params, first.params,
{ source: decoded }, { source: firstImage },
touchedFor(first.key), touchedFor(first.key),
), ),
decoded, firstImage,
), ),
}); });
} }
} catch (e) {
displayError = toDisplayError(e);
} }
async function handleFile(file: File) {
await handleFiles([file]);
} }
function assignResult(out: ToolResult) { function assignResult(out: ToolResult) {
if (resultKind === "image") { if (typeof out === "object" && out !== null && "files" in out) {
result = out as PixelImage; fileResult = out;
textResult = null;
fileResult = null;
} else if (resultKind === "files") {
fileResult = out as FileResult;
result = null; result = null;
textResult = 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 { } else {
if (typeof out === "string") { if (typeof out === "string") {
textResult = out; textResult = out;
@@ -378,46 +432,33 @@
displayError = null; displayError = null;
running = true; running = true;
try { try {
let current: PixelImage | undefined = source ?? undefined; const input =
const dims: (Dimension | undefined)[] = []; sourceFiles.length > 0
const results: (PixelImage | null)[] = []; ? sourceFiles
let out: ToolResult = ""; : source
for (let i = 0; i < steps.length; i++) { ? [{ name: `${baseName(page.slug)}.png`, image: source }]
const stepTool = getTool(steps[i].id); : [];
if (!stepTool) continue; const r = await runChain(steps, input, textSource || undefined, execute);
try { stepDims = r.stepDims;
out = await execute(stepTool, { stepResults = r.stepResults;
params: steps[i].params, stepFileSets = r.stepFileSets;
source: stepTool.input === "image" ? current : undefined, runWarnings = r.warnings;
text: assignResult(r.out);
stepTool.input === "text" ? textSource || undefined : undefined,
});
} catch (e) { } catch (e) {
const base = toDisplayError(e); if (e instanceof ChainStepError) {
const base = toDisplayError(e.cause);
const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text; const msg = base.kind === "i18n" ? t(base.key, base.vars) : base.text;
throw new Error( displayError = {
t("toolPage.stepError", { kind: "plain",
n: i + 1, text: t("toolPage.stepError", {
title: stepTitle(stepTool.id), n: e.stepIndex + 1,
title: stepTitle(e.toolId),
msg, 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 { } else {
current = undefined;
results[i] = null;
}
}
stepDims = dims;
stepResults = results;
assignResult(out);
} catch (e) {
displayError = toDisplayError(e); displayError = toDisplayError(e);
}
} finally { } finally {
running = false; running = false;
} }
@@ -428,8 +469,7 @@
running = true; running = true;
displayError = null; displayError = null;
try { try {
if (resultKind === "files") { if (fileResult) {
if (!fileResult) return;
await downloadZip(fileResult.files, `${page.slug}.zip`); await downloadZip(fileResult.files, `${page.slug}.zip`);
return; return;
} }
@@ -491,7 +531,13 @@
void steps; void steps;
if (isStructuralDefault(page, steps)) return; if (isStructuralDefault(page, steps)) return;
const chain = createNamedChain(autoChainName(), steps); 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}`), { goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
@@ -571,11 +617,31 @@
return { kind: "plain", text: String(e) }; 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 // Explicit save for a structurally default chain (single tuned step): the
// auto-create effect only fires on structural change. // auto-create effect only fires on structural change.
function saveAsChain() { function saveAsChain() {
const chain = createNamedChain(autoChainName(), steps); 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}`), { goto(resolve(`/pipeline?id=${chain.id}`), {
replaceState: true, replaceState: true,
keepFocus: true, keepFocus: true,
@@ -688,6 +754,8 @@
<SchemaPreview <SchemaPreview
toolId={lastTool.id} toolId={lastTool.id}
{source} {source}
sources={sourceFiles}
fileCount={sourceFiles.length}
{result} {result}
{resultNote} {resultNote}
{fileResult} {fileResult}
@@ -695,7 +763,7 @@
{textResult} {textResult}
textVars={verdictVars} textVars={verdictVars}
{inputMode} {inputMode}
{resultKind} resultKind={displayResultKind}
{running} {running}
error={errorText} error={errorText}
{format} {format}
@@ -703,6 +771,7 @@
{limitKb} {limitKb}
{alphaLoss} {alphaLoss}
{stepResults} {stepResults}
{stepFileSets}
{aligned} {aligned}
{stepMaskable} {stepMaskable}
{stepMaskOn} {stepMaskOn}
@@ -718,6 +787,7 @@
onquality={setFormatQuality} onquality={setFormatQuality}
onlimit={(v) => (limitKb = v)} onlimit={(v) => (limitKb = v)}
onupload={handleFile} onupload={handleFile}
onuploadmany={handleFiles}
ontextsource={(textValue) => { ontextsource={(textValue) => {
textSource = textValue; textSource = textValue;
}} }}
@@ -728,6 +798,9 @@
/> />
</section> </section>
</div> </div>
{#each allWarnings as w (warningText(w))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{:else} {:else}
<div class="aligned"> <div class="aligned">
<div class="aligned-row"> <div class="aligned-row">
@@ -740,7 +813,7 @@
</label> </label>
<SchemaActions <SchemaActions
{inputMode} {inputMode}
{resultKind} resultKind={displayResultKind}
canDownload={result !== null} canDownload={result !== null}
{running} {running}
{format} {format}
@@ -758,6 +831,9 @@
{#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))}
<p class="warn" role="status">{warningText(w)}</p>
{/each}
{#if canExtend} {#if canExtend}
<div class="aligned-row"> <div class="aligned-row">
<AddStepButton onadd={(id) => addStepAt(0, id)} /> <AddStepButton onadd={(id) => addStepAt(0, id)} />
@@ -782,19 +858,26 @@
<SchemaSourceTile <SchemaSourceTile
mode={inputMode} mode={inputMode}
{source} {source}
sources={sourceFiles}
{textSource} {textSource}
{running} {running}
fileCount={sourceFiles.length}
ontextinput={(v) => (textSource = v)} ontextinput={(v) => (textSource = v)}
onrendertext={run} onrendertext={run}
onupload={handleFile} onupload={handleFile}
onuploadmany={handleFiles}
/> />
{:else} {:else}
{@const inSet = stepFileSets[i - 1] ?? []}
<PreviewTile <PreviewTile
label={t("chain.inputLegend")} label={t("chain.inputLegend")}
viewMode="image" viewMode="image"
dims={input ? `${input.width} × ${input.height}` : undefined} 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="" /> <img src={toDataUrl(input)} alt="" />
{:else} {:else}
<span class="empty">{t("resultCard.noResult")}</span> <span class="empty">{t("resultCard.noResult")}</span>
@@ -803,7 +886,7 @@
{/if} {/if}
{#if i === steps.length - 1} {#if i === steps.length - 1}
<SchemaResultTile <SchemaResultTile
{resultKind} resultKind={displayResultKind}
result={out} result={out}
{resultNote} {resultNote}
{fileResult} {fileResult}
@@ -820,15 +903,19 @@
ondownloadtxt={downloadText} ondownloadtxt={downloadText}
/> />
{:else} {:else}
{@const outSet = stepFileSets[i] ?? []}
<PreviewTile <PreviewTile
label={t("chain.stepResult", { n: i + 1 })} label={t("chain.stepResult", { n: i + 1 })}
viewMode="image" viewMode="image"
dims={out ? `${out.width} × ${out.height}` : undefined} dims={out ? `${out.width} × ${out.height}` : undefined}
parts={outSet.length > 1 ? outSet.length : undefined}
> >
{#snippet actions()} {#snippet actions()}
{@render maskChip(i)} {@render maskChip(i)}
{/snippet} {/snippet}
{#if out} {#if outSet.length > 1 && !stepMaskOn[i]}
<PartsGrid files={outSet} />
{:else if out}
<img <img
src={toDataUrl( src={toDataUrl(
stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out, stepMaskOn[i] && stepMasks[i] ? stepMasks[i]! : out,
@@ -1003,6 +1090,11 @@
color: var(--color-danger); color: var(--color-danger);
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.warn {
margin: 0;
color: var(--color-warning);
font: var(--font-size-s) var(--font-mono);
}
.empty { .empty {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
+5
View File
@@ -63,6 +63,10 @@ export const en: Dict = {
busyTitle: "Processing…", busyTitle: "Processing…",
busyHint: "Running chain step", busyHint: "Running chain step",
removeStepAria: "Remove step", removeStepAria: "Remove step",
warnPartial: "Step {n}: {ok} of {total} files processed, the rest failed",
warnFirstOnly:
"Step {n}: analyze works on one image — showing the first of {total}",
warnSourceSkip: "{skipped} of {total} files could not be read",
}, },
savedChains: { savedChains: {
pageTitle: "Saved pipelines — easy-png-tools", pageTitle: "Saved pipelines — easy-png-tools",
@@ -85,6 +89,7 @@ export const en: Dict = {
source: "Source", source: "Source",
replaceImage: "Replace image", replaceImage: "Replace image",
replaceHint: "Click to upload an image", replaceHint: "Click to upload an image",
countFiles: "{count} files",
chooseImage: "choose an image", chooseImage: "choose an image",
noSource: "no source — configure the parameters", noSource: "no source — configure the parameters",
alt: "Source image", alt: "Source image",
+6
View File
@@ -64,6 +64,11 @@ export const ru: Dict = {
busyTitle: "Обработка…", busyTitle: "Обработка…",
busyHint: "Выполняется шаг цепочки", busyHint: "Выполняется шаг цепочки",
removeStepAria: "Убрать шаг", removeStepAria: "Убрать шаг",
warnPartial:
"Шаг {n}: обработано {ok} из {total} файлов, остальные с ошибкой",
warnFirstOnly:
"Шаг {n}: анализ работает с одним изображением — показан первый из {total}",
warnSourceSkip: "{skipped} из {total} файлов не удалось прочитать",
}, },
savedChains: { savedChains: {
pageTitle: "Сохранённые пайплайны — easy-png-tools", pageTitle: "Сохранённые пайплайны — easy-png-tools",
@@ -86,6 +91,7 @@ export const ru: Dict = {
source: "Источник", source: "Источник",
replaceImage: "Заменить изображение", replaceImage: "Заменить изображение",
replaceHint: "Кликните, чтобы загрузить изображение", replaceHint: "Кликните, чтобы загрузить изображение",
countFiles: "{count} шт.",
chooseImage: "выберите изображение", chooseImage: "выберите изображение",
noSource: "нет источника — настройте параметры", noSource: "нет источника — настройте параметры",
alt: "Исходное изображение", alt: "Исходное изображение",
+4 -6
View File
@@ -14,14 +14,12 @@ function uid(): string {
return crypto.randomUUID(); return crypto.randomUUID();
} }
/** Tool can be inserted mid-chain: it accepts and returns an image. */ /** Tool can be inserted mid-chain: image in, one or many images out. */
export function isChainable(toolId: string): boolean { export function isChainable(toolId: string): boolean {
const tool = getTool(toolId); const tool = getTool(toolId);
return ( if (!tool || tool.input !== "image") return false;
tool !== undefined && const kind = tool.result ?? "image";
tool.input === "image" && return kind === "image" || kind === "files";
(tool.result ?? "image") === "image"
);
} }
/** Pages whose tools can be added as steps (for the picker). */ /** Pages whose tools can be added as steps (for the picker). */
+2 -2
View File
@@ -21,10 +21,10 @@ describe("pipeline: step chain", () => {
expect(chain[0].params).toMatchObject({ x: 0, y: 0 }); expect(chain[0].params).toMatchObject({ x: 0, y: 0 });
}); });
it("isChainable: image->image allowed, terminal and text-input not", () => { it("isChainable: image->image and image->files allowed, terminal and text-input not", () => {
expect(isChainable("resize")).toBe(true); expect(isChainable("resize")).toBe(true);
expect(isChainable("split-into-parts")).toBe(true); // result: files, fan-out
expect(isChainable("to-base64")).toBe(false); // result: text expect(isChainable("to-base64")).toBe(false); // result: text
expect(isChainable("split-into-parts")).toBe(false); // result: files
expect(isChainable("from-svg")).toBe(false); // input: text expect(isChainable("from-svg")).toBe(false); // input: text
expect(isChainable("unknown-tool")).toBe(false); expect(isChainable("unknown-tool")).toBe(false);
}); });
+127
View File
@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import { makeImage } from "./core/test-helpers";
import type { PixelImage } from "./core/types";
import type { ExecuteContext } from "./executor";
import { getTool, type Tool, type ToolImageFile } from "./registry";
import { ChainStepError, glueName, runChain, uniqueName } from "./run-chain";
const realExecute = (tool: Tool, ctx: ExecuteContext) =>
Promise.resolve(tool.run(ctx as never));
function named(name: string, image: PixelImage): ToolImageFile {
return { name, image };
}
const img4x2 = makeImage(4, 2, new Array(8).fill([100, 100, 100, 255]));
const img2x2 = makeImage(2, 2, new Array(4).fill([200, 50, 0, 255]));
describe("runChain", () => {
it("single image through an image step returns a plain image", async () => {
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("photo.png", img2x2)],
undefined,
realExecute,
);
expect("data" in (r.out as PixelImage)).toBe(true);
expect(r.stepResults[0]?.width).toBe(2);
expect(r.warnings).toEqual([]);
});
it("split mid-chain fans out: next step applies to every part", async () => {
const r = await runChain(
[
{
id: "split-into-parts",
params: { mode: "grid", columns: 2, rows: 1 },
},
{ id: "grayscale", params: {} },
],
[named("img.png", img4x2)],
undefined,
realExecute,
);
const files = (r.out as { files: ToolImageFile[] }).files;
expect(files).toHaveLength(2);
expect(files.map((f) => f.name)).toEqual([
"img-part-1-1.png",
"img-part-1-2.png",
]);
// full per-step sets: split -> 2 parts, grayscale -> 2 images
expect(r.stepFileSets.map((s) => s.length)).toEqual([2, 2]);
// grayscale is deterministic: part pixels are gray both in R and B
const px = files[0].image.data;
expect(px[0]).toBe(px[2]);
});
it("batch input: one image step over many files yields a named set", async () => {
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("a.png", img2x2), named("b.png", img2x2)],
undefined,
realExecute,
);
const files = (r.out as { files: ToolImageFile[] }).files;
expect(files.map((f) => f.name)).toEqual(["a.png", "b.png"]);
expect(r.stepResults[0]).toBe(files[0].image);
});
it("partial failure: the step continues, warning reports the count", async () => {
const flaky = (tool: Tool, ctx: ExecuteContext) => {
if (ctx.source?.data[0] === 200) return Promise.reject(new Error("bad"));
return realExecute(tool, ctx);
};
const r = await runChain(
[{ id: "grayscale", params: {} }],
[named("ok.png", img4x2), named("bad.png", img2x2)],
undefined,
flaky,
);
// a single survivor degrades to a plain image result
expect("data" in (r.out as PixelImage)).toBe(true);
expect(r.warnings).toEqual([{ kind: "partial", step: 1, ok: 1, total: 2 }]);
});
it("total failure: ChainStepError with the step index", async () => {
const failing = () => Promise.reject(new Error("nope"));
await expect(
runChain(
[{ id: "grayscale", params: {} }],
[named("a.png", img2x2)],
undefined,
failing,
),
).rejects.toSatisfy(
(e) => e instanceof ChainStepError && e.stepIndex === 0,
);
});
it("analyze step over a batch sees only the first file", async () => {
const r = await runChain(
[{ id: "is-transparent", params: {} }],
[named("a.png", img2x2), named("b.png", img2x2)],
undefined,
realExecute,
);
expect(r.warnings).toEqual([{ kind: "firstOnly", step: 1, total: 2 }]);
expect(r.out).toBe("transparentNo");
});
});
describe("glueName", () => {
it("strips the parent extension, keeps the part name", () => {
expect(glueName("photo.jpeg", "part-1-1.png")).toBe("photo-part-1-1.png");
});
});
describe("uniqueName", () => {
it("keeps the first, suffixes duplicates", () => {
const used = new Set<string>();
const a = uniqueName("image.png", used);
used.add(a);
const b = uniqueName("image.png", used);
used.add(b);
const c = uniqueName("image.png", used);
expect([a, b, c]).toEqual(["image.png", "image-2.png", "image-3.png"]);
});
});
+165
View File
@@ -0,0 +1,165 @@
import type { PixelImage } from "./core/types";
import type { ExecuteContext } from "./executor";
import {
getTool,
type FileResult,
type Tool,
type ToolImageFile,
type ToolResult,
} from "./registry";
import type { Dimension } from "./registry-schema";
export type ChainExecutor = (
tool: Tool,
ctx: ExecuteContext,
) => Promise<ToolResult>;
export type ChainWarning =
| { kind: "partial"; step: number; ok: number; total: number }
| { kind: "firstOnly"; step: number; total: number }
| { kind: "sourceSkip"; skipped: number; total: number };
export class ChainStepError extends Error {
constructor(
readonly stepIndex: number,
readonly toolId: string,
readonly cause: unknown,
) {
super(`step ${stepIndex + 1} (${toolId}) failed`);
}
}
export interface ChainRunOutput {
stepResults: (PixelImage | null)[];
/** Full named set per step; empty for text/verdict/none steps. */
stepFileSets: ToolImageFile[][];
stepDims: (Dimension | undefined)[];
out: ToolResult;
warnings: ChainWarning[];
}
/** Zip entries are encoded as PNG (see zip.ts), so names stay .png. */
export function baseName(name: string): string {
return name.replace(/\.[^./\\]+$/, "");
}
export function glueName(parent: string, part: string): string {
return `${baseName(parent)}-${part}`;
}
/**
* Zip entries and keyed lists address files by name, so duplicates must not
* occur: "image.png" repeated -> "image-2.png", "image-3.png".
*/
export function uniqueName(name: string, used: Set<string>): string {
if (!used.has(name)) return name;
const base = baseName(name);
const ext = name.slice(base.length);
for (let n = 2; ; n++) {
const candidate = `${base}-${n}${ext}`;
if (!used.has(candidate)) return candidate;
}
}
function isImage(out: ToolResult): out is PixelImage {
return typeof out === "object" && out !== null && "data" in out;
}
/**
* Fan-out chain runner: image steps apply to every file of the set,
* files-steps glue part names, analyze steps see only the first file.
* A step fails the chain only when every file fails (ChainStepError).
*/
export async function runChain(
steps: { id: string; params: Record<string, unknown> }[],
source: ToolImageFile[],
textSource: string | undefined,
execute: ChainExecutor,
): Promise<ChainRunOutput> {
const stepResults: (PixelImage | null)[] = [];
const stepFileSets: ToolImageFile[][] = [];
const stepDims: (Dimension | undefined)[] = [];
const warnings: ChainWarning[] = [];
let current: ToolImageFile[] = source;
let out: ToolResult = "";
for (let i = 0; i < steps.length; i++) {
const tool = getTool(steps[i].id);
if (!tool) continue;
if (tool.input === "text" || tool.input === "none") {
out = await execute(tool, {
params: steps[i].params,
text: tool.input === "text" ? textSource : undefined,
});
if (isImage(out)) {
current = [{ name: tool.id, image: out }];
stepResults[i] = out;
stepFileSets[i] = current;
stepDims[i] = { width: out.width, height: out.height };
} else {
current = [];
stepResults[i] = null;
stepFileSets[i] = [];
}
continue;
}
const analyze = tool.result === "text" || tool.result === "verdict";
const inputs =
analyze && current.length > 1 ? current.slice(0, 1) : current;
if (analyze && current.length > 1) {
warnings.push({ kind: "firstOnly", step: i + 1, total: current.length });
}
const produced: ToolImageFile[] = [];
let failed = 0;
let firstError: unknown;
for (const file of inputs) {
try {
const res = await execute(tool, {
params: steps[i].params,
source: file.image,
});
out = res;
if (isImage(res)) {
produced.push({ name: file.name, image: res });
} else if (typeof res === "object" && res !== null && "files" in res) {
produced.push(
...res.files.map((f) => ({
name: glueName(file.name, f.name),
image: f.image,
})),
);
}
} catch (e) {
failed++;
firstError ??= e;
}
}
if (inputs.length > 0 && failed === inputs.length) {
throw new ChainStepError(i, tool.id, firstError);
}
if (failed > 0) {
warnings.push({
kind: "partial",
step: i + 1,
ok: inputs.length - failed,
total: inputs.length,
});
}
current = produced;
stepResults[i] = produced[0]?.image ?? null;
stepFileSets[i] = produced;
stepDims[i] = produced[0]
? { width: produced[0].image.width, height: produced[0].image.height }
: undefined;
}
if (current.length > 1) {
out = { files: current } satisfies FileResult;
} else if (current.length === 1) {
out = current[0].image;
}
return { stepResults, stepFileSets, stepDims, out, warnings };
}