lili 532161a7ce
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(art): 《夜市一条街》M4 资产整装——暖夜霓虹 92 图元换装+BGM3/SFX24
创始人锁锚候选 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>
2026-07-07 09:55:17 -07:00

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);