Add Flux2Klein turnaround test submitter

This commit is contained in:
Morpheus
2026-08-13 22:22:04 +00:00
parent 6657fd99fc
commit c31c6de030
2 changed files with 729 additions and 0 deletions
@@ -0,0 +1,110 @@
# 2026-08-13 Flux2Klein Turnaround Batch
## Goal
Build a fast local `flux2klein` turnaround workflow from a single reference image and push a broad overnight test sweep toward a clean 4-view character sheet:
- pure white background
- even studio lighting
- front / left profile / right profile / rear
- identity consistency prioritized over style variation
## What ran
### Cleanup pass
- Workflow: `models/flux2klein/image-edit-9b-distilled/workflow.json`
- Branch: single reference
- Prompt intent: convert the source photo into a front-facing full-body studio reference on white
- Output:
- prompt id: `b00b7d92-b7cc-4731-b8b7-199a257e3bdd`
- local output: `tmp/comfyui-workflow-tests/2026-08-13T22-15-27-386Z-workflow-single/01-flux2klein-front-clean_00001_.png`
### Probe turnaround pass
- Workflow: `models/flux2klein/image-edit-9b-distilled/workflow.json`
- Branch: dual reference
- References:
- original inbound image
- cleanup pass front reference
- Prompt intent: 4-view reference sheet on white
- Key settings:
- steps: `5`
- cfg: `1.2`
- seed: `1105`
- resolution: `1664x928`
- Output:
- prompt id: `36038921-42da-4b69-8d3d-baba0d97fad1`
- local output: `tmp/comfyui-workflow-tests/2026-08-13T22-17-26-853Z-workflow-multi/01-flux2klein-turnaround-probe_00001_.png`
### Overnight queue
- Successful queued turnaround submissions: `30`
- Family used for all successful queued jobs: `9b distilled`
- Matrix dimensions explored:
- steps: `4` to `9`
- cfg: `1.0` to `1.4`
- seeds: `1101-1110`, `4101-4120`
- resolutions:
- `1536x864`
- `1664x928`
- `1920x1088`
## First 10 queued prompt ids
- `9bd_s01` -> `a5c08d95-7dd2-4b5e-bb5d-f00a54ebfcd9`
- `9bd_s02` -> `1cdae84e-b356-44b5-9588-4af62c7af38b`
- `9bd_s03` -> `73be0ed7-04a2-4503-a7d7-5935f3d0f025`
- `9bd_s04` -> `0f63b054-cd8f-4e03-aaab-efa8c8ab20cf`
- `9bd_s05` -> `37c326a6-2930-47ea-a506-b9c600240466`
- `9bd_s06` -> `568f8687-db3d-4d96-85e8-045a3fb802cb`
- `9bd_s07` -> `385905af-575e-4e81-a902-b946f6c0bedd`
- `9bd_s08` -> `437c6d22-be23-4adc-ae77-ad9833459905`
- `9bd_s09` -> `6a0e7b57-6f29-4c3d-afd5-f6d767cba440`
- `9bd_s10` -> `57f83e11-8844-40b5-a31e-f4a023f97ebe`
## Extra 20 queued prompt ids
- `9bd_x01` -> `31ea5ad0-3893-495a-925f-2a65f4ad4ad6`
- `9bd_x02` -> `0d6a4b7e-685d-47d5-be94-010546b876f6`
- `9bd_x03` -> `e8ed1cc0-7dcb-43ec-be67-69bab9d98af4`
- `9bd_x04` -> `f984a196-9edf-4900-975d-aca71218d209`
- `9bd_x05` -> `58dad1cc-22c1-43ce-9125-d774722fe1ed`
- `9bd_x06` -> `fae2c765-48c7-4cb2-921b-09cca204d783`
- `9bd_x07` -> `38000496-c6b8-4803-92ce-f1615dd96528`
- `9bd_x08` -> `27e8d0b2-56a5-4e34-bd94-692563bdbf96`
- `9bd_x09` -> `2ecf88fb-58ec-402c-8ced-74967748dfaf`
- `9bd_x10` -> `97b481e5-5835-4c1a-b9c5-a9b1fc21ee12`
- `9bd_x11` -> `1e5bd564-1b29-45c3-b562-3f2ce41bccc8`
- `9bd_x12` -> `cac2b203-7298-449a-9537-edb5eba983ae`
- `9bd_x13` -> `d64cf656-6efb-4cbd-a533-8176967e3639`
- `9bd_x14` -> `7c3be665-58aa-4637-b467-3071fab4ac63`
- `9bd_x15` -> `0ce6b306-1080-41ef-80fe-1afd6e11e714`
- `9bd_x16` -> `28a91c5c-3bd9-4d71-acd9-587f42f6f657`
- `9bd_x17` -> `86c1500f-e8df-43ce-9a6b-cfefa1e63cf1`
- `9bd_x18` -> `2a130a3c-4b53-432b-8d09-10c9ca643000`
- `9bd_x19` -> `1162ed9d-956f-4898-8298-a83816fff6e9`
- `9bd_x20` -> `ace7959f-a2b0-49ac-b258-dd924d9ce591`
## Tooling added
- `scripts/submit-flux2klein-edit-test.mjs`
It:
- uploads local image refs into Comfy input
- expands the official template subgraph into a prompt graph
- applies prompt / seed / steps / cfg / sampler / resolution overrides
- remaps loader filenames to the live server when needed
- can submit and wait, or submit with `--no-wait`
## Blockers found
- `models/flux2klein/image-edit-4b-distilled/workflow.json`
- contains another nested template layer (`27eacb9f-0da2-421d-a0bf-b4b4e5fe5709`) not yet expanded by the current script
- `models/flux2klein/image-edit-9b-base/workflow.json`
- expects `flux-2-klein-base-9b-fp8.safetensors`, which is not present on the live server
## Current best visible result
The best immediate visible result so far is the probe turnaround sheet from `36038921-42da-4b69-8d3d-baba0d97fad1`. It is strong enough to justify the broader 9B distilled overnight sweep before spending more time on nested-template support.
+619
View File
@@ -0,0 +1,619 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
const DEFAULT_SERVER = process.env.COMFYUI_URL || 'http://192.168.1.202:8188';
const DEFAULT_OUT_ROOT = '/home/node/.openclaw/workspace/tmp/comfyui-workflow-tests';
const DEFAULT_POLL_MS = 3000;
const DEFAULT_TIMEOUT_SEC = 900;
function printUsage() {
console.log(`Usage:
node scripts/submit-flux2klein-edit-test.mjs --workflow <path> --image <path> [options]
Required:
--workflow <path> Flux2Klein UI workflow JSON with template subgraphs
--image <path> Primary reference image
Selection:
--target single|multi Select the 1-ref or 2-ref edit branch. Default: multi
--node-id <id> Override target branch by top-level node id
--image2 <path> Second reference image for multi mode
Overrides:
--prompt <text> Override the edit prompt
--negative <text> Override the negative prompt when the branch exposes one
--seed <int> Override noise seed
--steps <int> Override Flux2Scheduler steps
--cfg <number> Override CFG
--sampler <name> Override sampler
--width <int> Override output latent width
--height <int> Override output latent height
--prefix <text> SaveImage filename prefix
Execution:
--server <url> ComfyUI base URL. Default: ${DEFAULT_SERVER}
--out-dir <path> Output directory. Default: ${DEFAULT_OUT_ROOT}/<timestamp-slug>
--poll-ms <int> Poll interval in ms. Default: ${DEFAULT_POLL_MS}
--timeout-sec <int> Timeout in seconds. Default: ${DEFAULT_TIMEOUT_SEC}
--no-wait Submit and exit after prompt_id is returned
--dry-run Print the resolved prompt graph and exit
`);
}
function parseArgs(argv) {
const options = {
server: DEFAULT_SERVER,
workflow: '',
image: '',
image2: '',
target: 'multi',
nodeId: '',
prompt: '',
negative: '',
seed: null,
steps: null,
cfg: null,
sampler: '',
width: null,
height: null,
prefix: '',
outDir: '',
pollMs: DEFAULT_POLL_MS,
timeoutSec: DEFAULT_TIMEOUT_SEC,
dryRun: false,
noWait: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
printUsage();
process.exit(0);
}
if (arg === '--dry-run') {
options.dryRun = true;
continue;
}
if (arg === '--no-wait') {
options.noWait = true;
continue;
}
const next = argv[i + 1];
const take = () => {
if (next === undefined) throw new Error(`Missing value for ${arg}`);
i += 1;
return next;
};
switch (arg) {
case '--server': options.server = take(); break;
case '--workflow': options.workflow = take(); break;
case '--image': options.image = take(); break;
case '--image2': options.image2 = take(); break;
case '--target': options.target = take(); break;
case '--node-id': options.nodeId = take(); break;
case '--prompt': options.prompt = take(); break;
case '--negative': options.negative = take(); break;
case '--seed': options.seed = Number(take()); break;
case '--steps': options.steps = Number(take()); break;
case '--cfg': options.cfg = Number(take()); break;
case '--sampler': options.sampler = take(); break;
case '--width': options.width = Number(take()); break;
case '--height': options.height = Number(take()); break;
case '--prefix': options.prefix = take(); break;
case '--out-dir': options.outDir = take(); break;
case '--poll-ms': options.pollMs = Number(take()); break;
case '--timeout-sec': options.timeoutSec = Number(take()); break;
default:
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!options.workflow) throw new Error('--workflow is required');
if (!options.image) throw new Error('--image is required');
return options;
}
function makeSlug(value) {
return String(value || 'run')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'run';
}
function loadWorkflow(filePath) {
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!parsed || !Array.isArray(parsed.nodes) || !parsed.definitions?.subgraphs) {
throw new Error(`Workflow is not a Flux2Klein UI workflow with subgraphs: ${filePath}`);
}
return parsed;
}
function normalizeLink(link) {
if (Array.isArray(link)) {
const [id, origin_id, origin_slot, target_id, target_slot, type] = link;
return { id, origin_id, origin_slot, target_id, target_slot, type };
}
return link;
}
function indexLinks(links = []) {
const byId = new Map();
for (const raw of links) {
const link = normalizeLink(raw);
byId.set(link.id, link);
}
return { byId };
}
function pickTopLevelNode(workflow, options) {
if (options.nodeId) {
const direct = workflow.nodes.find((node) => String(node.id) === String(options.nodeId));
if (!direct) throw new Error(`Top-level node ${options.nodeId} was not found`);
return direct;
}
const candidateNodes = workflow.nodes.filter((node) => workflow.definitions.subgraphs.some((sg) => sg.id === node.type));
const wantMulti = String(options.target || 'multi').toLowerCase() !== 'single';
const picked = candidateNodes.find((node) => {
const imageInputs = (node.inputs || []).filter((input) => String(input.type || '').toUpperCase() === 'IMAGE');
return wantMulti ? imageInputs.length >= 2 : imageInputs.length === 1;
});
if (!picked) throw new Error(`Could not find a ${wantMulti ? 'multi' : 'single'} reference Flux2Klein branch in the workflow`);
return picked;
}
function getSubgraph(workflow, topNode) {
const subgraph = workflow.definitions.subgraphs.find((item) => item.id === topNode.type);
if (!subgraph) throw new Error(`Subgraph definition ${topNode.type} was not found`);
return subgraph;
}
function mapTopLevelInputs(topNode, subgraph, options) {
const widgetValues = Array.isArray(topNode.widgets_values) ? [...topNode.widgets_values] : [];
const mapped = {};
for (let i = 0; i < subgraph.inputs.length; i += 1) {
const input = subgraph.inputs[i];
mapped[input.name] = widgetValues[i];
}
if (options.prompt) mapped.text = options.prompt;
if (options.seed !== null) mapped.noise_seed = Math.trunc(options.seed);
if (options.image) mapped.image = options.image;
if (options.image2) mapped.image_1 = options.image2;
return mapped;
}
async function fetchObjectInfo(server, nodeName) {
const response = await fetch(`${server.replace(/\/$/, '')}/object_info/${encodeURIComponent(nodeName)}`);
if (!response.ok) throw new Error(`Failed to fetch object info for ${nodeName} (${response.status})`);
const payload = await response.json();
return payload?.[nodeName] || null;
}
function visibleInputNames(objectInfo) {
const order = objectInfo?.input_order || {};
return [
...(order.required || []),
...(order.optional || []),
];
}
function getInputSpec(objectInfo, inputName) {
return objectInfo?.input?.required?.[inputName]
|| objectInfo?.input?.optional?.[inputName]
|| null;
}
function isConnectionType(specType) {
return new Set(['MODEL', 'IMAGE', 'LATENT', 'VAE', 'CONDITIONING', 'SIGMAS', 'GUIDER', 'SAMPLER', 'NOISE', 'CLIP']).has(specType);
}
function isWidgetBacked(entry, inputName, orderedNames, objectInfo) {
if (entry?.widget) return true;
const spec = getInputSpec(objectInfo, inputName);
if (!spec) return false;
const specType = Array.isArray(spec) ? spec[0] : spec;
if (Array.isArray(specType)) return true;
return orderedNames.includes(inputName) && !isConnectionType(String(specType || ''));
}
function normalizeModelToken(value) {
return String(value || '')
.replace(/\\/g, '/')
.split('/')
.pop()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
function pickCompatibleModel(currentValue, available) {
if (available.includes(currentValue)) return currentValue;
const lower = available.find((item) => String(item).toLowerCase() === String(currentValue).toLowerCase());
if (lower) return lower;
const currentBase = path.basename(String(currentValue || '').replace(/\\/g, '/'));
const byBase = available.find((item) => path.basename(String(item).replace(/\\/g, '/')).toLowerCase() === currentBase.toLowerCase());
if (byBase) return byBase;
const aliases = new Map([
['flux-2-klein-9b-fp8.safetensors', 'flux-2-klein-9b-fp8mixed.safetensors'],
['flux-2-klein-4b-fp8.safetensors', 'flux-2-klein-4b-fp8mixed.safetensors'],
['qwen_3_4b.safetensors', 'Qwen\\qwen_3_4b.safetensors'],
]);
const aliased = aliases.get(String(currentValue || ''));
if (aliased && available.includes(aliased)) return aliased;
const normalized = normalizeModelToken(currentValue);
return available.find((item) => normalizeModelToken(item) === normalized) || null;
}
async function remapLoaderValue(server, classType, inputName, currentValue) {
const info = await fetchObjectInfo(server, classType);
const available = info?.input?.required?.[inputName]?.[0];
if (!Array.isArray(available) || available.includes(currentValue)) return currentValue;
const replacement = pickCompatibleModel(currentValue, available);
if (!replacement) {
throw new Error(`${classType}.${inputName} value '${currentValue}' is not available on the live server`);
}
return replacement;
}
async function uploadImageToComfyUi(server, filePath) {
const buffer = fs.readFileSync(filePath);
const fileName = path.basename(filePath);
const form = new FormData();
form.append('image', new Blob([buffer], { type: guessMimeType(filePath) }), fileName);
form.append('type', 'input');
form.append('subfolder', '');
form.append('overwrite', 'true');
const response = await fetch(`${server.replace(/\/$/, '')}/api/upload/image`, {
method: 'POST',
body: form,
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
throw new Error(payload?.error || payload?.message || `Upload failed (${response.status})`);
}
return String(payload?.name || payload?.filename || payload?.file || fileName);
}
function guessMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.png') return 'image/png';
if (ext === '.webp') return 'image/webp';
return 'image/jpeg';
}
function buildSubgraphPromptGraph(subgraph, externalValues, objectInfoByType, overrides = {}, extraNodes = {}) {
const { byId } = indexLinks(subgraph.links || []);
const prompt = { ...extraNodes };
const outputRefs = new Map();
for (const node of subgraph.nodes) {
const nodeKey = String(node.id);
const objectInfo = objectInfoByType.get(node.type);
if (!objectInfo) throw new Error(`Missing object info for ${node.type}`);
const orderedNames = visibleInputNames(objectInfo);
const inputs = {};
let widgetIndex = 0;
for (const inputName of orderedNames) {
const entry = (node.inputs || []).find((item) => item.name === inputName);
const link = entry?.link != null ? byId.get(entry.link) : null;
const shouldConsumeWidget = isWidgetBacked(entry, inputName, orderedNames, objectInfo);
const widgetValue = shouldConsumeWidget && widgetIndex < (node.widgets_values || []).length
? node.widgets_values[widgetIndex++]
: undefined;
if (link) {
if (Number(link.origin_id) === -10) {
const external = subgraph.inputs[Number(link.origin_slot)];
inputs[inputName] = externalValues[external.name];
} else {
inputs[inputName] = [String(link.origin_id), Number(link.origin_slot)];
}
} else if (widgetValue !== undefined) {
inputs[inputName] = widgetValue;
}
}
for (const entry of node.inputs || []) {
if (Object.prototype.hasOwnProperty.call(inputs, entry.name)) continue;
if (entry.link == null) continue;
const link = byId.get(entry.link);
if (!link) continue;
if (Number(link.origin_id) === -10) {
const external = subgraph.inputs[Number(link.origin_slot)];
inputs[entry.name] = externalValues[external.name];
} else {
inputs[entry.name] = [String(link.origin_id), Number(link.origin_slot)];
}
}
prompt[nodeKey] = {
class_type: node.type,
inputs,
_meta: { title: node.title || node.type },
};
if (node.type === 'RandomNoise' && prompt[nodeKey].inputs.noise_seed === undefined && Array.isArray(node.widgets_values) && node.widgets_values.length) {
prompt[nodeKey].inputs.noise_seed = node.widgets_values[0];
}
}
for (const link of byId.values()) {
if (Number(link.target_id) !== -20) continue;
outputRefs.set(Number(link.target_slot), [String(link.origin_id), Number(link.origin_slot)]);
}
for (const [nodeId, promptNode] of Object.entries(prompt)) {
if (promptNode.class_type === 'KSamplerSelect' && overrides.sampler) {
promptNode.inputs.sampler_name = overrides.sampler;
}
if (promptNode.class_type === 'Flux2Scheduler') {
if (overrides.steps !== null) promptNode.inputs.steps = Math.trunc(overrides.steps);
if (overrides.width !== null) promptNode.inputs.width = Math.trunc(overrides.width);
if (overrides.height !== null) promptNode.inputs.height = Math.trunc(overrides.height);
}
if (promptNode.class_type === 'EmptyFlux2LatentImage') {
if (overrides.width !== null) promptNode.inputs.width = Math.trunc(overrides.width);
if (overrides.height !== null) promptNode.inputs.height = Math.trunc(overrides.height);
}
if (promptNode.class_type === 'CFGGuider' && overrides.cfg !== null) {
promptNode.inputs.cfg = overrides.cfg;
}
if (promptNode.class_type === 'RandomNoise' && overrides.seed !== null) {
promptNode.inputs.noise_seed = Math.trunc(overrides.seed);
}
if (promptNode.class_type === 'CLIPTextEncode' && typeof promptNode.inputs.text === 'string') {
if (overrides.prompt && promptNode.inputs.text && !overrides.negativeApplied) {
promptNode.inputs.text = overrides.prompt;
overrides.negativeApplied = true;
} else if (!promptNode.inputs.text && overrides.negative) {
promptNode.inputs.text = overrides.negative;
}
}
}
const saveNodeId = String(Math.max(...Object.keys(prompt).map((value) => Number(value))) + 1000);
const primaryOutput = outputRefs.get(0);
if (!primaryOutput) throw new Error('The selected subgraph does not expose an IMAGE output');
prompt[saveNodeId] = {
class_type: 'SaveImage',
inputs: {
images: primaryOutput,
filename_prefix: overrides.prefix || 'Flux2-Klein',
},
_meta: { title: 'Save Image' },
};
return prompt;
}
async function buildPromptFromTemplate(workflow, topNode, subgraph, options) {
const externalValues = mapTopLevelInputs(topNode, subgraph, options);
const uploadedPrimary = await uploadImageToComfyUi(options.server, path.resolve(options.image));
const uploadedSecondary = externalValues.image_1
? await uploadImageToComfyUi(options.server, path.resolve(externalValues.image_1))
: null;
const loadImageInfo = await fetchObjectInfo(options.server, 'LoadImage');
const objectInfoByType = new Map([['LoadImage', loadImageInfo]]);
const extraNodes = {};
const imageInputDefs = subgraph.inputs.filter((input) => String(input.type || '').toUpperCase() === 'IMAGE');
let nextExternalNodeId = 900000;
for (const inputDef of imageInputDefs) {
const uploadedName = inputDef.name === 'image_1' ? uploadedSecondary : uploadedPrimary;
if (!uploadedName) continue;
const loadNodeId = String(nextExternalNodeId++);
externalValues[inputDef.name] = [loadNodeId, 0];
extraNodes[loadNodeId] = {
class_type: 'LoadImage',
inputs: {
image: uploadedName,
},
_meta: { title: `Load Image (${inputDef.name})` },
};
}
const typeNames = new Set(subgraph.nodes.map((node) => node.type));
typeNames.add('SaveImage');
for (const typeName of typeNames) {
if (objectInfoByType.has(typeName)) continue;
objectInfoByType.set(typeName, await fetchObjectInfo(options.server, typeName));
}
const prompt = buildSubgraphPromptGraph(subgraph, externalValues, objectInfoByType, {
prompt: options.prompt,
negative: options.negative,
seed: options.seed,
steps: options.steps,
cfg: options.cfg,
sampler: options.sampler,
width: options.width,
height: options.height,
prefix: options.prefix,
negativeApplied: false,
}, extraNodes);
for (const node of Object.values(prompt)) {
if (node.class_type === 'UNETLoader' && node.inputs.unet_name) {
node.inputs.unet_name = await remapLoaderValue(options.server, 'UNETLoader', 'unet_name', node.inputs.unet_name);
}
if (node.class_type === 'CLIPLoader' && node.inputs.clip_name) {
node.inputs.clip_name = await remapLoaderValue(options.server, 'CLIPLoader', 'clip_name', node.inputs.clip_name);
}
if (node.class_type === 'VAELoader' && node.inputs.vae_name) {
node.inputs.vae_name = await remapLoaderValue(options.server, 'VAELoader', 'vae_name', node.inputs.vae_name);
}
}
return {
prompt,
uploadedImages: {
image: uploadedPrimary,
image2: uploadedSecondary,
},
};
}
async function postPrompt(server, promptGraph) {
const response = await fetch(`${server.replace(/\/$/, '')}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: crypto.randomUUID(),
prompt: promptGraph,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`ComfyUI prompt submission failed (${response.status}): ${text}`);
}
return response.json();
}
function normalizeHistoryRecord(raw, promptId) {
if (!raw || typeof raw !== 'object') return null;
if (raw[promptId]) return raw[promptId];
if (raw.prompt_id || raw.status || raw.outputs) return raw;
const firstKey = Object.keys(raw)[0];
return firstKey ? raw[firstKey] : null;
}
async function waitForHistory(server, promptId, pollMs, timeoutSec) {
const deadline = Date.now() + (timeoutSec * 1000);
while (Date.now() < deadline) {
const response = await fetch(`${server.replace(/\/$/, '')}/history/${encodeURIComponent(promptId)}`);
if (response.ok) {
const payload = await response.json();
const record = normalizeHistoryRecord(payload, promptId);
const completed = Boolean(record?.status?.completed);
const failed = String(record?.status?.status_str || '').toLowerCase() === 'error';
if (completed || failed) return record;
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error(`Timed out waiting for ComfyUI history for prompt ${promptId}`);
}
function collectImageEntries(historyRecord) {
const images = [];
const outputs = historyRecord?.outputs || {};
for (const [nodeId, nodeOutput] of Object.entries(outputs)) {
if (!nodeOutput || typeof nodeOutput !== 'object') continue;
const entries = Array.isArray(nodeOutput.images) ? nodeOutput.images : [];
for (const entry of entries) {
images.push({ nodeId, ...entry });
}
}
return images;
}
async function downloadOutputImages(server, imageEntries, outDir) {
const saved = [];
fs.mkdirSync(outDir, { recursive: true });
for (let i = 0; i < imageEntries.length; i += 1) {
const image = imageEntries[i];
const params = new URLSearchParams({
filename: String(image.filename || ''),
subfolder: String(image.subfolder || ''),
type: String(image.type || 'output'),
});
const response = await fetch(`${server.replace(/\/$/, '')}/view?${params.toString()}`);
if (!response.ok) throw new Error(`Failed to download image ${image.filename} (${response.status})`);
const buffer = Buffer.from(await response.arrayBuffer());
const ext = path.extname(String(image.filename || '')) || '.png';
const localName = `${String(i + 1).padStart(2, '0')}-${path.basename(String(image.filename || `output${ext}`))}`;
const localPath = path.join(outDir, localName);
fs.writeFileSync(localPath, buffer);
saved.push({ ...image, localPath });
}
return saved;
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const workflowPath = path.resolve(options.workflow);
const workflow = loadWorkflow(workflowPath);
const topNode = pickTopLevelNode(workflow, options);
const subgraph = getSubgraph(workflow, topNode);
const { prompt, uploadedImages } = await buildPromptFromTemplate(workflow, topNode, subgraph, options);
const runSlug = makeSlug(`${path.basename(workflowPath, path.extname(workflowPath))}-${options.target}`);
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const outDir = path.resolve(options.outDir || path.join(DEFAULT_OUT_ROOT, `${timestamp}-${runSlug}`));
if (options.dryRun) {
console.log(JSON.stringify({ prompt, uploadedImages, topNodeId: topNode.id, subgraphId: subgraph.id }, null, 2));
return;
}
fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'resolved-prompt.json'), JSON.stringify(prompt, null, 2));
const submission = await postPrompt(options.server, prompt);
const promptId = String(submission?.prompt_id || submission?.promptId || '').trim();
if (!promptId) throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(submission)}`);
const runRecord = {
workflowPath,
server: options.server,
promptId,
submittedAt: new Date().toISOString(),
topNodeId: topNode.id,
subgraphId: subgraph.id,
target: options.target,
uploadedImages,
overrides: {
prompt: options.prompt || null,
negative: options.negative || null,
seed: options.seed,
steps: options.steps,
cfg: options.cfg,
sampler: options.sampler || null,
width: options.width,
height: options.height,
prefix: options.prefix || null,
},
outputDir: outDir,
};
if (options.noWait) {
fs.writeFileSync(path.join(outDir, 'run-record.json'), JSON.stringify({ ...runRecord, status: 'submitted-no-wait' }, null, 2));
console.log(JSON.stringify({
ok: true,
promptId,
outputDir: outDir,
status: 'submitted-no-wait',
}, null, 2));
return;
}
const history = await waitForHistory(options.server, promptId, options.pollMs, options.timeoutSec);
fs.writeFileSync(path.join(outDir, 'history.json'), JSON.stringify(history, null, 2));
const images = collectImageEntries(history);
const savedImages = await downloadOutputImages(options.server, images, outDir);
fs.writeFileSync(path.join(outDir, 'run-record.json'), JSON.stringify({
...runRecord,
imageCount: savedImages.length,
images: savedImages,
status: history?.status || null,
}, null, 2));
console.log(JSON.stringify({
ok: true,
promptId,
outputDir: outDir,
imageCount: savedImages.length,
images: savedImages.map((item) => item.localPath),
status: history?.status?.status_str || 'unknown',
}, null, 2));
}
main().catch((error) => {
console.error(error?.stack || error?.message || String(error));
process.exit(1);
});