lili 86df18c4d8
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(art): 山海风格版批次一——42 样张+产线三件真跑,创始人眼验拍锁锚开批产
矿彩国风锚 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>
2026-07-06 19:20:47 -07:00

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