Newer
Older
rpg / apps / game / tools / aiart / atlas.mjs
/**
 * Пайплайн генерации атласов персонажей из AI-листов (работа B, см. docs/plan.md):
 *   gen <id>      — AI-листы ракурсов (sd_sheet.py, возобновляемо, ~6.6 мин/вид)
 *   build <id>    — листы → кадры 16×24 → атлас + обзорный лист (build/<id>/)
 *   promote <id>  — копия атласа в assets/ (только при status: approved)
 *
 * Всё манифест-драйвенно: atlas-manifest.json (промпт, seed, ракурсы, палитра).
 * AI-вывод в assets/ напрямую не попадает — только через promote.
 */
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { decodePng, encodePng } from '@rpg/engine/tools/png.mjs';
import { findFigures, resizeRect, quantize } from '@rpg/engine/tools/imaging.mjs';
import { buildAtlas } from '@rpg/engine/tools/atlas.mjs';
import { PALETTE } from '../pixelart/palette.mjs';

const HERE = fileURLToPath(new URL('.', import.meta.url));
const SHEETS = join(HERE, 'sheets');
const FRAMES = join(HERE, 'frames');
const BUILD = join(HERE, 'build');
const MANIFEST = join(HERE, 'atlas-manifest.json');

// ---------- контекст персонажа ----------

function loadChar(id) {
    const { characters } = JSON.parse(readFileSync(MANIFEST, 'utf8'));
    const entry = characters.find((c) => c.id === id);
    if (!entry) throw new Error(`персонаж «${id}» не найден в atlas-manifest.json`);
    return entry;
}

/** Палитра из манифеста: "game" — 32 цвета игры; иначе palettes/<name>.json (hex). */
function loadPalette(entry) {
    const hexes = entry.palette === 'game'
        ? Object.values(PALETTE)
        : JSON.parse(readFileSync(join(HERE, 'palettes', `${entry.palette}.json`), 'utf8'));
    return hexes.map((hex) => {
        const n = parseInt(hex.slice(1), 16);
        return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    });
}

// ---------- gen: AI-листы ----------

function cmdGen(char, { force = false } = {}) {
    const py = process.env.RPG_SD_PYTHON ?? `${process.env.HOME}/.cache/rpg-ai/venv-pxgpt/bin/python`;
    const dir = join(SHEETS, char.id);
    mkdirSync(dir, { recursive: true });
    for (const view of char.views) {
        const out = join(dir, `${view}_${char.seed}.png`);
        if (existsSync(out) && !force) {
            console.log(`[skip] ${out} уже есть (—force для перегенерации)`);
            continue;
        }
        console.log(`[gen] ${view} (seed ${char.seed}) — это долго, ~6.6 мин`);
        const r = spawnSync(py, [join(HERE, 'sd_sheet.py'), view, String(char.seed), dir, char.prompt], { stdio: 'inherit' });
        if (r.status !== 0) throw new Error(`sd_sheet.py упал на ${view} (код ${r.status})`);
    }
}

// ---------- build: кадры + атлас ----------

/** Лист ракурса: фигуры — crop-оверрайды из манифеста (rect[]) или автодетект. */
function sheetFigures(char, view) {
    const sheet = decodePng(readFileSync(join(SHEETS, char.id, `${view}_${char.seed}.png`)));
    const override = char.crop?.[view];
    if (override) {
        return { sheet, figures: override.map(([x, y, w, h]) => ({ x, y, w, h })) };
    }
    return { sheet, figures: findFigures(sheet, { maxCount: 4 }) };
}

