559 lines
22 KiB
JavaScript
559 lines
22 KiB
JavaScript
'use strict';
|
||
|
||
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||
const VERSION = 'v66';
|
||
const SHOW_DEV_VERSION = true;
|
||
|
||
const path = require('path');
|
||
const os = require('os');
|
||
const fs = require('fs');
|
||
const i18n = window.SpineThumbI18n;
|
||
const t = i18n ? i18n.t : (key) => key;
|
||
|
||
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
|
||
const REQUIRED = [
|
||
'SpineThumbI18n',
|
||
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
|
||
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
|
||
'SpineThumbProgress',
|
||
];
|
||
const missing = REQUIRED.filter((name) => !window[name]);
|
||
if (missing.length) {
|
||
const modules = missing.join(', ');
|
||
document.getElementById('status').textContent = t('thumb.modulesMissing', { modules });
|
||
throw new Error(t('thumb.moduleLoadError', { modules }));
|
||
}
|
||
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;
|
||
let versionLogged = 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 logVersion() {
|
||
if (!SHOW_DEV_VERSION || versionLogged) return;
|
||
versionLogged = true;
|
||
log(t('thumb.version', { version: VERSION }));
|
||
}
|
||
|
||
function setStatus(text) {
|
||
statusEl.textContent = text;
|
||
}
|
||
|
||
function refreshStats() {
|
||
statsEl.textContent = t('thumb.stats', stats);
|
||
}
|
||
|
||
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)) || t('thumb.uncategorized');
|
||
}
|
||
|
||
/** 日志里的渲染结果描述。 */
|
||
function describeThumbnail(rendered) {
|
||
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
|
||
if (rendered.mode === 'animated') {
|
||
return t('thumb.thumbnailDetailsAnimated', {
|
||
size: rendered.detail + '*' + rendered.detail,
|
||
fps: rendered.fps,
|
||
quality: rendered.quality,
|
||
kb,
|
||
target: rendered.targetKilobytes,
|
||
frames: rendered.frames,
|
||
animation: rendered.animation,
|
||
strategy: t(rendered.strategy === 'speed' ? 'thumb.bestSpeed' : 'thumb.bestSize'),
|
||
limit: rendered.fits === false ? t('thumb.limitNote') : '',
|
||
});
|
||
}
|
||
return t('thumb.thumbnailDetailsStatic', {
|
||
width: rendered.width,
|
||
height: rendered.height,
|
||
kb,
|
||
fallback: rendered.fallback ? t('thumb.fallbackNote', { text: 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(t('thumb.missingItem', { label }), '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, t('thumb.fixSuffixAction', { label }));
|
||
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
|
||
} catch (error) {
|
||
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
|
||
}
|
||
}
|
||
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
|
||
if (eagleItem.customThumbnail === true) {
|
||
stats.skipped += 1;
|
||
countRun('skipped', false, item.entryName);
|
||
log(t('thumb.existingThumbnail', { 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(t('thumb.statusRendering', { current, total, queued: queue.length }));
|
||
},
|
||
}), animated ? 300000 : 60000, t('thumb.renderAction', { 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,
|
||
t('thumb.setThumbnailAction', { label: item.dirName }),
|
||
);
|
||
if (applied !== true) throw new Error(t('thumb.thumbnailRejected'));
|
||
stats.generated += 1;
|
||
countRun('generated', rendered.mode === 'animated', item.entryName);
|
||
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
|
||
} catch (error) {
|
||
stats.failed += 1;
|
||
countRun('failed', false, item.entryName);
|
||
log(t('thumb.generateFailed', { label, error: (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(t('thumb.statusPaused'));
|
||
} else if (control.action === 'resume') {
|
||
paused = false;
|
||
} else if (control.action === 'cancel') {
|
||
paused = false;
|
||
const dropped = queue.length;
|
||
queue.length = 0;
|
||
log(t('thumb.cancelled', {
|
||
dropped,
|
||
done: runStats ? runStats.done : 0,
|
||
generated: runStats ? runStats.generated : 0,
|
||
failed: runStats ? runStats.failed : 0,
|
||
skipped: 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(t('thumb.statusGenerating', { queued: queue.length }));
|
||
await generateOne(item);
|
||
}
|
||
setStatus(t('thumb.statusListening'));
|
||
} 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 = t('thumb.runSummary', {
|
||
generated: run.generated,
|
||
failed: run.failed,
|
||
skipped: run.skipped,
|
||
seconds,
|
||
});
|
||
log(summary, 'ok');
|
||
progress.finish(summary);
|
||
}
|
||
|
||
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
|
||
let batchChain = Promise.resolve();
|
||
|
||
function handleBatch(batch) {
|
||
batchChain = batchChain
|
||
.then(() => handleBatchAsync(batch))
|
||
.catch((error) => log(t('thumb.batchFailed', { error: (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;
|
||
const reasonText = t(reason === 'fallback' ? 'thumb.reasonFallback' : 'thumb.reasonQuiet');
|
||
log(t('thumb.batchFinished', { added: added.length, total, reason: reasonText }));
|
||
// 本次新增 + 之前配套不全的,一起重新检查。
|
||
const candidates = [...new Set([...added, ...pending])];
|
||
if (pending.size) log(t('thumb.recheck', { count: pending.size }), 'dim');
|
||
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
|
||
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
|
||
setStatus(t('thumb.statusPairing'));
|
||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
|
||
setStatus(t('thumb.statusPairingProgress', { current, total: 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(t('thumb.noSpine'), '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(t(settings.enabled ? 'thumb.incompletePending' : 'thumb.incompleteIgnored', { count: incomplete.length }),
|
||
settings.enabled ? 'warn' : 'dim');
|
||
}
|
||
for (const item of items) {
|
||
const name = (filePath) => (filePath ? path.basename(filePath) : t('thumb.none'));
|
||
log(t('thumb.itemSummary', {
|
||
name: item.entryName,
|
||
path: folderPathOf(item.folders),
|
||
state: t(item.complete ? 'thumb.complete' : 'thumb.incomplete'),
|
||
skeleton: name(item.skeletonPath),
|
||
atlas: name(item.atlasPath),
|
||
pages: item.pages,
|
||
missing: item.missingTextures,
|
||
}),
|
||
item.complete ? 'ok' : 'warn');
|
||
}
|
||
refreshStats();
|
||
|
||
const ready = items.filter((item) => item.complete);
|
||
if (!ready.length) return;
|
||
if (!settings.enabled) {
|
||
log(t('thumb.autoSkipped', { count: ready.length }), 'warn');
|
||
setStatus(t('thumb.statusAutoSkipped'));
|
||
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(t('thumb.queued', {
|
||
added: ready.length,
|
||
queued: queue.length,
|
||
state: running ? t('thumb.queuedRunning') : '',
|
||
}), 'dim');
|
||
processQueue();
|
||
}
|
||
|
||
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log, t });
|
||
|
||
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(t('thumb.libraryInfoNoPath', { info: JSON.stringify(info) }), 'dim');
|
||
} catch (error) {
|
||
log(t('thumb.libraryInfoFailed', { error: (error && error.message) || error }), 'error');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function start() {
|
||
const resolved = await resolveLibraryPath();
|
||
if (!resolved) {
|
||
setStatus(t('thumb.statusNoLibrary'));
|
||
return;
|
||
}
|
||
libraryPath = resolved;
|
||
imagesDir = path.join(resolved, 'images');
|
||
folderPaths = null; // 换库了,文件夹树重新读
|
||
libraryEl.textContent = resolved;
|
||
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
|
||
}
|
||
|
||
function startSafely() {
|
||
start().catch((error) => {
|
||
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
|
||
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
|
||
});
|
||
}
|
||
|
||
async function loadSettings() {
|
||
try {
|
||
settings = await settingsStore.read();
|
||
} catch (error) {
|
||
log(t('thumb.settingsReadFailed', { error: (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(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||
}
|
||
if (settings.enabled) {
|
||
log(t('thumb.autoEnabled'), 'ok');
|
||
setStatus(t('thumb.statusListening'));
|
||
} else {
|
||
queue.length = 0;
|
||
log(t('thumb.autoDisabled'), 'dim');
|
||
setStatus(t('thumb.statusAutoOff'));
|
||
}
|
||
});
|
||
|
||
// 只清显示,不动统计和 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(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||
}
|
||
log(t(settings.mode === 'static' ? 'thumb.modeStaticLog' : 'thumb.modeAnimatedLog'), 'dim');
|
||
});
|
||
|
||
strategyEl.addEventListener('change', async () => {
|
||
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
|
||
try {
|
||
await settingsStore.write(settings);
|
||
} catch (error) {
|
||
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||
}
|
||
log(t(settings.strategy === 'speed' ? 'thumb.strategySpeedLog' : 'thumb.strategySizeLog'), '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 = t('thumb.statusLoaded', { version: VERSION });
|
||
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
|
||
|
||
if (typeof eagle === 'undefined') {
|
||
setStatus(t('thumb.statusEagleMissing'));
|
||
log(t('thumb.statusEagleMissing'), 'error');
|
||
} else {
|
||
eagle.onPluginCreate(async (plugin) => {
|
||
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
|
||
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
|
||
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
|
||
if (i18n) i18n.setLocale(eagle.app && eagle.app.locale);
|
||
logVersion();
|
||
log(t('thumb.pluginCreated', {
|
||
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
|
||
}));
|
||
try {
|
||
await loadSettings();
|
||
} catch (error) {
|
||
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
|
||
}
|
||
startSafely();
|
||
});
|
||
eagle.onLibraryChanged((changedPath) => {
|
||
log(t('thumb.switchingLibrary', { path: changedPath }));
|
||
startSafely();
|
||
});
|
||
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
|
||
setTimeout(() => {
|
||
if (libraryEl.textContent === '—') {
|
||
if (i18n) i18n.setLocale(i18n.requestedLocale());
|
||
logVersion();
|
||
log(t('thumb.noCreateEvent'), 'dim');
|
||
loadSettings().catch(() => {}).then(startSafely);
|
||
}
|
||
}, 800);
|
||
}
|