#!/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-duration-sweep'; const DEFAULT_WIDTH = 1088; const DEFAULT_HEIGHT = 608; const DEFAULT_STEPS = 8; const DEFAULT_DURATION_SECONDS = [2, 5, 8, 12, 15, 17, 20]; function printUsage() { console.log(`Usage: node scripts/queue-minimax-r2v-duration-sweep.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} --durations Comma-separated duration seconds. Default: ${DEFAULT_DURATION_SECONDS.join(',')} --width Output width. Default: ${DEFAULT_WIDTH} --height Output height. Default: ${DEFAULT_HEIGHT} --steps First-pass steps. Default: ${DEFAULT_STEPS} --high-quality Enable HIGH QUALITY branch. Default: false --upscale-multiplier Override RTX/HQ upscale multiplier. Default: prompt value --prefix Output filename prefix root. Default: MiniMax/r2v-duration-sweep --out-dir Run artifact directory. Default: ${DEFAULT_OUT_ROOT}/ --dry-run Resolve prompts locally but do not submit `); } function parseBoolean(raw) { const value = String(raw).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(value)) return true; if (['0', 'false', 'no', 'off'].includes(value)) return false; throw new Error(`Invalid boolean value: ${raw}`); } function parseArgs(argv) { const options = { server: DEFAULT_SERVER, sceneImage: DEFAULT_SCENE_REF, characterImage: DEFAULT_CHARACTER_REF, durations: [...DEFAULT_DURATION_SECONDS], width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, steps: DEFAULT_STEPS, highQuality: false, upscaleMultiplier: null, prefix: 'MiniMax/r2v-duration-sweep', 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 '--durations': options.durations = take().split(',').map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value > 0); break; case '--width': options.width = Math.trunc(Number(take())); break; case '--height': options.height = Math.trunc(Number(take())); break; case '--steps': options.steps = Math.trunc(Number(take())); break; case '--high-quality': options.highQuality = parseBoolean(take()); break; case '--upscale-multiplier': options.upscaleMultiplier = Number(take()); break; case '--prefix': options.prefix = take(); break; case '--out-dir': options.outDir = take(); break; default: throw new Error(`Unknown argument: ${arg}`); } } if (!options.durations.length) throw new Error('At least one duration is required.'); 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 buildPrompt(basePrompt, variant, uploadedRefs) { const prompt = clone(basePrompt); const frameLength = secondsToFrames(variant.durationSeconds); prompt['574'].inputs.image = uploadedRefs.scene; prompt['575'].inputs.image = uploadedRefs.character; prompt['576'].inputs.width = variant.width; prompt['576'].inputs.height = variant.height; prompt['576'].inputs.length = frameLength; prompt['514'].inputs.ResolutionState = JSON.stringify({ mode: 'custom', ratio: 'custom', w: variant.width, h: variant.height, custom_w: variant.width, custom_h: variant.height, custom_ratio_w: variant.width, custom_ratio_h: variant.height, snap: 16, }); prompt['550:537'].inputs.value = variant.durationSeconds; prompt['566'].inputs.value = variant.steps; prompt['715'].inputs.value = variant.highQuality; if (variant.upscaleMultiplier !== null) { prompt['723'].inputs.value = variant.upscaleMultiplier; } prompt['539'].inputs.noise_seed = variant.seed; prompt['591'].inputs.string_b = prompt['591'].inputs.string_b.replace( /A clean 10-second cinematic shot/, `A clean ${variant.durationSeconds}-second cinematic shot`, ); prompt['602'].inputs.filename_prefix = `${variant.prefix}/${variant.slug}`; return prompt; } 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(); } 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 = options.durations.map((durationSeconds, index) => ({ slug: `${String(index + 1).padStart(2, '0')}-${durationSeconds}s`, durationSeconds, width: options.width, height: options.height, steps: options.steps, highQuality: options.highQuality, upscaleMultiplier: options.upscaleMultiplier, seed: 8200 + index, prefix: options.prefix, })); 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), width: options.width, height: options.height, steps: options.steps, highQuality: options.highQuality, upscaleMultiplier: options.upscaleMultiplier, durations: options.durations, prefix: options.prefix, }, variants, }); const summary = []; for (const variant of variants) { const prompt = buildPrompt(basePrompt, variant, uploadedRefs); 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: variant.durationSeconds, frameLength: prompt['576'].inputs.length, upscaleMultiplier: variant.upscaleMultiplier, 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: variant.durationSeconds, frameLength: prompt['576'].inputs.length, upscaleMultiplier: variant.upscaleMultiplier, 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); });