干净发布副本

This commit is contained in:
tianmo committed 2026-10-28 19:19:04 +08:00
1 parent c29de7a26b
commit acc93d5404
35 files changed
+82427

No files matched your search

@@ -0,0 +1,102 @@
// 动态缩略图的档位配置。数值与主插件 js/animated-profiles.js 保持一致,
// 后台插件没有参数面板,所以这里只当默认值来源,改这里就等于改后台插件的默认渲染参数。
(function () {
'use strict';
/**
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
* - 细节分辨率决定画面保留多少细节(输出画布始终是画布边长,小分辨率会被放大填满);
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
* - 编码质量是 0–1 的 WebP 质量。
*
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
* 路径,再按目标体积在路径上定位,取命中位置附近若干档完整生成,挑出符合体积的那一档。
*/
const ANIMATED_PRIORITIES = {
// 分辨率优先:先把帧率压到底,再降分辨率。
resolution: [
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
],
// 帧率优先:先把分辨率压到底,再降帧率。
framerate: [
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
],
// 交替进行:分辨率和帧率交替下降。
balanced: [
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
],
};
const DEFAULT_PRIORITY = 'resolution';
/** 可选的输出画布边长(正方形)。后台插件默认用 200。 */
const ANIMATED_OUTPUT_SIZES = [200, 400];
const DEFAULT_OUTPUT_SIZE = 200;
/** 目标体积的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。 */
const SIZE_PER_SECOND = 25;
const SIZE_SUGGESTION_RANGE = [25, 100];
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,粗估阶段耗时略增。
const PATH_STEPS_PER_SEGMENT = 6;
function suggestSize(seconds) {
const [min, max] = SIZE_SUGGESTION_RANGE;
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
}
function animatedProfiles(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
return plan.map(([size, fps, quality]) => ({
size: Math.min(size, limit),
fps,
quality,
}));
}
/**
* 把锚点路径插值成更密的采样路径,供按目标体积定位使用。
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
*/
function expandAnimatedPath(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
const lerp = (from, to, t) => from + (to - from) * t;
const path = [];
for (let index = 0; index < anchors.length - 1; index++) {
const [fromSize, fromFps, fromQuality] = anchors[index];
const [toSize, toFps, toQuality] = anchors[index + 1];
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
const t = step / PATH_STEPS_PER_SEGMENT;
path.push({
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
});
}
}
const [size, fps, quality] = anchors[anchors.length - 1];
path.push({ size: Math.min(size, limit), fps, quality });
return path;
}
const api = {
ANIMATED_PRIORITIES,
DEFAULT_PRIORITY,
ANIMATED_OUTPUT_SIZES,
DEFAULT_OUTPUT_SIZE,
SIZE_PER_SECOND,
SIZE_SUGGESTION_RANGE,
PATH_STEPS_PER_SEGMENT,
animatedProfiles,
expandAnimatedPath,
suggestSize,
};
if (typeof window !== 'undefined') window.SpineAnimatedProfiles = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,85 @@
// 把若干张 WebP 静帧封装成一个动态 WebP(ANMF 全帧、不混合)。
// 与主插件 js/animated-webp-encoder.js 是同一份实现,只是出口兼容 <script> 加载。
(function () {
'use strict';
function writeUint24LE(buffer, offset, value) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >>> 8) & 0xff;
buffer[offset + 2] = (value >>> 16) & 0xff;
}
function chunk(type, payload) {
const padding = payload.length & 1;
const output = Buffer.alloc(8 + payload.length + padding);
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function frameData(webp) {
if (!webp) throw new Error('WebP 帧数据缺失');
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
const api = { encode };
if (typeof window !== 'undefined') window.SpineAnimatedWebpEncoder = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,121 @@
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载;包成 IIFE 是为了
// 让多个页面脚本各自的 const 不互相冲突。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
// Eagle 没有暴露「素材导入完成」事件,所以用「目录静默检测」代替:
// 监听素材目录,任何变化都重置防抖计时器,安静 QUIET_MS 之后才认为这一批拖入结束。
//
// 1 秒:足够合并连续写入。Eagle 的「重复添加提示」是主窗口里的 Angular 模态框,
// 没有独立窗口句柄可检测,插件只能靠静默判断——那期间素材库是静止的,但重复文件
// 还没入库,扫到的只是已入库的非重复素材。
const QUIET_MS = 1000;
// fs.watch 在网络盘/外置盘上可能漏事件,用低频兜底扫描补上。
const FALLBACK_MS = 30000;
/** 删除指定目录中超过保留时间的本插件临时文件。 */
function cleanupStaleFiles(directory, prefix, maxAgeMs, now = Date.now()) {
let names;
try { names = fs.readdirSync(directory); } catch (error) { return 0; }
let removed = 0;
for (const name of names) {
if (!name.startsWith(prefix)) continue;
const file = path.join(directory, name);
try {
const stat = fs.lstatSync(file);
if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue;
fs.unlinkSync(file);
removed += 1;
} catch (error) { /* 单个文件失败不影响其余清理和监听 */ }
}
return removed;
}
function createLibraryWatcher(options) {
const onBatch = options.onBatch || (() => {});
const onLog = options.onLog || (() => {});
let imagesDir = null;
let watcher = null;
let quietTimer = null;
let fallbackTimer = null;
let known = null;
function listItemDirs() {
try {
return new Set(fs.readdirSync(imagesDir).filter((name) => name.endsWith('.info')));
} catch (error) {
onLog('读取素材目录失败:' + ((error && error.message) || error));
return new Set();
}
}
/** 与上次快照比对,把新增的素材目录交给上层处理。 */
function flush(reason) {
if (!imagesDir || !known) return;
const current = listItemDirs();
const added = [...current].filter((name) => !known.has(name));
known = current;
onBatch({ added, total: current.size, reason });
}
function scheduleFlush() {
if (quietTimer) clearTimeout(quietTimer);
quietTimer = setTimeout(() => {
quietTimer = null;
flush('quiet');
}, QUIET_MS);
}
function stop() {
if (watcher) { watcher.close(); watcher = null; }
if (quietTimer) { clearTimeout(quietTimer); quietTimer = null; }
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null; }
imagesDir = null;
known = null;
}
/** 监听指定素材库。首次快照里的素材视为历史数据,不处理。 */
function start(libraryPath) {
stop();
imagesDir = path.join(libraryPath, 'images');
if (!fs.existsSync(imagesDir)) {
onLog('素材目录不存在:' + imagesDir);
return false;
}
known = listItemDirs();
onLog('监听 ' + imagesDir + '(现有 ' + known.size + ' 个素材视为历史,不处理)');
try {
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
} catch (error) {
onLog('建立监听失败:' + ((error && error.message) || error));
return false;
}
fallbackTimer = setInterval(() => {
const current = listItemDirs();
// 不能只比数量:同一次导里如果有增有删,数量可能刚好抵消。
let changed = current.size !== known.size;
if (!changed) {
for (const name of current) {
if (!known.has(name)) { changed = true; break; }
}
}
if (changed) flush('fallback');
}, FALLBACK_MS);
return true;
}
return { start, stop, flush, isRunning: () => !!watcher };
}
// 同时支持两种加载方式:Node 里用 require(跑测试),插件页面里用 <script> 标签。
// service 插件页面的 require 解析基准不是插件目录,所以页面里必须走 script 标签。
const libraryWatcherApi = { createLibraryWatcher, cleanupStaleFiles, QUIET_MS, FALLBACK_MS };
// 优先挂 window:Eagle 插件页面里也存在 module 对象(它有 Node 模块系统),
// 先判断 module 会让页面拿到模块却取不到 window 上的名字。Node 里没有 window,自然走 require 分支。
if (typeof window !== 'undefined') window.SpineThumbLibraryWatcher = libraryWatcherApi;
else if (typeof module !== 'undefined' && module.exports) module.exports = libraryWatcherApi;
})();
@@ -0,0 +1,526 @@
'use strict';
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v65';
const path = require('path');
const os = require('os');
const fs = require('fs');
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
const REQUIRED = [
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
throw new Error('本地模块未加载,请检查 index.html 里 script 的顺序:' + missing.join(', '));
}
const { createLibraryWatcher, cleanupStaleFiles } = window.SpineThumbLibraryWatcher;
const { scanSpineItems } = window.SpineThumbSpineAssets;
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
const progress = window.SpineThumbProgress;
const statusEl = document.getElementById('status');
const libraryEl = document.getElementById('library');
const statsEl = document.getElementById('stats');
const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
// 判定用排队数,不要求最终真的都产出动态 WebP。
const PROGRESS_MIN_DYNAMIC = 10;
// 弹窗上的「停止」按钮会把动作写进这个文件,宿主读回来执行暂停/继续/取消。
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
let settings = { ...settingsStore.DEFAULTS };
let libraryPath = null;
let imagesDir = null;
const queue = [];
let running = false;
// 本轮(一次队列排空)的统计和是否要开进度弹窗的标记;排空后一起清掉。
let runStats = null;
let runShowsProgress = false;
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
let paused = false;
let lastControlAt = 0;
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
const pending = new Set();
function log(message, kind) {
const line = document.createElement('li');
if (kind) line.className = kind;
line.textContent = '[' + new Date().toTimeString().slice(0, 8) + '] ' + message;
logEl.appendChild(line);
while (logEl.childElementCount > 400) logEl.removeChild(logEl.firstElementChild);
logEl.scrollTop = logEl.scrollHeight;
console.log('[SpineThumbnailGenerateServer] ' + message);
}
function setStatus(text) {
statusEl.textContent = text;
}
function refreshStats() {
statsEl.textContent = '批次 ' + stats.batches +
' · 新增 ' + stats.added +
' · Spine ' + stats.spine +
' · 完整 ' + stats.complete +
' · 已生成 ' + stats.generated +
' · 跳过 ' + stats.skipped +
' · 失败 ' + stats.failed;
}
function itemIdOf(dirName) {
return String(dirName).replace(/\.info$/i, '');
}
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
// 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
let folderPaths = null;
function loadFolderPaths() {
folderPaths = new Map();
try {
const tree = JSON.parse(fs.readFileSync(path.join(libraryPath, 'metadata.json'), 'utf8')).folders || [];
const walk = (list, prefix) => {
for (const f of list || []) {
const full = prefix + (f.name || f.id);
folderPaths.set(f.id, full);
walk(f.children, full + '/');
}
};
walk(tree, '');
} catch (error) { /* 读不到就只显示未分类 */ }
}
function folderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '未分类';
}
/** 日志里的渲染结果描述。参数不走本地化,和主插件的候选标签是同一套写法。 */
function describeThumbnail(rendered) {
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
if (rendered.mode === 'animated') {
return '动态 ' + rendered.detail + '*' + rendered.detail + '@' + rendered.fps + 'fps/q' +
rendered.quality + '/' + kb + 'kb(目标 ' + rendered.targetKilobytes + 'kb)/' +
rendered.frames + ' 帧/动画 ' + rendered.animation +
'/' + (rendered.strategy === 'speed' ? '最优速度' : '最优体积') +
(rendered.fits === false ? '(最小档仍超出目标体积,可以考虑提高缩略图大小)' : '');
}
return '静帧 ' + rendered.width + '*' + rendered.height + '/' + kb + 'kb' +
(rendered.fallback ? '(' + rendered.fallback + ')' : '');
}
/**
* 记本轮(一次队列排空)的产出,顺手把进度推给弹窗。
* animated 只统计真正做出动态 WebP 的数量;name 用来在弹窗里显示最近完成的文件。
*/
function countRun(outcome, animated, name) {
if (!runStats) return;
// 弹窗的暂停/取消随时生效:万一用户在最后一个素材渲染期间点了取消,这里也能记上。
applyControl();
runStats[outcome] += 1;
if (animated) runStats.animated += 1;
runStats.done += 1;
runStats.name = name || '';
if (runShowsProgress) {
progress.update({ done: runStats.done, total: runStats.total, name: runStats.name });
}
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
const label = item.entryName + ' | ' + folderPathOf(item.folders);
try {
const itemId = itemIdOf(item.dirName);
const eagleItem = await eagle.item.getById(itemId);
if (!eagleItem) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('跳过 ' + label + ':Eagle 里找不到这个素材', 'warn');
return;
}
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
try {
eagleItem.name = parts.name;
eagleItem.ext = parts.ext;
await withTimeout(eagleItem.save(), 20000, '修正复合后缀 ' + label);
log('修正复合后缀 ' + label + ' → ' + parts.name + '.' + parts.ext, 'dim');
} catch (error) {
log('修正复合后缀失败 ' + label + ':' + ((error && error.message) || error), 'warn');
}
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log('跳过 ' + label + ':已有自定义缩略图', 'dim');
return;
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
const animated = settings.mode !== 'static';
const rendered = await withTimeout(renderThumbnailAuto({
skeletonPath: item.skeletonPath,
atlasPath: item.atlasPath,
texturePaths: item.texturePaths,
runtimeDir,
isBinary: item.isBinary,
version,
size: 400,
mode: settings.mode,
strategy: settings.strategy,
onProgress: (current, total) => {
setStatus('正在渲染动画帧 ' + current + '/' + total + '(排队 ' + queue.length + ' 个)');
},
}), animated ? 300000 : 60000, '渲染 ' + label);
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + (rendered.extension || '.png'));
fs.writeFileSync(tempPath, rendered.buffer);
const applied = await withTimeout(eagleItem.setCustomThumbnail(tempPath), 20000, '设置缩略图 ' + item.dirName);
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
stats.generated += 1;
countRun('generated', rendered.mode === 'animated', item.entryName);
log('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
} catch (error) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
refreshStats();
}
}
/** 读弹窗写下的控制指令:{ action: 'pause' | 'resume' | 'cancel', at: 时间戳 }。 */
function readControl() {
try {
const data = JSON.parse(fs.readFileSync(CONTROL_FILE, 'utf8'));
if (data && typeof data.action === 'string' && Number.isFinite(data.at)) return data;
} catch (error) { /* 没有文件或内容不完整 */ }
return null;
}
/** 执行弹窗按钮的意图:暂停、继续、取消(取消 = 丢掉剩下的队列,已生成的保留)。 */
function applyControl() {
const control = readControl();
if (!control || control.at === lastControlAt) return;
lastControlAt = control.at;
if (control.action === 'pause') {
paused = true;
setStatus('已暂停(在进度弹窗里继续或取消)');
} else if (control.action === 'resume') {
paused = false;
} else if (control.action === 'cancel') {
paused = false;
const dropped = queue.length;
queue.length = 0;
log('已取消本轮生成:丢弃剩余 ' + dropped + ' 个素材,已完成 ' +
(runStats ? runStats.done : 0) + ' 张(成功 ' + (runStats ? runStats.generated : 0) +
' 张,失败 ' + (runStats ? runStats.failed : 0) +
' 张,跳过 ' + (runStats ? runStats.skipped : 0) + ' 张)', 'warn');
}
}
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
async function processQueue() {
if (running) return;
running = true;
// 一次队列排空算一轮,从零开始记;排空后再决定要不要弹通知。
if (!runStats) {
runStats = {
startedAt: Date.now(),
total: queue.length,
done: 0,
name: '',
animated: 0,
generated: 0,
failed: 0,
skipped: 0,
};
}
paused = false;
// 记下开跑那一刻控制文件的时间戳:上一轮遗留的暂停/取消指令不能再生效。
const stale = readControl();
lastControlAt = stale ? stale.at : 0;
try {
while (queue.length) {
applyControl();
if (paused) {
await new Promise((resolve) => setTimeout(resolve, 300));
continue;
}
const item = queue.shift();
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
if (!item) break;
setStatus('正在生成缩略图…剩余 ' + queue.length + ' 个');
await generateOne(item);
}
setStatus('监听中');
} finally {
running = false;
paused = false;
// 收尾放进 finally:哪怕中途出异常,进度弹窗也必须收到"本轮结束",不能一直挂着。
const run = runStats;
const showsProgress = runShowsProgress;
runStats = null;
runShowsProgress = false;
if (run && showsProgress) finishRun(run);
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
}
}
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
function finishRun(run) {
const seconds = Math.max(1, Math.round((Date.now() - run.startedAt) / 1000));
const summary = '成功 ' + run.generated + ' 张,失败 ' + run.failed + ' 张,跳过 ' +
run.skipped + ' 张,用时 ' + seconds + ' 秒。';
log('本轮完成:' + summary, 'ok');
progress.finish(summary);
}
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
let batchChain = Promise.resolve();
function handleBatch(batch) {
batchChain = batchChain
.then(() => handleBatchAsync(batch))
.catch((error) => log('处理批次失败:' + ((error && error.message) || error), 'error'));
}
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
async function handleBatchAsync({ added, total, reason }) {
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
if (!added.length) return;
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
loadFolderPaths();
stats.batches += 1;
stats.added += added.length;
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
// 本次新增 + 之前配套不全的,一起重新检查。
const candidates = [...new Set([...added, ...pending])];
if (pending.size) log('连带重新检查之前 ' + pending.size + ' 个配套不全的资产', 'dim');
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
setStatus('正在配对新增素材…');
await new Promise((resolve) => setTimeout(resolve, 0));
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
setStatus('正在配对 ' + current + '/' + all);
});
// 统计只算本次新增的:待观察的重查会重复计入,数字会虚高。
const fresh = items.filter((item) => added.includes(item.dirName));
stats.spine += fresh.length;
stats.complete += fresh.filter((item) => item.complete).length;
if (!items.length) log('这批里没有 Spine 资产', 'dim');
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
const incomplete = items.filter((item) => !item.complete);
pending.clear();
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
if (settings.enabled) {
for (const item of incomplete) pending.add(item.dirName);
}
if (incomplete.length) {
log(settings.enabled
? '有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐'
: '有 ' + incomplete.length + ' 个资产配套不全,本批不处理',
settings.enabled ? 'warn' : 'dim');
}
for (const item of items) {
const name = (filePath) => (filePath ? path.basename(filePath) : '无');
log(' ' + item.entryName + ' | ' + folderPathOf(item.folders) + ' | ' +
(item.complete ? '完整' : '不完整') +
'|骨架 ' + name(item.skeletonPath) +
'|atlas ' + name(item.atlasPath) +
'|贴图页 ' + item.pages + '(缺 ' + item.missingTextures + ')',
item.complete ? 'ok' : 'warn');
}
refreshStats();
const ready = items.filter((item) => item.complete);
if (!ready.length) return;
if (!settings.enabled) {
log('自动生成已关闭,本批 ' + ready.length + ' 个资产已跳过', 'warn');
setStatus('自动生成未启用,本批已跳过');
return;
}
queue.push(...ready);
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
if (runStats) runStats.total += ready.length;
// 硬性条件:动态模式且这一次排队超过 10 个,才开进度弹窗,跑完在里面给汇总。
if (settings.mode !== 'static' && ready.length > PROGRESS_MIN_DYNAMIC) {
runShowsProgress = true;
progress.open();
progress.update({
done: runStats ? runStats.done : 0,
total: runStats ? runStats.total : queue.length,
});
}
log('已加入队列 ' + ready.length + ' 个,当前排队 ' + queue.length + ' 个' +
(running ? '(正在处理中)' : ''), 'dim');
processQueue();
}
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log });
log('插件代码版本 ' + VERSION);
async function resolveLibraryPath() {
const direct = eagle.library && eagle.library.path;
if (direct) return direct;
try {
const info = await eagle.library.info();
const fromApi = info && (info.path || info.libraryPath || info.dir);
if (fromApi) return fromApi;
log('library.info() 没有返回路径字段:' + JSON.stringify(info), 'dim');
} catch (error) {
log('library.info() 调用失败:' + ((error && error.message) || error), 'error');
}
return null;
}
async function start() {
const resolved = await resolveLibraryPath();
if (!resolved) {
setStatus('没有拿到素材库路径');
log('切换素材库时会重试', 'warn');
return;
}
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = resolved;
setStatus(libraryWatcher.start(resolved) ? '监听中' : '监听失败');
}
function startSafely() {
start().catch((error) => {
setStatus('启动失败:' + ((error && error.message) || error));
log('启动失败:' + ((error && error.stack) || error), 'error');
});
}
async function loadSettings() {
try {
settings = await settingsStore.read();
} catch (error) {
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
settings = { ...settingsStore.DEFAULTS };
}
toggleEl.checked = settings.enabled === true;
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
}
toggleEl.addEventListener('change', async () => {
settings = { ...settings, enabled: toggleEl.checked };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
if (settings.enabled) {
log('自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。', 'ok');
setStatus('监听中');
} else {
queue.length = 0;
log('自动生成已关闭,待处理队列已清空。', 'dim');
setStatus('监听中(自动生成已关闭)');
}
});
// 只清显示,不动统计和 queue:Eagle 的 devtools 控制台里仍然留着完整历史。
clearEl.addEventListener('click', () => {
logEl.textContent = '';
});
modeEl.addEventListener('change', async () => {
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
log(settings.mode === 'static'
? '生成方式:静帧(始终 setup pose,PNG)'
: '生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)', 'dim');
});
strategyEl.addEventListener('change', async () => {
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
log(settings.strategy === 'speed'
? '生成策略:最优速度(粗估挑档,只生成最小的一两档)'
: '生成策略:最优体积(十档全生成,取实际体积最小的那一档)', 'dim');
});
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
filtersEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-filter]');
if (!button) return;
logEl.dataset.filter = button.dataset.filter;
for (const item of filtersEl.children) item.classList.toggle('active', item === button);
});
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
document.getElementById('status').textContent = '脚本已加载 ' + VERSION + ',等待 Eagle 事件…';
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
if (typeof eagle === 'undefined') {
setStatus('eagle API 未注入');
log('页面上没有 eagle 对象,插件无法与 Eagle 通信', 'error');
} else {
eagle.onPluginCreate(async (plugin) => {
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
log('插件已创建:' + ((plugin && plugin.manifest && plugin.manifest.name) || 'SpineThumbnailGenerateServer'));
try {
await loadSettings();
} catch (error) {
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
}
startSafely();
});
eagle.onLibraryChanged((changedPath) => {
log('切换素材库:' + changedPath);
startSafely();
});
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
setTimeout(() => {
if (libraryEl.textContent === '—') {
log('没有收到创建事件,直接尝试启动', 'dim');
loadSettings().catch(() => {}).then(startSafely);
}
}, 800);
}
@@ -0,0 +1,140 @@
// 进度弹窗:用 Eagle 自己的 create-plugin-window 通道开一个正规插件窗口。
//
// 为什么不用 window.open:那样开出来的是 Electron 默认的裸窗口——没有 Node、没有插件 API、
// 还带着 Eagle 的应用菜单栏(Eagle 只对自己创建的插件窗口调 setMenuBarVisibility(false))。
// create-plugin-window 正是 Eagle 前端开插件窗口用的通道,manifest 由调用方传进来,
// 所以我们可以指定 url / 尺寸 / 置顶,拿到的就是"正规的插件窗口"。
//
// 两个窗口之间没有 opener 关系,进度用临时目录里的状态文件传:宿主写,弹窗轮询。
// 弹窗反过来写一个心跳文件,宿主靠它判断窗口是不是被用户关掉了。
(function () {
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
// 高度只是初值,弹窗加载后会按内容自己调(eagle.window.setSize)。
const WINDOW_SIZE = { width: 380, height: 160 };
// 心跳超过这个时间就认为弹窗已经被关掉,下次需要时重新开。
const ALIVE_WINDOW_MS = 3000;
// 刚创建完窗口要给它一点加载时间,这段时间内不重复创建。
const CREATE_COOLDOWN_MS = 4000;
let state = { done: 0, total: 0, name: '', finished: false, text: '' };
let lastCreateAt = 0;
let ipcRenderer = null;
/** 插件目录的绝对路径。装了 eagleplugin:// 协议后 location.href 不是文件路径,只能靠 Eagle 给的。 */
function pluginPath() {
if (window.SpinePluginPath) return window.SpinePluginPath;
if (typeof eagle !== 'undefined' && eagle.plugin && eagle.plugin.path) return eagle.plugin.path;
return typeof __dirname !== 'undefined' ? __dirname : null;
}
function getIpcRenderer() {
if (!ipcRenderer) {
// Eagle 的 preload 把 require('electron') 换成了 {},另起一个 require 才能拿到真模块。
// createRequire 只接受绝对路径或 file:// URL,所以基准用插件目录拼出来,不能直接用 location.href。
const base = path.join(pluginPath() || process.cwd(), 'index.html');
ipcRenderer = require('module').createRequire(base)('electron').ipcRenderer;
}
return ipcRenderer;
}
/** 弹窗还开着吗?靠它写的心跳判断,窗口关掉后心跳自然就停了。 */
function popupAlive() {
try {
return Date.now() - Number(fs.readFileSync(ALIVE_FILE, 'utf8')) < ALIVE_WINDOW_MS;
} catch (error) {
return false;
}
}
function writeState() {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, updatedAt: Date.now() }), 'utf8');
} catch (error) { /* 写不进去也不影响生成,面板里一样有进度 */ }
}
/** 用 Eagle 的通道创建插件窗口;manifest 覆盖成进度页面的配置。 */
function createPopup() {
const plugin = (typeof eagle !== 'undefined' && eagle.plugin) || {};
const dir = pluginPath();
let manifest = plugin.manifest;
if (!manifest && dir) {
// 正常情况下 eagle.plugin 里就带 manifest;万一还没注入就自己从插件目录读一份。
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
}
if (!manifest) return Promise.reject(new Error('拿不到插件 manifest'));
return getIpcRenderer().invoke('create-plugin-window', {
plugin: {
...plugin,
path: dir,
manifest: {
...manifest,
// 这个窗口的标题栏文字(只影响这扇窗,插件本身的名字不变)。
name: 'Spine缩略图',
main: {
...manifest.main,
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
serviceMode: false,
url: 'progress.html',
width: WINDOW_SIZE.width,
height: WINDOW_SIZE.height,
show: true,
// 挂在 Eagle 主窗口上:子窗口天然压在父窗口之上,而且跟着 Eagle 一起显示/隐藏。
// 这里显式写 false,免得主 manifest 哪天又开了 alwaysOnTop 被 ...manifest.main 带进来,
// 那会让弹窗盖住所有程序,而不是只待在 Eagle 上面。
alwaysOnTop: false,
childWindow: true,
// 保留系统标题栏:最小化/关闭按钮都在上面(manifest.name 决定标题文字)。
frame: true,
resizable: false,
minimizable: true,
maximizable: false,
fullscreenable: false,
},
},
},
args: {},
});
}
/** 需要时开窗(或复用已开着的);被环境挡住时返回 false,调用方照常继续跑。 */
function open() {
try {
if (popupAlive()) return true;
if (Date.now() - lastCreateAt < CREATE_COOLDOWN_MS) return false;
lastCreateAt = Date.now();
writeState();
createPopup().catch(() => { /* 开不出来就只在面板里看进度 */ });
return true;
} catch (error) {
// 弹窗只是附加显示,任何问题都不能影响缩略图生成本身。
return false;
}
}
function update(next) {
try {
state = { ...state, ...next, finished: false };
writeState();
} catch (error) { /* 同上:弹窗不能影响生成流程 */ }
}
function finish(text) {
try {
state = { ...state, finished: true, text };
writeState();
} catch (error) { /* ignore */ }
}
const api = { open, update, finish };
if (typeof window !== 'undefined') window.SpineThumbProgress = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,723 @@
// 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
// 两条产物,互不影响,由插件面板选择:
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const runtimeCache = new Map();
// 长动画不截断时间轴,只降低采样密度:18 fps 采样,最多 240 帧。
const SOURCE_SAMPLE_FPS = 18;
const MAX_SOURCE_FRAMES = 240;
// 时长小于这个值的动画按静帧处理。
const STATIC_EPSILON = 1e-6;
// 逐帧编码时轮转使用的离屏画布数量:成批提交比逐帧等待快得多。
const FRAME_POOL_SIZE = 4;
// 生成策略:
// 'size' = 十档全部生成,取实际体积最小的那一档(最省体积,也就是主插件画廊默认打勾的);
// 'speed' = 只用粗估挑出体积最小的两档真正生成(最快,其余开销跟着一起砍)。
const DEFAULT_STRATEGY = 'size';
const SPEED_PROBE_SIZE = 256;
const SPEED_PROBE_POSES = 12;
const SPEED_ESTIMATE_POINTS = 8;
const SPEED_CANDIDATES = 2;
/**
* 让每个版本在自己的 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。交给 Node 的 pathToFileURL,Windows / macOS 都正确。 */
function fileUrl(filePath) {
return pathToFileURL(filePath).href;
}
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 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, delta = 0, resetPhysics = false) {
if (spine.Physics && spine.Physics.update !== undefined) {
if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta);
const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update;
skeleton.updateWorldTransform(mode);
}
else skeleton.updateWorldTransform();
}
/** 载入运行时、atlas 与骨骼数据。静态和动态两条路共用。 */
async function loadSkeleton(options) {
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('骨骼数据为空');
return {
spine,
data,
atlasText,
runtimeDir: options.runtimeDir,
premultipliedAlpha: window.SpineUtils.parseAtlasPma(atlasText) === true,
};
}
/** 骨骼当前姿态的包围盒(世界坐标)。 */
function boundsOf(spine, skeleton) {
try {
if (typeof skeleton.getBoundsRect === 'function') {
const rect = skeleton.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof skeleton.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
skeleton.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;
}
/**
* 渲染 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 loaded = await loadSkeleton(options);
const { spine, data } = loaded;
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, 0, true);
const bounds = boundsOf(spine, 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 = loaded.premultipliedAlpha;
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 || '' };
}
// ---------- 动态缩略图(animated WebP) ----------
// 参数与主插件的默认值一致:画布 200×200、分辨率优先档位表、目标体积 = 动画时长 × 25 KB
// (夹在 25–100 KB)。区别是没有候选画廊,所以直接采用主插件画廊里默认打勾的那一档
// (十档里实际体积最小的那个),目标体积只用来判断有没有达标并在日志里提示。
function collectKeyframeTimes(animation) {
if (!animation || !animation.timelines) return [];
const times = new Set();
for (const timeline of animation.timelines) {
const frames = timeline && timeline.frames;
if (!frames || !frames.length) continue;
try {
if (Array.isArray(frames[0])) {
for (const frame of frames) {
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
}
continue;
}
const frameCount = typeof timeline.getFrameCount === 'function' ? timeline.getFrameCount() : 0;
const entries = typeof timeline.getFrameEntries === 'function'
? timeline.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
if (frameCount > 0) {
for (let index = 0; index < frameCount; index++) {
const value = frames[index * entries];
if (Number.isFinite(value)) times.add(value);
}
}
} catch (error) { /* 读数失败就跳过这条时间轴 */ }
}
return [...times];
}
function animationDurationOf(animation) {
const declared = animation && Number(animation.duration);
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
const times = collectKeyframeTimes(animation);
return times.length ? Math.max.apply(null, times) : 0;
}
/** 取骨骼里的第一个可用动画(没有动画或时长为 0 就返回 null,由上层回退静帧)。 */
function firstAnimation(data) {
for (const animation of (data && data.animations) || []) {
if (animation && animationDurationOf(animation) > STATIC_EPSILON) return animation;
}
return null;
}
function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) {
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
const entry = targetState.setAnimation(0, animation.name, false);
if (entry) entry.loop = false;
targetState.update(Math.max(0, time));
targetState.apply(targetSkeleton);
updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined);
}
function mergeBounds(total, next) {
if (!next || next.width <= 0 || next.height <= 0) return total;
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
const left = Math.min(total.x, next.x);
const bottom = Math.min(total.y, next.y);
const right = Math.max(total.x + total.width, next.x + next.width);
const top = Math.max(total.y + total.height, next.y + next.height);
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
/**
* 采样若干姿态,用像素 alpha 求真实可见范围,避免取景跟着骨骼包围盒留大片空白。
* 最快策略下采样点变少、探测画布变小,取景精度略降但省掉一大截渲染 + 回读。
*/
async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, poseCount, applyPose, options) {
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
const probeOptions = options || {};
const visibleBounds = window.SpineVisibleBounds;
const size = probeOptions.probeSize || 512;
const maxPoses = probeOptions.maxPoses || poseCount;
const step = Math.max(1, Math.ceil(poseCount / maxPoses));
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
let measured = null;
let probe = null;
try {
probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir);
probe.premultipliedAlpha = loaded.premultipliedAlpha;
let previousIndex = -1;
for (let index = 0; index < poseCount; index += step) {
applyPose(index, previousIndex);
previousIndex = index;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, size, size);
context.save();
context.translate(size / 2, size / 2);
context.scale(scale, -scale);
context.translate(-centerX, -centerY);
probe.draw(targetSkeleton);
context.restore();
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (probe && probe.dispose) probe.dispose();
}
return measured;
}
function profileFrameCount(sourceFrameCount, range, fps) {
return Math.max(2, Math.min(sourceFrameCount, Math.ceil(range * fps)));
}
function resizeFramePixels(data, sourceSize, targetSize) {
if (sourceSize === targetSize) return data;
const output = new Uint8Array(targetSize * targetSize * 4);
for (let y = 0; y < targetSize; y++) {
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
for (let x = 0; x < targetSize; x++) {
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
const source = (sourceY * sourceSize + sourceX) * 4;
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
}
}
return output;
}
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
function scaledFramePixels(data, size, detailSize) {
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
}
let badgeImagePromise = null;
function badgeImage() {
if (!badgeImagePromise) {
badgeImagePromise = new Promise((resolve) => {
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = window.SpineBadge.dataUrl();
} catch (error) {
resolve(null);
}
});
}
return badgeImagePromise;
}
/** 一次把所有采样帧画到 size×size 上(含角标),后续每档只从这些像素重新编码。 */
async function renderAnimatedSourceFrames(loaded, animation, options) {
const utils = window.SpineUtils;
const spine = loaded.spine;
const size = options.canvasSize;
const range = options.range;
const frameCount = Math.max(2, Math.min(MAX_SOURCE_FRAMES, Math.ceil(range * SOURCE_SAMPLE_FPS)));
const frameDuration = range / frameCount;
const sampleTimes = Array.from({ length: frameCount }, (_, index) => index * frameDuration);
const skeleton = new spine.Skeleton(loaded.data);
utils.applySkin(skeleton, utils.preferredSkin(loaded.data));
const stateData = new spine.AnimationStateData(loaded.data);
stateData.defaultMix = 0;
const state = new spine.AnimationState(stateData);
let bounds = null;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
bounds = mergeBounds(bounds, boundsOf(spine, skeleton));
}
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围');
bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined);
}, options.probe) || bounds;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const badge = await badgeImage();
const version = loaded.data.version || options.version || '';
const frames = [];
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir);
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-centerX, -centerY);
renderer.draw(skeleton);
ctx.restore();
if (badge) {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(badge, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
}
window.SpineBadge.drawVersion(ctx, size, size, version);
// 动态 WebP 用全帧替换,透明区域直接压成背景色,省掉 alpha 通道的体积。
const rgba = new Uint8Array(ctx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
frames.push(rgba);
if (options.onProgress) options.onProgress(index + 1, frameCount);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (renderer && renderer.dispose) renderer.dispose();
}
return { frames, size, range };
}
/** 轮转 4 张离屏画布,把 RGBA 像素编成 WebP 静帧。 */
function createFrameEncoder(size) {
const pool = Array.from({ length: FRAME_POOL_SIZE }, () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
return { canvas, context, image: context.createImageData(size, size) };
});
return (slot, pixels, quality) => new Promise((resolve, reject) => {
const unit = pool[slot % pool.length];
unit.image.data.set(pixels);
unit.context.putImageData(unit.image, 0, 0);
unit.canvas.toBlob((blob) => {
if (!blob || blob.type !== 'image/webp') {
reject(new Error('当前环境不支持 WebP 编码'));
return;
}
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
}, 'image/webp', quality);
});
}
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
async function estimateAnimatedBytes(profile, sourceFrames, size, range, encodeFrame) {
const count = profileFrameCount(sourceFrames.length, range, profile.fps);
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
let total = 0;
for (let slot = 0; slot < indexes.length; slot++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
total += (await encodeFrame(slot, pixels, profile.quality)).length;
}
return (total / indexes.length) * count;
}
// 沿整条搜索路径粗估体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」取候选。
// 返回顺序就是从预计体积最小到最大,最快策略只需要前面几档。
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, targetBytes, count, encodeFrame, samplePoints) {
const stride = Math.max(1, Math.ceil(searchPath.length / (samplePoints || 24)));
const samples = [];
for (let index = 0; index < searchPath.length; index += stride) {
samples.push({
index,
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, encodeFrame),
});
}
const lastIndex = searchPath.length - 1;
if (samples[samples.length - 1].index !== lastIndex) {
samples.push({
index: lastIndex,
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, encodeFrame),
});
}
// 先按体积从小到大排:配置允许分段,排序后再取点才能保证候选体积递增。
samples.sort((left, right) => left.bytes - right.bytes);
// 体积 → 参数:在「体积相邻」的两个采样点之间插值,候选因此可以落在配置表之外。
const profileForBytes = (wantedBytes) => {
let upper = samples.length - 1;
for (let index = 0; index < samples.length; index++) {
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
}
const lower = Math.max(0, upper - 1);
const before = searchPath[samples[lower].index];
const after = searchPath[samples[upper].index];
const gap = samples[upper].bytes - samples[lower].bytes;
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
const mix = (start, end) => start + (end - start) * ratio;
return {
size: Math.max(1, Math.round(mix(before.size, after.size))),
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
};
};
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
const span = Math.max(1, count - 1);
const picked = [];
for (let slot = 0; slot < count; slot++) {
picked.push(profileForBytes(Math.exp(low + (high - low) * slot / span)));
}
return picked;
}
/** 按一档参数把采样帧全部编码并封装成动态 WebP。 */
async function encodeAnimatedProfile(profile, source, encodeFrame) {
const count = profileFrameCount(source.frames.length, source.range, profile.fps);
const duration = Math.max(1, Math.round(source.range * 1000 / count));
const frames = [];
// 帧编码是异步的,一批 4 帧(正好对上画布池)一起提交,比逐帧 await 快得多。
for (let start = 0; start < count; start += FRAME_POOL_SIZE) {
const batch = [];
for (let index = start; index < Math.min(count, start + FRAME_POOL_SIZE); index++) {
const sourceIndex = Math.min(source.frames.length - 1, Math.floor(index * source.frames.length / count));
batch.push({
slot: index % FRAME_POOL_SIZE,
pixels: scaledFramePixels(source.frames[sourceIndex], source.size, profile.size),
});
}
const buffers = await Promise.all(batch.map(({ slot, pixels }) => encodeFrame(slot, pixels, profile.quality)));
for (const webp of buffers) frames.push({ webp, duration });
await new Promise((resolve) => setTimeout(resolve, 0));
}
return {
buffer: window.SpineAnimatedWebpEncoder.encode({
width: source.size,
height: source.size,
frames,
plays: 0,
}),
frames: count,
detail: profile.size,
fps: profile.fps,
quality: profile.quality,
};
}
/** 所有候选里体积最小的那一档(主插件画廊默认打勾选中的就是它)。 */
function smallestCandidate(candidates) {
return candidates.reduce(
(best, candidate) => (!best || candidate.buffer.length < best.buffer.length ? candidate : best),
null,
);
}
async function pickAnimatedThumbnail(source, options) {
const config = window.SpineAnimatedProfiles;
const fast = options.strategy === 'speed';
const encodeFrame = createFrameEncoder(source.size);
const targetBytes = options.maxKilobytes * 1024;
const searchPath = config.expandAnimatedPath(options.priority, options.canvasSize);
const candidateCount = config.animatedProfiles(options.priority, options.canvasSize).length;
const profiles = await pickAnimatedCandidates(
searchPath, source.frames, source.size, source.range, targetBytes, candidateCount, encodeFrame,
fast ? SPEED_ESTIMATE_POINTS : undefined,
);
// 用户看不到候选画廊,就完全照主插件的默认选择来,取实际体积最小的那一档。
// 最优体积:十档全生成再比;最优速度:只生成粗估最小的那几档(粗估已经按体积排过序)。
const wanted = fast ? profiles.slice(0, SPEED_CANDIDATES) : profiles;
const candidates = [];
for (const profile of wanted) {
candidates.push(await encodeAnimatedProfile(profile, source, encodeFrame));
}
const best = smallestCandidate(candidates);
return {
...best,
// 最小档仍然超预算时告诉用户,让他考虑改配置里的目标体积。
fits: best.buffer.length <= targetBytes,
};
}
/**
* 生成动态缩略图。骨骼里没有可用动画时返回 null,交给调用方回退到 setup pose 静帧。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, version,
* canvasSize, priority, maxKilobytes, onProgress }
*/
async function renderAnimatedThumbnail(options) {
const config = window.SpineAnimatedProfiles;
const loaded = await loadSkeleton(options);
const animation = firstAnimation(loaded.data);
if (!animation) return null;
const canvasSize = options.canvasSize || config.DEFAULT_OUTPUT_SIZE;
const range = animationDurationOf(animation);
const maxKilobytes = options.maxKilobytes || config.suggestSize(range);
const fast = (options.strategy || DEFAULT_STRATEGY) === 'speed';
const source = await renderAnimatedSourceFrames(loaded, animation, {
canvasSize,
range,
version: options.version,
onProgress: options.onProgress,
// 最快策略用小画布、少采样点做可见范围探测。
probe: fast ? { probeSize: SPEED_PROBE_SIZE, maxPoses: SPEED_PROBE_POSES } : null,
});
const picked = await pickAnimatedThumbnail(source, {
priority: options.priority || config.DEFAULT_PRIORITY,
maxKilobytes,
canvasSize,
strategy: options.strategy || DEFAULT_STRATEGY,
});
return {
buffer: picked.buffer,
extension: '.webp',
width: source.size,
height: source.size,
mode: 'animated',
animation: animation.name,
version: loaded.data.version || options.version || '',
frames: picked.frames,
detail: picked.detail,
fps: picked.fps,
quality: picked.quality,
duration: range,
targetKilobytes: maxKilobytes,
strategy: options.strategy || DEFAULT_STRATEGY,
fits: picked.fits,
};
}
/** 静帧产物统一成和动态一样的返回结构,调用方只认 buffer + extension。 */
async function renderStillBuffer(options) {
const still = await renderThumbnail(options);
return {
buffer: Buffer.from(await still.blob.arrayBuffer()),
extension: '.png',
width: still.width,
height: still.height,
mode: 'still',
version: still.version,
};
}
/**
* 入口:mode === 'static' 一律出 setup pose 静帧;否则有动画就出动态 WebP,
* 没有动画回退静帧(reason 里说明原因,日志会带出来)。
*/
async function renderThumbnailAuto(options) {
if (options.mode === 'static') return renderStillBuffer(options);
try {
const animated = await renderAnimatedThumbnail(options);
if (animated) return animated;
return { ...(await renderStillBuffer(options)), fallback: '骨骼里没有动画' };
} catch (error) {
const still = await renderStillBuffer(options);
return { ...still, fallback: '动态生成失败:' + ((error && error.message) || error) };
}
}
// 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,
renderThumbnailAuto,
renderAnimatedThumbnail,
loadRuntime,
loadSkeleton,
firstAnimation,
animationDurationOf,
profileFrameCount,
resizeFramePixels,
scaledFramePixels,
pickAnimatedCandidates,
smallestCandidate,
withTimeout,
};
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,43 @@
// 插件设置持久化。默认关闭自动生成——Eagle 审核标准要求「批量或不可逆的变更
// 必须事先明确说明并获得确认」,所以必须由用户主动打开这个开关。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
// mode: 'animated' = 有动画就出动态 WebP(没有动画回退静帧),'static' = 一律 setup pose 静帧。
// strategy: 'size' = 十档全生成取体积最小的(慢、最省体积),'speed' = 只生成粗估最小的那几档。
const DEFAULTS = { enabled: false, mode: 'animated', strategy: 'size' };
/** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */
async function resolveFile() {
const userData = await eagle.app.getPath('userData');
return path.join(userData, 'SpineThumbnailGenerateServer', 'settings.json');
}
async function read() {
const file = await resolveFile();
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return { ...DEFAULTS, ...data };
} catch (error) {
if (error && error.code === 'ENOENT') return { ...DEFAULTS };
throw error;
}
}
async function write(next) {
const file = await resolveFile();
fs.mkdirSync(path.dirname(file), { recursive: true });
const body = JSON.stringify({ ...DEFAULTS, ...next }, null, 2) + '\n';
const temp = file + '.' + process.pid + '.tmp';
fs.writeFileSync(temp, body, 'utf8');
fs.renameSync(temp, file);
}
const api = { read, write, resolveFile, DEFAULTS };
if (typeof window !== 'undefined') window.SpineThumbSettings = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,140 @@
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载后挂到 window。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const SKELETON_PATTERN = /\.(skel|skel\.bytes|json)$/i;
const ATLAS_PATTERN = /\.(atlas|atlas\.txt)$/i;
const TEXTURE_PATTERN = /\.(png|jpe?g|webp)$/i;
const THUMBNAIL_PATTERN = /_thumbnail\.(png|webp)$/i;
/**
* 分析一个 Eagle item 目录。
*
* Eagle 会把导入的每个文件拆成独立的 <id>.info 目录,所以一套 Spine 资产的骨架、
* atlas、贴图通常分散在多个目录里,必须跨目录配对。配对逻辑不重写,直接复用主插件的
* discoverAssets(后缀表、同名筛选、路径校验都在里面)。
*/
function analyzeItemDir(itemDir, dirName, utils, pairCache, searchRoots, refreshIndex) {
let files;
try {
files = fs.readdirSync(itemDir);
} catch (error) {
return null;
}
// metadata.json 是 Eagle 的 item 元数据,不是骨骼文件,必须排除。
const skeletons = files.filter((name) => SKELETON_PATTERN.test(name) && name.toLowerCase() !== 'metadata.json');
const atlases = files.filter((name) => ATLAS_PATTERN.test(name));
const textures = files.filter((name) => TEXTURE_PATTERN.test(name) && !THUMBNAIL_PATTERN.test(name));
// .skel / .skel.bytes 的后缀够特定,直接信任;.json 必须确认内容真的是 Spine 骨骼,
// 否则任何普通 JSON(甚至改了后缀的其它数据)都会被误当成骨骼文件。
const usableSkeletons = skeletons.filter((name) => {
if (!/\.json$/i.test(name)) return true;
try {
// classifySkeletonJson 的肯定标识是 'spine',普通 JSON 是 'other',仍在写入是 'pending'。
return utils.classifySkeletonJson(path.join(itemDir, name)) === 'spine';
} catch (error) {
return false;
}
});
// 一个目录里既没有可用骨架也没有 atlas,说明它不是 Spine 资产的入口。
if (!usableSkeletons.length && !atlases.length) return null;
const entryName = usableSkeletons[0] || atlases[0];
// Eagle 删除素材只是把 item 的 metadata.json 标记成 isDeleted,目录和文件都留在
// 磁盘上。这种目录仍然会出现在「新增目录」里,必须在配对前挡掉,否则会给回收站
// 里的素材渲染一次缩略图(白做,还可能算成失败)。
if (utils.isItemDeleted(path.join(itemDir, entryName))) return null;
let assets = null;
// 一套资产的多个入口(atlas / skel / json)配对结果完全一样,缓存下来避免重复扫描。
// 缓存键必须带上 Eagle 文件夹:同名素材可能分属不同文件夹(同一示例的不同版本就是这样),
// 只用 stem 会把文件夹 A 的配对结果复用给文件夹 B。
let folders = [];
try {
folders = utils.foldersOfItem(path.join(itemDir, entryName));
} catch (error) { /* 读不到就按未分类处理 */ }
const folderKey = folders.slice().sort().join(',') || 'root';
const cacheKey = utils.normalizeStem(entryName) + '|' + folderKey;
if (pairCache && pairCache.has(cacheKey)) {
assets = pairCache.get(cacheKey);
} else {
try {
// searchRoots 限定在本次新增的目录里。不传的话 Utils 会把搜索范围扩到整个库,
// 几百上千个素材时一次配对就要一秒以上——Eagle 的「文件夹」只是逻辑分组,
// 磁盘上所有素材都平铺在 images/ 下,所以「扫同级」等于「扫全库」。
assets = utils.discoverAssets(path.join(itemDir, entryName), {
strictStem: true,
searchRoots,
// 文件索引有 1.5 秒缓存,刚导入的目录可能还没进索引,
// 所以每批的第一个目录强制刷新,其余复用。
refreshIndex,
});
} catch (error) {
assets = null;
}
if (pairCache) pairCache.set(cacheKey, assets);
}
// 贴图是否齐全要看 atlas 里声明了哪些页面,再看这些页面能不能在库里找到。
let pages = 0;
let missingTextures = 0;
if (assets && assets.atlasPath) {
try {
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
const pageNames = utils.parseAtlasPages(atlasText);
pages = pageNames.length;
missingTextures = pageNames.filter((name) => !assets.texturePaths[name]).length;
} catch (error) { /* atlas 读不了就当作不完整 */ }
}
// 骨架路径必须是骨骼文件后缀,防止配对结果指到贴图或其它文件上。
const skeletonPath = (assets && assets.skeletonPath) || null;
const skeletonOk = !!skeletonPath && /\.(skel|skel\.bytes|json)$/i.test(skeletonPath);
return {
dirName,
// 日志里要显示 Eagle 界面上的文件名和文件夹路径,而不是 MU9RVSxxxx.info 这种 id。
entryName,
folders,
skeletonPath,
atlasPath: (assets && assets.atlasPath) || null,
texturePaths: (assets && assets.texturePaths) || {},
// 必须按「实际要读的那个骨架文件」判断是否二进制:配对的骨架可能是同名的 .skel,
// 而当前目录里放的是 .json,用目录里的文件名判断就会拿 JSON 解析器去读二进制。
isBinary: /\.(skel|skel\.bytes)$/i.test(skeletonPath || entryName),
hasSkeletonInDir: usableSkeletons.length > 0,
hasAtlasInDir: atlases.length > 0,
hasTextureInDir: textures.length > 0,
pages,
missingTextures,
complete: !!(skeletonOk && assets.atlasPath && pages > 0 && missingTextures === 0),
};
}
/**
* 扫描指定的 item 目录,返回其中的 Spine 资产(其他素材会被过滤掉)。
* 每处理完一个目录就让出一次主线程:discoverAssets 是同步扫全库的重活,
* 连续跑几十个会把界面彻底卡住,日志写了也刷不出来。
*/
async function scanSpineItems(imagesDir, dirNames, utils, onProgress) {
const found = [];
// 本次新增的目录就是配对范围:配套的骨架 / atlas / 贴图在同一次导入里,
// 因此不需要、也不应该去翻整个素材库。
const searchRoots = dirNames.map((dirName) => path.resolve(imagesDir, dirName));
const pairCache = new Map();
for (let index = 0; index < dirNames.length; index++) {
const dirName = dirNames[index];
const info = analyzeItemDir(path.join(imagesDir, dirName), dirName, utils, pairCache, searchRoots, index === 0);
if (info) found.push(info);
if (onProgress) onProgress(index + 1, dirNames.length);
await new Promise((resolve) => setTimeout(resolve, 0));
}
return found;
}
const api = { analyzeItemDir, scanSpineItems, SKELETON_PATTERN, ATLAS_PATTERN, TEXTURE_PATTERN };
if (typeof window !== 'undefined') window.SpineThumbSpineAssets = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,31 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
const api = { drawCheckerboard };
if (typeof window !== 'undefined') window.SpineBackground = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,49 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
const fs = require('fs');
const path = require('path');
// 插件根目录必须延迟计算:Eagle 在 onPluginCreate 里才给出 plugin.path,
// 那时代码已经加载完了,所以这里用函数而不是常量。
function pluginRootPath() {
if (typeof window !== 'undefined') {
const fromEagle = window.SpinePluginPath || window.SpinePluginRoot;
if (fromEagle) return fromEagle;
}
return typeof __dirname !== 'undefined' ? path.join(__dirname, '..') : '.';
}
function badgeFile() {
return path.join(pluginRootPath(), 'assets', 'spine-badge.png');
}
let cachedDataUrl = null;
function dataUrl() {
if (!cachedDataUrl) {
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgeFile()).toString('base64');
}
return cachedDataUrl;
}
function drawVersion(ctx, width, height, version) {
version = String(version || '');
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(version)) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save();
try {
ctx.font = '600 ' + fontSize + 'px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const boxHeight = fontSize + 12;
const y = height - margin - boxHeight;
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(margin, y, ctx.measureText(version).width + 16, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.fillText(version, margin + 8, y + boxHeight / 2);
} finally { ctx.restore(); }
}
const api = { get path() { return badgeFile(); }, dataUrl, drawVersion };
if (typeof window !== 'undefined') window.SpineBadge = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,253 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!image.width || !image.height) return image;
let canvas;
if (doc) canvas = doc.createElement('canvas');
else {
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
catch (e) { return image; }
}
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles, darkColor) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, false);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, false);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
} else {
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = clipper;
return base;
}
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, false); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
}
}
} finally {
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
// 页面里 rasterizer 已经由 <script> 提前加载并挂在 window 上,Node 里才走 require。
const rasterizer = typeof window !== 'undefined' && window.SpineWebglRasterizer
? window.SpineWebglRasterizer
: require('./spine-webgl-rasterizer.js');
return rasterizer.attach(renderer, context);
}
const api = { createRenderer };
if (typeof window !== 'undefined') window.SpineCanvasClipping = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,263 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// Adapted from Esoteric Software's 3.4/3.5/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
const dict = () => Object.create(null);
class Input {
constructor(bytes) {
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.offset = 0;
}
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
byte() { this.need(1); return this.view.getUint8(this.offset++); }
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
varint(positive = true) {
let value = 0;
for (let shift = 0; shift <= 28; shift += 7) {
const b = this.byte();
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
value |= (b & 127) << shift;
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
}
this.fail('无效变长整数');
}
count() { const n = this.varint(); this.need(n); return n; }
string() {
const size = this.varint();
if (size === 0) return null;
this.need(size - 1);
const end = this.offset + size - 1;
let result = '';
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
// code units (including surrogate pairs), rather than string references.
while (this.offset < end) {
const b = this.byte();
if (b < 128) result += String.fromCharCode(b);
else {
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
let code = b & (extra === 1 ? 31 : 15);
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
result += String.fromCharCode(code);
}
}
return result;
}
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
}
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
const v34 = version === '3.4';
const before36 = v34 || version === '3.5';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
const boneList = () => Array.from({ length: r.count() }, boneName);
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
if (!before36) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!before36) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
function vertices(n) {
if (!r.bool()) return r.floats(n * 2);
const out = [];
for (let i = 0; i < n; i++) {
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
}
return out;
}
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
} else if (type === 2) {
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
a.vertices = vertices(count); a.hull = r.varint();
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
} else if (type === 3) {
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
} else if (type === 5) {
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
} else {
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
if (type === 6) a.end = slotName();
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
if (nonessential) a.color = r.color();
}
return a;
}
const skinNames = [];
function skin(name, isDefault = false) {
const count = r.count(); if (!count && isDefault) return;
const data = root.skins[name] = dict(); skinNames.push(name);
for (let i = 0; i < count; i++) {
const attachments = data[slotName()] = dict();
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
}
}
skin('default', true);
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
const eventNames = [];
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.string(); eventNames.push(name);
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
}
function curve(frame) {
const type = r.byte();
if (type === 1) frame.curve = 'stepped';
else if (type === 2) frame.curve = r.floats(4);
else if (type !== 0) r.fail('未知曲线类型:' + type);
}
function frames(read, curved = true) {
const count = r.count(); if (!count) r.fail('空时间线');
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
}
function animation() {
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.slots[slotName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.bones[boneName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
if (!name) r.fail('未知骨骼时间线');
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.ik).name;
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.paths[r.ref(root.path).name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
for (let k = 0, q = r.count(); k < q; k++) {
const key = r.string();
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
}
}
}
const drawCount = r.count();
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
const f = { time: r.float(), offsets: [] }; let previous = -1;
for (let i = 0, n = r.count(); i < n; i++) {
const index = r.varint(); const offset = r.varint();
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
}
return f;
});
const eventCount = r.count();
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
});
return a;
}
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
return root;
}
function install(spine, runtimeDir) {
if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
// 4.x 的运行时用 Object.defineProperty 导出,同名属性是只读 getter,写入会抛
// "which has only a getter"。这里的替换只对确实缺少 SkeletonBinary 的老版本有意义,
// 失败时保留运行时自带实现,绝不能让一次赋值把整个渲染流程打断。
try {
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
return loader.readSkeletonData(decode(bytes, runtimeDir));
}
};
} catch (error) {
// 只读属性:沿用运行时自带实现。
}
return spine;
}
const api = { install, decode };
if (typeof window !== 'undefined') window.SpineLegacyBinary = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,874 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
// service 插件页面的 require 不以插件目录为基准,所以页面里必须走 <script>。
(function () {
/**
* SpineViewer - 工具模块
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
* 职责:
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
* - 从骨骼数据中检测 Spine 版本
* - 根据版本选择合适的本地运行时
*/
const fs = require('fs');
const path = require('path');
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
const SUFFIX_MAX_LENGTH = 50;
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
let activeSuffixes = DEFAULT_SUFFIXES.slice();
let suffixRe = buildSuffixRe(activeSuffixes);
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSuffixRe(list) {
if (!list || list.length === 0) return null;
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
}
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
function normalizeSuffixName(raw) {
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
}
/**
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
*/
function setSuffixes(list) {
const custom = [];
for (const raw of Array.isArray(list) ? list : []) {
const name = normalizeSuffixName(raw);
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
if (custom.indexOf(name) === -1) custom.push(name);
}
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
suffixRe = buildSuffixRe(activeSuffixes);
return getSuffixes();
}
/** 当前生效的尾缀(内置 + 自定义) */
function getSuffixes() {
return activeSuffixes.slice();
}
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
function getCustomSuffixes() {
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
}
function unsafeAtlasPath(name, reason) {
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
error.code = 'UNSAFE_ATLAS_PATH';
return error;
}
/**
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
*/
function validateAtlasPageName(name) {
const raw = String(name == null ? '' : name).trim();
if (!raw) throw unsafeAtlasPath(name, '路径为空');
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
const portable = raw.replace(/\\/g, '/');
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
throw unsafeAtlasPath(raw, '不允许绝对路径');
}
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
const parts = portable.split('/');
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
const normalized = path.posix.normalize(portable);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw unsafeAtlasPath(raw, '路径无效');
}
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
}
return normalized.replace(/^\.\//, '');
}
function isPathInside(basePath, targetPath) {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
}
function realpath(filePath) {
const implementation = fs.realpathSync.native || fs.realpathSync;
return implementation(filePath);
}
/**
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
*/
function resolveFileWithin(baseDir, relativeName, atlasPage) {
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
const base = path.resolve(baseDir);
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
if (!isPathInside(base, candidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
return null;
}
if (!fs.existsSync(candidate)) return null;
let realBase;
let realCandidate;
try {
realBase = realpath(base);
realCandidate = realpath(candidate);
} catch (e) {
return null;
}
if (!isPathInside(realBase, realCandidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
return null;
}
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
return candidate;
}
function resolveAtlasTextureFile(atlasDir, pageName) {
return resolveFileWithin(atlasDir, pageName, true);
}
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
}
for (let i = 0; i < ATLAS_EXTS.length; i++) {
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
}
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
}
return null;
}
function isSkeletonFile(name) {
const ext = extOf(name);
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
}
function isAtlasFile(name) {
const ext = extOf(name);
return ext === '.atlas' || ext === '.atlas.txt';
}
function isTextureFile(name) {
const ext = extOf(name);
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
if (!ext) return name;
return name.slice(0, name.length - ext.length);
}
/** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
function normalizeStem(name) {
const lower = stemOf(name).toLowerCase();
return suffixRe ? lower.replace(suffixRe, '') : lower;
}
// 复合后缀:Eagle 按「最后一个点」拆 name / ext,于是 EmoticonSpine.atlas.txt 变成
// name=EmoticonSpine.atlas + ext=txt。而 Eagle 的卡片把 ext === 'txt' 写死成文本预览,
// 会整个盖掉自定义缩略图。把整段复合后缀塞进 ext(name=EmoticonSpine + ext=atlas.txt),
// Eagle 认不出这个类型,就会退回「用自定义缩略图当封面」,而 name + '.' + ext 拼出来
// 仍是磁盘上的真实文件名,显示和路径都不变。
const COMPOUND_SUFFIXES = ['.skel.bytes', '.atlas.txt'];
/** 返回 { name, ext };不是复合后缀(或没有主干名)时返回 null。 */
function splitCompoundName(fileName) {
const lower = String(fileName || '').toLowerCase();
const suffix = COMPOUND_SUFFIXES.find((s) => lower.endsWith(s));
if (!suffix) return null;
const name = String(fileName).slice(0, fileName.length - suffix.length);
if (!name) return null;
return { name, ext: suffix.slice(1) };
}
/** 从 JSON 文本中读取 spine 版本 */
function detectVersionFromJson(text) {
try {
const json = JSON.parse(text);
if (json && json.skeleton && json.skeleton.spine) {
return String(json.skeleton.spine);
}
} catch (e) { /* ignore */ }
return null;
}
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
function detectVersionFromBuffer(buf) {
let s = '';
const bytes = new Uint8Array(buf);
const len = Math.min(bytes.length, 160);
for (let i = 0; i < len; i++) {
s += String.fromCharCode(bytes[i]);
}
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
}
/** 从文件读取 spine 版本 */
function detectVersion(filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith('.json')) {
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
}
return detectVersionFromBuffer(fs.readFileSync(filePath));
}
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/
function chooseRuntimeDir(version, isBinary) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
const minor = parseFloat(version);
if (minor === 3.4) return '3.4';
if (minor === 3.5) return '3.5';
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
} else if (major === 4) {
const minor = parseFloat(version);
if (minor <= 4.0 + 0.0001) return '4.0';
if (minor <= 4.1 + 0.0001) return '4.1';
if (minor <= 4.2 + 0.0001) return '4.2';
return '4.3';
}
return null;
}
/**
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
*/
function classifySkeletonJson(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return 'pending';
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
const cached = skeletonJsonCache.get(filePath);
if (cached && cached.signature === signature) return cached.kind;
const length = Math.min(stat.size, JSON_PROBE_BYTES);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(length);
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
const text = buffer.toString('utf8');
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
let kind;
if (versionMatch) {
kind = 'spine';
} else if (stat.size > JSON_PROBE_BYTES) {
kind = 'other';
} else {
try {
JSON.parse(text);
kind = 'other';
} catch (e) {
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
kind = hasSkeletonKey ? 'pending' : 'other';
}
}
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
skeletonJsonCache.set(filePath, { signature, kind });
return kind;
} catch (e) {
return 'pending';
}
}
function looksLikeSkeletonJson(filePath) {
return classifySkeletonJson(filePath) === 'spine';
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
}
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
function btimeOf(fp) {
const d = itemMeta(path.dirname(fp));
return (d && d.btime) || 0;
}
/**
* Eagle item 的实际入库时间。
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
*/
function importTimeOf(fp) {
try {
const metaPath = path.join(path.dirname(fp), 'metadata.json');
const stat = fs.statSync(metaPath);
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
} catch (e) {
return btimeOf(fp);
}
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return major <= 3 ? '3x' : '4x';
}
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
function atlasFamily(atlasText) {
if (!atlasText) return null;
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
if (/\bbounds\s*:/.test(atlasText)) return '4x';
if (/\bxy\s*:/.test(atlasText)) return '3x';
return '3x';
}
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
function itemMeta(itemDir) {
if (!itemDir) return null;
try {
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
} catch (e) {
return null;
}
}
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
function foldersOfItem(filePath) {
const d = itemMeta(path.dirname(filePath));
return d && Array.isArray(d.folders) ? d.folders : [];
}
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
function isItemDeleted(filePath) {
const d = itemMeta(path.dirname(filePath));
return !!(d && d.isDeleted);
}
/** 从任意 item 路径向上定位 Eagle 库根目录 */
function findLibraryRoot(filePath) {
let dir = path.dirname(filePath); // item 文件夹
for (let i = 0; i < 8; i++) {
const parent = path.dirname(dir);
if (parent === dir) break;
try {
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
return parent;
}
} catch (e) { /* ignore */ }
dir = parent;
}
return null;
}
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
function readFolderInfo(libRoot) {
const map = {};
const names = {};
if (!libRoot) return { map, names };
try {
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
const folders = d.folders || [];
for (const f of folders) {
if (!f) continue;
map[f.id] = Array.isArray(f.children) ? f.children : [];
names[f.id] = f.name || '';
}
} catch (e) { /* ignore */ }
return { map, names };
}
/**
* 计算允许匹配的 Eagle 文件夹 id 集合:
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
*/
function getValidFolders(filePath) {
const itemFolders = foldersOfItem(filePath);
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
return new Set(itemFolders);
}
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
function isEagleItemDir(dir) {
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
}
/**
* 计算需要搜索的目录列表。
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
*/
function getSearchRoots(filePath) {
const roots = [];
const itemDir = path.dirname(filePath);
roots.push(itemDir);
if (isEagleItemDir(itemDir)) {
const parent = path.dirname(itemDir);
try {
for (const name of fs.readdirSync(parent)) {
const sub = path.join(parent, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
return [...new Set(roots)];
}
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
function buildRootsWithLibraryRoot(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return;
for (const name of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
if (roots.indexOf(sub) === -1) roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
/**
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
*/
function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
const probe = path.join(root, '__spineviewer_probe__');
const libRoot = findLibraryRoot(probe);
if (libRoot) {
try {
const imagesReal = realpath(path.join(libRoot, 'images'));
const rootReal = realpath(root);
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
} catch (e) { continue; }
}
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
const full = resolveFileWithin(root, name, false);
if (!full) continue;
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
const itemFolders = foldersOfItem(full);
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
}
if (seen.has(full)) continue;
seen.add(full);
out.push({ name, fullPath: full });
}
}
return out;
}
/**
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
* 正在落盘的新文件。
*/
function collectSpineFilesCached(filePath, roots, refresh) {
const libRoot = findLibraryRoot(filePath);
// Outside an Eagle library, sibling asset folders are independent search
// scopes. Sharing their parent as a cache key can return another skeleton.
const key = libRoot || JSON.stringify([...roots].sort());
const now = Date.now();
const cached = fileIndexCache.get(key);
if (!refresh && cached && now - cached.time < 1500) return cached.files;
const files = collectSpineFiles(roots, null);
fileIndexCache.set(key, { time: now, files });
return files;
}
/**
* 修正不可靠的源路径。
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
* 源文件,作为后续资源发现的基准。
*/
function resolveRealSource(filePath) {
// 源目录存在且是有效 item 目录 → 直接用
const dir = path.dirname(filePath);
if (isEagleItemDir(dir)) return filePath;
const name = path.basename(filePath);
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return filePath;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return filePath;
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
let fallback = null;
for (const d of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, d);
if (!isEagleItemDir(sub)) continue;
const cand = path.join(sub, name);
if (fs.existsSync(cand)) {
// 优先选择未被删除的项
if (!isItemDeleted(cand)) return cand;
if (!fallback) fallback = cand;
}
}
if (fallback) return fallback;
} catch (e) { /* ignore */ }
return filePath;
}
/**
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
let roots;
if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
} else {
roots = getSearchRoots(filePath);
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
// 明确列举的候选 item 目录,避免每次预览扫描整库。
buildRootsWithLibraryRoot(filePath, roots);
}
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
let files;
if (strictStem) {
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
// 无论是否允许跨批次,都必须限定在同一个 Eagle 文件夹内。同名素材很常见
//(同一套资源的 3.6 与 4.0 导出、同一角色的不同批次),只按文件名 stem
// 匹配会配到别的版本,渲染时要么区域找不到,要么贴图坐标整个错位。
files = validFolders && validFolders.size
? allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)))
: allFiles;
} else {
files = collectSpineFiles(roots, validFolders);
}
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
// 避免每个回调都解析库中所有普通 JSON。
if (strictStem) {
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
}
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
return true;
});
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refImportTime = importTimeOf(refFile);
const refBtime = btimeOf(refFile);
const ranked = cands.map((c) => {
const cFolders = foldersOfItem(c.fullPath);
return {
c,
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
};
});
ranked.sort((a, b) => allowCrossBatch
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
);
return ranked[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
const selectedAtlasFamily = isAtlasFile(selected)
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
: null;
// 1) 骨骼文件
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
if (selectedAtlasFamily && cands.length > 1) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
}
if (!skeleton && realSkeletonFiles.length > 0) {
if (strictStem) {
skeleton = null;
} else {
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = selectBest(cands, filePath);
}
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
if (isAtlasFile(selected)) {
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
atlasFiles.find((f) => f.name === selected) || null;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
const pickAtlas = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
if (skelFamily) {
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
if (same.length > 0) cands = same;
}
return selectBest(cands, refFile);
};
if (!atlas) {
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
atlas = pickAtlas(cands, filePath);
}
if (!atlas && skeleton) {
const skelStem = normalizeStem(skeleton.name);
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
if (strictStem) {
atlas = null;
} else {
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
}
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > allowedImportDelta) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
}
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
if (atlas) {
let text = '';
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
if (inAtlasDir) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
}
}
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
texturePaths,
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
};
}
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
function parseAtlasPages(atlasText) {
const pages = [];
if (!atlasText) return pages;
const lines = atlasText.split(/\r?\n/);
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
pages.push(validateAtlasPageName(line));
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
return [...new Set(pages)];
}
/**
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
*/
function parseAtlasPma(atlasText) {
if (typeof atlasText !== 'string') return undefined;
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
}
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
function preferredSkin(skeletonData) {
if (!skeletonData) return null;
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
const skins = skeletonData.skins || [];
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
}
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
function applySkin(skeleton, skin) {
if (!skeleton || !skin) return false;
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
else return false;
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
return true;
}
const spineUtilsApi = {
SKELETON_EXTS,
ATLAS_EXTS,
TEXTURE_EXTS,
ALL_EXTS,
extOf,
isSkeletonFile,
isAtlasFile,
isTextureFile,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
SUFFIX_MAX_LENGTH,
normalizeSuffixName,
setSuffixes,
getSuffixes,
getCustomSuffixes,
detectVersion,
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
classifySkeletonJson,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
validateAtlasPageName,
resolveAtlasTextureFile,
parseAtlasPma,
preferredSkin,
applySkin,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
foldersOfItem,
findLibraryRoot,
readFolderInfo,
getValidFolders,
splitCompoundName,
mtimeOf,
btimeOf,
importTimeOf,
versionFamily,
atlasFamily,
itemMeta,
isItemDeleted,
};
if (typeof window !== 'undefined') window.SpineUtils = spineUtilsApi;
else if (typeof module !== 'undefined' && module.exports) module.exports = spineUtilsApi;
})();
@@ -0,0 +1,33 @@
// 从画布像素里找实际有内容的包围盒(动态缩略图按整个动画的可见范围取景用)。
// 与主插件 js/spine-visible-bounds.js 是同一份实现,只是出口兼容 <script> 加载。
(function () {
'use strict';
function findAlphaBounds(rgba, width, height, threshold = 4) {
let left = width, top = height, right = 0, bottom = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x + 1);
bottom = Math.max(bottom, y + 1);
}
}
return right > left && bottom > top ? { left, top, right, bottom } : null;
}
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
if (!pixels || !isFinite(scale) || scale <= 0) return null;
const left = centerX + (pixels.left - width / 2) / scale;
const right = centerX + (pixels.right - width / 2) / scale;
const top = centerY - (pixels.top - height / 2) / scale;
const bottom = centerY - (pixels.bottom - height / 2) / scale;
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
const api = { findAlphaBounds, toWorldBounds };
if (typeof window !== 'undefined') window.SpineVisibleBounds = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,183 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
const preparedTextures = new WeakMap();
async function prepareTexture(image, blob) {
if (typeof createImageBitmap !== 'function') return;
const options = { colorSpaceConversion: 'none' };
const [pma, straight] = await Promise.all([
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
]);
preparedTextures.set(image, { pma, straight });
}
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
attribute vec4 light;
attribute vec4 dark;
uniform vec2 resolution;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
lightColor = light; darkColor = dark;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * lightColor.a;
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
gl_FragColor = vec4(rgb, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution');
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false;
let premultipliedAlpha = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
const pma = image !== context.canvas && premultipliedAlpha;
let pair = textures.get(image);
if (!pair) { pair = {}; textures.set(image, pair); }
const key = pma ? 'pma' : 'straight';
if (pair[key] && image !== context.canvas) return pair[key];
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const prepared = preparedTextures.get(image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
pair[key] = t; return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
coordinates.length = 0;
}
return {
begin(pma = false) {
if (disposed || lost || gl.isContextLost()) return false;
premultipliedAlpha = pma;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 48, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
flush(); currentImage = null;
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
const alpha = context.globalAlpha;
const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform;
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
for (let i=0; i<3; i++) {
const [x,y,u,v] = points[i], j=i*4;
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
}
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const pair of textures.values()) for (const value of Object.values(pair)) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin(!!renderer.premultipliedAlpha)) return draw(skeleton);
renderer.gpuDrawing = true;
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
const api = { createRasterizer, attach, prepareTexture };
if (typeof window !== 'undefined') window.SpineWebglRasterizer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();