#!/usr/bin/env node // scripts/asset-pack.mjs —— 《山海巡异录》资产一期 · 产线打包(抠图→缩尺→图集→manifest→机检) // owner:美术批产执行席(2026-07-06;批次一 key-out.py / atlas-pack.mjs 迁移整合版) // 用法: // node scripts/asset-pack.mjs --raw-dir <生图原图目录> # 全量打包 // node scripts/asset-pack.mjs --raw-dir <目录> --group cards # 单组 // 产物: // assets/atlas/-N.webp + .json 图集(Phaser3 坐标,webp q80;卡插画独立分包 cards-jia) // assets/bg/.webp 满幅大图(场景/结算,批次一复用件标 source) // assets/manifest.json 资产清单 + 字节账(账A 输入) // docs-design/山海风格版-批次一/images-巡异录资产一期// 定稿缩尺评审版(webp) // 机检(每组):id 齐套 / 精灵透明底占比 / 尺寸合规 / 图集帧数对账;结果打印并追加台账(kind=qa-pack)。 import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync, rmSync } from 'node:fs'; import { execFileSync, spawnSync } from 'node:child_process'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { SHOTS, REUSE, CARD_IDS, RELIC_IDS } from './asset-shots.mjs'; const GAME_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const REPO_ROOT = resolve(GAME_ROOT, '..', '..', '..'); const BATCH1 = join(REPO_ROOT, 'docs-design', '山海风格版-批次一'); const REVIEW_DIR = join(BATCH1, 'images-巡异录资产一期'); const MANIFEST_LEDGER = join(BATCH1, 'manifest-巡异录资产一期.jsonl'); const ASSETS = join(GAME_ROOT, 'assets'); const ATLAS_DIR = join(ASSETS, 'atlas'); const BG_DIR = join(ASSETS, 'bg'); const args = process.argv.slice(2); const getArg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; }; const RAW_DIR = resolve(getArg('--raw-dir') || join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-raw')); const onlyGroup = getArg('--group'); const WORK = join(process.env.TMPDIR || '/tmp', 'xunyilu-assets1-pack'); const STAGE = join(WORK, 'stage'); // 按 atlas 分目录的定稿 png(图集输入) const KEYED = join(WORK, 'keyed'); // 抠图中间产物 for (const d of [WORK, STAGE, KEYED, ATLAS_DIR, BG_DIR, REVIEW_DIR]) mkdirSync(d, { recursive: true }); const findRaw = (group, id) => { const dir = join(RAW_DIR, group); if (!existsSync(dir)) return null; const f = readdirSync(dir).find((x) => x.startsWith(id + '.')); return f ? join(dir, f) : null; }; // ── 批量图像操作:一次 python 进程吃一张作业单(缩尺/转码;PIL LANCZOS)── function runImageOps(ops, label) { if (!ops.length) return; const jobFile = join(WORK, `ops-${label}.json`); writeFileSync(jobFile, JSON.stringify(ops)); const py = ` import json, sys, os from PIL import Image ops = json.load(open(sys.argv[1])) for op in ops: im = Image.open(op['src']) if op['mode'] == 'cover': # 等比放缩后居中裁到目标框(满幅件) sw, sh = im.size; tw, th = op['w'], op['h'] s = max(tw / sw, th / sh) im = im.resize((max(1, round(sw * s)), max(1, round(sh * s))), Image.LANCZOS) l, t = (im.width - tw) // 2, (im.height - th) // 2 im = im.crop((l, t, l + tw, t + th)) elif op['mode'] == 'contain': # 最长边限界(透明底精灵,保比例) im.thumbnail((op['box'], op['box']), Image.LANCZOS) os.makedirs(os.path.dirname(op['dst']), exist_ok=True) if op['dst'].endswith('.webp'): im.save(op['dst'], quality=80, method=6) else: im.save(op['dst']) `; execFileSync('python3', ['-c', py, jobFile], { stdio: 'inherit', timeout: 300_000 }); } // ── 抠图:批次一 key-out.py 原脚本复用(色键+连通域除杂,印章尘点自动清)── function keyOut(files, outDir) { if (!files.length) return; mkdirSync(outDir, { recursive: true }); execFileSync('python3', [join(BATCH1, 'scripts', 'key-out.py'), ...files, '--out', outDir], { stdio: 'inherit', timeout: 600_000 }); } // ── 透明底机检:alpha 通道存在且全透明像素占比在合理带(防抠图失效/全空)── function alphaCheck(pngPaths) { if (!pngPaths.length) return []; const jobFile = join(WORK, 'alpha-check.json'); writeFileSync(jobFile, JSON.stringify(pngPaths)); const py = ` import json, sys import numpy as np from PIL import Image bad = [] for p in json.load(open(sys.argv[1])): im = Image.open(p) if im.mode != 'RGBA': bad.append({'file': p, 'why': 'no-alpha'}); continue a = np.asarray(im)[:, :, 3] frac = float((a == 0).mean()) if frac < 0.03: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过低(疑似未抠净)'}) if frac > 0.98: bad.append({'file': p, 'why': f'transparent-frac={frac:.3f} 过高(疑似全空)'}) print(json.dumps(bad)) `; const r = execFileSync('python3', ['-c', py, jobFile], { encoding: 'utf8', timeout: 300_000 }); return JSON.parse(r.trim() || '[]'); } // ── 图集:free-tex-packer-cli(schema 同批次一)→ png 页 → webp q80 → JSON 改指 webp ── function packAtlas(atlasName, stageDir, pageSize) { const pngs = readdirSync(stageDir).filter((f) => f.endsWith('.png')); if (!pngs.length) return null; const outTmp = join(WORK, 'atlas-out', atlasName); rmSync(outTmp, { recursive: true, force: true }); mkdirSync(outTmp, { recursive: true }); const project = { savePath: outTmp, images: pngs.map((f) => ({ name: f, path: join(stageDir, f) })), folders: [], packOptions: { textureName: atlasName, width: pageSize, height: pageSize, fixedSize: false, powerOfTwo: false, padding: 2, extrude: 1, allowRotation: false, detectIdentical: true, allowTrim: true, trimMode: 'trim', alphaThreshold: 0, exporter: 'Phaser3', removeFileExtension: true, prependFolderName: false, textureFormat: 'png', base64Export: false, scale: 1, packer: 'MaxRectsPacker', }, }; const proj = join(outTmp, `${atlasName}.ftpp`); writeFileSync(proj, JSON.stringify(project, null, 2)); execFileSync('free-tex-packer-cli', ['--project', proj, '--output', outTmp], { stdio: 'inherit', timeout: 300_000 }); const pages = []; for (const png of readdirSync(outTmp).filter((f) => f.startsWith(atlasName) && f.endsWith('.png'))) { const base = png.replace(/\.png$/, ''); const jsonPath = join(outTmp, `${base}.json`); const webpDst = join(ATLAS_DIR, `${base}.webp`); runImageOps([{ src: join(outTmp, png), dst: webpDst, mode: 'contain', box: pageSize }], `webp-${base}`); const j = JSON.parse(readFileSync(jsonPath, 'utf8')); for (const t of j.textures || []) if (t.image) t.image = t.image.replace(/\.png$/, '.webp'); const jsonDst = join(ATLAS_DIR, `${base}.json`); writeFileSync(jsonDst, JSON.stringify(j, null, 2)); const frames = (j.textures || []).reduce((s, t) => s + (t.frames?.length || 0), 0); pages.push({ image: `atlas/${base}.webp`, json: `atlas/${base}.json`, bytes: statSync(webpDst).size, frames }); } return pages; } const kb = (n) => `${(n / 1024).toFixed(0)}KB`; const problems = []; const qa = { missingRaw: [], alphaBad: [], counts: {}, bytes: {} }; // ═══ 1. 分组落 stage ═══ const groups = ['cards', 'beasts', 'ui', 'relics', 'bg'].filter((g) => !onlyGroup || g === onlyGroup); // cards / ui:bleed,cover 缩尺直接进 stage(frame 名 = 去前缀 id) for (const g of groups.filter((x) => x === 'cards' || x === 'ui')) { const ops = []; for (const s of SHOTS.filter((x) => x.group === g)) { const raw = findRaw(g, s.id); if (!raw) { qa.missingRaw.push(s.id); continue; } ops.push({ src: raw, dst: join(STAGE, s.atlas, `${s.frame}.png`), mode: 'cover', w: s.final.w, h: s.final.h }); ops.push({ src: raw, dst: join(REVIEW_DIR, g, `${s.frame}.webp`), mode: 'cover', w: s.final.w, h: s.final.h }); } runImageOps(ops, g); } // beasts / relics:sprite,先抠图再 contain 缩尺 for (const g of groups.filter((x) => x === 'beasts' || x === 'relics')) { const shots = SHOTS.filter((x) => x.group === g); const toKey = []; const meta = []; // {keyedPng, atlas, frame, box, group} for (const s of shots) { const raw = findRaw(g, s.id); if (!raw) { qa.missingRaw.push(s.id); continue; } toKey.push(raw); meta.push({ keyed: join(KEYED, g, `${s.id}.png`), atlas: s.atlas, frame: s.frame, box: s.final.box }); } if (g === 'beasts') { for (const r of REUSE.beasts) { // 批次一定稿复用(创始人已眼验) toKey.push(join(BATCH1, r.src)); const base = r.src.split('/').pop().replace(/\.[a-z]+$/, ''); meta.push({ keyed: join(KEYED, g, `${base}.png`), atlas: r.atlas, frame: r.id, box: r.box }); } } keyOut(toKey, join(KEYED, g)); const bad = alphaCheck(meta.map((m) => m.keyed).filter((p) => existsSync(p))); qa.alphaBad.push(...bad); const ops = []; for (const m of meta) { if (!existsSync(m.keyed)) { problems.push(`${g}:抠图无产物 ${m.keyed}`); continue; } ops.push({ src: m.keyed, dst: join(STAGE, m.atlas, `${m.frame}.png`), mode: 'contain', box: m.box }); ops.push({ src: m.keyed, dst: join(REVIEW_DIR, g, `${m.frame}.webp`), mode: 'contain', box: m.box }); } runImageOps(ops, g); } // bg:新产 2 + 批次一复用 4,webp q80 原尺进 assets/bg const bgFiles = []; if (groups.includes('bg')) { const ops = []; for (const s of SHOTS.filter((x) => x.group === 'bg')) { const raw = findRaw('bg', s.id); if (!raw) { qa.missingRaw.push(s.id); continue; } ops.push({ src: raw, dst: join(BG_DIR, `${s.id}.webp`), mode: 'cover', w: s.final.w, h: s.final.h }); bgFiles.push({ id: s.id, source: '资产一期新产' }); } for (const r of REUSE.bg) { ops.push({ src: join(BATCH1, r.src), dst: join(BG_DIR, `${r.id}.webp`), mode: 'cover', w: 768, h: 1360 }); bgFiles.push({ id: r.id, source: '批次一定稿复用' }); } runImageOps(ops, 'bg'); for (const f of bgFiles) { const p = join(BG_DIR, `${f.id}.webp`); if (existsSync(p)) { f.file = `bg/${f.id}.webp`; f.bytes = statSync(p).size; copyFileSync(p, join(REVIEW_DIR, 'bg', `${f.id}.webp`)); } } mkdirSync(join(REVIEW_DIR, 'bg'), { recursive: true }); } // ═══ 2. 打图集 ═══ const PAGE_SIZE = { 'cards-jia': 2048, 'beasts-act1': 2048, 'beasts-act2': 2048, 'beasts-act3': 2048, relics: 1024, ui: 2048 }; const atlases = {}; for (const atlasName of Object.keys(PAGE_SIZE)) { const g = atlasName.startsWith('cards') ? 'cards' : atlasName.startsWith('beasts') ? 'beasts' : atlasName; if (!groups.includes(g)) continue; const stageDir = join(STAGE, atlasName); if (!existsSync(stageDir)) continue; const pages = packAtlas(atlasName, stageDir, PAGE_SIZE[atlasName]); if (pages) atlases[atlasName] = pages; } // ═══ 3. 机检对账 ═══ const expect = { 'cards-jia': CARD_IDS.length, // 66 relics: RELIC_IDS.length, // 40 ui: SHOTS.filter((s) => s.group === 'ui').length, // 15 beastsTotal: SHOTS.filter((s) => s.group === 'beasts').length + REUSE.beasts.length, // 39 }; const frameCount = (a) => (atlases[a] || []).reduce((s, p) => s + p.frames, 0); if (groups.includes('cards') && frameCount('cards-jia') !== expect['cards-jia']) problems.push(`cards-jia 帧数 ${frameCount('cards-jia')} ≠ ${expect['cards-jia']}`); if (groups.includes('relics') && frameCount('relics') !== expect.relics) problems.push(`relics 帧数 ${frameCount('relics')} ≠ ${expect.relics}`); if (groups.includes('ui') && frameCount('ui') !== expect.ui) problems.push(`ui 帧数 ${frameCount('ui')} ≠ ${expect.ui}`); const beastFrames = frameCount('beasts-act1') + frameCount('beasts-act2') + frameCount('beasts-act3'); if (groups.includes('beasts') && beastFrames !== expect.beastsTotal) problems.push(`beasts 帧数 ${beastFrames} ≠ ${expect.beastsTotal}`); if (qa.missingRaw.length) problems.push(`缺原图 ${qa.missingRaw.length}:${qa.missingRaw.join(',')}`); for (const b of qa.alphaBad) problems.push(`透明底异常 ${b.file}(${b.why})`); // ═══ 4. manifest + 字节账 ═══ const byGroup = {}; for (const [name, pages] of Object.entries(atlases)) byGroup[name] = pages.reduce((s, p) => s + p.bytes, 0); byGroup.bg = bgFiles.reduce((s, f) => s + (f.bytes || 0), 0); const totalBytes = Object.values(byGroup).reduce((s, n) => s + n, 0); const manifest = { version: 1, generatedAt: new Date().toISOString(), anchor: 'docs-design/山海风格版-批次一/anchor-pack.json(风格锚 v1 + 工艺修订 r1,单源)', ledger: 'docs-design/山海风格版-批次一/manifest-巡异录资产一期.jsonl', snapshotNote: '卡/遗物/敌帧名对齐 2026-07-06 tables.js 快照(66 卡/40 遗物/8 敌)+ 兽谱-增补-巡异录.md 31 兽;职业乙插画位未产,等 M3 表落地下一批补齐', atlases, bg: bgFiles, totals: { webpBytes: totalBytes, byGroup }, }; if (!onlyGroup) writeFileSync(join(ASSETS, 'manifest.json'), JSON.stringify(manifest, null, 2)); appendFileSync(MANIFEST_LEDGER, JSON.stringify({ ts: new Date().toISOString(), kind: 'qa-pack', groups, problems, byGroup, totalBytes }) + '\n'); console.log('\n═══ 打包结果 ═══'); for (const [name, pages] of Object.entries(atlases)) { console.log(`${name}:${pages.length} 页 / ${pages.reduce((s, p) => s + p.frames, 0)} 帧 / ${kb(byGroup[name])}`); } if (bgFiles.length) console.log(`bg:${bgFiles.length} 张 / ${kb(byGroup.bg)}`); console.log(`合计 webp:${kb(totalBytes)}(${(totalBytes / 1048576).toFixed(2)}MB)`); if (problems.length) { console.error(`\n机检 ${problems.length} 项异常:`); for (const p of problems) console.error(' - ' + p); process.exit(1); } console.log('机检:全部通过');