八国语言+eagle图片

This commit is contained in:
tianmo committed 2026-10-29 11:02:01 +08:00
1 parent acc93d5404
commit 3bd3ba7404
46 files changed
+2520 -290

No files matched your search

+121 -90
View File
@@ -2,22 +2,26 @@
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v65';
const VERSION = 'v66';
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) {
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
throw new Error('本地模块未加载,请检查 index.html 里 script 的顺序:' + missing.join(', '));
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;
@@ -54,6 +58,7 @@ let runShowsProgress = false;
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
let paused = false;
let lastControlAt = 0;
let versionLogged = false;
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
const pending = new Set();
@@ -69,18 +74,18 @@ function log(message, kind) {
console.log('[SpineThumbnailGenerateServer] ' + message);
}
function logVersion() {
if (versionLogged) return;
versionLogged = true;
log(t('thumb.version', { version: VERSION }));
}
function setStatus(text) {
statusEl.textContent = text;
}
function refreshStats() {
statsEl.textContent = '批次 ' + stats.batches +
' · 新增 ' + stats.added +
' · Spine ' + stats.spine +
' · 完整 ' + stats.complete +
' · 已生成 ' + stats.generated +
' · 跳过 ' + stats.skipped +
' · 失败 ' + stats.failed;
statsEl.textContent = t('thumb.stats', stats);
}
function itemIdOf(dirName) {
@@ -111,21 +116,31 @@ function loadFolderPaths() {
function folderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '未分类';
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 '动态 ' + rendered.detail + '*' + rendered.detail + '@' + rendered.fps + 'fps/q' +
rendered.quality + '/' + kb + 'kb(目标 ' + rendered.targetKilobytes + 'kb)/' +
rendered.frames + ' 帧/动画 ' + rendered.animation +
'/' + (rendered.strategy === 'speed' ? '最优速度' : '最优体积') +
(rendered.fits === false ? '(最小档仍超出目标体积,可以考虑提高缩略图大小)' : '');
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 '静帧 ' + rendered.width + '*' + rendered.height + '/' + kb + 'kb' +
(rendered.fallback ? '(' + rendered.fallback + ')' : '');
return t('thumb.thumbnailDetailsStatic', {
width: rendered.width,
height: rendered.height,
kb,
fallback: rendered.fallback ? t('thumb.fallbackNote', { text: rendered.fallback }) : '',
});
}
/**
@@ -157,7 +172,7 @@ async function generateOne(item) {
if (!eagleItem) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('跳过 ' + label + ':Eagle 里找不到这个素材', 'warn');
log(t('thumb.missingItem', { label }), 'warn');
return;
}
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
@@ -169,17 +184,17 @@ async function generateOne(item) {
try {
eagleItem.name = parts.name;
eagleItem.ext = parts.ext;
await withTimeout(eagleItem.save(), 20000, '修正复合后缀 ' + label);
log('修正复合后缀 ' + label + ' → ' + parts.name + '.' + parts.ext, 'dim');
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
} catch (error) {
log('修正复合后缀失败 ' + label + ':' + ((error && error.message) || error), 'warn');
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('跳过 ' + label + ':已有自定义缩略图', 'dim');
log(t('thumb.existingThumbnail', { label }), 'dim');
return;
}
const version = utils.detectVersion(item.skeletonPath);
@@ -197,20 +212,24 @@ async function generateOne(item) {
mode: settings.mode,
strategy: settings.strategy,
onProgress: (current, total) => {
setStatus('正在渲染动画帧 ' + current + '/' + total + '(排队 ' + queue.length + ' 个)');
setStatus(t('thumb.statusRendering', { current, total, queued: queue.length }));
},
}), animated ? 300000 : 60000, '渲染 ' + label);
}), 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, '设置缩略图 ' + item.dirName);
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
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('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
} catch (error) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
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();
@@ -233,17 +252,20 @@ function applyControl() {
lastControlAt = control.at;
if (control.action === 'pause') {
paused = true;
setStatus('已暂停(在进度弹窗里继续或取消)');
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('已取消本轮生成:丢弃剩余 ' + dropped + ' 个素材,已完成 ' +
(runStats ? runStats.done : 0) + ' 张(成功 ' + (runStats ? runStats.generated : 0) +
' 张,失败 ' + (runStats ? runStats.failed : 0) +
' 张,跳过 ' + (runStats ? runStats.skipped : 0) + ' 张)', 'warn');
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');
}
}
@@ -278,10 +300,10 @@ async function processQueue() {
const item = queue.shift();
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
if (!item) break;
setStatus('正在生成缩略图…剩余 ' + queue.length + ' 个');
setStatus(t('thumb.statusGenerating', { queued: queue.length }));
await generateOne(item);
}
setStatus('监听中');
setStatus(t('thumb.statusListening'));
} finally {
running = false;
paused = false;
@@ -298,9 +320,13 @@ async function processQueue() {
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
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');
const summary = t('thumb.runSummary', {
generated: run.generated,
failed: run.failed,
skipped: run.skipped,
seconds,
});
log(summary, 'ok');
progress.finish(summary);
}
@@ -310,7 +336,7 @@ let batchChain = Promise.resolve();
function handleBatch(batch) {
batchChain = batchChain
.then(() => handleBatchAsync(batch))
.catch((error) => log('处理批次失败:' + ((error && error.message) || error), 'error'));
.catch((error) => log(t('thumb.batchFailed', { error: (error && error.message) || error }), 'error'));
}
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
@@ -322,22 +348,23 @@ async function handleBatchAsync({ added, total, reason }) {
loadFolderPaths();
stats.batches += 1;
stats.added += added.length;
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
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('连带重新检查之前 ' + pending.size + ' 个配套不全的资产', 'dim');
if (pending.size) log(t('thumb.recheck', { count: pending.size }), 'dim');
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
setStatus('正在配对新增素材…');
setStatus(t('thumb.statusPairing'));
await new Promise((resolve) => setTimeout(resolve, 0));
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
setStatus('正在配对 ' + 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('这批里没有 Spine 资产', 'dim');
if (!items.length) log(t('thumb.noSpine'), 'dim');
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
const incomplete = items.filter((item) => !item.complete);
pending.clear();
@@ -347,18 +374,20 @@ async function handleBatchAsync({ added, total, reason }) {
for (const item of incomplete) pending.add(item.dirName);
}
if (incomplete.length) {
log(settings.enabled
? '有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐'
: '有 ' + 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) : '无');
log(' ' + item.entryName + ' | ' + folderPathOf(item.folders) + ' | ' +
(item.complete ? '完整' : '不完整') +
'|骨架 ' + name(item.skeletonPath) +
'|atlas ' + name(item.atlasPath) +
'|贴图页 ' + item.pages + '(缺 ' + item.missingTextures + ')',
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();
@@ -366,8 +395,8 @@ 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('自动生成未启用,本批已跳过');
log(t('thumb.autoSkipped', { count: ready.length }), 'warn');
setStatus(t('thumb.statusAutoSkipped'));
return;
}
queue.push(...ready);
@@ -382,14 +411,15 @@ async function handleBatchAsync({ added, total, reason }) {
total: runStats ? runStats.total : queue.length,
});
}
log('已加入队列 ' + ready.length + ' 个,当前排队 ' + queue.length + ' 个' +
(running ? '(正在处理中)' : ''), 'dim');
log(t('thumb.queued', {
added: ready.length,
queued: queue.length,
state: running ? t('thumb.queuedRunning') : '',
}), 'dim');
processQueue();
}
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log });
log('插件代码版本 ' + VERSION);
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log, t });
async function resolveLibraryPath() {
const direct = eagle.library && eagle.library.path;
@@ -398,9 +428,9 @@ async function resolveLibraryPath() {
const info = await eagle.library.info();
const fromApi = info && (info.path || info.libraryPath || info.dir);
if (fromApi) return fromApi;
log('library.info() 没有返回路径字段:' + JSON.stringify(info), 'dim');
log(t('thumb.libraryInfoNoPath', { info: JSON.stringify(info) }), 'dim');
} catch (error) {
log('library.info() 调用失败:' + ((error && error.message) || error), 'error');
log(t('thumb.libraryInfoFailed', { error: (error && error.message) || error }), 'error');
}
return null;
}
@@ -408,21 +438,20 @@ async function resolveLibraryPath() {
async function start() {
const resolved = await resolveLibraryPath();
if (!resolved) {
setStatus('没有拿到素材库路径');
log('切换素材库时会重试', 'warn');
setStatus(t('thumb.statusNoLibrary'));
return;
}
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = resolved;
setStatus(libraryWatcher.start(resolved) ? '监听中' : '监听失败');
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
}
function startSafely() {
start().catch((error) => {
setStatus('启动失败:' + ((error && error.message) || error));
log('启动失败:' + ((error && error.stack) || error), 'error');
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
});
}
@@ -430,7 +459,7 @@ async function loadSettings() {
try {
settings = await settingsStore.read();
} catch (error) {
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
settings = { ...settingsStore.DEFAULTS };
}
toggleEl.checked = settings.enabled === true;
@@ -443,15 +472,15 @@ toggleEl.addEventListener('change', async () => {
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
if (settings.enabled) {
log('自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。', 'ok');
setStatus('监听中');
log(t('thumb.autoEnabled'), 'ok');
setStatus(t('thumb.statusListening'));
} else {
queue.length = 0;
log('自动生成已关闭,待处理队列已清空。', 'dim');
setStatus('监听中(自动生成已关闭)');
log(t('thumb.autoDisabled'), 'dim');
setStatus(t('thumb.statusAutoOff'));
}
});
@@ -465,11 +494,9 @@ modeEl.addEventListener('change', async () => {
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
log(settings.mode === 'static'
? '生成方式:静帧(始终 setup pose,PNG)'
: '生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)', 'dim');
log(t(settings.mode === 'static' ? 'thumb.modeStaticLog' : 'thumb.modeAnimatedLog'), 'dim');
});
strategyEl.addEventListener('change', async () => {
@@ -477,11 +504,9 @@ strategyEl.addEventListener('change', async () => {
try {
await settingsStore.write(settings);
} catch (error) {
log('保存设置失败:' + ((error && error.message) || error), 'warn');
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
log(settings.strategy === 'speed'
? '生成策略:最优速度(粗估挑档,只生成最小的一两档)'
: '生成策略:最优体积(十档全生成,取实际体积最小的那一档)', 'dim');
log(t(settings.strategy === 'speed' ? 'thumb.strategySpeedLog' : 'thumb.strategySizeLog'), 'dim');
});
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
@@ -493,33 +518,39 @@ filtersEl.addEventListener('click', (event) => {
});
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
document.getElementById('status').textContent = '脚本已加载 ' + VERSION + ',等待 Eagle 事件…';
document.getElementById('status').textContent = t('thumb.statusLoaded', { version: VERSION });
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
if (typeof eagle === 'undefined') {
setStatus('eagle API 未注入');
log('页面上没有 eagle 对象,插件无法与 Eagle 通信', 'error');
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;
log('插件已创建:' + ((plugin && plugin.manifest && plugin.manifest.name) || 'SpineThumbnailGenerateServer'));
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('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
}
startSafely();
});
eagle.onLibraryChanged((changedPath) => {
log('切换素材库:' + changedPath);
log(t('thumb.switchingLibrary', { path: changedPath }));
startSafely();
});
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
setTimeout(() => {
if (libraryEl.textContent === '—') {
log('没有收到创建事件,直接尝试启动', 'dim');
if (i18n) i18n.setLocale(i18n.requestedLocale());
logVersion();
log(t('thumb.noCreateEvent'), 'dim');
loadSettings().catch(() => {}).then(startSafely);
}
}, 800);