212 lines
7.7 KiB
JavaScript
212 lines
7.7 KiB
JavaScript
/**
|
|
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
|
|
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
|
|
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
|
|
*/
|
|
const fs = require('fs');
|
|
const vm = require('vm');
|
|
const path = require('path');
|
|
const utils = require('./spine-utils.js');
|
|
const spineBadge = require('./spine-badge.js');
|
|
|
|
let canvasLib = null;
|
|
try {
|
|
canvasLib = require('@napi-rs/canvas');
|
|
} catch (e) {
|
|
canvasLib = null;
|
|
}
|
|
|
|
let spineBadgePromise = null;
|
|
|
|
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
|
|
async function drawSpineBadge(ctx, width, height) {
|
|
if (!canvasLib || !ctx) return;
|
|
try {
|
|
if (!spineBadgePromise) {
|
|
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
|
|
}
|
|
const badge = await spineBadgePromise;
|
|
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
|
|
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
|
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
|
|
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
|
|
}
|
|
|
|
/**
|
|
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
|
|
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
|
|
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
|
|
*/
|
|
function loadRuntime(dir) {
|
|
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
|
|
const sandbox = {
|
|
console,
|
|
setTimeout,
|
|
clearTimeout,
|
|
setInterval,
|
|
clearInterval,
|
|
performance: { now: () => Date.now() },
|
|
};
|
|
// 部分运行时通过 window/self/globalThis 访问全局
|
|
sandbox.window = sandbox;
|
|
sandbox.self = sandbox;
|
|
sandbox.globalThis = sandbox;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
|
|
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
|
|
}
|
|
|
|
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
|
|
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
|
|
*/
|
|
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
|
|
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
|
return new Promise((resolve, reject) => {
|
|
(async () => {
|
|
// 解析所有页面贴图名,并预先异步加载
|
|
let pageNames = [];
|
|
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
|
|
|
|
const textureMap = {};
|
|
await Promise.all(pageNames.map(async (name) => {
|
|
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
|
|
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
|
|
if (!fs.existsSync(p)) return;
|
|
try {
|
|
const img = await canvasLib.loadImage(p);
|
|
textureMap[name] = img;
|
|
} catch (e) { /* ignore */ }
|
|
}));
|
|
|
|
const makeTexture = (name) => {
|
|
let img = textureMap[name];
|
|
if (!img) {
|
|
// 找不到图片时用 2x2 灰色占位纹理
|
|
const c = canvasLib.createCanvas(2, 2);
|
|
const cx = c.getContext('2d');
|
|
cx.fillStyle = '#888888';
|
|
cx.fillRect(0, 0, 2, 2);
|
|
img = c;
|
|
}
|
|
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
|
|
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
|
|
return new spine.Texture(img);
|
|
};
|
|
|
|
let atlas;
|
|
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
|
|
if (is4x) {
|
|
atlas = new spine.TextureAtlas(atlasText);
|
|
for (const page of atlas.pages) {
|
|
const tex = makeTexture(page.name);
|
|
if (page.setTexture) page.setTexture(tex);
|
|
else page.texture = tex;
|
|
}
|
|
} else {
|
|
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
|
|
}
|
|
resolve(atlas);
|
|
})().catch(reject);
|
|
});
|
|
}
|
|
|
|
/** 读取骨骼数据(json 或二进制) */
|
|
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
|
|
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
|
if (isBinary) {
|
|
const loader = new spine.SkeletonBinary(atlasLoader);
|
|
loader.scale = 1;
|
|
const buf = fs.readFileSync(skeletonPath);
|
|
return loader.readSkeletonData(new Uint8Array(buf));
|
|
} else {
|
|
const loader = new spine.SkeletonJson(atlasLoader);
|
|
loader.scale = 1;
|
|
const text = fs.readFileSync(skeletonPath, 'utf8');
|
|
return loader.readSkeletonData(text);
|
|
}
|
|
}
|
|
|
|
/** 计算包围盒(跨版本兼容) */
|
|
function getBounds(spine, skeleton) {
|
|
try {
|
|
if (skeleton.getBoundsRect) {
|
|
const r = skeleton.getBoundsRect();
|
|
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
}
|
|
if (skeleton.getBounds) {
|
|
const offset = new spine.Vector2();
|
|
const size = new spine.Vector2();
|
|
skeleton.getBounds(offset, size, []);
|
|
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 渲染首帧动画到 PNG buffer。
|
|
* @returns {Promise<Buffer>}
|
|
*/
|
|
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
|
|
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
|
|
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
|
|
const dir = path.dirname(skeletonPath);
|
|
const atlasText = fs.readFileSync(atlasPath, 'utf8');
|
|
|
|
const spine = loadRuntime(runtimeDir);
|
|
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
|
|
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
|
|
|
|
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
|
|
const name = path.basename(skeletonPath);
|
|
throw new Error('骨骼数据为空:' + name);
|
|
}
|
|
const skeleton = new spine.Skeleton(skeletonData);
|
|
|
|
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
|
|
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
|
|
let bounds = null;
|
|
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
|
|
if (skeleton.updateWorldTransform) {
|
|
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
|
|
else skeleton.updateWorldTransform();
|
|
}
|
|
bounds = getBounds(spine, skeleton);
|
|
|
|
const cw = size, ch = size;
|
|
const canvas = canvasLib.createCanvas(cw, ch);
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#1e1e1e';
|
|
ctx.fillRect(0, 0, cw, ch);
|
|
|
|
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
|
const pad = 0.1;
|
|
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
|
|
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
|
|
const cx = bounds.x + bounds.width / 2;
|
|
const cy = bounds.y + bounds.height / 2;
|
|
ctx.save();
|
|
ctx.translate(cw / 2, ch / 2);
|
|
ctx.scale(scale, -scale);
|
|
ctx.translate(-cx, -cy);
|
|
|
|
let renderer = null;
|
|
if (runtimeDir === '3.4') renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir);
|
|
else if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
|
|
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
|
|
if (renderer) {
|
|
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
|
|
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
|
|
renderer.draw(skeleton);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
await drawSpineBadge(ctx, cw, ch);
|
|
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
|
|
const buf = canvas.toBuffer('image/png');
|
|
return buf;
|
|
}
|
|
|
|
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
|