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

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

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SpineThumbnailGenerateServer</title>
<style>
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; --warn: #e3b341; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
h1 { font-size: 15px; margin: 0; font-weight: 600; }
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice b { color: var(--text); font-weight: 500; }
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
.toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
.toolbar { display: flex; gap: 8px; }
.toolbar .toggle-row { flex: 1; }
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; }
#clear:hover { border-color: var(--accent); color: var(--accent); }
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
.row b { color: var(--text); font-weight: 500; word-break: break-all; }
#stats { color: var(--accent); }
#log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
#log li { white-space: pre-wrap; word-break: break-all; }
#log li.dim { color: var(--dim); }
#log li.ok { color: var(--accent); }
#log li.fail { color: #e06c75; }
#log li.warn { color: var(--warn); }
#log li.error { color: #ff5f56; }
/* 过滤直接用 CSS:给 #log 一个 data-filter,非该类别的行就不显示,不用遍历 DOM。 */
#log[data-filter="ok"] li:not(.ok),
#log[data-filter="fail"] li:not(.fail),
#log[data-filter="warn"] li:not(.warn),
#log[data-filter="error"] li:not(.error) { display: none; }
.filters { display: flex; gap: 6px; }
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
.filters button.active { color: var(--accent); border-color: var(--accent); }
</style>
</head>
<body>
<h1>SpineThumbnailGenerateServer</h1>
<div class="notice">
导入 Spine 资产(<code>.skel</code> / <code>.json</code> + <code>.atlas</code> + 贴图)后,
本插件会为还没设置缩略图的素材生成一张骨骼预览图。
<b>它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</b>
自动生成默认关闭,需要你在下面手动启用。
</div>
<div class="toolbar">
<label class="toggle-row"><input type="checkbox" id="toggle"><span>导入时自动生成缩略图</span></label>
<button id="clear" type="button">清空日志</button>
</div>
<div class="row"><span>状态</span><b id="status">启动中…</b></div>
<div class="row"><span>素材库</span><b id="library">—</b></div>
<div class="row"><span>统计</span><b id="stats">—</b></div>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active">全部</button>
<button type="button" data-filter="ok">成功</button>
<button type="button" data-filter="fail">失败</button>
<button type="button" data-filter="warn">警告</button>
<button type="button" data-filter="error">错误</button>
</div>
<ul id="log"></ul>
<script>
// 诊断用:脚本一旦抛错,直接把原因写在状态栏,避免停在「启动中…」看不出问题。
window.addEventListener('error', (event) => {
const el = document.getElementById('status');
if (el) el.textContent = '脚本错误:' + (event.message || event.error || '未知错误');
});
window.addEventListener('unhandledrejection', (event) => {
const el = document.getElementById('status');
if (el) el.textContent = '未处理的 Promise 错误:' + (event.reason && event.reason.message || event.reason);
});
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
(function () {
try {
// Windows 上 pathname 可能是 /C:/... 也可能是 \C:\...,统一成正斜杠并去掉前导分隔符。
const pathname = decodeURIComponent(new URL('.', location.href).pathname);
window.SpinePluginRoot = pathname.replace(/\\/g, '/').replace(/^\/+/, '');
} catch (error) {
window.SpinePluginRoot = '';
}
})();
</script>
<!-- 顺序有依赖:rasterizer 先于 clipping,utils/badge 先于 renderer,plugin.js 最后。 -->
<script src="js/spine-webgl-rasterizer.js"></script>
<script src="js/spine-canvas-clipping.js"></script>
<script src="js/spine-background.js"></script>
<script src="js/spine-utils.js"></script>
<script src="js/spine-legacy-binary.js"></script>
<script src="js/spine-badge.js"></script>
<script src="js/settings.js"></script>
<script src="js/renderer.js"></script>
<script src="js/library-watcher.js"></script>
<script src="js/spine-assets.js"></script>
<script src="js/plugin.js"></script>
</body>
</html>
@@ -0,0 +1,103 @@
// 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 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, 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;
})();
+343
View File
@@ -0,0 +1,343 @@
'use strict';
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v26';
const path = require('path');
const os = require('os');
const fs = require('fs');
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
const REQUIRED = ['SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings', 'SpineUtils', 'SpineBadge'];
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 } = window.SpineThumbLibraryWatcher;
const { scanSpineItems } = window.SpineThumbSpineAssets;
const { renderThumbnail, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
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 stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
let settings = { ...settingsStore.DEFAULTS };
let libraryPath = null;
let imagesDir = null;
const queue = [];
let running = false;
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
// 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 界面上显示的路径。
// 文件夹树只在切换素材库时重新读,避免每个素材都解析一次库 metadata。
let folderPaths = null;
function folderPathOf(folderIds) {
if (!folderPaths) {
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) { /* 读不到就只显示未分类 */ }
}
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '未分类';
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
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;
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;
log('跳过 ' + label + ':已有自定义缩略图', 'dim');
return;
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
const rendered = await withTimeout(renderThumbnail({
skeletonPath: item.skeletonPath,
atlasPath: item.atlasPath,
texturePaths: item.texturePaths,
runtimeDir,
isBinary: item.isBinary,
version,
size: 400,
}), 60000, '渲染 ' + item.dirName);
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + '.png');
fs.writeFileSync(tempPath, Buffer.from(await rendered.blob.arrayBuffer()));
const applied = await withTimeout(eagleItem.setCustomThumbnail(tempPath), 20000, '设置缩略图 ' + item.dirName);
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
stats.generated += 1;
log('已生成 ' + label + ' | Spine ' + version, 'ok');
} catch (error) {
stats.failed += 1;
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
refreshStats();
}
}
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
async function processQueue() {
if (running) return;
running = true;
try {
while (queue.length) {
const item = queue.shift();
setStatus('正在生成缩略图…剩余 ' + queue.length + ' 个');
await generateOne(item);
}
setStatus('监听中');
} finally {
running = false;
}
}
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
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;
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();
for (const item of incomplete) pending.add(item.dirName);
if (incomplete.length) {
log('有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐', 'warn');
}
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('检测到新增 ' + added.length + ' 个素材(自动生成未启用)');
return;
}
queue.push(...ready);
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;
}
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 = '';
});
// 日志分类过滤交给 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 事件…';
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);
}
+218
View File
@@ -0,0 +1,218 @@
// 在 service 插件页面里渲染一张缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出 PNG。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const runtimeCache = new Map();
/**
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
*
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
*/
function loadRuntime(runtimeDir) {
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
const job = new Promise((resolve, reject) => {
const frame = document.createElement('iframe');
frame.style.display = 'none';
frame.onload = () => {
try {
const win = frame.contentWindow;
const script = win.document.createElement('script');
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
script.onload = () => {
try {
const spine = win.spine;
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
resolve(spine);
} catch (error) {
reject(error);
}
};
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
win.document.head.appendChild(script);
} catch (error) {
reject(error);
}
};
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
document.body.appendChild(frame);
});
runtimeCache.set(runtimeDir, job);
return job;
}
/** 本地绝对路径 → file:// URL(逐段编码,保留盘符)。 */
function fileUrl(filePath) {
const normalized = String(filePath).replace(/\\/g, '/');
return 'file:///' + normalized.split('/')
.map((part, index) => (index === 0 ? part : encodeURIComponent(part)))
.join('/');
}
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
function withTimeout(promise, timeoutMs, label) {
let timer = null;
return Promise.race([
promise.finally(() => { if (timer) clearTimeout(timer); }),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
}),
]);
}
function loadImage(filePath) {
return withTimeout(new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
image.src = fileUrl(filePath);
}), 10000, '读取贴图 ' + path.basename(filePath));
}
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
async function buildTextureAtlas(spine, atlasText, texturePaths) {
const images = new Map();
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
const filePath = texturePaths[pageName];
if (!filePath) return;
images.set(pageName, await loadImage(filePath));
}));
const makeTexture = (name) => {
const image = images.get(name);
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
return new spine.Texture(image);
};
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
const atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const texture = makeTexture(page.name);
if (page.setTexture) page.setTexture(texture);
else page.texture = texture;
}
return atlas;
}
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buffer = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buffer));
}
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
}
function updateWorld(spine, skeleton) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
/**
* 渲染 setup pose 的缩略图。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
* 返回 { blob, width, height, version }
*/
async function renderThumbnail(options) {
const utils = window.SpineUtils;
const size = options.size || 400;
const spine = await loadRuntime(options.runtimeDir);
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data));
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton);
const bounds = (function boundsOf(sk) {
try {
if (typeof sk.getBoundsRect === 'function') {
const rect = sk.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof sk.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
sk.getBounds(offset, dimensions, []);
if (dimensions.x > 0 || dimensions.y > 0) {
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
}
}
} catch (error) { /* ignore */ }
return null;
})(skeleton);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
renderer.draw(skeleton);
} finally {
if (renderer && renderer.dispose) renderer.dispose();
ctx.restore();
}
}
if (options.drawBadge !== false) await drawBadge(ctx, size);
if (options.drawVersion !== false) {
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('导出缩略图失败');
return { blob, width: size, height: size, version: data.version || options.version || '' };
}
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
function drawBadge(ctx, size) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
resolve();
};
image.onerror = () => resolve();
image.src = window.SpineBadge.dataUrl();
});
}
const api = { renderThumbnail, loadRuntime, withTimeout };
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,41 @@
// 插件设置持久化。默认关闭自动生成——Eagle 审核标准要求「批量或不可逆的变更
// 必须事先明确说明并获得确认」,所以必须由用户主动打开这个开关。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULTS = { enabled: false };
/** 存放在 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,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;
})();
@@ -0,0 +1,9 @@
# Spine 3.4 runtime
Bundled from the official `3.4.02` tag, commit `ef5013143178918ddfc7cabfbddeae7aec7cf84e`, `spine-ts/build/spine-canvas.js`.
Local correction: `SkeletonJson` path-length loop writes `lengths[i]`, not `lengths[i++]`. The original increments twice and reads beyond the source array, producing missing/NaN lengths.
The plugin provides binary decoding and the `AtlasAttachmentLoader` name alias in `js/spine-legacy-binary.js`. Rendering uses the 3.4 attachment vertex API in `js/spine-canvas-clipping.js`. 3.4 has no clipping attachments; no newer attachment types are synthesized.
License: `LICENSES/Spine-3.4-License.txt`.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
# Spine 3.5
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
License: `LICENSES/Spine-3.5-License.txt`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

@@ -0,0 +1,20 @@
{
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
"version": "0.1.0",
"platform": "all",
"arch": "all",
"name": "SpineThumbnailGenerateServer",
"logo": "/logo.png",
"keywords": [
"spine",
"thumbnail",
"缩略图"
],
"devTools": true,
"main": {
"serviceMode": true,
"url": "index.html",
"width": 560,
"height": 460
}
}
@@ -0,0 +1,211 @@
'use strict';
// 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖:
// 1. 历史素材被忽略、静默之后才触发、只报增量;
// 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 <id>.info 目录,
// 一套资产的骨架 / atlas / 贴图分散在不同目录里,必须能关联起来;
// 3. 本地模块在「页面同时有 window 和 module」时仍然挂到 window 上。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { createLibraryWatcher, QUIET_MS } = require('../js/library-watcher.js');
const { scanSpineItems } = require('../js/spine-assets.js');
const utils = require('../js/spine-utils.js');
const { withTimeout } = require('../js/renderer.js');
// —— 超时保护:卡住的 Promise 必须被拒绝,否则队列会被永久堵死 ——
(async () => {
assert.equal(await withTimeout(Promise.resolve('ok'), 1000, '测试'), 'ok', '正常 Promise 应原样返回');
await assert.rejects(
() => withTimeout(new Promise(() => {}), 30, '测试'),
/超时/,
'永不 resolve 的 Promise 必须被超时拒绝',
);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
// —— 回归:Eagle 插件页面里也存在 module 对象,本地模块必须优先挂 window ——
const pageSandbox = {
require, console, setTimeout, clearTimeout, setInterval, clearInterval,
window: {}, module: { exports: {} },
document: { getElementById: () => null, createElement: () => ({}) },
};
pageSandbox.self = pageSandbox;
vm.createContext(pageSandbox);
for (const file of ['library-watcher.js', 'spine-assets.js', 'spine-utils.js']) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', file), 'utf8'), pageSandbox, { filename: file });
}
assert.ok(pageSandbox.window.SpineThumbLibraryWatcher, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineThumbSpineAssets, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineUtils, '存在 module 对象时也必须挂到 window');
// —— 复合后缀:Eagle 只按最后一个点拆 name/ext,atlas.txt 会被当成纯文本素材 ——
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.atlas.txt'), { name: 'EmoticonSpine', ext: 'atlas.txt' });
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.skel.bytes'), { name: 'EmoticonSpine', ext: 'skel.bytes' });
assert.equal(
utils.splitCompoundName('hero.skel'), null,
'普通后缀不动(否则会把 Eagle 里显式支持的类型也拆坏)',
);
assert.equal(utils.splitCompoundName('notes.txt'), null, '普通 txt 不是 Spine 素材,不碰');
assert.equal(utils.splitCompoundName('.atlas.txt'), null, '没有主干名就不改');
// —— 搭一个仿真素材库:<root>/images/<id>.info/<file> ——
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-server-'));
const imagesDir = path.join(root, 'images');
fs.mkdirSync(imagesDir, { recursive: true });
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const MINIMAL_ATLAS = ['hero.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
// 真实 Spine JSON 的最小形态:必须有 skeleton.spine 版本字段才会被判为骨骼数据。
const MINIMAL_SPINE_JSON = JSON.stringify({ skeleton: { spine: '3.8.55' }, bones: [{ name: 'root' }] });
const LEGACY_ATLAS = ['legacy.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOR_DUP = ['dup.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOLD_A = ['dup2.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
function makeItem(id, files, folders, deleted) {
const dir = path.join(imagesDir, id + '.info');
fs.mkdirSync(dir, { recursive: true });
// Eagle 的每个 item 目录都必须有 metadata.json,spine-utils 靠它判断这是不是一个 item。
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({
id, name: id, folders: folders || [], isDeleted: !!deleted,
}));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content === true ? 'x' : content);
}
return dir;
}
// 兜底超时,避免配对逻辑异常时测试挂住不退出。
setTimeout(() => {
console.error('测试超时(60 秒)');
process.exit(1);
}, 60000).unref();
// 历史素材:监听建立之前就存在,不能处理。
makeItem('old-skel', { 'spineboy.skel': true });
makeItem('old-atlas', { 'spineboy.atlas': MINIMAL_ATLAS });
makeItem('old-tex', { 'spineboy.png': true });
// 历史里还留着一套 legacy 资产的 atlas + 贴图,用来验证新增素材不能借用它们。
makeItem('old-legacy-atlas', { 'legacy.atlas': LEGACY_ATLAS });
makeItem('old-legacy-tex', { 'legacy.png': true });
const batches = [];
const watcher = createLibraryWatcher({ onBatch: (batch) => batches.push(batch), onLog: () => {} });
assert.equal(watcher.start(root), true, '监听应当建立成功');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
(async () => {
// 模拟一次拖入:一套分散在三处的完整资产 + 一张普通图片 + 只有骨架的残缺资产。
makeItem('new-skel', { 'hero.skel': true });
makeItem('new-atlas', { 'hero.atlas': MINIMAL_ATLAS });
makeItem('new-tex', { 'hero.png': true });
makeItem('new-photo', { 'photo.png': true });
makeItem('new-broken', { 'lonely.skel': true });
makeItem('new-json', { 'notes.json': '{"hello":"world"}' });
makeItem('new-skeljson', { 'beast.json': MINIMAL_SPINE_JSON });
// 只有骨架,配套的 legacy.atlas / legacy.png 都在历史素材里 → 不该跨批次配上
makeItem('new-orphan', { 'legacy.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 1, '静默之后应当只触发一批');
assert.equal(batches[0].reason, 'quiet');
assert.deepEqual(
batches[0].added.slice().sort(),
['new-atlas.info', 'new-broken.info', 'new-json.info', 'new-orphan.info', 'new-photo.info', 'new-skel.info', 'new-skeljson.info', 'new-tex.info'],
);
const spine = await scanSpineItems(imagesDir, batches[0].added, utils);
const names = spine.map((item) => item.dirName).sort();
assert.deepEqual(
names,
['new-atlas.info', 'new-broken.info', 'new-orphan.info', 'new-skel.info', 'new-skeljson.info'],
'普通图片和普通 JSON 不算 Spine 资产,真实的 Spine JSON 要算',
);
// 关键:骨架在 new-skel.info 里,atlas 在 new-atlas.info 里,贴图在 new-tex.info 里,
// 三者必须被判成同一套完整资产。
const hero = spine.find((item) => item.dirName === 'new-skel.info');
assert.equal(hero.complete, true, '跨目录的三件套必须配成完整资产');
// 日志要显示 Eagle 里的文件名和文件夹路径,扫描结果必须带上这两项。
assert.equal(hero.entryName, 'hero.skel', '要带上 item 目录里的入口文件名');
assert.deepEqual(hero.folders, [], '未归入文件夹的素材 folders 为空数组');
assert.equal(path.basename(hero.atlasPath || ''), 'hero.atlas', '要找到另一个目录里的 atlas');
assert.equal(path.basename(hero.texturePaths['hero.png'] || ''), 'hero.png', '要找到 atlas 引用的贴图');
assert.equal(hero.pages, 1);
assert.equal(hero.missingTextures, 0);
const broken = spine.find((item) => item.dirName === 'new-broken.info');
assert.equal(broken.complete, false, '只有骨架不算完整');
assert.equal(broken.missingTextures, 0, '没有 atlas 时缺图计数为 0');
// 配对范围只限本次新增:历史素材里有 legacy.atlas 和 legacy.png,
// 但新导入的 legacy.skel 不能去借用它们。
const orphan = spine.find((item) => item.dirName === 'new-orphan.info');
assert.equal(orphan.complete, false, '新增素材不能借用历史素材的配套文件');
assert.equal(orphan.atlasPath, null, '不应该跨批次找到历史 atlas');
// 第二批:只报新增的那个。
makeItem('new-skel2', { 'boss.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 2, '第二批应当再触发一次');
assert.deepEqual(batches[1].added, ['new-skel2.info']);
// 分批导入(模拟 Eagle 的「重复添加提示」把一次导入拆成两段):
// 先只写入骨架,配套的 atlas / 贴图过一会儿才写入。
makeItem('dup-skel', { 'dup.skel': true });
await wait(QUIET_MS + 1200);
const firstPass = await scanSpineItems(imagesDir, ['dup-skel.info'], utils);
assert.equal(firstPass[0].complete, false, '只有骨架时不算完整');
makeItem('dup-atlas', { 'dup.atlas': ATLAS_FOR_DUP });
makeItem('dup-tex', { 'dup.png': true });
await wait(QUIET_MS + 1200);
// plugin.js 的「待观察」会把上一批的目录和新一批一起送进来,这里模拟同样的调用。
const secondPass = await scanSpineItems(imagesDir, ['dup-skel.info', 'dup-atlas.info', 'dup-tex.info'], utils);
const dup = secondPass.find((item) => item.dirName === 'dup-skel.info');
assert.equal(dup.complete, true, '配套补齐后应当能配成完整资产');
assert.equal(dup.missingTextures, 0);
// 同名素材分属不同 Eagle 文件夹时不能互相配对:文件夹 A 里有完整三件套,
// 文件夹 B 里只有一个同名骨架,B 不应该借用 A 的 atlas。
makeItem('fold-a-skel', { 'dup2.skel': true }, ['folderA']);
makeItem('fold-a-atlas', { 'dup2.atlas': ATLAS_FOLD_A }, ['folderA']);
makeItem('fold-a-tex', { 'dup2.png': true }, ['folderA']);
makeItem('fold-b-skel', { 'dup2.skel': true }, ['folderB']);
const crossFolder = await scanSpineItems(
imagesDir,
['fold-a-skel.info', 'fold-a-atlas.info', 'fold-a-tex.info', 'fold-b-skel.info'],
utils,
);
const inA = crossFolder.find((item) => item.dirName === 'fold-a-skel.info');
assert.equal(inA.complete, true, '同一 Eagle 文件夹里的三件套要能配上');
assert.deepEqual(inA.folders, ['folderA'], '要带上 Eagle 文件夹 id,日志才能还原路径');
assert.equal(path.basename(inA.atlasPath), 'dup2.atlas');
const inB = crossFolder.find((item) => item.dirName === 'fold-b-skel.info');
assert.equal(inB.complete, false, '不能借用别的 Eagle 文件夹里的同名 atlas');
assert.equal(inB.atlasPath, null);
// 回收站素材:Eagle 删除只是把 metadata 标成 isDeleted,目录和文件都还在磁盘上。
// 三件套齐全也不能进结果,否则会给回收站里的素材渲染缩略图。
makeItem('trash-skel', { 'gone.skel': true }, [], true);
makeItem('trash-atlas', { 'gone.atlas': ATLAS_FOR_DUP }, [], true);
makeItem('trash-tex', { 'gone.png': true }, [], true);
const trashed = await scanSpineItems(
imagesDir,
['trash-skel.info', 'trash-atlas.info', 'trash-tex.info'],
utils,
);
assert.deepEqual(trashed, [], '回收站里的素材不能被当成待生成资产');
watcher.stop();
fs.rmSync(root, { recursive: true, force: true });
console.log('PASS: 静默检测、历史忽略、增量识别、跨目录配对、回收站过滤、浏览器加载方式');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});