272 lines
9.4 KiB
JavaScript
272 lines
9.4 KiB
JavaScript
#!/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 <url> ComfyUI base URL. Default: ${DEFAULT_SERVER}
|
|
--scene-image <path> Local scene reference image. Default: ${DEFAULT_SCENE_REF}
|
|
--character-image <path> Local character reference image. Default: ${DEFAULT_CHARACTER_REF}
|
|
--durations <csv> Comma-separated duration seconds. Default: ${DEFAULT_DURATION_SECONDS.join(',')}
|
|
--width <int> Output width. Default: ${DEFAULT_WIDTH}
|
|
--height <int> Output height. Default: ${DEFAULT_HEIGHT}
|
|
--steps <int> First-pass steps. Default: ${DEFAULT_STEPS}
|
|
--high-quality <bool> Enable HIGH QUALITY branch. Default: false
|
|
--prefix <text> Output filename prefix root. Default: MiniMax/r2v-duration-sweep
|
|
--out-dir <path> Run artifact directory. Default: ${DEFAULT_OUT_ROOT}/<timestamp>
|
|
--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,
|
|
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 '--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;
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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);
|
|
});
|