Files
SpineViewer/SpineThumbnailGenerateServer/js/plugin.js
T
2026-09-21 10:30:33 +08:00

527 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v65';
const path = require('path');
const os = require('os');
const fs = require('fs');
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
const REQUIRED = [
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
throw new Error('本地模块未加载,请检查 index.html 里 script 的顺序:' + missing.join(', '));
}
const { createLibraryWatcher, cleanupStaleFiles } = window.SpineThumbLibraryWatcher;
const { scanSpineItems } = window.SpineThumbSpineAssets;
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
const progress = window.SpineThumbProgress;
const statusEl = document.getElementById('status');
const libraryEl = document.getElementById('library');
const statsEl = document.getElementById('stats');
const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
// 判定用排队数,不要求最终真的都产出动态 WebP。
const PROGRESS_MIN_DYNAMIC = 10;
// 弹窗上的「停止」按钮会把动作写进这个文件,宿主读回来执行暂停/继续/取消。
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
let settings = { ...settingsStore.DEFAULTS };
let libraryPath = null;
let imagesDir = null;
const queue = [];
let running = false;
// 本轮(一次队列排空)的统计和是否要开进度弹窗的标记;排空后一起清掉。
let runStats = null;
let runShowsProgress = false;
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
let paused = false;
let lastControlAt = 0;
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
const pending = new Set();
function log(message, kind) {
const line = document.createElement('li');
if (kind) line.className = kind;
line.textContent = '[' + new Date().toTimeString().slice(0, 8) + '] ' + message;
logEl.appendChild(line);
while (logEl.childElementCount > 400) logEl.removeChild(logEl.firstElementChild);
logEl.scrollTop = logEl.scrollHeight;
console.log('[SpineThumbnailGenerateServer] ' + message);
}
function setStatus(text) {
statusEl.textContent = text;
}
function refreshStats() {
statsEl.textContent = '批次 ' + stats.batches +
' · 新增 ' + stats.added +
' · Spine ' + stats.spine +
' · 完整 ' + stats.complete +
' · 已生成 ' + stats.generated +
' · 跳过 ' + stats.skipped +
' · 失败 ' + stats.failed;
}
function itemIdOf(dirName) {
return String(dirName).replace(/\.info$/i, '');
}
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
// 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
let folderPaths = null;
function loadFolderPaths() {
folderPaths = new Map();
try {
const tree = JSON.parse(fs.readFileSync(path.join(libraryPath, 'metadata.json'), 'utf8')).folders || [];
const walk = (list, prefix) => {
for (const f of list || []) {
const full = prefix + (f.name || f.id);
folderPaths.set(f.id, full);
walk(f.children, full + '/');
}
};
walk(tree, '');
} catch (error) { /* 读不到就只显示未分类 */ }
}
function folderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '未分类';
}
/** 日志里的渲染结果描述。参数不走本地化,和主插件的候选标签是同一套写法。 */
function describeThumbnail(rendered) {
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
if (rendered.mode === 'animated') {
return '动态 ' + rendered.detail + '*' + rendered.detail + '@' + rendered.fps + 'fps/q' +
rendered.quality + '/' + kb + 'kb(目标 ' + rendered.targetKilobytes + 'kb)/' +
rendered.frames + ' 帧/动画 ' + rendered.animation +
'/' + (rendered.strategy === 'speed' ? '最优速度' : '最优体积') +
(rendered.fits === false ? '(最小档仍超出目标体积,可以考虑提高缩略图大小)' : '');
}
return '静帧 ' + rendered.width + '*' + rendered.height + '/' + kb + 'kb' +
(rendered.fallback ? '(' + rendered.fallback + ')' : '');
}
/**
* 记本轮(一次队列排空)的产出,顺手把进度推给弹窗。
* animated 只统计真正做出动态 WebP 的数量;name 用来在弹窗里显示最近完成的文件。
*/
function countRun(outcome, animated, name) {
if (!runStats) return;
// 弹窗的暂停/取消随时生效:万一用户在最后一个素材渲染期间点了取消,这里也能记上。
applyControl();
runStats[outcome] += 1;
if (animated) runStats.animated += 1;
runStats.done += 1;
runStats.name = name || '';
if (runShowsProgress) {
progress.update({ done: runStats.done, total: runStats.total, name: runStats.name });
}
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
const label = item.entryName + ' | ' + folderPathOf(item.folders);
try {
const itemId = itemIdOf(item.dirName);
const eagleItem = await eagle.item.getById(itemId);
if (!eagleItem) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('跳过 ' + label + ':Eagle 里找不到这个素材', 'warn');
return;
}
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
try {
eagleItem.name = parts.name;
eagleItem.ext = parts.ext;
await withTimeout(eagleItem.save(), 20000, '修正复合后缀 ' + label);
log('修正复合后缀 ' + label + ' → ' + parts.name + '.' + parts.ext, 'dim');
} catch (error) {
log('修正复合后缀失败 ' + label + ':' + ((error && error.message) || error), 'warn');
}
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log('跳过 ' + label + ':已有自定义缩略图', 'dim');
return;
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
const animated = settings.mode !== 'static';
const rendered = await withTimeout(renderThumbnailAuto({
skeletonPath: item.skeletonPath,
atlasPath: item.atlasPath,
texturePaths: item.texturePaths,
runtimeDir,
isBinary: item.isBinary,
version,
size: 400,
mode: settings.mode,
strategy: settings.strategy,
onProgress: (current, total) => {
setStatus('正在渲染动画帧 ' + current + '/' + total + '(排队 ' + queue.length + ' 个)');
},
}), animated ? 300000 : 60000, '渲染 ' + label);
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + (rendered.extension || '.png'));
fs.writeFileSync(tempPath, rendered.buffer);
const applied = await withTimeout(eagleItem.setCustomThumbnail(tempPath), 20000, '设置缩略图 ' + item.dirName);
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
stats.generated += 1;
countRun('generated', rendered.mode === 'animated', item.entryName);
log('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
} catch (error) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
refreshStats();
}
}
/** 读弹窗写下的控制指令:{ action: 'pause' | 'resume' | 'cancel', at: 时间戳 }。 */
function readControl() {
try {
const data = JSON.parse(fs.readFileSync(CONTROL_FILE, 'utf8'));
if (data && typeof data.action === 'string' && Number.isFinite(data.at)) return data;
} catch (error) { /* 没有文件或内容不完整 */ }
return null;
}
/** 执行弹窗按钮的意图:暂停、继续、取消(取消 = 丢掉剩下的队列,已生成的保留)。 */
function applyControl() {
const control = readControl();
if (!control || control.at === lastControlAt) return;
lastControlAt = control.at;
if (control.action === 'pause') {
paused = true;
setStatus('已暂停(在进度弹窗里继续或取消)');
} else if (control.action === 'resume') {
paused = false;
} else if (control.action === 'cancel') {
paused = false;
const dropped = queue.length;
queue.length = 0;
log('已取消本轮生成:丢弃剩余 ' + dropped + ' 个素材,已完成 ' +
(runStats ? runStats.done : 0) + ' 张(成功 ' + (runStats ? runStats.generated : 0) +
' 张,失败 ' + (runStats ? runStats.failed : 0) +
' 张,跳过 ' + (runStats ? runStats.skipped : 0) + ' 张)', 'warn');
}
}
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
async function processQueue() {
if (running) return;
running = true;
// 一次队列排空算一轮,从零开始记;排空后再决定要不要弹通知。
if (!runStats) {
runStats = {
startedAt: Date.now(),
total: queue.length,
done: 0,
name: '',
animated: 0,
generated: 0,
failed: 0,
skipped: 0,
};
}
paused = false;
// 记下开跑那一刻控制文件的时间戳:上一轮遗留的暂停/取消指令不能再生效。
const stale = readControl();
lastControlAt = stale ? stale.at : 0;
try {
while (queue.length) {
applyControl();
if (paused) {
await new Promise((resolve) => setTimeout(resolve, 300));
continue;
}
const item = queue.shift();
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
if (!item) break;
setStatus('正在生成缩略图…剩余 ' + queue.length + ' 个');
await generateOne(item);
}
setStatus('监听中');
} finally {
running = false;
paused = false;
// 收尾放进 finally:哪怕中途出异常,进度弹窗也必须收到"本轮结束",不能一直挂着。
const run = runStats;
const showsProgress = runShowsProgress;
runStats = null;
runShowsProgress = false;
if (run && showsProgress) finishRun(run);
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
}
}
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
function finishRun(run) {
const seconds = Math.max(1, Math.round((Date.now() - run.startedAt) / 1000));
const summary = '成功 ' + run.generated + ' 张,失败 ' + run.failed + ' 张,跳过 ' +
run.skipped + ' 张,用时 ' + seconds + ' 秒。';
log('本轮完成:' + summary, 'ok');
progress.finish(summary);
}
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
let batchChain = Promise.resolve();
function handleBatch(batch) {
batchChain = batchChain
.then(() => handleBatchAsync(batch))
.catch((error) => log('处理批次失败:' + ((error && error.message) || error), 'error'));
}
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
async function handleBatchAsync({ added, total, reason }) {
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
if (!added.length) return;
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
loadFolderPaths();
stats.batches += 1;
stats.added += added.length;
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
// 本次新增 + 之前配套不全的,一起重新检查。
const candidates = [...new Set([...added, ...pending])];
if (pending.size) log('连带重新检查之前 ' + pending.size + ' 个配套不全的资产', 'dim');
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
setStatus('正在配对新增素材…');
await new Promise((resolve) => setTimeout(resolve, 0));
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
setStatus('正在配对 ' + current + '/' + all);
});
// 统计只算本次新增的:待观察的重查会重复计入,数字会虚高。
const fresh = items.filter((item) => added.includes(item.dirName));
stats.spine += fresh.length;
stats.complete += fresh.filter((item) => item.complete).length;
if (!items.length) log('这批里没有 Spine 资产', 'dim');
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
const incomplete = items.filter((item) => !item.complete);
pending.clear();
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
if (settings.enabled) {
for (const item of incomplete) pending.add(item.dirName);
}
if (incomplete.length) {
log(settings.enabled
? '有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐'
: '有 ' + incomplete.length + ' 个资产配套不全,本批不处理',
settings.enabled ? 'warn' : 'dim');
}
for (const item of items) {
const name = (filePath) => (filePath ? path.basename(filePath) : '无');
log(' ' + item.entryName + ' | ' + folderPathOf(item.folders) + ' | ' +
(item.complete ? '完整' : '不完整') +
'|骨架 ' + name(item.skeletonPath) +
'|atlas ' + name(item.atlasPath) +
'|贴图页 ' + item.pages + '(缺 ' + item.missingTextures + ')',
item.complete ? 'ok' : 'warn');
}
refreshStats();
const ready = items.filter((item) => item.complete);
if (!ready.length) return;
if (!settings.enabled) {
log('自动生成已关闭,本批 ' + ready.length + ' 个资产已跳过', 'warn');
setStatus('自动生成未启用,本批已跳过');
return;
}
queue.push(...ready);
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
if (runStats) runStats.total += ready.length;
// 硬性条件:动态模式且这一次排队超过 10 个,才开进度弹窗,跑完在里面给汇总。
if (settings.mode !== 'static' && ready.length > PROGRESS_MIN_DYNAMIC) {
runShowsProgress = true;
progress.open();
progress.update({
done: runStats ? runStats.done : 0,
total: runStats ? runStats.total : queue.length,
});
}
log('已加入队列 ' + ready.length + ' 个,当前排队 ' + queue.length + ' 个' +
(running ? '(正在处理中)' : ''), 'dim');
processQueue();
}
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log });
log('插件代码版本 ' + VERSION);
async function resolveLibraryPath() {
const direct = eagle.library && eagle.library.path;
if (direct) return direct;
try {
const info = await eagle.library.info();
const fromApi = info && (info.path || info.libraryPath || info.dir);
if (fromApi) return fromApi;
log('library.info() 没有返回路径字段:' + JSON.stringify(info), 'dim');
} catch (error) {
log('library.info() 调用失败:' + ((error && error.message) || error), 'error');
}
return null;
}
async function start() {
const resolved = await resolveLibraryPath();
if (!resolved) {
setStatus('没有拿到素材库路径');
log('切换素材库时会重试', 'warn');
return;
}
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = resolved;
setStatus(libraryWatcher.start(resolved) ? '监听中' : '监听失败');
}
function startSafely() {
start().catch((error) => {
setStatus('启动失败:' + ((error && error.message) || error));
log('启动失败:' + ((error && error.stack) || error), 'error');
});
}
async function loadSettings() {
try {
settings = await settingsStore.read();
} catch (error) {
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
settings = { ...settingsStore.DEFAULTS };
}
toggleEl.checked = settings.enabled === true;
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
}
toggleEl.addEventListener('change', async () => {
settings = { ...settings, enabled: toggleEl.checked };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
if (settings.enabled) {
log('自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。', 'ok');
setStatus('监听中');
} else {
queue.length = 0;
log('自动生成已关闭,待处理队列已清空。', 'dim');
setStatus('监听中(自动生成已关闭)');
}
});
// 只清显示,不动统计和 queue:Eagle 的 devtools 控制台里仍然留着完整历史。
clearEl.addEventListener('click', () => {
logEl.textContent = '';
});
modeEl.addEventListener('change', async () => {
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
log(settings.mode === 'static'
? '生成方式:静帧(始终 setup pose,PNG)'
: '生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)', 'dim');
});
strategyEl.addEventListener('change', async () => {
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
}
log(settings.strategy === 'speed'
? '生成策略:最优速度(粗估挑档,只生成最小的一两档)'
: '生成策略:最优体积(十档全生成,取实际体积最小的那一档)', 'dim');
});
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
filtersEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-filter]');
if (!button) return;
logEl.dataset.filter = button.dataset.filter;
for (const item of filtersEl.children) item.classList.toggle('active', item === button);
});
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
document.getElementById('status').textContent = '脚本已加载 ' + VERSION + ',等待 Eagle 事件…';
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
if (typeof eagle === 'undefined') {
setStatus('eagle API 未注入');
log('页面上没有 eagle 对象,插件无法与 Eagle 通信', 'error');
} else {
eagle.onPluginCreate(async (plugin) => {
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
log('插件已创建:' + ((plugin && plugin.manifest && plugin.manifest.name) || 'SpineThumbnailGenerateServer'));
try {
await loadSettings();
} catch (error) {
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
}
startSafely();
});
eagle.onLibraryChanged((changedPath) => {
log('切换素材库:' + changedPath);
startSafely();
});
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
setTimeout(() => {
if (libraryEl.textContent === '—') {
log('没有收到创建事件,直接尝试启动', 'dim');
loadSettings().catch(() => {}).then(startSafely);
}
}, 800);
}