304 lines
22 KiB
JavaScript
304 lines
22 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, 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; });
|