#!/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 = 600; function printUsage() { console.log(`Usage: node scripts/submit-workflow-test.mjs --workflow [options] Required: --workflow Workflow file. Supports PNG with embedded Comfy prompt metadata or prompt-style JSON exported for /prompt submission. Common text-to-image options: --server ComfyUI base URL. Default: ${DEFAULT_SERVER} --prompt Override positive prompt --negative Override negative prompt --cfg Override CFG --steps Override steps --seed Override seed --sampler Override sampler_name --scheduler Override scheduler --denoise Override denoise --width Override latent width --height Override latent height --batch-size Override latent batch_size --prefix Override SaveImage filename_prefix --image-url Download a remote image and replace every LoadImage node with it --image-file Upload a local image and replace every LoadImage node with it --set Arbitrary prompt-graph override. Repeatable. --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} --submit-only Submit the prompt and exit without waiting for completion --dry-run Print the resolved prompt graph and exit Examples: node scripts/submit-workflow-test.mjs \\ --workflow optimized/text-to-image/zimage-turbo-3070-fast-start/workflow.png \\ --prompt "A fox wizard on a rainy neon street, cinematic lighting" \\ --negative "blurry, ugly, low detail" \\ --steps 10 --cfg 1 --width 1024 --height 1024 `); } function parseArgs(argv) { const options = { server: DEFAULT_SERVER, workflow: '', prompt: '', negative: '', cfg: null, steps: null, seed: null, sampler: '', scheduler: '', denoise: null, width: null, height: null, batchSize: null, prefix: '', imageUrl: '', imageFile: '', outDir: '', pollMs: DEFAULT_POLL_MS, timeoutSec: DEFAULT_TIMEOUT_SEC, submitOnly: false, dryRun: false, setPairs: [], }; 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 === '--submit-only') { options.submitOnly = true; continue; } if (arg === '--set') { const next = argv[++i]; if (!next || !next.includes('=')) throw new Error('--set expects path=value'); options.setPairs.push(next); 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 '--prompt': options.prompt = take(); break; case '--negative': options.negative = take(); break; case '--cfg': options.cfg = Number(take()); break; case '--steps': options.steps = Number(take()); break; case '--seed': options.seed = Number(take()); break; case '--sampler': options.sampler = take(); break; case '--scheduler': options.scheduler = take(); break; case '--denoise': options.denoise = Number(take()); break; case '--width': options.width = Number(take()); break; case '--height': options.height = Number(take()); break; case '--batch-size': options.batchSize = Number(take()); break; case '--prefix': options.prefix = take(); break; case '--image-url': options.imageUrl = take(); break; case '--image-file': options.imageFile = 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'); return options; } function makeSlug(value) { return String(value || 'run') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'run'; } function readPngTextChunks(filePath) { const buffer = fs.readFileSync(filePath); const signature = buffer.subarray(0, 8).toString('hex'); if (signature !== '89504e470d0a1a0a') throw new Error(`Not a PNG file: ${filePath}`); const chunks = {}; let offset = 8; while (offset + 12 <= buffer.length) { const len = buffer.readUInt32BE(offset); offset += 4; const type = buffer.subarray(offset, offset + 4).toString('ascii'); offset += 4; const data = buffer.subarray(offset, offset + len); offset += len; offset += 4; // skip CRC if (type === 'tEXt') { const nul = data.indexOf(0); if (nul > 0) { const key = data.subarray(0, nul).toString(); chunks[key] = data.subarray(nul + 1).toString(); } } if (type === 'IEND') break; } return chunks; } function loadWorkflowSource(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === '.png') { const chunks = readPngTextChunks(filePath); if (!chunks.prompt) throw new Error(`PNG does not contain a prompt text chunk: ${filePath}`); return { kind: 'prompt', graph: JSON.parse(chunks.prompt) }; } if (ext === '.json') { const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { if (parsed.prompt && typeof parsed.prompt === 'object') return { kind: 'prompt', graph: parsed.prompt }; if (parsed.extra?.prompt && typeof parsed.extra.prompt === 'object') return { kind: 'prompt', graph: parsed.extra.prompt }; if (Array.isArray(parsed.nodes) && Array.isArray(parsed.links)) return { kind: 'workflow', workflow: parsed }; if (!Array.isArray(parsed.nodes)) return { kind: 'prompt', graph: parsed }; } throw new Error(`JSON file is not a prompt-style workflow graph: ${filePath}`); } throw new Error(`Unsupported workflow file type: ${ext}`); } 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; } async function getNodeSchema(server, classType, cache) { if (cache.has(classType)) return cache.get(classType); const payload = await fetchObjectInfo(server, classType).catch(() => null); const schema = payload?.[classType] || null; cache.set(classType, schema); return schema; } function isWidgetBackedSchemaInput(schemaInput) { const typeSpec = schemaInput?.[0]; if (Array.isArray(typeSpec)) return true; return ['STRING', 'INT', 'FLOAT', 'BOOLEAN', 'COMBO'].includes(String(typeSpec || '')); } async function convertWorkflowJsonToPromptGraph(server, workflow) { const virtualNodeTypes = new Set(['Note', 'Reroute']); const nodeById = new Map((workflow.nodes || []).map((node) => [String(node.id), node])); const linkById = new Map((workflow.links || []).map((link) => { const normalized = normalizeLink(link); return [normalized.id, normalized]; })); const schemaCache = new Map(); function resolveOrigin(linkId) { const link = linkById.get(linkId); if (!link) throw new Error(`Missing link metadata for link ${linkId}`); const originId = String(link.origin_id); const originNode = nodeById.get(originId); if (originNode && String(originNode.type || '') === 'Reroute') { const upstreamLink = originNode.inputs?.[0]?.link; if (upstreamLink === null || upstreamLink === undefined) { throw new Error(`Reroute node ${originId} is missing its upstream link`); } return resolveOrigin(upstreamLink); } return { origin_id: originId, origin_slot: link.origin_slot }; } const prompt = {}; for (const node of workflow.nodes || []) { if (!node || typeof node !== 'object' || node.mode === 2 || virtualNodeTypes.has(String(node.type || ''))) continue; const inputs = {}; for (const input of node.inputs || []) { if (input.link !== null && input.link !== undefined) { const origin = resolveOrigin(input.link); inputs[input.name] = [origin.origin_id, origin.origin_slot]; } } const schema = await getNodeSchema(server, String(node.type || ''), schemaCache); const requiredOrder = schema?.input_order?.required || []; const optionalOrder = schema?.input_order?.optional || []; let widgetIndex = 0; for (const inputName of [...requiredOrder, ...optionalOrder]) { const schemaInput = schema?.input?.required?.[inputName] || schema?.input?.optional?.[inputName]; const usesWidgetSlot = isWidgetBackedSchemaInput(schemaInput); if (Object.prototype.hasOwnProperty.call(inputs, inputName)) { if (usesWidgetSlot && widgetIndex < (node.widgets_values || []).length) widgetIndex += 1; continue; } if (widgetIndex < (node.widgets_values || []).length) { inputs[inputName] = node.widgets_values[widgetIndex]; widgetIndex += 1; continue; } const defaultValue = schemaInput?.[1]?.default; if (defaultValue !== undefined) inputs[inputName] = defaultValue; } prompt[String(node.id)] = { inputs, class_type: node.type, _meta: node.title ? { title: node.title } : undefined, }; } return prompt; } function findFirstNodeId(graph, predicate) { return Object.entries(graph).find(([, node]) => predicate(node))?.[0] || ''; } function cloneGraph(graph) { return JSON.parse(JSON.stringify(graph)); } function setByPath(target, rawPath, value) { const parts = String(rawPath || '') .replace(/\[(.+?)\]/g, '.$1') .split('.') .filter(Boolean); if (!parts.length) throw new Error(`Invalid path: ${rawPath}`); let cursor = target; for (let i = 0; i < parts.length - 1; i += 1) { const key = parts[i]; if (cursor[key] === undefined) throw new Error(`Path not found: ${rawPath}`); cursor = cursor[key]; } cursor[parts[parts.length - 1]] = value; } function coerceValue(raw) { const trimmed = String(raw).trim(); if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) || (trimmed.startsWith('{') && trimmed.endsWith('}'))) { try { return JSON.parse(trimmed); } catch { // Fall through to simple coercions below. } } if (raw === 'true') return true; if (raw === 'false') return false; if (raw !== '' && !Number.isNaN(Number(raw))) return Number(raw); return raw; } function applyTextToImageOverrides(graph, options) { const next = cloneGraph(graph); const samplerId = findFirstNodeId(next, (node) => String(node?.class_type || '').startsWith('KSampler')); const samplerNode = samplerId ? next[samplerId] : null; const positiveId = samplerNode?.inputs?.positive?.[0]; const negativeId = samplerNode?.inputs?.negative?.[0]; const latentId = samplerNode?.inputs?.latent_image?.[0]; const saveId = findFirstNodeId(next, (node) => String(node?.class_type || '') === 'SaveImage'); if (options.prompt && positiveId && next[positiveId]?.inputs) next[positiveId].inputs.text = options.prompt; if (options.negative && negativeId && next[negativeId]?.inputs) next[negativeId].inputs.text = options.negative; if (options.cfg !== null && samplerNode?.inputs) samplerNode.inputs.cfg = options.cfg; if (options.steps !== null && samplerNode?.inputs) samplerNode.inputs.steps = Math.trunc(options.steps); if (options.seed !== null && samplerNode?.inputs) samplerNode.inputs.seed = Math.trunc(options.seed); if (options.sampler && samplerNode?.inputs) samplerNode.inputs.sampler_name = options.sampler; if (options.scheduler && samplerNode?.inputs) samplerNode.inputs.scheduler = options.scheduler; if (options.denoise !== null && samplerNode?.inputs) samplerNode.inputs.denoise = options.denoise; if (latentId && next[latentId]?.inputs) { if (options.width !== null) next[latentId].inputs.width = Math.trunc(options.width); if (options.height !== null) next[latentId].inputs.height = Math.trunc(options.height); if (options.batchSize !== null) next[latentId].inputs.batch_size = Math.trunc(options.batchSize); } if (options.prefix && saveId && next[saveId]?.inputs) next[saveId].inputs.filename_prefix = options.prefix; for (const pair of options.setPairs) { const idx = pair.indexOf('='); const key = pair.slice(0, idx); const value = coerceValue(pair.slice(idx + 1)); setByPath(next, key, value); } return next; } 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})`); return response.json(); } function guessFilenameFromUrl(url) { const pathname = new URL(url).pathname; const base = path.basename(pathname) || 'reference-image'; return base.includes('.') ? base : `${base}.png`; } async function uploadInputImage(server, source) { let buffer; let filename; let contentType = 'image/png'; if (source.url) { const response = await fetch(source.url); if (!response.ok) throw new Error(`Failed to download image URL ${source.url} (${response.status})`); buffer = Buffer.from(await response.arrayBuffer()); filename = guessFilenameFromUrl(source.url); contentType = response.headers.get('content-type') || contentType; } else if (source.filePath) { const absPath = path.resolve(source.filePath); buffer = fs.readFileSync(absPath); filename = path.basename(absPath); const ext = path.extname(absPath).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; else if (ext === '.webp') contentType = 'image/webp'; } else { throw new Error('uploadInputImage requires a url or filePath'); } const form = new FormData(); form.set('type', 'input'); form.set('overwrite', 'true'); form.set('image', new Blob([buffer], { type: contentType }), filename); const response = await fetch(`${server.replace(/\/$/, '')}/upload/image`, { method: 'POST', body: form, }); if (!response.ok) { const text = await response.text(); throw new Error(`ComfyUI image upload failed (${response.status}): ${text}`); } const payload = await response.json(); return String(payload?.name || filename); } async function applyLoadImageOverride(server, graph, options) { if (!options.imageUrl && !options.imageFile) return { graph, uploadedImage: null }; const uploadedImage = await uploadInputImage(server, options.imageUrl ? { url: options.imageUrl } : { filePath: options.imageFile }); const next = cloneGraph(graph); for (const node of Object.values(next)) { if (String(node?.class_type || '') !== 'LoadImage') continue; if (!node.inputs || typeof node.inputs !== 'object') continue; node.inputs.image = uploadedImage; } return { graph: next, uploadedImage }; } function normalizeModelToken(value) { return String(value || '') .replace(/\\/g, '/') .split('/') .pop() .toLowerCase() .replace(/[^a-z0-9]+/g, ''); } function pickCompatibleModel(currentValue, available) { const exact = available.find((item) => item === currentValue); if (exact) return exact; const ci = available.find((item) => String(item).toLowerCase() === String(currentValue).toLowerCase()); if (ci) return ci; 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([ ['z_image_turbo_bf16.safetensors', 'zImageTurbo_turbo.safetensors'], ['flux-2-klein-9b-fp8.safetensors', 'flux-2-klein-9b-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 normalizedCurrent = normalizeModelToken(currentValue); return available.find((item) => normalizeModelToken(item) === normalizedCurrent) || null; } async function remapLoaderModels(server, graph) { const next = cloneGraph(graph); const remaps = []; const loaderSpecs = [ { classType: 'UNETLoader', inputName: 'unet_name' }, { classType: 'DiffusionModelLoaderKJ', inputName: 'model_name' }, { classType: 'CLIPLoader', inputName: 'clip_name' }, { classType: 'VAELoader', inputName: 'vae_name' }, ]; for (const spec of loaderSpecs) { const nodeId = findFirstNodeId(next, (node) => String(node?.class_type || '') === spec.classType); if (!nodeId) continue; const node = next[nodeId]; const currentValue = String(node?.inputs?.[spec.inputName] || '').trim(); if (!currentValue) continue; const objectInfo = await fetchObjectInfo(server, spec.classType); const available = objectInfo?.[spec.classType]?.input?.required?.[spec.inputName]?.[0]; if (!Array.isArray(available) || available.includes(currentValue)) continue; const replacement = pickCompatibleModel(currentValue, available); if (!replacement) { throw new Error(`${spec.classType}.${spec.inputName} value '${currentValue}' is not available on the live server and no compatible remap was found.`); } node.inputs[spec.inputName] = replacement; remaps.push({ nodeId, classType: spec.classType, inputName: spec.inputName, from: currentValue, to: replacement, }); } return { graph: next, remaps }; } 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; } function collectFileEntries(historyRecord) { const files = []; const outputs = historyRecord?.outputs || {}; for (const [nodeId, nodeOutput] of Object.entries(outputs)) { if (!nodeOutput || typeof nodeOutput !== 'object') continue; for (const key of ['images', 'gifs', 'audio', 'videos', 'files']) { const entries = Array.isArray(nodeOutput[key]) ? nodeOutput[key] : []; for (const entry of entries) { files.push({ nodeId, mediaType: key, ...entry }); } } } return files; } async function downloadOutputFiles(server, fileEntries, outDir) { const saved = []; fs.mkdirSync(outDir, { recursive: true }); for (let i = 0; i < fileEntries.length; i += 1) { const file = fileEntries[i]; const params = new URLSearchParams({ filename: String(file.filename || ''), subfolder: String(file.subfolder || ''), type: String(file.type || 'output'), }); const response = await fetch(`${server.replace(/\/$/, '')}/view?${params.toString()}`); if (!response.ok) throw new Error(`Failed to download output ${file.filename} (${response.status})`); const buffer = Buffer.from(await response.arrayBuffer()); const ext = path.extname(String(file.filename || '')) || '.bin'; const localName = `${String(i + 1).padStart(2, '0')}-${path.basename(String(file.filename || `output${ext}`))}`; const localPath = path.join(outDir, localName); fs.writeFileSync(localPath, buffer); saved.push({ ...file, localPath, }); } return saved; } async function main() { const options = parseArgs(process.argv.slice(2)); const workflowPath = path.resolve(options.workflow); const source = loadWorkflowSource(workflowPath); const graph = source.kind === 'workflow' ? await convertWorkflowJsonToPromptGraph(options.server, source.workflow) : source.graph; const overrideGraph = applyTextToImageOverrides(graph, options); const { graph: imageReadyGraph, uploadedImage } = await applyLoadImageOverride(options.server, overrideGraph, options); const { graph: resolvedGraph, remaps } = await remapLoaderModels(options.server, imageReadyGraph); const runSlug = makeSlug(path.basename(workflowPath, path.extname(workflowPath))); 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(resolvedGraph, null, 2)); return; } fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(path.join(outDir, 'resolved-prompt.json'), JSON.stringify(resolvedGraph, null, 2)); const submission = await postPrompt(options.server, resolvedGraph); const promptId = String(submission?.prompt_id || submission?.promptId || '').trim(); if (!promptId) throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(submission)}`); fs.writeFileSync(path.join(outDir, 'submission.json'), JSON.stringify(submission, null, 2)); if (options.submitOnly) { const runRecord = { workflowPath, server: options.server, promptId, submittedAt: new Date().toISOString(), overrides: { prompt: options.prompt || null, negative: options.negative || null, cfg: options.cfg, steps: options.steps, seed: options.seed, sampler: options.sampler || null, scheduler: options.scheduler || null, denoise: options.denoise, width: options.width, height: options.height, batchSize: options.batchSize, prefix: options.prefix || null, imageUrl: options.imageUrl || null, imageFile: options.imageFile || null, setPairs: options.setPairs, }, outputDir: outDir, uploadedImage, modelRemaps: remaps, status: 'submitted', }; fs.writeFileSync(path.join(outDir, 'run-record.json'), JSON.stringify(runRecord, null, 2)); console.log(JSON.stringify({ ok: true, promptId, outputDir: outDir, uploadedImage, modelRemaps: remaps, status: 'submitted', }, 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 imageEntries = collectImageEntries(history); const fileEntries = collectFileEntries(history); const savedFiles = await downloadOutputFiles(options.server, fileEntries, outDir); const runRecord = { workflowPath, server: options.server, promptId, submittedAt: new Date().toISOString(), overrides: { prompt: options.prompt || null, negative: options.negative || null, cfg: options.cfg, steps: options.steps, seed: options.seed, sampler: options.sampler || null, scheduler: options.scheduler || null, denoise: options.denoise, width: options.width, height: options.height, batchSize: options.batchSize, prefix: options.prefix || null, imageUrl: options.imageUrl || null, imageFile: options.imageFile || null, setPairs: options.setPairs, }, outputDir: outDir, uploadedImage, modelRemaps: remaps, imageCount: imageEntries.length, fileCount: savedFiles.length, files: savedFiles, status: history?.status || null, }; fs.writeFileSync(path.join(outDir, 'run-record.json'), JSON.stringify(runRecord, null, 2)); console.log(JSON.stringify({ ok: true, promptId, outputDir: outDir, uploadedImage, imageCount: imageEntries.length, fileCount: savedFiles.length, modelRemaps: remaps, files: savedFiles.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); });