Files
2026-11-06 14:11:49 +08:00

932 lines
35 KiB
JavaScript
Raw Permalink 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 = 'v79';
const SHOW_DEV_VERSION = false;
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', 'SpineThumbCompoundCompat', 'SpineThumbInstanceCoordinator',
];
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 compoundCompat = window.SpineThumbCompoundCompat;
const instanceCoordinatorModule = window.SpineThumbInstanceCoordinator;
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 restoreNamesEl = document.getElementById('restoreNames');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const compatDialogEl = document.getElementById('compatDialog');
const compatListEl = document.getElementById('compatList');
const compatConfirmEl = document.getElementById('compatConfirm');
const compatSkipEl = document.getElementById('compatSkip');
const restoreDialogEl = document.getElementById('restoreDialog');
const restoreListEl = document.getElementById('restoreList');
const restoreCancelEl = document.getElementById('restoreCancel');
const restoreApplyEl = document.getElementById('restoreApply');
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 compatStore = null;
let instanceCoordinator = null;
let pluginUserDataPath = null;
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 optionalFolderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '';
}
function folderPathOf(folderIds) {
return optionalFolderPathOf(folderIds) || t('thumb.uncategorized');
}
function backupSourceExists(entry) {
if (!imagesDir || !entry.sourceName) return false;
return fs.existsSync(path.join(imagesDir, entry.itemId + '.info', entry.sourceName));
}
/** 日志里的渲染结果描述。 */
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 });
}
}
function compoundMatchesApproval(plan, approved) {
return plan && !plan.blocked &&
plan.currentName === approved.currentName &&
plan.currentExt === approved.currentExt &&
plan.newName === approved.newName &&
plan.newExt === approved.newExt;
}
function showCompoundConfirmation(changes) {
compatListEl.textContent = '';
const checkboxes = [];
const updateConfirm = () => {
const count = checkboxes.filter((input) => input.checked).length;
compatConfirmEl.disabled = count === 0;
compatConfirmEl.textContent = t('thumb.compoundConfirmSelected', { count });
};
for (const change of changes) {
const plan = change.plan;
const row = document.createElement('label');
row.className = 'compat-row';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true;
input.dataset.itemId = itemIdOf(change.item.dirName);
input.addEventListener('change', updateConfirm);
checkboxes.push(input);
const content = document.createElement('div');
content.className = 'compat-row-content';
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = plan.sourceName;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = optionalFolderPathOf(change.item.folders);
const values = document.createElement('div');
values.className = 'compat-change';
const oldValue = document.createElement('code');
oldValue.textContent = 'name=' + JSON.stringify(plan.currentName) + '; ext=' + JSON.stringify(plan.currentExt);
const newValue = document.createElement('code');
newValue.textContent = 'name=' + JSON.stringify(plan.newName) + '; ext=' + JSON.stringify(plan.newExt);
values.append(
t('thumb.compoundOldValue') + ' ', oldValue,
' → ', t('thumb.compoundNewValue') + ' ', newValue,
);
content.append(file);
if (location.textContent) content.append(location);
content.append(values);
row.append(input, content);
compatListEl.appendChild(row);
}
updateConfirm();
compatDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 窗口显示失败时仍可处理后续批次 */ }
return new Promise((resolve) => {
const finish = (selected) => {
compatDialogEl.classList.add('hidden');
compatConfirmEl.removeEventListener('click', confirm);
compatSkipEl.removeEventListener('click', skip);
document.removeEventListener('keydown', keydown);
resolve(selected);
};
const confirm = () => finish(checkboxes
.filter((input) => input.checked)
.map((input) => input.dataset.itemId));
const skip = () => finish([]);
const keydown = (event) => {
if (event.key === 'Escape') skip();
};
compatConfirmEl.addEventListener('click', confirm);
compatSkipEl.addEventListener('click', skip);
document.addEventListener('keydown', keydown);
checkboxes[0].focus();
});
}
function fieldsText(name, ext) {
return 'name=' + JSON.stringify(name) + '; ext=' + JSON.stringify(ext);
}
function showRestoreSelection(rows) {
restoreListEl.textContent = '';
const checkboxes = [];
const statusKeys = {
ready: 'restoreStatusReady',
pluginApplied: 'restoreStatusReady',
alreadyOriginal: 'restoreStatusAlreadyOriginal',
changed: 'restoreStatusChanged',
missing: 'restoreStatusMissing',
error: 'restoreStatusError',
};
const updateApply = () => {
const count = checkboxes.filter((input) => !input.disabled && input.checked).length;
restoreApplyEl.disabled = count === 0;
restoreApplyEl.textContent = t('thumb.restoreSelected', { count });
};
for (const row of rows) {
const entry = row.entry;
const canRestore = row.status === 'ready' || row.status === 'alreadyOriginal';
const label = document.createElement('label');
label.className = 'restore-row' + (canRestore ? '' : ' disabled');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = canRestore;
input.disabled = !canRestore;
input.dataset.itemId = entry.itemId;
input.addEventListener('change', updateApply);
checkboxes.push(input);
const details = document.createElement('div');
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = entry.sourceName || entry.itemId;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = entry.folderPath || '';
const current = document.createElement('div');
current.className = 'restore-current';
current.textContent = t('thumb.restoreCurrent') + ' ' + fieldsText(
row.currentName === undefined ? '' : row.currentName,
row.currentExt === undefined ? '' : row.currentExt,
);
const target = document.createElement('div');
target.className = 'restore-target';
target.textContent = t('thumb.restoreTarget') + ' ' + fieldsText(entry.originalName, entry.originalExt);
const status = document.createElement('div');
status.className = 'restore-status';
if (row.status === 'ready' || row.status === 'pluginApplied') {
status.classList.add('applied');
status.textContent = t('thumb.restoreStatusReady', {
fields: fieldsText(row.currentName, row.currentExt),
});
} else {
status.textContent = t('thumb.' + statusKeys[row.status]);
}
details.append(file);
if (location.textContent) details.append(location);
details.append(current, target, status);
label.append(input, details);
restoreListEl.appendChild(label);
}
updateApply();
restoreDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 列表仍可通过已打开的面板操作 */ }
return new Promise((resolve) => {
const finish = (value) => {
restoreDialogEl.classList.add('hidden');
restoreCancelEl.removeEventListener('click', cancel);
restoreApplyEl.removeEventListener('click', apply);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const cancel = () => finish(null);
const apply = () => finish(checkboxes
.filter((input) => !input.disabled && input.checked)
.map((input) => input.dataset.itemId));
const keydown = (event) => {
if (event.key === 'Escape') cancel();
};
restoreCancelEl.addEventListener('click', cancel);
restoreApplyEl.addEventListener('click', apply);
document.addEventListener('keydown', keydown);
const first = checkboxes.find((input) => !input.disabled);
(first || restoreCancelEl).focus();
});
}
/** 修改前统一检查已有缩略图、真实文件名和用户确认,避免任何静默元数据变更。 */
async function prepareCompoundCompatibility(items) {
const changes = [];
for (const item of items) {
try {
const eagleItem = await eagle.item.getById(itemIdOf(item.dirName));
if (!eagleItem || eagleItem.customThumbnail === true) continue;
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (!plan) continue;
const label = item.entryName + ' | ' + folderPathOf(item.folders);
if (plan.blocked) {
item.compoundBlocked = plan;
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
continue;
}
changes.push({ item, plan });
} catch (error) {
item.compoundBlocked = { error };
log(t('thumb.compoundCheckFailed', {
label: item.entryName,
error: (error && error.message) || error,
}), 'warn');
}
}
if (!changes.length) return;
if (!compatStore) {
for (const change of changes) change.item.compoundBlocked = { error: 'backup unavailable' };
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
const selectedIds = new Set(await showCompoundConfirmation(changes));
for (const change of changes) {
if (selectedIds.has(itemIdOf(change.item.dirName))) change.item.compoundApproval = change.plan;
else change.item.compoundDeclined = true;
}
}
async function rollbackCompoundChange(eagleItem, backup, label) {
if (!backup) return false;
try {
eagleItem.name = backup.originalName;
eagleItem.ext = backup.originalExt;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
compatStore.remove(backup.itemId);
log(t('thumb.compoundRolledBack', { label }), 'warn');
return true;
} catch (error) {
log(t('thumb.compoundRollbackFailed', {
label,
error: (error && error.message) || error,
}), 'error');
return false;
}
}
/** 一个素材的缩略图生成流程:查重 → 确认兼容变更 → 渲染 → 写临时文件 → 设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
let compatibilityAttempted = false;
let compatibilityBackup = 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;
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.existingThumbnail', { label }), 'dim');
return;
}
if (item.compoundDeclined || item.compoundBlocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSkipped', { label }), 'warn');
return;
}
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (plan && plan.blocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
return;
}
if (plan) {
const approved = item.compoundApproval;
if (!approved || !compoundMatchesApproval(plan, approved)) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundNeedsConfirmation', { label }), 'warn');
return;
}
if (!compatStore) throw new Error(t('thumb.compoundBackupUnavailable'));
compatibilityBackup = compatStore.remember({
itemId,
sourceName: plan.sourceName,
folderPath: folderPathOf(item.folders),
currentName: plan.currentName,
currentExt: plan.currentExt,
newName: plan.newName,
newExt: plan.newExt,
});
eagleItem.name = plan.newName;
eagleItem.ext = plan.newExt;
compatibilityAttempted = true;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: plan.newFileName }), 'dim');
}
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) {
if (compatibilityAttempted) {
await rollbackCompoundChange(eagleItem, compatibilityBackup, label);
}
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;
}
await prepareCompoundCompatibility(ready);
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 = path.basename(resolved);
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
}
function startSafely() {
if (instanceCoordinator && !instanceCoordinator.isActive()) return;
start().catch((error) => {
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
});
}
async function initializeInstanceCoordinator(plugin) {
if (instanceCoordinator) {
instanceCoordinator.evaluate();
return;
}
await initializeCompatStore();
const stateFile = pluginUserDataPath
? path.join(pluginUserDataPath, 'SpineThumbnailGenerateServer', 'active-instance.json')
: path.join(os.tmpdir(), 'spine-thumbnail-active-instance.json');
const storage = instanceCoordinatorModule.createFileStorage(stateFile);
const id = String(window.windowID || (plugin && plugin.webContentsID) || Date.now());
instanceCoordinator = instanceCoordinatorModule.createCoordinator({
storage,
id,
isVisible: () => document.visibilityState === 'visible',
hide: () => eagle.window && eagle.window.hide && eagle.window.hide(),
onActiveChanged(active) {
if (active) {
startSafely();
return;
}
libraryWatcher.stop();
queue.length = 0;
try { if (eagle.window && eagle.window.hide) eagle.window.hide(); } catch (error) { /* already hidden */ }
},
intervalMs: 250,
});
document.addEventListener('visibilitychange', () => instanceCoordinator.handleVisibility());
window.addEventListener('beforeunload', () => instanceCoordinator.stop());
instanceCoordinator.start();
}
async function initializeCompatStore() {
if (compatStore) return true;
try {
pluginUserDataPath = await eagle.app.getPath('userData');
compatStore = compoundCompat.createStore(pluginUserDataPath);
return true;
} catch (error) {
log(t('thumb.compoundBackupUnavailable') + ': ' + ((error && error.message) || error), 'warn');
return false;
}
}
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 = '';
});
restoreNamesEl.addEventListener('click', async () => {
if (!compatStore) {
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
let rows;
try {
rows = await compoundCompat.inspectBackups(
compatStore,
(itemId) => eagle.item.getById(itemId),
backupSourceExists,
);
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
return;
}
if (!rows.length) {
log(t('thumb.restoreEmpty'), 'dim');
return;
}
const selected = await showRestoreSelection(rows);
if (!selected || !selected.length) return;
restoreNamesEl.disabled = true;
try {
const result = await compoundCompat.restoreBackups(
compatStore,
selected,
(itemId) => eagle.item.getById(itemId),
(item) => withTimeout(item.save(), 20000, t('thumb.restoreNames')),
);
log(t('thumb.restoreComplete', result), result.failed ? 'warn' : 'ok');
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
} finally {
restoreNamesEl.disabled = false;
}
});
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();
await initializeCompatStore();
try {
await loadSettings();
} catch (error) {
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
}
initializeInstanceCoordinator(plugin);
});
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');
Promise.all([initializeCompatStore(), loadSettings().catch(() => {})])
.then(() => initializeInstanceCoordinator());
}
}, 800);
}