#!/usr/bin/env node // 风格版批次一 · mmx 生图批跑脚本(零依赖,直接调本机 mmx CLI) // 用法: // node gen-batch.mjs # 全量(跳过 images/ 下已存在的镜头,可断点续跑) // node gen-batch.mjs --game xingji # 只跑一款 // node gen-batch.mjs --only xj-hero-01,xj-zheng-02 # 只跑指定镜头 // node gen-batch.mjs --only xj-hero-01 --seed-bump 100000 --force # 重摇(换 seed 重产,覆盖) // node gen-batch.mjs --only xj-ui-01 --subject-extra ",额外约束词" --force # 定向补词重产 // 纪律: // - prompt = 镜头 subject + 锚包 styleWordsShared + perGame + (sprite|bleed) 后缀,拼接即所发,原样入账; // - 每次外呼 150s 超时,失败退避重试(内容过滤 exit=10 不重试,配额 exit=4 全批中止); // - 每次调用(无论成败)追加一行 ../manifest.jsonl —— 逐张 prompt/参数/耗时/状态可审计; // - 串行 + 1s 间隔,防限流;成本按官方口径 $0.0035/张 估记(实测口径另见台账)。 import { readFileSync, existsSync, mkdirSync, renameSync, appendFileSync, readdirSync, rmSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { dirname, join, resolve, extname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const MANIFEST = join(ROOT, 'manifest.jsonl'); const TMP = join(process.env.TMPDIR || '/tmp', 'shanhai-batch1-gen'); mkdirSync(TMP, { recursive: true }); // ---- 参数解析 ---- const args = process.argv.slice(2); const getArg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; const onlyIds = getArg('--only')?.split(',').map(s => s.trim()).filter(Boolean); const onlyGame = getArg('--game'); const seedBump = Number(getArg('--seed-bump') || 0); const subjectExtra = getArg('--subject-extra') || ''; const force = args.includes('--force'); // ---- 读锚包与镜头单 ---- const anchor = JSON.parse(readFileSync(join(ROOT, 'anchor-pack.json'), 'utf8')); const { shots } = JSON.parse(readFileSync(join(ROOT, 'shots.json'), 'utf8')); // prompt 拼装(锚包工艺修订 r1 口径):领句定体裁 + 主体 + 共用风格锚 + 按 sprite/bleed 取分组词 + 工艺后缀 function buildPrompt(shot) { const kind = shot.type === 'sprite' ? 'sprite' : 'bleed'; const per = anchor.perGame[shot.game][kind]; const suffix = shot.type === 'sprite' ? anchor.spriteSuffix : anchor.fullBleedSuffix; return `${anchor.promptPrefix}${shot.subject}${subjectExtra}。${anchor.styleWordsShared}。${per}。${suffix}`; } // 追加台账一行(逐次调用,成败都记) function ledger(entry) { appendFileSync(MANIFEST, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n'); } // 调 mmx 一次(150s 超时);返回 { ok, file?, error?, ms, exit } function callMmx(shot, prompt, seedUsed) { // 清理该镜头的临时残留,防串档 for (const f of readdirSync(TMP)) if (f.startsWith(shot.id)) rmSync(join(TMP, f)); const t0 = Date.now(); const r = spawnSync('mmx', [ 'image', 'generate', '--prompt', prompt, '--width', String(shot.w), '--height', String(shot.h), '--seed', String(seedUsed), '--out-dir', TMP, '--out-prefix', shot.id, '--quiet', '--non-interactive', ], { timeout: 150_000, encoding: 'utf8' }); const ms = Date.now() - t0; if (r.error?.code === 'ETIMEDOUT' || r.signal) return { ok: false, error: 'timeout-150s', ms, exit: 5 }; if (r.status !== 0) return { ok: false, error: (r.stderr || r.stdout || '').trim().slice(0, 300) || `exit=${r.status}`, ms, exit: r.status ?? 1 }; // --quiet + --out-dir 模式:stdout 为保存路径;兜底扫临时目录 const saved = (r.stdout || '').split('\n').map(s => s.trim()).find(s => s && existsSync(s)) || readdirSync(TMP).filter(f => f.startsWith(shot.id)).map(f => join(TMP, f))[0]; if (!saved) return { ok: false, error: 'exit=0 但未找到产物文件', ms, exit: 1 }; return { ok: true, file: saved, ms, exit: 0 }; } // ---- 主循环:串行 ---- const todo = shots.filter(s => (!onlyIds || onlyIds.includes(s.id)) && (!onlyGame || s.game === onlyGame)); let done = 0, skipped = 0, failed = 0, calls = 0; for (const shot of todo) { const outDir = join(ROOT, 'images', shot.game); mkdirSync(outDir, { recursive: true }); const existing = readdirSync(outDir).find(f => f.startsWith(shot.id + '.')); if (existing && !force) { skipped++; continue; } const prompt = buildPrompt(shot); const seedUsed = shot.seed + seedBump; let result = null; for (let attempt = 1; attempt <= 3; attempt++) { result = callMmx(shot, prompt, seedUsed); calls++; ledger({ kind: 'gen-call', id: shot.id, game: shot.game, type: shot.type, w: shot.w, h: shot.h, seed: seedUsed, seedBump, attempt, model: 'image-01', prompt, status: result.ok ? 'ok' : 'fail', error: result.error, durationMs: result.ms }); if (result.ok) break; if (result.exit === 10) { console.error(`[${shot.id}] 内容过滤(exit=10),不重试`); break; } if (result.exit === 4) { console.error(`[${shot.id}] 配额超限(exit=4),全批中止`); process.exit(4); } if (attempt < 3) { const wait = attempt * 5000; console.error(`[${shot.id}] 第${attempt}次失败(${result.error}),${wait / 1000}s 后重试`); spawnSync('sleep', [String(wait / 1000)]); } } if (result.ok) { const ext = extname(result.file) || '.png'; const dest = join(outDir, shot.id + ext.toLowerCase()); renameSync(result.file, dest); done++; console.log(`[${shot.id}] OK ${Math.round(result.ms / 1000)}s -> ${dest}`); } else { failed++; console.error(`[${shot.id}] FAILED: ${result.error}`); } spawnSync('sleep', ['1']); // 限流间隔 } console.log(`\n批跑完成:成 ${done} / 跳过 ${skipped} / 败 ${failed};本次外呼 ${calls} 次(≈$${(calls * 0.0035).toFixed(3)} 官方口径)`); process.exit(failed > 0 ? 1 : 0);