接通第四步

This commit is contained in:
Ukawka
2026-09-22 01:02:21 +08:00
parent e9072aecf2
commit 8080d52a99
10 changed files with 960 additions and 22 deletions
+79
View File
@@ -0,0 +1,79 @@
import {execFileSync} from 'node:child_process';
import {readFileSync, readdirSync} from 'node:fs';
import path from 'node:path';
import {projectRoot} from '../lib/config.mjs';
import {selectPython} from '../lib/python-runtime.mjs';
import {validateParts} from '../lib/validate-parts.mjs';
import {sha256} from '../lib/images.mjs';
import {prepareRun, jsonFile, finishRun} from '../lib/run-artifacts.mjs';
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
const filesIn = (dir, prefix = '') => readdirSync(dir, {withFileTypes: true}).flatMap((entry) => entry.isDirectory()
? filesIn(path.join(dir, entry.name), `${prefix}${entry.name}/`) : [`${prefix}${entry.name}`]);
export async function runSpine(options) {
if (!options.inputDir || (!options.outDir && !options.dryRun)) throw new Error('Spine 阶段需要 --input-dir SAM目录 和独立的 --out-dir');
const input = path.resolve(options.inputDir);
const completion = options.completionDir ? path.resolve(options.completionDir) : undefined;
const plan = readJson(path.join(input, 'semantic-parts.json'));
const validation = validateParts(plan);
if (!validation.valid || plan.schema_version !== 2) throw new Error(`Spine 阶段需要有效 v2 计划:${validation.errors.join(', ')}`);
const fallback = options.fallbackPolicy || 'sam';
const angle = Number(options.checkAngle ?? 10);
if (!['sam', 'error'].includes(fallback)) throw new Error('--fallback-policy 仅支持 sam / error');
if (!(angle > 0 && angle <= 45)) throw new Error('--check-angle 必须在 (0, 45] 度之间');
const python = selectPython(options.python);
const external = path.resolve(options.genielabsDir || path.join(projectRoot, '..', 'spine-animation-ai'));
const scripts = ['make_atlas.py', 'generate_spine_player.py'].map((name) => path.join(external, 'scripts', name));
const codeFiles = ['spine_assembly.py', 'stages/spine-stage.mjs', 'tools/model-check.mjs',
'lib/python-runtime.mjs', 'lib/run-artifacts.mjs', 'lib/images.mjs', 'lib/validate-parts.mjs',
'schemas/semantic-parts.schema.json'].map((name) => path.join(projectRoot, name));
const codeHash = sha256([...codeFiles, ...scripts].map((file) => readFileSync(file)).map((bytes) => sha256(bytes)).join('\n'));
function call(script, args) {
try {
return execFileSync(python, [script, ...args], {cwd: projectRoot, windowsHide: true,
encoding: 'utf8', timeout: 120000, maxBuffer: 8 * 1024 * 1024,
env: {...process.env, PYTHONUTF8: '1'}, stdio: ['ignore', 'pipe', 'pipe']});
} catch (error) { throw new Error(`Spine 离线处理失败:${error.stderr || error.message}`); }
}
const worker = path.join(projectRoot, 'spine_assembly.py');
const args = ['--input-dir', input, '--fallback-policy', fallback, '--check-angle', String(angle)];
if (completion) args.push('--completion-dir', completion);
if (options.drawOrderFile) args.push('--draw-order-file', path.resolve(options.drawOrderFile));
// Inspect all masks, selected PNGs, crop coordinates and input identities before creating output.
const inspected = JSON.parse(call(worker, args));
if (options.dryRun) {
const result = {dryRun: true, stage: 'spine', ...inspected, network_requested: false};
console.log(JSON.stringify(result, null, 2));
return result;
}
const identity = {schema_version: 1, stage: 'spine', spine_version: '4.2.0',
inputs: inspected.input_hashes, code_sha256: codeHash, fallback_policy: fallback, check_angle: angle};
const fingerprint = sha256(JSON.stringify(identity));
const run = prepareRun(options.outDir, fingerprint, options.resume);
if (run.reused) return {out: run.out, reused: true};
const manifest = {...identity, fingerprint, status: 'running', quality_status: 'needs_review',
network_requested: false, started_at: new Date().toISOString(),
external_dependency: {name: 'spine-animation-ai', license: 'PolyForm-Noncommercial-1.0.0'},
limitations: ['region attachments only; no mesh, weights or IK',
'setup and per-bone check animations only; semantic and motion quality need review',
'preview embeds assets but loads Spine Player 4.2 from CDN']};
jsonFile(run.manifestPath, manifest);
try {
const result = JSON.parse(call(worker, [...args, '--output', run.out]));
if (JSON.stringify(result.input_hashes) !== JSON.stringify(inspected.input_hashes)) throw new Error('处理期间输入发生变化,请使用新输出目录重跑');
call(scripts[0], ['--parts', path.join(run.out, 'images'), '--output', path.join(run.out, 'atlas'), '--name', 'skeleton']);
call(scripts[1], ['--skeleton', path.join(run.out, 'skeleton.json'),
'--atlas', path.join(run.out, 'atlas', 'skeleton.atlas'), '--atlas-image', path.join(run.out, 'atlas', 'skeleton.png'),
'--output', path.join(run.out, 'preview.html'), '--animation', 'setup', '--title', 'Spine Assembly Review']);
Object.assign(manifest, {part_count: result.parts, bone_count: result.bones,
completion_part_count: result.completion_parts, fallback_part_count: result.fallback_parts,
completed_at: new Date().toISOString()});
finishRun(run, manifest, filesIn(run.out).filter((name) => name !== 'manifest.json'));
return {out: run.out, reused: false, ...result};
} catch (error) {
manifest.status = 'failed'; manifest.error = error.message;
jsonFile(run.manifestPath, manifest);
throw error;
}
}