219 lines
8.4 KiB
JavaScript
219 lines
8.4 KiB
JavaScript
// 在 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;
|
||
|
||
})();
|