#!/usr/bin/env node import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const REPO_ROOT = path.resolve(__dirname, '..'); const WORKFLOW_ROOT = path.join(REPO_ROOT, 'optimized/image-to-video/minimax-h3-r2v-master'); const BASE_PROMPT = path.join(WORKFLOW_ROOT, 'api-prompt.json'); const DEFAULT_SCENE_REF = path.join(WORKFLOW_ROOT, 'references/default-location-reference.jpg'); const DEFAULT_CHARACTER_REF = path.join(WORKFLOW_ROOT, 'references/default-character-reference.jpg'); const DEFAULT_SERVER = process.env.COMFYUI_URL || 'http://192.168.1.202:8188'; const DEFAULT_OUT_ROOT = '/home/node/.openclaw/workspace/tmp/minimax-r2v-8s-chunk-audio-matrix'; const DURATION_SECONDS = 8; const AUDIO_DENOISE_VALUES = [0.2, 0.35, 0.5]; const CHUNK_VALUES = [2, 4, 8]; function printUsage() { console.log(`Usage: node scripts/queue-minimax-r2v-8s-chunk-audio-matrix.mjs [options] Options: --server ComfyUI base URL. Default: ${DEFAULT_SERVER} --scene-image Local scene reference image. Default: ${DEFAULT_SCENE_REF} --character-image Local character reference image. Default: ${DEFAULT_CHARACTER_REF} --steps First-pass steps. Default: value from api-prompt.json --prefix Output filename prefix root. Default: MiniMax/r2v-8s-chunk-audio-matrix --out-dir Run artifact directory. Default: ${DEFAULT_OUT_ROOT}/ --dry-run Resolve prompts locally but do not submit `); } function parseArgs(argv) { const options = { server: DEFAULT_SERVER, sceneImage: DEFAULT_SCENE_REF, characterImage: DEFAULT_CHARACTER_REF, steps: null, prefix: 'MiniMax/r2v-8s-chunk-audio-matrix', outDir: '', dryRun: false, }; 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; } 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 '--scene-image': options.sceneImage = take(); break; case '--character-image': options.characterImage = take(); break; case '--steps': options.steps = Math.trunc(Number(take())); break; case '--prefix': options.prefix = take(); break; case '--out-dir': options.outDir = take(); break; default: throw new Error(`Unknown argument: ${arg}`); } } return options; } function clone(value) { return JSON.parse(JSON.stringify(value)); } function writeJson(filePath, value) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } function secondsToFrames(seconds) { return Math.round(seconds * 24) + 3; } function guessContentType(filePath) { const ext = path.extname(filePath).toLowerCase(); if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; if (ext === '.webp') return 'image/webp'; return 'image/png'; } async function uploadImage(server, filePath) { const absPath = path.resolve(filePath); const form = new FormData(); form.set('type', 'input'); form.set('overwrite', 'true'); form.set('image', new Blob([fs.readFileSync(absPath)], { type: guessContentType(absPath) }), path.basename(absPath)); const response = await fetch(`${server.replace(/\/$/, '')}/upload/image`, { method: 'POST', body: form, }); if (!response.ok) { const text = await response.text(); throw new Error(`Failed to upload ${absPath} (${response.status}): ${text}`); } const payload = await response.json(); return String(payload?.name || path.basename(absPath)); } async function submitPrompt(server, prompt) { const response = await fetch(`${server.replace(/\/$/, '')}/prompt`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ client_id: crypto.randomUUID(), prompt, }), }); if (!response.ok) { const text = await response.text(); throw new Error(`Prompt submission failed (${response.status}): ${text}`); } return response.json(); } function buildVariants(basePrompt, options) { const baseSteps = options.steps ?? Math.trunc(Number(basePrompt['566']?.inputs?.value ?? 8)); const variants = [ { slug: 'ctrl-current', refImageSize: 'max', twoPass: false, highQuality: false, headChunks: 4, ffChunks: 4, audioDenoise: Number(basePrompt['571:531']?.inputs?.audio_denoise ?? 0.35), steps: baseSteps, notes: 'Current maintained behavior except duration forced to 8s.', }, { slug: 'ctrl-2pass-match', refImageSize: 'match', twoPass: true, highQuality: false, headChunks: 4, ffChunks: 4, audioDenoise: 0.35, steps: baseSteps, notes: 'Baseline for the matrix with match refs and 2 Pass enabled.', }, ]; for (const headChunks of CHUNK_VALUES) { for (const ffChunks of CHUNK_VALUES) { for (const audioDenoise of AUDIO_DENOISE_VALUES) { variants.push({ slug: `h${headChunks}-f${ffChunks}-a${String(audioDenoise).replace('.', '')}`, refImageSize: 'match', twoPass: true, highQuality: false, headChunks, ffChunks, audioDenoise, steps: baseSteps, notes: 'Chunk/audio matrix job.', }); } } } return variants; } function buildPrompt(basePrompt, variant, uploadedRefs, options) { const prompt = clone(basePrompt); const width = Math.trunc(Number(prompt['576']?.inputs?.width)); const height = Math.trunc(Number(prompt['576']?.inputs?.height)); prompt['574'].inputs.image = uploadedRefs.scene; prompt['575'].inputs.image = uploadedRefs.character; prompt['576'].inputs.width = width; prompt['576'].inputs.height = height; prompt['576'].inputs.length = secondsToFrames(DURATION_SECONDS); prompt['576'].inputs.ref_image_size = variant.refImageSize; prompt['514'].inputs.ResolutionState = JSON.stringify({ mode: 'custom', ratio: 'custom', w: width, h: height, custom_w: width, custom_h: height, custom_ratio_w: width, custom_ratio_h: height, snap: 16, }); prompt['550:537'].inputs.value = DURATION_SECONDS; prompt['566'].inputs.value = variant.steps; prompt['548'].inputs.value = variant.twoPass; prompt['715'].inputs.value = variant.highQuality; prompt['680:497'].inputs.head_chunks = variant.headChunks; prompt['680:498'].inputs.chunks = variant.ffChunks; prompt['571:531'].inputs.audio_denoise = variant.audioDenoise; prompt['539'].inputs.noise_seed = 9100 + options.variantIndex; prompt['591'].inputs.string_b = String(prompt['591'].inputs.string_b).replace( /A clean \d+-second cinematic shot/, `A clean ${DURATION_SECONDS}-second cinematic shot`, ); prompt['602'].inputs.filename_prefix = `${options.prefix}/${variant.slug}`; return prompt; } async function main() { const options = parseArgs(process.argv.slice(2)); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const outDir = path.resolve(options.outDir || path.join(DEFAULT_OUT_ROOT, timestamp)); fs.mkdirSync(outDir, { recursive: true }); const basePrompt = JSON.parse(fs.readFileSync(BASE_PROMPT, 'utf8')); const uploadedRefs = { scene: await uploadImage(options.server, options.sceneImage), character: await uploadImage(options.server, options.characterImage), }; const variants = buildVariants(basePrompt, options); writeJson(path.join(outDir, 'run.json'), { createdAt: new Date().toISOString(), server: options.server, basePrompt: BASE_PROMPT, uploadedRefs, options: { sceneImage: path.resolve(options.sceneImage), characterImage: path.resolve(options.characterImage), durationSeconds: DURATION_SECONDS, prefix: options.prefix, dryRun: options.dryRun, stepsOverride: options.steps, }, variants, }); const summary = []; for (const [index, variant] of variants.entries()) { const prompt = buildPrompt(basePrompt, variant, uploadedRefs, { prefix: options.prefix, variantIndex: index }); const variantDir = path.join(outDir, variant.slug); fs.mkdirSync(variantDir, { recursive: true }); writeJson(path.join(variantDir, 'prompt.json'), prompt); if (options.dryRun) { summary.push({ slug: variant.slug, durationSeconds: DURATION_SECONDS, frameLength: prompt['576'].inputs.length, refImageSize: variant.refImageSize, twoPass: variant.twoPass, highQuality: variant.highQuality, headChunks: variant.headChunks, ffChunks: variant.ffChunks, audioDenoise: variant.audioDenoise, steps: variant.steps, dryRun: true, }); continue; } const submission = await submitPrompt(options.server, prompt); const promptId = String(submission?.prompt_id || '').trim(); if (!promptId) throw new Error(`Missing prompt_id for ${variant.slug}: ${JSON.stringify(submission)}`); writeJson(path.join(variantDir, 'submission.json'), { at: new Date().toISOString(), promptId, submission, variant, }); summary.push({ slug: variant.slug, durationSeconds: DURATION_SECONDS, frameLength: prompt['576'].inputs.length, refImageSize: variant.refImageSize, twoPass: variant.twoPass, highQuality: variant.highQuality, headChunks: variant.headChunks, ffChunks: variant.ffChunks, audioDenoise: variant.audioDenoise, steps: variant.steps, promptId, }); } writeJson(path.join(outDir, 'summary.json'), summary); console.log(JSON.stringify({ outDir, uploadedRefs, summary }, null, 2)); } main().catch((error) => { console.error(error?.stack || error?.message || String(error)); process.exit(1); });