移除导入时的缩略图生成
This commit is contained in:
1 parent
bb73cc9227
commit
fe71d2d6ce
32 files changed
+35
-4192
No files matched your search
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* 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, 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) => {
|
||||
// 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
|
||||
// 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
|
||||
const p = texturePaths && texturePaths[name];
|
||||
if (!p || !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 atlasText = fs.readFileSync(atlasPath, 'utf8');
|
||||
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
|
||||
|
||||
const spine = loadRuntime(runtimeDir);
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, 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);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(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;
|
||||
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
|
||||
catch (e) {
|
||||
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.premultipliedAlpha = premultipliedAlpha;
|
||||
renderer.draw(skeleton);
|
||||
if (renderer.dispose) renderer.dispose();
|
||||
}
|
||||
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 };
|
||||
Reference in new issue
Block a user