自动缩略图插件

This commit is contained in:
Yeomai committed 2026-09-20 23:29:13 +08:00
1 parent 96a0db09d8
commit 07c21f5954
5 files changed
+474 -8

No files matched your search

+127 -5
View File
@@ -2,7 +2,7 @@
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v32';
const VERSION = 'v59';
const path = require('path');
const os = require('os');
@@ -12,6 +12,7 @@ const fs = require('fs');
const REQUIRED = [
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
@@ -23,6 +24,7 @@ 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');
@@ -35,11 +37,22 @@ 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');
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();
@@ -114,6 +127,23 @@ function describeThumbnail(rendered) {
(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;
@@ -125,6 +155,7 @@ async function generateOne(item) {
const eagleItem = await eagle.item.getById(itemId);
if (!eagleItem) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('跳过 ' + label + ':Eagle 里找不到这个素材', 'warn');
return;
}
@@ -146,6 +177,7 @@ async function generateOne(item) {
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log('跳过 ' + label + ':已有自定义缩略图', 'dim');
return;
}
@@ -172,9 +204,11 @@ async function generateOne(item) {
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 */ }
@@ -182,22 +216,92 @@ async function generateOne(item) {
}
}
/** 读弹窗写下的控制指令:{ 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);
}
}
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
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();
@@ -235,9 +339,16 @@ async function handleBatchAsync({ added, total, reason }) {
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
const incomplete = items.filter((item) => !item.complete);
pending.clear();
for (const item of incomplete) pending.add(item.dirName);
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
if (settings.enabled) {
for (const item of incomplete) pending.add(item.dirName);
}
if (incomplete.length) {
log('有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐', 'warn');
log(settings.enabled
? '有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐'
: '有 ' + incomplete.length + ' 个资产配套不全,本批不处理',
settings.enabled ? 'warn' : 'dim');
}
for (const item of items) {
const name = (filePath) => (filePath ? path.basename(filePath) : '无');
@@ -253,11 +364,22 @@ async function handleBatchAsync({ added, total, reason }) {
const ready = items.filter((item) => item.complete);
if (!ready.length) return;
if (!settings.enabled) {
log('检测到 ' + ready.length + ' 个可生成的资产,但自动生成处于关闭状态;勾选上方开关后会立即处理', 'warn');
setStatus('检测到新增 ' + added.length + ' 个素材(自动生成未启用)');
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();