Add server-safe MiniMax H3 60s loop workflow
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# MiniMax H3 Contex Loop 3070 Safe 60s AWQ INT8
|
||||
|
||||
## Summary
|
||||
|
||||
Server-safe `MiniMax H3` imported-video continuation workflow built from the
|
||||
successful local `1024x576` `ref2v` run and adapted back into the `Contex Loop`
|
||||
lane for unattended `60s` generation.
|
||||
|
||||
This variant keeps the `Contex Loop` existing-video flow, but swaps to the
|
||||
proven `INT8` base model, drops the Heretic text encoder, disables the failing
|
||||
`MiniMaxH3MemoryEfficientSageAttentionPatch`, and expands the continuation plan
|
||||
to `6 x 10s` clips with review pauses disabled.
|
||||
|
||||
## Status
|
||||
|
||||
- `server-safe-loop-60s-test`
|
||||
|
||||
## Workflow asset
|
||||
|
||||
- `workflow.json`
|
||||
|
||||
## Built from
|
||||
|
||||
- `optimized/image-to-video/minimax-h3-contex-loop-3070-compact-prepend-480p-3x10s/`
|
||||
- successful ComfyUI run `cc74efa5-1a53-4b2a-9d7c-7170ea7eba5b`
|
||||
|
||||
## Key model stack
|
||||
|
||||
- `minimax_h3_ref2va_pruned_int8_convrot.safetensors`
|
||||
- `qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors`
|
||||
- `minimax_h3_video_vae_int8_convrot.safetensors`
|
||||
- `minimax_h3_audio_vae_fp32.safetensors`
|
||||
- `minimax\minimax_h3_ref2v_lightx2v_turbo_4step_v0.1_resized_avg_rank_20_bf16.safetensors`
|
||||
|
||||
## Main changes
|
||||
|
||||
- switches from the `w4a8` loop stack to the successful `int8` `ref2v` base
|
||||
- replaces the Heretic encoder with `qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors`
|
||||
- keeps `SolAttn`, but bypasses `MiniMaxH3MemoryEfficientSageAttentionPatch`
|
||||
because it crashes on the current server
|
||||
- sets `prepend_original=false` so the assembled output is a generated `60s`
|
||||
continuation rather than source-plus-extension
|
||||
- disables the review gate for unattended queue runs
|
||||
- expands the plan to `6 x 10s` continuation shots with continuity-first handoff
|
||||
|
||||
## Notes
|
||||
|
||||
- Queue with a real server-side source video override because the placeholder
|
||||
`1.mp4` is not present on the live ComfyUI box.
|
||||
- This is intended as the first unattended minute-long loop test, not the final
|
||||
quality-maxed master.
|
||||
+2620
File diff suppressed because one or more lines are too long
@@ -31,10 +31,13 @@ Common text-to-image options:
|
||||
--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:
|
||||
@@ -62,9 +65,12 @@ function parseArgs(argv) {
|
||||
height: null,
|
||||
batchSize: null,
|
||||
prefix: '',
|
||||
imageUrl: '',
|
||||
imageFile: '',
|
||||
outDir: '',
|
||||
pollMs: DEFAULT_POLL_MS,
|
||||
timeoutSec: DEFAULT_TIMEOUT_SEC,
|
||||
submitOnly: false,
|
||||
dryRun: false,
|
||||
setPairs: [],
|
||||
};
|
||||
@@ -79,6 +85,10 @@ function parseArgs(argv) {
|
||||
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');
|
||||
@@ -106,6 +116,8 @@ function parseArgs(argv) {
|
||||
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;
|
||||
@@ -149,24 +161,115 @@ function readPngTextChunks(filePath) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function loadPromptGraph(filePath) {
|
||||
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 JSON.parse(chunks.prompt);
|
||||
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 parsed.prompt;
|
||||
if (!Array.isArray(parsed.nodes)) return 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] || '';
|
||||
}
|
||||
@@ -191,6 +294,16 @@ function setByPath(target, rawPath, 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);
|
||||
@@ -237,6 +350,69 @@ async function fetchObjectInfo(server, nodeName) {
|
||||
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, '/')
|
||||
@@ -274,6 +450,7 @@ async function remapLoaderModels(server, 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' },
|
||||
];
|
||||
@@ -360,25 +537,40 @@ function collectImageEntries(historyRecord) {
|
||||
return images;
|
||||
}
|
||||
|
||||
async function downloadOutputImages(server, imageEntries, outDir) {
|
||||
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 < imageEntries.length; i += 1) {
|
||||
const image = imageEntries[i];
|
||||
for (let i = 0; i < fileEntries.length; i += 1) {
|
||||
const file = fileEntries[i];
|
||||
const params = new URLSearchParams({
|
||||
filename: String(image.filename || ''),
|
||||
subfolder: String(image.subfolder || ''),
|
||||
type: String(image.type || 'output'),
|
||||
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 image ${image.filename} (${response.status})`);
|
||||
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(image.filename || '')) || '.png';
|
||||
const localName = `${String(i + 1).padStart(2, '0')}-${path.basename(String(image.filename || `output${ext}`))}`;
|
||||
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({
|
||||
...image,
|
||||
...file,
|
||||
localPath,
|
||||
});
|
||||
}
|
||||
@@ -388,9 +580,13 @@ async function downloadOutputImages(server, imageEntries, outDir) {
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const workflowPath = path.resolve(options.workflow);
|
||||
const graph = loadPromptGraph(workflowPath);
|
||||
const source = loadWorkflowSource(workflowPath);
|
||||
const graph = source.kind === 'workflow'
|
||||
? await convertWorkflowJsonToPromptGraph(options.server, source.workflow)
|
||||
: source.graph;
|
||||
const overrideGraph = applyTextToImageOverrides(graph, options);
|
||||
const { graph: resolvedGraph, remaps } = await remapLoaderModels(options.server, overrideGraph);
|
||||
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, '-');
|
||||
@@ -407,12 +603,54 @@ async function main() {
|
||||
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 images = collectImageEntries(history);
|
||||
const savedImages = await downloadOutputImages(options.server, images, outDir);
|
||||
const imageEntries = collectImageEntries(history);
|
||||
const fileEntries = collectFileEntries(history);
|
||||
const savedFiles = await downloadOutputFiles(options.server, fileEntries, outDir);
|
||||
const runRecord = {
|
||||
workflowPath,
|
||||
server: options.server,
|
||||
@@ -431,12 +669,16 @@ async function main() {
|
||||
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: savedImages.length,
|
||||
images: savedImages,
|
||||
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));
|
||||
@@ -445,9 +687,11 @@ async function main() {
|
||||
ok: true,
|
||||
promptId,
|
||||
outputDir: outDir,
|
||||
imageCount: savedImages.length,
|
||||
uploadedImage,
|
||||
imageCount: imageEntries.length,
|
||||
fileCount: savedFiles.length,
|
||||
modelRemaps: remaps,
|
||||
images: savedImages.map((item) => item.localPath),
|
||||
files: savedFiles.map((item) => item.localPath),
|
||||
status: history?.status?.status_str || 'unknown',
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user