Add direct ComfyUI workflow test submitter

This commit is contained in:
Morpheus
2026-08-09 09:20:21 +00:00
parent 719140cde3
commit 583e24e950
3 changed files with 476 additions and 2 deletions
+8
View File
@@ -5,3 +5,11 @@ Home for maintained category masters focused on text-to-image generation.
## Current masters
- `zimage-turbo-3070-fast-start`
## Test submission
For direct server tests, use:
`node scripts/submit-workflow-test.mjs --workflow optimized/text-to-image/zimage-turbo-3070-fast-start/workflow.png --prompt "<your prompt>"`
The first version is intentionally scoped to direct ComfyUI text-to-image submission. It works best with prompt-style JSON workflows and PNG workflows that contain an embedded `prompt` text chunk.
@@ -16,6 +16,14 @@ This master stays close to the official `Z-Image Turbo` example because that is
- `workflow.png`
- Load it by dragging the PNG into ComfyUI.
## Direct test command
You can also submit it straight to the live ComfyUI server with:
`node scripts/submit-workflow-test.mjs --workflow optimized/text-to-image/zimage-turbo-3070-fast-start/workflow.png --prompt "<your prompt>"`
That path reads the embedded PNG `prompt` chunk, applies text-to-image overrides, submits the job, polls history, and downloads the outputs locally.
## Built from
- `models/zimage/z-image-turbo-official-example/`
@@ -41,8 +49,8 @@ This master stays close to the official `Z-Image Turbo` example because that is
## Required models
- `models/text_encoders/qwen_3_4b.safetensors`
- `models/diffusion_models/z_image_turbo_bf16.safetensors`
- `models/text_encoders/Qwen/qwen_3_4b.safetensors`
- `models/diffusion_models/zImageTurbo_turbo.safetensors`
- `models/vae/ae.safetensors`
## Required custom nodes
+458
View File
@@ -0,0 +1,458 @@
#!/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
--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}
--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: '',
outDir: '',
pollMs: DEFAULT_POLL_MS,
timeoutSec: DEFAULT_TIMEOUT_SEC,
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 === '--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 '--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 loadPromptGraph(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 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 parsed.prompt;
if (!Array.isArray(parsed.nodes)) return parsed;
}
throw new Error(`JSON file is not a prompt-style workflow graph: ${filePath}`);
}
throw new Error(`Unsupported workflow file type: ${ext}`);
}
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) {
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 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: '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;
}
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 graph = loadPromptGraph(workflowPath);
const overrideGraph = applyTextToImageOverrides(graph, options);
const { graph: resolvedGraph, remaps } = await remapLoaderModels(options.server, overrideGraph);
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)}`);
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);
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,
setPairs: options.setPairs,
},
outputDir: outDir,
modelRemaps: remaps,
imageCount: savedImages.length,
images: savedImages,
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,
imageCount: savedImages.length,
modelRemaps: remaps,
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);
});