Files

432 lines
32 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Genarrative -> Genielabs Spine Animation AI pipeline.
*
* The only provider operation here is the GPT image edit request. All keys
* stay server-side/in the local process. Genielabs is invoked as an external
* tool so its PolyForm license and dependencies remain isolated.
*/
import {Buffer} from 'node:buffer';
import {spawnSync} from 'node:child_process';
import {cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
import path from 'node:path';
const repoRoot = path.dirname(new URL(import.meta.url).pathname);
const defaultGenielabsDir = '/Users/zhaoziyu/Documents/Codex/2026-09-10/diao/work/repos/spine-animation-ai';
const defaultModel = 'gpt-image-2';
// These prompts are deliberately character-agnostic. The reference is the
// only source of truth; the model must not infer a conventional T-pose or fill
// in a hidden/missing counterpart. Keeping several phrasings is useful since
// image models sometimes satisfy one wording while ignoring another.
const positivePrompt = [
'Create a production-ready 2D cutout sheet for a skeletal animation rig from the supplied reference image.',
'Use the reference as the only ground truth. Preserve the exact camera view, proportions, relative scale, handedness, limb bends, body lean and every visible asymmetry.',
'Make individually movable layers at natural articulation or occlusion boundaries: include only visible regions that actually exist in the reference (for example head, hair, face, hat, torso, clothing, sleeves, upper/lower limbs, hands, feet and accessories as applicable).',
'Move cutout pieces apart by translation only. Keep each piece in its original orientation and preserve screen-left/screen-right ownership. Extract visible surfaces; do not invent a fully hidden limb or a mirrored counterpart.',
'Render one copy of every layer only. Do not mirror, straighten, normalize, symmetrize, pose, redesign, or complete the character. Do not move parts into a different pose or make a second version of any part.',
'Output isolated cutout pieces ONLY. Do not include the original assembled figure, a full-body preview, a reference thumbnail or a before/after comparison anywhere in the output. Keep eyes, pupils, eyebrows, nose and mouth painted on the face rather than emitting separate facial fragments.',
'Preserve the original rendering technique, linework, texture, shading and colors. Leave clear white gaps between every pair of pieces. Use a loose exploded arrangement following the original anatomy, with every piece fully inside the canvas. Do not join neighboring pieces just to preserve their original coordinates.',
].join(' ');
const negativePrompt = [
'assembled preview figure, reference thumbnail, duplicate complete character, redesign, new outfit, alternate pose, T-pose, mirrored pose, duplicated limb, invented hidden part, merged pieces, cut-off layers, isolated pupils, isolated facial features, scenery, background shadows, labels, arrows, text, watermark',
].join(' ');
const promptVariants = [
positivePrompt,
`${positivePrompt} Registration pass: keep scale and angle fixed across pieces; only their translation changes to open gaps. Retain the exact screen-left/screen-right ownership.`,
`${positivePrompt} Articulation pass: make cuts only at real joints, clothing seams or occlusion boundaries. Keep hands, shoes, hair and accessories as coherent shapes; never shatter a single shape into noise fragments.`,
`${positivePrompt} Occlusion audit: inspect what is actually visible before cutting. Move visible rear surfaces apart for this sheet; never add an assembled view to demonstrate how they fit together.`,
`${positivePrompt} Conservative pass: prefer fewer complete, useful layers over many tiny fragments. Each output layer must be a meaningful movable part with enough surrounding pixels for animation.`,
`${positivePrompt} Pixel-fidelity pass: preserve the reference's exact edge contours, facial features, costume details, colors and non-symmetric construction. Do not redraw or reinterpret the artwork.`,
];
function args(argv) {
const out = {reference: '', outDir: '', genielabsDir: defaultGenielabsDir, python: '', envFile: '', promptFile: '', model: process.env.GENARRATIVE_SPINE_IMAGE_MODEL || defaultModel, size: '1024x1024', animations: 'idle,walk,wave', layoutMode: 'matched', minScore: 0.3, maxParts: 28, attempts: 6, resume: false};
for (let i = 2; i < argv.length; i += 1) {
const key = argv[i];
if (key === '--reference') out.reference = argv[++i] || '';
else if (key === '--out-dir') out.outDir = argv[++i] || '';
else if (key === '--genielabs-dir') out.genielabsDir = argv[++i] || '';
else if (key === '--python') out.python = argv[++i] || 'python3';
else if (key === '--env-file') out.envFile = argv[++i] || '';
else if (key === '--prompt-file') out.promptFile = argv[++i] || '';
else if (key === '--image-model') out.model = argv[++i] || defaultModel;
else if (key === '--size') out.size = argv[++i] || '1024x1024';
else if (key === '--animations') out.animations = argv[++i] || 'idle';
else if (key === '--layout-mode') out.layoutMode = argv[++i] || 'matched';
else if (key === '--min-score') out.minScore = Number(argv[++i] || 0.55);
else if (key === '--max-parts') out.maxParts = Number(argv[++i] || 18);
else if (key === '--attempts') out.attempts = Math.max(1, Math.min(promptVariants.length, Number(argv[++i] || promptVariants.length)));
else if (key === '--resume') out.resume = true;
else if (key === '--help' || key === '-h') { console.log('用法:node pipeline.mjs --reference ref.png --out-dir ./output --genielabs-dir /path/to/spine-animation-ai'); process.exit(0); }
}
return out;
}
function run(command, commandArgs, cwd = repoRoot) {
const result = spawnSync(command, commandArgs, {cwd, encoding: 'utf8', stdio: 'inherit'});
if (result.error || result.status !== 0) throw new Error(`${command} 执行失败(状态 ${result.status ?? 'unknown'})`);
}
function resolveModel(model) {
const normalized = String(model || '').trim();
if (normalized === 'gpt-image-2.5' || normalized === 'gpt image 2.5') {
console.warn('提示:VectorEngine 当前没有 gpt-image-2.5,已兼容映射为 gpt-image-2。');
return defaultModel;
}
return normalized || defaultModel;
}
function endpoint(base, suffix) { return base.endsWith('/v1') ? `${base}/${suffix}` : `${base}/v1/${suffix}`; }
function envConfig(envFile) {
const env = {...loadEnvFile(envFile || path.join(repoRoot, '.env')), ...process.env};
const base = String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, '');
const key = String(env.VECTOR_ENGINE_API_KEY || '').trim();
if (!base || !key) throw new Error('需要配置 VECTOR_ENGINE_BASE_URL 和 VECTOR_ENGINE_API_KEY(只在后端/本地进程使用)');
return {base, key, timeout: Number(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || 1200000)};
}
function loadEnvFile(file) {
if (!existsSync(file)) return {};
const result = {};
for (const line of readFileSync(file, 'utf8').split(/\r?\n/u)) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/u);
if (match && !match[1].includes('KEY')) result[match[1]] = match[2].replace(/^['"]|['"]$/gu, '');
else if (match && match[1].includes('KEY') && !process.env[match[1]]) result[match[1]] = match[2].replace(/^['"]|['"]$/gu, '');
}
return result;
}
function selectPython(requested) {
const candidates = [requested, '/Users/zhaoziyu/anaconda3/bin/python', '/opt/homebrew/bin/python3', 'python3'].filter(Boolean);
for (const candidate of candidates) {
const check = spawnSync(candidate, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return candidate;
}
const venvPython = path.join(repoRoot, '.venv', 'bin', 'python');
if (!existsSync(venvPython)) {
const bootstrap = spawnSync('python3', ['-m', 'venv', path.join(repoRoot, '.venv')], {stdio: 'inherit'});
if (bootstrap.status !== 0) throw new Error('无法创建项目虚拟环境');
const install = spawnSync(venvPython, ['-m', 'pip', 'install', '-r', path.join(repoRoot, 'requirements.txt')], {stdio: 'inherit'});
if (install.status !== 0) throw new Error('Python 依赖安装失败,请检查网络或手动安装 requirements.txt');
}
const check = spawnSync(venvPython, ['-c', 'import cv2, PIL, numpy'], {stdio: 'ignore'});
if (check.status === 0) return venvPython;
throw new Error('虚拟环境安装后仍缺少 cv2、Pillow、numpy');
}
async function requestAtlas(reference, model, size, config, prompt) {
for (let attempt = 0; attempt < 3; attempt += 1) {
const form = new FormData();
form.append('model', model); form.append('prompt', `${prompt}\nAvoid: ${negativePrompt}`); form.append('n', '1'); form.append('size', size);
form.append('image', new Blob([readFileSync(reference)], {type: 'image/png'}), path.basename(reference));
const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), config.timeout);
try {
const response = await fetch(endpoint(config.base, 'images/edits'), {method: 'POST', headers: {Authorization: `Bearer ${config.key}`}, body: form, signal: controller.signal});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = payload.error?.message || 'unknown error';
if ((response.status === 429 || response.status >= 500) && attempt < 2) { await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 2000)); continue; }
throw Object.assign(new Error(`VectorEngine 图片编辑失败(${response.status}):${message}`), {status: response.status});
}
const item = payload.data?.[0] || payload.images?.[0] || payload.output?.[0];
if (!item) throw new Error('VectorEngine 未返回图片');
if (item.b64_json) return Buffer.from(item.b64_json, 'base64');
if (item.url) return Buffer.from(await (await fetch(item.url)).arrayBuffer());
throw new Error('VectorEngine 图片响应缺少 url/b64_json');
} finally { clearTimeout(timer); }
}
throw new Error('VectorEngine 图片编辑重试次数耗尽');
}
function buildSpineConfig(layoutPath, outputPath, animations) {
// position_parts.py can emit Python's non-standard NaN for low-texture
// template matches; normalize it before parsing in strict JavaScript.
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const names = Object.keys(layout.parts || {});
const bones = [{name: 'root'}];
const slots = [];
const attachments = {};
const requestedAnimations = animations.split(',').map((v) => v.trim()).filter(Boolean);
const customAnimations = Object.fromEntries(requestedAnimations.map((animation, animationIndex) => {
const amplitude = animationIndex % 2 === 0 ? 2 : 4;
const timelines = {};
for (const name of (layout.z_order || names)) {
const boneName = name.replace(/[^a-zA-Z0-9_-]/gu, '_');
timelines[boneName] = {rotate: [{time: 0, angle: 0}, {time: 0.5, angle: amplitude}, {time: 1, angle: 0}]};
}
return [animation, {bones: timelines}];
}));
for (const name of (layout.z_order || names)) {
const part = layout.parts[name];
if (!part) continue;
const boneName = name.replace(/[^a-zA-Z0-9_-]/gu, '_');
const x = Number(part.x || 0) + Number(part.width || 0) / 2 - Number(layout.canvas_width || 0) / 2;
const y = Number(layout.canvas_height || 0) - (Number(part.y || 0) + Number(part.height || 0) / 2);
bones.push({name: boneName, parent: 'root', x: Math.round(x), y: Math.round(y), rotation: Number(part.rotation || 0), length: Math.max(1, Math.round(part.height || 1))});
slots.push({name: boneName, bone: boneName, attachment: name});
attachments[name] = {type: 'region', path: name, width: Number(part.width || 1), height: Number(part.height || 1)};
}
writeFileSync(outputPath, JSON.stringify({skeleton: {name: path.basename(outputPath, '.json'), width: layout.canvas_width, height: layout.canvas_height}, bones, slots, attachments, animations: [], custom_animations: customAnimations}, null, 2));
}
function filterLayout(layoutPath, partsDir, minScore, maxParts) {
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const originalCount = Object.keys(layout.parts || {}).length;
const ranked = Object.entries(layout.parts || {}).filter(([, part]) => {
// Genielabs may serialize low-texture template scores as NaN. Keep them
// as low-confidence candidates so the rig is not reduced to only head and
// torso, while the max-parts cap prevents the old "exploded" output.
const score = Number.isFinite(part.score) ? part.score : (part.method === 'template' ? 0.3 : 0);
part.score = score;
return score >= minScore;
}).sort(([, a], [, b]) => Number(b.score || 0) - Number(a.score || 0));
const keep = new Set(ranked.slice(0, Math.max(1, maxParts)).map(([name]) => name));
// A cutout model sometimes emits both a complete limb/clothing piece and
// a second low-texture fragment of that same piece. Once positioned,
// suppress the weaker template candidate when most of its bbox is covered
// by a larger, stronger part. This is geometry-based and works across
// characters; it prevents duplicate arms/legs from peeking through.
for (const [name, part] of ranked) {
if (!keep.has(name) || part.method !== 'template') continue;
const area = Math.max(1, Number(part.width || 0) * Number(part.height || 0));
for (const [otherName, other] of ranked) {
if (name === otherName || !keep.has(otherName)) continue;
const otherArea = Number(other.width || 0) * Number(other.height || 0);
if (otherArea <= area || Number(other.score || 0) < Number(part.score || 0)) continue;
const ix = Math.max(0, Math.min(part.x + part.width, other.x + other.width) - Math.max(part.x, other.x));
const iy = Math.max(0, Math.min(part.y + part.height, other.y + other.height) - Math.max(part.y, other.y));
if ((ix * iy) / area >= 0.5) { keep.delete(name); break; }
}
}
for (const name of Object.keys(layout.parts || {})) {
if (!keep.has(name)) {
delete layout.parts[name];
}
}
layout.z_order = (layout.z_order || []).filter((name) => keep.has(name));
layout.quality = {minScore, maxParts, keptParts: layout.z_order.length, droppedParts: originalCount - layout.z_order.length};
writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
if (layout.z_order.length < 3) throw new Error(`有效拆件过少(${layout.z_order.length}),请提高拆件图质量或降低 --min-score`);
}
function pngSize(file) {
const bytes = readFileSync(file);
if (bytes.readUInt32BE(0) === 0x89504e47) return {width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20)};
throw new Error('参考图目前需要 PNG 格式');
}
// Score a generated sheet without assuming a particular character or an
// expected number of limbs. Count-only selection used to prefer exactly 12
// parts, which rewarded hallucinated duplicates on simple characters and
// dropped valid parts on unusual silhouettes. This score instead penalizes
// obvious failure modes: tiny noise components, one giant merged silhouette,
// implausible foreground coverage and extreme component counts.
function candidateQuality(stats) {
const width = Number(stats.atlas_width || 0);
const height = Number(stats.atlas_height || 0);
const area = Math.max(1, width * height);
const parts = Array.isArray(stats.parts) ? stats.parts : [];
const count = parts.length;
if (!count) return {score: Number.POSITIVE_INFINITY, count, tinyRatio: 1, giantRatio: 1, borderRatio: 1, coverage: 0};
const componentAreas = parts.map((part) => Math.max(0, Number(part.area || 0)));
const minUsefulArea = Math.max(500, area * 0.00045);
const tiny = componentAreas.filter((value) => value < minUsefulArea).length;
const giant = componentAreas.filter((value) => value > area * 0.42).length;
const border = parts.filter((part) => Number(part.x || 0) <= 1 || Number(part.y || 0) <= 1 || Number(part.x || 0) + Number(part.width || 0) >= width - 1 || Number(part.y || 0) + Number(part.height || 0) >= height - 1).length;
const coverage = componentAreas.reduce((sum, value) => sum + value, 0) / area;
const countPenalty = count < 4 ? (4 - count) * 5 : count > 28 ? (count - 28) * 1.5 : 0;
const tinyPenalty = (tiny / count) * 18;
const giantPenalty = giant * 20;
const borderPenalty = (border / count) * 8;
const coveragePenalty = coverage < 0.015 ? 8 : coverage > 0.88 ? 8 : 0;
return {
score: countPenalty + tinyPenalty + giantPenalty + borderPenalty + coveragePenalty,
count,
tinyRatio: tiny / count,
giantRatio: giant / count,
borderRatio: border / count,
coverage,
};
}
function evaluateCandidatePlacement(reference, partsDir, outputPath, python, positionScript) {
const result = spawnSync(python, [positionScript, '--reference', reference, '--parts', partsDir, '--output', outputPath], {encoding: 'utf8'});
if (result.status !== 0 || !existsSync(outputPath)) return {score: 50, matchedParts: 0, meanMatch: 0, overlapPenalty: 0, error: (result.stderr || '').trim().slice(-400)};
try {
const layout = JSON.parse(readFileSync(outputPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const parts = Object.values(layout.parts || {});
const matchScores = parts.map((part) => Number(part.score || 0)).filter(Number.isFinite);
let overlapPenalty = 0;
for (let i = 0; i < parts.length; i += 1) {
for (let j = i + 1; j < parts.length; j += 1) {
const a = parts[i]; const b = parts[j];
const ix = Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x));
const iy = Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y));
const intersection = ix * iy;
const union = Math.max(1, a.width * a.height + b.width * b.height - intersection);
const iou = intersection / union;
if (iou > 0.55) overlapPenalty += (iou - 0.55) * 12;
}
}
const meanMatch = matchScores.length ? matchScores.reduce((sum, value) => sum + value, 0) / matchScores.length : 0;
const score = (1 - meanMatch) * 10 + overlapPenalty + Math.max(0, 5 - parts.length) * 2;
return {score, matchedParts: parts.length, meanMatch, overlapPenalty};
} catch (error) {
return {score: 50, matchedParts: 0, meanMatch: 0, overlapPenalty: 0, error: error.message};
}
}
function repairLayerOrder(layoutPath) {
const layout = JSON.parse(readFileSync(layoutPath, 'utf8').replace(/\b(?:NaN|Infinity|-Infinity)\b/gu, 'null'));
const order = [...(layout.z_order || Object.keys(layout.parts || {}))];
const index = new Map(order.map((name, i) => [name, i]));
const parts = layout.parts || {};
// Broad top accessories such as a hat brim should sit behind the face. A
// visual occlusion vote can invert this relation because both layers share
// similar colours. Enforce only the distinctive geometry pattern: a part
// above the canvas midpoint, at least 1.45x wider than a lower overlapping
// part. This remains character-agnostic and leaves hair/torso relations
// untouched in ordinary cases.
for (const [wideName, wide] of Object.entries(parts)) {
const canvasHeight = Number(layout.canvas_height || 0);
if (Number(wide.height) > Number(wide.width) * 0.65 || Number(wide.y) + Number(wide.height) / 2 > canvasHeight * 0.35) continue;
for (const [narrowName, narrow] of Object.entries(parts)) {
if (wideName === narrowName || Number(wide.y) >= Number(narrow.y) || Number(wide.width) < Number(narrow.width) * 1.45) continue;
const ix = Math.max(0, Math.min(wide.x + wide.width, narrow.x + narrow.width) - Math.max(wide.x, narrow.x));
const iy = Math.max(0, Math.min(wide.y + wide.height, narrow.y + narrow.height) - Math.max(wide.y, narrow.y));
const overlap = (ix * iy) / Math.max(1, Number(narrow.width) * Number(narrow.height));
if (overlap < 0.08) continue;
const wi = index.get(wideName); const ni = index.get(narrowName);
if (wi === undefined || ni === undefined || wi < ni) continue;
order.splice(wi, 1);
order.splice(order.indexOf(narrowName), 0, wideName);
order.forEach((name, i) => index.set(name, i));
}
}
layout.z_order = order;
writeFileSync(layoutPath, JSON.stringify(layout, null, 2));
}
function buildAnchoredLayout(reference, partsDir, layoutPath) {
const source = pngSize(reference);
const manifest = JSON.parse(readFileSync(path.join(partsDir, 'parts-manifest.json'), 'utf8'));
const partsByY = [...manifest.parts].sort((a, b) => a.y - b.y);
const top = partsByY.filter((part) => (part.y + part.height / 2) / manifest.atlas_height < 0.25).sort((a, b) => b.area - a.area);
const torso = partsByY.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.25 && y < 0.43; }).sort((a, b) => b.area - a.area);
const torsoNames = new Set(torso.map((part) => part.name));
const middle = partsByY.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.35 && y < 0.56 && !torsoNames.has(part.name); }).sort((a, b) => a.x - b.x);
const lower = partsByY.filter((part) => (part.y + part.height / 2) / manifest.atlas_height >= 0.56);
const parts = {};
const add = (part, x, y, rotation = 0) => { parts[part.name] = {x: Math.round(x - part.width / 2), y: Math.round(y - part.height / 2), width: part.width, height: part.height, original_width: part.width, original_height: part.height, scale: 1, rotation, score: 1, method: 'anchored'}; };
const cx = source.width / 2;
// Head layers: put the hat behind the face so its brim cannot cover the eyes.
if (top[0]) add(top[0], cx, source.height * 0.13);
if (top[2]) add(top[2], cx, source.height * 0.27);
if (top[1]) add(top[1], cx, source.height * 0.30);
const leftPieces = middle.filter((part) => part.x < manifest.atlas_width * 0.5);
const rightPieces = middle.filter((part) => part.x >= manifest.atlas_width * 0.5);
// The rear arm must be drawn behind the shirt; otherwise it appears to grow
// out of the chest instead of sitting behind the torso.
rightPieces.forEach((part) => add(part, source.width * 0.67, source.height * 0.55, -25));
const torsoPart = torso[0]; if (torsoPart) add(torsoPart, cx, source.height * 0.53);
const sleeves = torso.slice(1).sort((a, b) => a.x - b.x); if (sleeves[0]) add(sleeves[0], source.width * 0.38, source.height * 0.49); if (sleeves[1]) add(sleeves[1], source.width * 0.68, source.height * 0.50);
leftPieces.sort((a, b) => a.x - b.x).forEach((part, i) => add(part, source.width * (i === 0 ? 0.15 : 0.27), source.height * 0.51, i === 0 ? 5 : 0));
const legRows = [...lower].sort((a, b) => a.y - b.y);
const upperLegs = legRows.filter((part) => (part.y + part.height / 2) / manifest.atlas_height < 0.72).sort((a, b) => a.x - b.x);
const lowerLegs = legRows.filter((part) => { const y = (part.y + part.height / 2) / manifest.atlas_height; return y >= 0.72 && y < 0.88; }).sort((a, b) => a.x - b.x);
const shoes = legRows.filter((part) => (part.y + part.height / 2) / manifest.atlas_height >= 0.88).sort((a, b) => a.x - b.x);
upperLegs.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.68));
lowerLegs.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.80));
shoes.slice(0, 2).forEach((part, i) => add(part, source.width * (i ? 0.60 : 0.43), source.height * 0.91));
const zOrder = Object.keys(parts);
writeFileSync(layoutPath, JSON.stringify({reference_image: path.basename(reference), canvas_width: source.width, canvas_height: source.height, parts, z_order: zOrder, quality: {mode: 'anchored', keptParts: zOrder.length, sourceSheetParts: manifest.parts.length}}, null, 2));
}
function writeReferencePreview(reference, outputPath) {
const data = readFileSync(reference).toString('base64');
writeFileSync(outputPath, `<!doctype html><meta charset="utf-8"><title>Reference Pose Preview</title><style>html,body{margin:0;min-height:100%;display:grid;place-items:center;background:#1a1a30}img{max-height:96vh;max-width:96vw;object-fit:contain}</style><img alt="reference pose" src="data:image/png;base64,${data}">`);
}
async function main() {
const options = args(process.argv);
if (!options.reference || !existsSync(options.reference) || !options.outDir || !options.genielabsDir) throw new Error('必须提供 --reference、--out-dir、--genielabs-dir');
options.python = selectPython(options.python);
const splitScript = path.join(repoRoot, 'segment.py');
const positionScript = path.join(options.genielabsDir, 'scripts', 'position_parts.py');
const buildScript = path.join(options.genielabsDir, 'scripts', 'build_spine_json.py');
const atlasScript = path.join(options.genielabsDir, 'scripts', 'make_atlas.py');
const playerScript = path.join(options.genielabsDir, 'scripts', 'generate_spine_player.py');
for (const file of [positionScript, buildScript, atlasScript, playerScript]) if (!existsSync(file)) throw new Error(`Genielabs 脚本不存在:${file}`);
const out = path.resolve(options.outDir); const parts = path.join(out, 'parts'); const atlasDir = path.join(out, 'atlas');
mkdirSync(out, {recursive: true}); mkdirSync(parts, {recursive: true}); mkdirSync(atlasDir, {recursive: true});
const model = resolveModel(options.model);
const atlasPath = path.join(out, 'generated-parts-sheet.png');
writeFileSync(path.join(out, 'source-original.png'), readFileSync(options.reference));
const positionReference = path.join(out, 'reference-position.png');
run(options.python, [path.join(repoRoot, 'normalize_reference.py'), '--input', options.reference, '--output', positionReference]);
writeFileSync(path.join(out, 'source.png'), readFileSync(positionReference));
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify({status: 'generating', model, requestedModel: options.model, pipelineVersion: 'genarrative-genielabs-spine-v1', source: path.basename(options.reference)}, null, 2));
const candidateRoot = path.join(out, 'candidates'); mkdirSync(candidateRoot, {recursive: true});
let selectedCandidate = null;
let candidateDiagnostics = [];
if (options.resume && existsSync(atlasPath) && existsSync(path.join(parts, 'parts-manifest.json'))) {
selectedCandidate = {atlas: atlasPath, parts};
console.log(`复用已有拆件工作图:${atlasPath}`);
} else {
const config = envConfig(options.envFile);
const candidates = [];
for (let index = 0; index < options.attempts; index += 1) {
const candidateDir = path.join(candidateRoot, `attempt-${index + 1}`); mkdirSync(candidateDir, {recursive: true});
const candidateAtlas = path.join(candidateDir, 'generated-parts-sheet.png');
const candidateParts = path.join(candidateDir, 'parts'); mkdirSync(candidateParts, {recursive: true});
try {
console.log(`尝试第 ${index + 1}/${options.attempts} 个通用拆件提示词`);
const prompt = options.promptFile ? readFileSync(options.promptFile, 'utf8') : promptVariants[index % promptVariants.length];
writeFileSync(path.join(candidateDir, 'prompt.txt'), `${prompt}\n\nAvoid: ${negativePrompt}\n`);
writeFileSync(candidateAtlas, await requestAtlas(options.reference, model, options.size, config, prompt));
run(options.python, [splitScript, '--atlas', candidateAtlas, '--output-dir', candidateParts]);
const stats = JSON.parse(readFileSync(path.join(candidateParts, 'parts-manifest.json'), 'utf8'));
const quality = candidateQuality(stats);
const placement = evaluateCandidatePlacement(positionReference, candidateParts, path.join(candidateDir, 'layout-eval.json'), options.python, positionScript);
const totalScore = quality.score + placement.score;
candidates.push({atlas: candidateAtlas, parts: candidateParts, count: quality.count, score: totalScore, quality, placement});
candidateDiagnostics.push({attempt: index + 1, count: quality.count, score: totalScore, quality, placement});
console.log(`候选 ${index + 1}: ${quality.count} 个部件,综合分 ${totalScore.toFixed(2)}(拆件 ${quality.score.toFixed(2)},定位 ${placement.score.toFixed(2)},匹配 ${(placement.meanMatch * 100).toFixed(0)}%,重叠罚分 ${placement.overlapPenalty.toFixed(2)})`);
} catch (error) {
candidateDiagnostics.push({attempt: index + 1, status: 'failed', error: error.message});
console.warn(`候选 ${index + 1} 失败:${error.message}`);
if (error.status === 401 || error.status === 403) break;
}
}
if (!candidates.length) throw new Error('所有拆件候选均失败');
selectedCandidate = candidates.sort((a, b) => a.score - b.score)[0];
cpSync(selectedCandidate.atlas, atlasPath); rmSync(parts, {recursive: true, force: true}); cpSync(selectedCandidate.parts, parts, {recursive: true});
console.log(`选择候选:${selectedCandidate.count} 个部件`);
}
const layout = path.join(out, 'layout.json');
if (options.layoutMode === 'matched') {
run(options.python, [positionScript, '--reference', positionReference, '--parts', parts, '--output', layout]);
filterLayout(layout, parts, options.minScore, options.maxParts);
repairLayerOrder(layout);
} else {
buildAnchoredLayout(options.reference, parts, layout);
}
// Emit a deterministic raster composite so placement can be inspected
// without loading the Web Player. It uses the same layout coordinates,
// scale, rotation and z-order that are written to the Spine skeleton.
run(options.python, [path.join(repoRoot, 'compose_preview.py'), '--reference', positionReference, '--parts', parts, '--layout', layout, '--output', path.join(out, 'assembled.png')]);
const spineConfig = path.join(out, 'spine-config.json'); buildSpineConfig(layout, spineConfig, options.animations);
const skeleton = path.join(out, 'skeleton.json'); run(options.python, [buildScript, '--config', spineConfig, '--output', skeleton]);
const skeletonJson = JSON.parse(readFileSync(skeleton, 'utf8'));
skeletonJson.skeleton = {...skeletonJson.skeleton, images: './atlas/'};
writeFileSync(skeleton, JSON.stringify(skeletonJson, null, 2));
run(options.python, [atlasScript, '--parts', parts, '--output', atlasDir, '--name', 'skeleton']);
const riggedPreview = path.join(out, 'preview-rigged.html'); run(options.python, [playerScript, '--skeleton', skeleton, '--atlas', path.join(atlasDir, 'skeleton.atlas'), '--atlas-image', path.join(atlasDir, 'skeleton.png'), '--output', riggedPreview, '--animation', options.animations.split(',')[0] || 'idle']);
const preview = path.join(out, 'preview.html'); writeReferencePreview(path.join(out, 'assembled.png'), preview);
const manifest = {status: 'completed', pipelineVersion: 'genarrative-genielabs-spine-v1', requestedModel: options.model, model, source: path.basename(options.reference), attempts: options.attempts, selectedCandidate: selectedCandidate.count ?? null, selectedCandidateScore: selectedCandidate.score ?? null, candidateDiagnostics, promptVariants: promptVariants.length, outputs: {parts, layout, skeleton, atlas: atlasDir, preview, riggedPreview}, limitations: ['默认 preview.html 显示参考图的真实非对称姿态,保证形体结构正常;preview-rigged.html 显示自动拆件 rig,需人工校正。', '当前自动生成的是 region attachments;mesh/weights/IK 仍需在 Spine 中人工校正。', 'Genielabs 脚本为外部依赖,请按其 PolyForm Noncommercial 许可证处理商业授权。']};
writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(manifest, null, 2)); console.log(`Spine pipeline 完成:${out}`);
}
main().catch((error) => { console.error(`Spine pipeline 失败:${error.message}`); process.exitCode = 1; });