矿彩国风锚 r1 定稿:《山海行纪》30 帧(hero×6/狰梼杌穷奇×15/法器×3/UI×2/场景/兽潮/Boss/拾取)+ 《山海巡异录》12 张(卡面 3 稀有度×2 职业/敌兽 2/场景 2/结算 2);同名兽狰两款同谱面可辨= 「一条美术管线供两款」首检成立;clean-room 五条全程生效(prompt 零现代作品专名、兽形古籍演绎、 参考图仅自产、全量留痕 manifest.jsonl 含 12 张淘汰逐张原因)。 产线三件真跑演示:色键抠图+除杂(角落印章自动清)/图集 2 页 png 4006KB→webp 599KB(实测修正: free-tex-packer 不直出 webp 补 PIL 转码)/字体子集 780 字→176KB woff2(LXGW OFL)。 成本良率实测:57 呼 ¥1.44(官方口径)/¥1.88(实测口径),首过率 34/42=81%,重摇≤4 轮填满; 工艺发现 7 条入批次 README(兽名不入 prompt/「鲛」prime 龙形/subject-ref 证伪/UI 伪字走程序排字等)。 创始人裁定(2026-07-07):锁锚开批产——两款按锚 r1 进 M3/M4 资产批产,主角跨帧漂移用 「定妆帧作参考图+关键姿态逐帧」工艺压,不换通道。台账:作战清单 M2 波全记账+_index 三行 M2 已交+设定档裁定戳。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110 lines
5.8 KiB
JavaScript
Executable File
110 lines
5.8 KiB
JavaScript
Executable File
#!/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);
|