From c31c6de03057d7076c4d120f91f8f33f3d9a2e47 Mon Sep 17 00:00:00 2001 From: Morpheus Date: Thu, 13 Aug 2026 22:22:04 +0000 Subject: [PATCH] Add Flux2Klein turnaround test submitter --- .../2026-08-13-flux2klein-turnaround-batch.md | 110 ++++ scripts/submit-flux2klein-edit-test.mjs | 619 ++++++++++++++++++ 2 files changed, 729 insertions(+) create mode 100644 optimized/notes/reviews/2026-08-13-flux2klein-turnaround-batch.md create mode 100644 scripts/submit-flux2klein-edit-test.mjs diff --git a/optimized/notes/reviews/2026-08-13-flux2klein-turnaround-batch.md b/optimized/notes/reviews/2026-08-13-flux2klein-turnaround-batch.md new file mode 100644 index 0000000..f286c6a --- /dev/null +++ b/optimized/notes/reviews/2026-08-13-flux2klein-turnaround-batch.md @@ -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. diff --git a/scripts/submit-flux2klein-edit-test.mjs b/scripts/submit-flux2klein-edit-test.mjs new file mode 100644 index 0000000..43a754b --- /dev/null +++ b/scripts/submit-flux2klein-edit-test.mjs @@ -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 --image [options] + +Required: + --workflow Flux2Klein UI workflow JSON with template subgraphs + --image Primary reference image + +Selection: + --target single|multi Select the 1-ref or 2-ref edit branch. Default: multi + --node-id Override target branch by top-level node id + --image2 Second reference image for multi mode + +Overrides: + --prompt Override the edit prompt + --negative Override the negative prompt when the branch exposes one + --seed Override noise seed + --steps Override Flux2Scheduler steps + --cfg Override CFG + --sampler Override sampler + --width Override output latent width + --height Override output latent height + --prefix SaveImage filename prefix + +Execution: + --server ComfyUI base URL. Default: ${DEFAULT_SERVER} + --out-dir Output directory. Default: ${DEFAULT_OUT_ROOT}/ + --poll-ms Poll interval in ms. Default: ${DEFAULT_POLL_MS} + --timeout-sec 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); +});