自动缩略图插件
This commit is contained in:
1 parent
96a0db09d8
commit
07c21f5954
5 files changed
+474
-8
No files matched your search
@@ -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 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>SpineThumbnailGenerateServer</h1>
|
||||
<h1>Spine缩略图</h1>
|
||||
<div class="notice">
|
||||
导入 Spine 资产(<code>.skel</code> / <code>.json</code> + <code>.atlas</code> + 贴图)后,
|
||||
本插件会为还没设置缩略图的素材生成一张骨骼预览图。
|
||||
@@ -124,6 +124,7 @@
|
||||
<script src="js/spine-legacy-binary.js"></script>
|
||||
<script src="js/spine-badge.js"></script>
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/progress-window.js"></script>
|
||||
<script src="js/renderer.js"></script>
|
||||
<script src="js/library-watcher.js"></script>
|
||||
<script src="js/spine-assets.js"></script>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
})();
|
||||
@@ -15,6 +15,7 @@
|
||||
"serviceMode": true,
|
||||
"url": "index.html",
|
||||
"width": 780,
|
||||
"height": 620
|
||||
"height": 620,
|
||||
"childWindow": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spine缩略图</title>
|
||||
<style>
|
||||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
|
||||
html, body { margin: 0; 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: 8px; padding: 14px; box-sizing: border-box; }
|
||||
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
|
||||
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
|
||||
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
|
||||
#count { color: var(--text); }
|
||||
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#action { align-self: center; }
|
||||
#confirm { display: none; flex-direction: column; gap: 8px; }
|
||||
#confirmText { text-align: center; color: var(--text); }
|
||||
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
|
||||
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
|
||||
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
|
||||
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
|
||||
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
|
||||
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>正在生成动态缩略图……</h1>
|
||||
<div class="bar"><i id="bar"></i></div>
|
||||
<div id="count">等待开始…</div>
|
||||
<div id="name"></div>
|
||||
<div id="confirm">
|
||||
<div id="confirmText">是否确认取消?</div>
|
||||
<div id="confirmButtons">
|
||||
<button id="resume" type="button">继续</button>
|
||||
<button id="cancel" type="button">确认取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="action" type="button">停止</button>
|
||||
<script>
|
||||
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
|
||||
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
|
||||
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
|
||||
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');
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const WIDTH = 380;
|
||||
const bar = document.getElementById('bar');
|
||||
const countEl = document.getElementById('count');
|
||||
const nameEl = document.getElementById('name');
|
||||
const actionEl = document.getElementById('action');
|
||||
const confirmEl = document.getElementById('confirm');
|
||||
const confirmTextEl = document.getElementById('confirmText');
|
||||
const resumeEl = document.getElementById('resume');
|
||||
let finished = false;
|
||||
let paused = false;
|
||||
let doneCount = 0;
|
||||
let lastHeight = 0;
|
||||
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
|
||||
|
||||
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
|
||||
function userDataPath() {
|
||||
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
|
||||
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
|
||||
}
|
||||
|
||||
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
|
||||
function centerTarget(width, height) {
|
||||
let state = null;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
|
||||
} catch (error) { /* 读不到就用不了 */ }
|
||||
if (!state || !Number.isFinite(state.x)) return null;
|
||||
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
|
||||
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
|
||||
return [
|
||||
Math.round(area.x + (area.width - width) / 2),
|
||||
Math.round(area.y + (area.height - height) / 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
|
||||
async function reCenter(width, height) {
|
||||
try {
|
||||
const current = await eagle.window.getPosition();
|
||||
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
|
||||
if (movedByUser) return;
|
||||
} catch (error) { /* 拿不到位置就照常居中 */ }
|
||||
const target = centerTarget(width, height);
|
||||
if (!target) {
|
||||
await eagle.window.center();
|
||||
return;
|
||||
}
|
||||
placedAt = target;
|
||||
await eagle.window.setPosition(target[0], target[1]);
|
||||
}
|
||||
|
||||
/** 窗口高度跟着内容走。 */
|
||||
function fitHeight() {
|
||||
const height = Math.ceil(document.body.scrollHeight);
|
||||
if (Math.abs(height - lastHeight) < 2) return;
|
||||
lastHeight = height;
|
||||
eagle.window.setSize(WIDTH, height)
|
||||
.then(() => reCenter(WIDTH, height))
|
||||
.catch(() => { /* 调整失败不影响显示 */ });
|
||||
}
|
||||
|
||||
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
|
||||
function syncButtons() {
|
||||
actionEl.textContent = finished ? '确认' : '停止';
|
||||
actionEl.style.display = paused ? 'none' : '';
|
||||
confirmEl.style.display = paused ? 'flex' : 'none';
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
function writeControl(action) {
|
||||
try {
|
||||
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
|
||||
} catch (error) { /* 写不进去就等于没按 */ }
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
const total = Math.max(1, state.total || 0);
|
||||
const done = state.done || 0;
|
||||
finished = state.finished === true;
|
||||
if (finished) paused = false;
|
||||
doneCount = done;
|
||||
confirmTextEl.textContent = '已生成 ' + doneCount + ' 张缩略图,是否确认取消?';
|
||||
if (finished) {
|
||||
bar.style.width = '100%';
|
||||
countEl.textContent = state.text || '已完成';
|
||||
nameEl.textContent = '';
|
||||
} else {
|
||||
bar.style.width = Math.min(100, done / total * 100) + '%';
|
||||
countEl.textContent = '已完成 ' + done + ' / ' + (state.total || 0) +
|
||||
'(' + Math.round(done / total * 100) + '%)';
|
||||
nameEl.textContent = state.name || '';
|
||||
}
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
actionEl.addEventListener('click', () => {
|
||||
if (finished) {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
// 先让宿主暂停(它会在当前素材做完后停在原地),再就地问要不要继续。
|
||||
paused = true;
|
||||
writeControl('pause');
|
||||
syncButtons();
|
||||
resumeEl.focus(); // 默认选中「继续」
|
||||
});
|
||||
|
||||
resumeEl.addEventListener('click', () => {
|
||||
paused = false;
|
||||
writeControl('resume');
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
document.getElementById('cancel').addEventListener('click', () => {
|
||||
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
|
||||
writeControl('cancel');
|
||||
window.close();
|
||||
});
|
||||
|
||||
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
|
||||
(async () => {
|
||||
try {
|
||||
await eagle.window.setResizable(false);
|
||||
} catch (error) { /* ignore */ }
|
||||
syncButtons();
|
||||
})();
|
||||
|
||||
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
|
||||
const beat = () => {
|
||||
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
|
||||
};
|
||||
beat();
|
||||
setInterval(beat, 1000);
|
||||
|
||||
let lastStamp = 0;
|
||||
setInterval(() => {
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
return; // 状态文件还没写出来
|
||||
}
|
||||
if (state.updatedAt === lastStamp) return;
|
||||
lastStamp = state.updatedAt;
|
||||
render(state);
|
||||
}, 400);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user