缩略图自动生成背景服务插件

This commit is contained in:
Yeomai committed 2026-09-20 20:34:17 +08:00
1 parent c28adf9fae
commit 065b6466f1
27 files changed
+81166

No files matched your search

+218
View File
@@ -0,0 +1,218 @@
// 在 service 插件页面里渲染一张缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出 PNG。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const runtimeCache = new Map();
/**
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
*
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
*/
function loadRuntime(runtimeDir) {
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
const job = new Promise((resolve, reject) => {
const frame = document.createElement('iframe');
frame.style.display = 'none';
frame.onload = () => {
try {
const win = frame.contentWindow;
const script = win.document.createElement('script');
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
script.onload = () => {
try {
const spine = win.spine;
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
resolve(spine);
} catch (error) {
reject(error);
}
};
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
win.document.head.appendChild(script);
} catch (error) {
reject(error);
}
};
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
document.body.appendChild(frame);
});
runtimeCache.set(runtimeDir, job);
return job;
}
/** 本地绝对路径 → file:// URL(逐段编码,保留盘符)。 */
function fileUrl(filePath) {
const normalized = String(filePath).replace(/\\/g, '/');
return 'file:///' + normalized.split('/')
.map((part, index) => (index === 0 ? part : encodeURIComponent(part)))
.join('/');
}
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
function withTimeout(promise, timeoutMs, label) {
let timer = null;
return Promise.race([
promise.finally(() => { if (timer) clearTimeout(timer); }),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
}),
]);
}
function loadImage(filePath) {
return withTimeout(new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
image.src = fileUrl(filePath);
}), 10000, '读取贴图 ' + path.basename(filePath));
}
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
async function buildTextureAtlas(spine, atlasText, texturePaths) {
const images = new Map();
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
const filePath = texturePaths[pageName];
if (!filePath) return;
images.set(pageName, await loadImage(filePath));
}));
const makeTexture = (name) => {
const image = images.get(name);
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
return new spine.Texture(image);
};
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
const atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const texture = makeTexture(page.name);
if (page.setTexture) page.setTexture(texture);
else page.texture = texture;
}
return atlas;
}
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buffer = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buffer));
}
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
}
function updateWorld(spine, skeleton) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
/**
* 渲染 setup pose 的缩略图。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
* 返回 { blob, width, height, version }
*/
async function renderThumbnail(options) {
const utils = window.SpineUtils;
const size = options.size || 400;
const spine = await loadRuntime(options.runtimeDir);
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data));
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton);
const bounds = (function boundsOf(sk) {
try {
if (typeof sk.getBoundsRect === 'function') {
const rect = sk.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof sk.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
sk.getBounds(offset, dimensions, []);
if (dimensions.x > 0 || dimensions.y > 0) {
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
}
}
} catch (error) { /* ignore */ }
return null;
})(skeleton);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
renderer.draw(skeleton);
} finally {
if (renderer && renderer.dispose) renderer.dispose();
ctx.restore();
}
}
if (options.drawBadge !== false) await drawBadge(ctx, size);
if (options.drawVersion !== false) {
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('导出缩略图失败');
return { blob, width: size, height: size, version: data.version || options.version || '' };
}
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
function drawBadge(ctx, size) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
resolve();
};
image.onerror = () => resolve();
image.src = window.SpineBadge.dataUrl();
});
}
const api = { renderThumbnail, loadRuntime, withTimeout };
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();