703 lines
26 KiB
JavaScript
703 lines
26 KiB
JavaScript
#!/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 <path> [options]
|
|
|
|
Required:
|
|
--workflow <path> Workflow file. Supports PNG with embedded Comfy prompt metadata
|
|
or prompt-style JSON exported for /prompt submission.
|
|
|
|
Common text-to-image options:
|
|
--server <url> ComfyUI base URL. Default: ${DEFAULT_SERVER}
|
|
--prompt <text> Override positive prompt
|
|
--negative <text> Override negative prompt
|
|
--cfg <number> Override CFG
|
|
--steps <int> Override steps
|
|
--seed <int> Override seed
|
|
--sampler <name> Override sampler_name
|
|
--scheduler <name> Override scheduler
|
|
--denoise <number> Override denoise
|
|
--width <int> Override latent width
|
|
--height <int> Override latent height
|
|
--batch-size <int> Override latent batch_size
|
|
--prefix <text> Override SaveImage filename_prefix
|
|
--image-url <url> Download a remote image and replace every LoadImage node with it
|
|
--image-file <path> Upload a local image and replace every LoadImage node with it
|
|
--set <path=value> Arbitrary prompt-graph override. Repeatable.
|
|
--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}
|
|
--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);
|
|
});
|