Files
AI_spine/pipeline.mjs
T
2026-09-17 18:50:43 +08:00

304 lines
22 KiB
JavaScript
Raw 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, unlinkSync, writeFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import path from 'node:path';
import {selectPython} from './lib/python-runtime.mjs';
const repoRoot = path.dirname(fileURLToPath(import.meta.url));
const defaultGenielabsDir = path.resolve(repoRoot, '..', 'spine-animation-ai');
const defaultModel = 'gpt-image-2';
const positivePrompt = [
'Create a complete 2D game sprite sheet for Spine animation from the exact reference character.',
'Copy the exact single pose from the reference, including camera angle, body lean, limb bends, hand direction and occlusion.',
'Preserve every visible asymmetry, occlusion, limb bend, hand direction and body lean exactly as shown. Do not mirror, straighten, normalize or symmetrize the pose.',
'Output one set of isolated visible parts only. Do not add alternate poses, duplicate parts, extra props, effects or text. Keep the original style and lay parts on a white background.',
'Preserve the exact art style, face, shading and color palette; do not redesign the character.',
].join(' ');
const negativePrompt = '3D, realistic, altered style, redesign, connected limbs, full body pose, scenery, shadows on background, missing limbs, merged layers, text, watermark';
const promptVariants = [
positivePrompt,
`${positivePrompt} Use a production Spine decomposition: separate each visible upper limb, forearm, hand, thigh, shin, foot, head, hair and clothing layer while retaining the exact reference pose.`,
`Technical 2D cutout sheet. Treat the reference as ground truth. Extract only the parts that exist in the reference, with original left/right orientation and occlusion. Never invent a symmetric counterpart or a second version of any part. ${positivePrompt}`,
];
function args(argv) {
const out = {reference: '', outDir: '', genielabsDir: defaultGenielabsDir, python: '', envFile: '', model: process.env.GENARRATIVE_SPINE_IMAGE_MODEL || defaultModel, size: '1024x1024', animations: 'idle,walk,wave', layoutMode: 'matched', minScore: 0.3, maxParts: 18, attempts: 3, 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 === '--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(3, Number(argv[++i] || 3)));
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) {
// Preserve exact provider IDs so experiments never silently use another model.
return String(model || '').trim() || 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;
}
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 new Error(`VectorEngine 图片编辑失败(${response.status}):${message}`);
}
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)) {
const file = path.join(partsDir, `${name}.png`);
if (existsSync(file)) unlinkSync(file);
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 格式');
}
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 modeIndex = process.argv.indexOf('--mode');
if (modeIndex >= 0) {
if (process.argv[modeIndex + 1] !== 'annotated-parts') throw new Error('新增 --mode 仅支持 annotated-parts;旧流程无需 --mode');
const {main: modelMain} = await import('./tools/model-check.mjs');
return modelMain(process.argv.slice(2));
}
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 config = envConfig(options.envFile);
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;
if (options.resume && existsSync(atlasPath) && existsSync(path.join(parts, 'parts-manifest.json'))) {
selectedCandidate = {atlas: atlasPath, parts};
console.log(`复用已有拆件工作图:${atlasPath}`);
} else {
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} 个通用拆件提示词`);
writeFileSync(candidateAtlas, await requestAtlas(options.reference, model, options.size, config, promptVariants[index % promptVariants.length]));
run(options.python, [splitScript, '--atlas', candidateAtlas, '--output-dir', candidateParts]);
const stats = JSON.parse(readFileSync(path.join(candidateParts, 'parts-manifest.json'), 'utf8'));
const count = stats.parts.length; const score = Math.abs(count - 12) + (count > 24 ? count - 24 : 0);
candidates.push({atlas: candidateAtlas, parts: candidateParts, count, score});
console.log(`候选 ${index + 1}: ${count} 个部件,质量分 ${score}`);
} catch (error) { console.warn(`候选 ${index + 1} 失败:${error.message}`); }
}
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);
} 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(options.reference, 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, 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; });