缩略图自动生成背景服务插件
This commit is contained in:
1 parent
c28adf9fae
commit
065b6466f1
27 files changed
+81166
No files matched your search
@@ -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;
|
||||
|
||||
})();
|
||||
Reference in new issue
Block a user