mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
166 lines
4.6 KiB
TypeScript
166 lines
4.6 KiB
TypeScript
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 };
|
|
}
|