432 lines
32 KiB
JavaScript
432 lines
32 KiB
JavaScript
#!/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; });
|