/** * 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} */ 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 };