diff --git a/optimized/text-to-image/README.md b/optimized/text-to-image/README.md index 3ad9a49..9342b95 100644 --- a/optimized/text-to-image/README.md +++ b/optimized/text-to-image/README.md @@ -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 ""` + +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. diff --git a/optimized/text-to-image/zimage-turbo-3070-fast-start/README.md b/optimized/text-to-image/zimage-turbo-3070-fast-start/README.md index 3347f0a..1899970 100644 --- a/optimized/text-to-image/zimage-turbo-3070-fast-start/README.md +++ b/optimized/text-to-image/zimage-turbo-3070-fast-start/README.md @@ -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 ""` + +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 diff --git a/scripts/submit-workflow-test.mjs b/scripts/submit-workflow-test.mjs new file mode 100644 index 0000000..1f643a8 --- /dev/null +++ b/scripts/submit-workflow-test.mjs @@ -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 [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 + --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} + --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); +});