diff --git a/SpineThumbnailGenerateServer/index.html b/SpineThumbnailGenerateServer/index.html index aca502b..836f1eb 100644 --- a/SpineThumbnailGenerateServer/index.html +++ b/SpineThumbnailGenerateServer/index.html @@ -8,7 +8,7 @@ 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; } + h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; } .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; } @@ -52,7 +52,7 @@ -

SpineThumbnailGenerateServer

+

Spine缩略图

导入 Spine 资产(.skel / .json + .atlas + 贴图)后, 本插件会为还没设置缩略图的素材生成一张骨骼预览图。 @@ -124,6 +124,7 @@ + diff --git a/SpineThumbnailGenerateServer/js/plugin.js b/SpineThumbnailGenerateServer/js/plugin.js index 69b1815..a3122ec 100644 --- a/SpineThumbnailGenerateServer/js/plugin.js +++ b/SpineThumbnailGenerateServer/js/plugin.js @@ -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(); diff --git a/SpineThumbnailGenerateServer/js/progress-window.js b/SpineThumbnailGenerateServer/js/progress-window.js new file mode 100644 index 0000000..1f7f715 --- /dev/null +++ b/SpineThumbnailGenerateServer/js/progress-window.js @@ -0,0 +1,140 @@ +// 进度弹窗:用 Eagle 自己的 create-plugin-window 通道开一个正规插件窗口。 +// +// 为什么不用 window.open:那样开出来的是 Electron 默认的裸窗口——没有 Node、没有插件 API、 +// 还带着 Eagle 的应用菜单栏(Eagle 只对自己创建的插件窗口调 setMenuBarVisibility(false))。 +// create-plugin-window 正是 Eagle 前端开插件窗口用的通道,manifest 由调用方传进来, +// 所以我们可以指定 url / 尺寸 / 置顶,拿到的就是"正规的插件窗口"。 +// +// 两个窗口之间没有 opener 关系,进度用临时目录里的状态文件传:宿主写,弹窗轮询。 +// 弹窗反过来写一个心跳文件,宿主靠它判断窗口是不是被用户关掉了。 +(function () { +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json'); +const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive'); +// 高度只是初值,弹窗加载后会按内容自己调(eagle.window.setSize)。 +const WINDOW_SIZE = { width: 380, height: 160 }; +// 心跳超过这个时间就认为弹窗已经被关掉,下次需要时重新开。 +const ALIVE_WINDOW_MS = 3000; +// 刚创建完窗口要给它一点加载时间,这段时间内不重复创建。 +const CREATE_COOLDOWN_MS = 4000; + +let state = { done: 0, total: 0, name: '', finished: false, text: '' }; +let lastCreateAt = 0; +let ipcRenderer = null; + +/** 插件目录的绝对路径。装了 eagleplugin:// 协议后 location.href 不是文件路径,只能靠 Eagle 给的。 */ +function pluginPath() { + if (window.SpinePluginPath) return window.SpinePluginPath; + if (typeof eagle !== 'undefined' && eagle.plugin && eagle.plugin.path) return eagle.plugin.path; + return typeof __dirname !== 'undefined' ? __dirname : null; +} + +function getIpcRenderer() { + if (!ipcRenderer) { + // Eagle 的 preload 把 require('electron') 换成了 {},另起一个 require 才能拿到真模块。 + // createRequire 只接受绝对路径或 file:// URL,所以基准用插件目录拼出来,不能直接用 location.href。 + const base = path.join(pluginPath() || process.cwd(), 'index.html'); + ipcRenderer = require('module').createRequire(base)('electron').ipcRenderer; + } + return ipcRenderer; +} + +/** 弹窗还开着吗?靠它写的心跳判断,窗口关掉后心跳自然就停了。 */ +function popupAlive() { + try { + return Date.now() - Number(fs.readFileSync(ALIVE_FILE, 'utf8')) < ALIVE_WINDOW_MS; + } catch (error) { + return false; + } +} + +function writeState() { + try { + fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, updatedAt: Date.now() }), 'utf8'); + } catch (error) { /* 写不进去也不影响生成,面板里一样有进度 */ } +} + +/** 用 Eagle 的通道创建插件窗口;manifest 覆盖成进度页面的配置。 */ +function createPopup() { + const plugin = (typeof eagle !== 'undefined' && eagle.plugin) || {}; + const dir = pluginPath(); + let manifest = plugin.manifest; + if (!manifest && dir) { + // 正常情况下 eagle.plugin 里就带 manifest;万一还没注入就自己从插件目录读一份。 + manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8')); + } + if (!manifest) return Promise.reject(new Error('拿不到插件 manifest')); + return getIpcRenderer().invoke('create-plugin-window', { + plugin: { + ...plugin, + path: dir, + manifest: { + ...manifest, + // 这个窗口的标题栏文字(只影响这扇窗,插件本身的名字不变)。 + name: 'Spine缩略图', + main: { + ...manifest.main, + // 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。 + serviceMode: false, + url: 'progress.html', + width: WINDOW_SIZE.width, + height: WINDOW_SIZE.height, + show: true, + // 挂在 Eagle 主窗口上:子窗口天然压在父窗口之上,而且跟着 Eagle 一起显示/隐藏。 + // 这里显式写 false,免得主 manifest 哪天又开了 alwaysOnTop 被 ...manifest.main 带进来, + // 那会让弹窗盖住所有程序,而不是只待在 Eagle 上面。 + alwaysOnTop: false, + childWindow: true, + // 保留系统标题栏:最小化/关闭按钮都在上面(manifest.name 决定标题文字)。 + frame: true, + resizable: false, + minimizable: true, + maximizable: false, + fullscreenable: false, + }, + }, + }, + args: {}, + }); +} + +/** 需要时开窗(或复用已开着的);被环境挡住时返回 false,调用方照常继续跑。 */ +function open() { + try { + if (popupAlive()) return true; + if (Date.now() - lastCreateAt < CREATE_COOLDOWN_MS) return false; + lastCreateAt = Date.now(); + writeState(); + createPopup().catch(() => { /* 开不出来就只在面板里看进度 */ }); + return true; + } catch (error) { + // 弹窗只是附加显示,任何问题都不能影响缩略图生成本身。 + return false; + } +} + +function update(next) { + try { + state = { ...state, ...next, finished: false }; + writeState(); + } catch (error) { /* 同上:弹窗不能影响生成流程 */ } +} + +function finish(text) { + try { + state = { ...state, finished: true, text }; + writeState(); + } catch (error) { /* ignore */ } +} + +const api = { open, update, finish }; + +if (typeof window !== 'undefined') window.SpineThumbProgress = api; +else if (typeof module !== 'undefined' && module.exports) module.exports = api; + +})(); diff --git a/SpineThumbnailGenerateServer/manifest.json b/SpineThumbnailGenerateServer/manifest.json index 1cf694a..6dc5d0f 100644 --- a/SpineThumbnailGenerateServer/manifest.json +++ b/SpineThumbnailGenerateServer/manifest.json @@ -15,6 +15,7 @@ "serviceMode": true, "url": "index.html", "width": 780, - "height": 620 + "height": 620, + "childWindow": true } } diff --git a/SpineThumbnailGenerateServer/progress.html b/SpineThumbnailGenerateServer/progress.html new file mode 100644 index 0000000..0b882be --- /dev/null +++ b/SpineThumbnailGenerateServer/progress.html @@ -0,0 +1,202 @@ + + + + + Spine缩略图 + + + +

正在生成动态缩略图……

+
+
等待开始…
+
+
+
是否确认取消?
+
+ + +
+
+ + + +