mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: flow for multiple files - pipeline, tools
This commit is contained in:
@@ -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
@@ -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` на тайлах результата) — попробовать
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: "Исходное изображение",
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user