创始人锁锚候选 A 暖夜霓虹落地(纠正重派:看图+复用批次二产线+镜头单从数据表 import,首派 FAILED 后收敛): P1 生图 92(顾客 39=13 类×3 态/菜品 30=recipes 真 id/设备 6/UI 图标 14+框 3)→抠图 89 sprite→ 图集 nightmarket 89 帧 968KB webp+Phaser3 JSON;场景背景 4(日/夜/高峰/雨,复用锚图); BGM 3(闲市 95/高峰 125/雨夜 78bpm,mmx music 各截 75s 循环 96k≈902KB 共 2.6MB 懒加载,压缩码率优于卡牌 256k); SFX 24 类 ZzFX 程序合成零包体;表现层换装(顾客立绘 typeId+态切帧/点单气泡菜品图标/背景昼夜雨切换/HUD 金币/ dock 4 页签,全带缺图降级,readState 语义零改)。 门证据(主会话逐门复验):换装后三门零倒退(logic-smoke 95 断言/tree-check 15 查/单测 30)+两本包体账实测过门 (账一 assets 4.23MB≤10MB/首屏 1.13MB≤2MB,账二引擎首屏传输~2.42MB 与设计书预估 2.35 吻合)+CDP 九门 8/9 (唯 C_frame=本机 headless 无 vsync~110fps 环境采样非换装,mini-desktop 复验)+富三门全过;成本 <¥5(101 呼)。 少年类顾客漂卡通词面顽疾用「去学生词+反卡通厚涂」攻克;单骑手不满态待重摇=已知项。dist/evidence 按惯例不入库。 M5 缺口=rare/critic 立绘 6 类分包/食材图标 12/HUD 图标+9-patch/BGM 无缝循环点/C_frame 真机复验。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
6.6 KiB
JavaScript
115 lines
6.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// 风格版批次二(夜市·暖夜霓虹)· mmx 生图批跑脚本(复用批次一方法,零依赖,直接调本机 mmx CLI)
|
|
// 用法:
|
|
// node gen-batch.mjs # 全量(跳过 images/ 下已存在的镜头,可断点续跑)
|
|
// node gen-batch.mjs --only nm-scene-night-01,nm-cust-01 # 只跑指定镜头
|
|
// node gen-batch.mjs --only nm-cust-01 --seed-bump 100000 --force # 重摇(换 seed 重产,覆盖)
|
|
// node gen-batch.mjs --only nm-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', 'yeshi-batch2-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 shotsFile = getArg('--shots') || 'shots.json'; // M4:P1 镜头单走 shots-p1.json
|
|
const outSubdir = getArg('--out-subdir'); // M4:P1 产物落独立子目录,不与 12 张锚图混
|
|
const idPrefix = getArg('--id-prefix'); // M4:按 id 前缀分类批产(nm-cust-/nm-dish-…)
|
|
const limit = Number(getArg('--limit') || 0); // M4:每次只新生成 N 张(配 skip 续跑,单 Bash 不超时)
|
|
|
|
// ---- 读锚包与镜头单 ----
|
|
const anchor = JSON.parse(readFileSync(join(ROOT, 'anchor-pack.json'), 'utf8'));
|
|
const { shots } = JSON.parse(readFileSync(join(ROOT, shotsFile), 'utf8'));
|
|
|
|
// prompt 拼装(批次一 r1 口径直接内建):领句定体裁 + 主体 + 共用风格锚 + 按 sprite/bleed/bleed-day 取分组词 + 工艺后缀
|
|
// r3:新增 bleed-day 日间变体组——bleed 组硬编码夜幕光源层,白天命题会被夜词压死(day-01 首摇实证)
|
|
function buildPrompt(shot) {
|
|
const kind = shot.type === 'sprite' ? 'sprite' : (shot.type === 'bleed-day' ? 'bleedDay' : '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) && (!idPrefix || s.id.startsWith(idPrefix)));
|
|
let done = 0, skipped = 0, failed = 0, calls = 0;
|
|
for (const shot of todo) {
|
|
if (limit && done >= limit) break; // M4:本批新生成额度用尽,余下留下次续跑
|
|
const outDir = join(ROOT, 'images', outSubdir || 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);
|