/** Обзорный лист: кадры ×scale в сетке (для апрува человеком). */
function frameSheet(frames, fw, fh, scale = 4) {
    const pad = 6, cellW = fw * scale, cellH = fh * scale;
    const cols = 4, rows = Math.ceil(frames.length / cols);
    const W = cols * (cellW + pad) + pad, H = rows * (cellH + pad) + pad;
    const out = new Uint8Array(W * H * 4);
    for (let p = 0; p < out.length; p += 4) { out[p] = 30; out[p + 1] = 32; out[p + 2] = 38; out[p + 3] = 255; }
    frames.forEach((rgba, i) => {
        const ox = pad + (i % cols) * (cellW + pad), oy = pad + ((i / cols) | 0) * (cellH + pad);
        for (let y = 0; y < cellH; y++) for (let x = 0; x < cellW; x++) {
            const si = (((y / scale) | 0) * fw + ((x / scale) | 0)) * 4;
            if (rgba[si + 3] === 0) continue;
            out.set(rgba.subarray(si, si + 4), ((oy + y) * W + ox + x) * 4);
        }
    });
    return encodePng(W, H, Buffer.from(out));
}

function cmdBuild(char) {
    const pal = loadPalette(char);
    const [fw, fh] = char.frameSize;
    const frames = [], names = [];
    mkdirSync(join(FRAMES, char.id), { recursive: true });
    for (const [dirName, set] of Object.entries(char.frameSets)) {
        if (!existsSync(join(SHEETS, char.id, `${set.view}_${char.seed}.png`))) {
            console.log(`[skip] ${set.view}: лист ещё не сгенерирован (atlas.mjs gen ${char.id})`);
            continue;
        }
        const { sheet, figures } = sheetFigures(char, set.view);
        if (Math.max(...set.poses) >= figures.length) {
            throw new Error(`${set.view}: найдено фигур ${figures.length}, поза ${Math.max(...set.poses)} не выбирается (см. crop в манифесте)`);
        }
        for (const pose of set.poses) {
            const rgba = quantize(resizeRect(sheet, figures[pose], fw, fh), pal);
            frames.push(rgba);
            names.push(`${char.id}_${dirName}_${pose + 1}`);
            writeFileSync(join(FRAMES, char.id, `${names[names.length - 1]}.png`), encodePng(fw, fh, Buffer.from(rgba)));
        }
    }
    if (!frames.length) throw new Error('ни одного ракурса нет — сначала atlas.mjs gen');
    const outDir = join(BUILD, char.id);
    mkdirSync(dirname(join(outDir, char.target)), { recursive: true });
    const { png, json } = buildAtlas(frames, names, { frameW: fw, frameH: fh, image: char.target });
    writeFileSync(join(outDir, char.target), png);
    writeFileSync(join(outDir, char.target.replace(/\.png$/, '.json')), JSON.stringify(json, null, 2));
    writeFileSync(join(outDir, '_review.png'), frameSheet(frames, fw, fh));
    console.log(`[build] ${frames.length} кадров -> ${outDir} (обзорный лист _review.png, статус: ${char.status})`);
}

// ---------- promote: в assets после апрува ----------

function cmdPromote(char) {
    if (char.status !== 'approved') {
        throw new Error(`статус «${char.status}» — promote только при approved (правь atlas-manifest.json после апрува обзорного листа)`);
    }
    const outDir = join(BUILD, char.id);
    const dst = join(HERE, '..', '..', 'assets');
    const jsonTarget = char.target.replace(/\.png$/, '.json');
    copyFileSync(join(outDir, char.target), join(dst, char.target));
    copyFileSync(join(outDir, jsonTarget), join(dst, jsonTarget));
    console.log(`[promote] ${char.target} (+ JSON) -> assets. Внимание: npm run art перегенерирует кодовый атлас поверх.`);
}

// ---------- CLI ----------

const [, , cmd, id, ...rest] = process.argv;
if (!cmd || !id) {
    console.log('Использование: node atlas.mjs <gen|build|promote> <id> [--force]');
    process.exit(1);
}
const char = loadChar(id);
const handlers = { gen: cmdGen, build: cmdBuild, promote: cmdPromote };
if (!handlers[cmd]) throw new Error(`неизвестная команда «${cmd}»`);
handlers[cmd](char, { force: rest.includes('--force') });