缩略图插件
This commit is contained in:
1 parent
065b6466f1
commit
96a0db09d8
9 files changed
+984
-59
No files matched your search
@@ -2,14 +2,17 @@
|
||||
|
||||
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||||
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||||
const VERSION = 'v26';
|
||||
const VERSION = 'v32';
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
|
||||
const REQUIRED = ['SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings', 'SpineUtils', 'SpineBadge'];
|
||||
const REQUIRED = [
|
||||
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
|
||||
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !window[name]);
|
||||
if (missing.length) {
|
||||
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
|
||||
@@ -17,7 +20,7 @@ if (missing.length) {
|
||||
}
|
||||
const { createLibraryWatcher } = window.SpineThumbLibraryWatcher;
|
||||
const { scanSpineItems } = window.SpineThumbSpineAssets;
|
||||
const { renderThumbnail, withTimeout } = window.SpineThumbRenderer;
|
||||
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
|
||||
const settingsStore = window.SpineThumbSettings;
|
||||
const utils = window.SpineUtils;
|
||||
|
||||
@@ -28,6 +31,8 @@ const logEl = document.getElementById('log');
|
||||
const toggleEl = document.getElementById('toggle');
|
||||
const clearEl = document.getElementById('clear');
|
||||
const filtersEl = document.getElementById('filters');
|
||||
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 };
|
||||
let settings = { ...settingsStore.DEFAULTS };
|
||||
@@ -70,27 +75,45 @@ function itemIdOf(dirName) {
|
||||
|
||||
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
|
||||
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
|
||||
// 文件夹树只在切换素材库时重新读,避免每个素材都解析一次库 metadata。
|
||||
// 文件夹树每批重读一次(见 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 folderPathOf(folderIds) {
|
||||
if (!folderPaths) {
|
||||
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) { /* 读不到就只显示未分类 */ }
|
||||
}
|
||||
if (!folderPaths) loadFolderPaths();
|
||||
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
|
||||
return (id && folderPaths.get(id)) || '未分类';
|
||||
}
|
||||
|
||||
/** 日志里的渲染结果描述。参数不走本地化,和主插件的候选标签是同一套写法。 */
|
||||
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 '静帧 ' + rendered.width + '*' + rendered.height + '/' + kb + 'kb' +
|
||||
(rendered.fallback ? '(' + rendered.fallback + ')' : '');
|
||||
}
|
||||
|
||||
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
|
||||
async function generateOne(item) {
|
||||
let tempPath = null;
|
||||
@@ -128,7 +151,9 @@ async function generateOne(item) {
|
||||
}
|
||||
const version = utils.detectVersion(item.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
|
||||
const rendered = await withTimeout(renderThumbnail({
|
||||
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
|
||||
const animated = settings.mode !== 'static';
|
||||
const rendered = await withTimeout(renderThumbnailAuto({
|
||||
skeletonPath: item.skeletonPath,
|
||||
atlasPath: item.atlasPath,
|
||||
texturePaths: item.texturePaths,
|
||||
@@ -136,13 +161,18 @@ async function generateOne(item) {
|
||||
isBinary: item.isBinary,
|
||||
version,
|
||||
size: 400,
|
||||
}), 60000, '渲染 ' + item.dirName);
|
||||
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + '.png');
|
||||
fs.writeFileSync(tempPath, Buffer.from(await rendered.blob.arrayBuffer()));
|
||||
mode: settings.mode,
|
||||
strategy: settings.strategy,
|
||||
onProgress: (current, total) => {
|
||||
setStatus('正在渲染动画帧 ' + current + '/' + total + '(排队 ' + queue.length + ' 个)');
|
||||
},
|
||||
}), animated ? 300000 : 60000, '渲染 ' + 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 拒绝了这个缩略图');
|
||||
stats.generated += 1;
|
||||
log('已生成 ' + label + ' | Spine ' + version, 'ok');
|
||||
log('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
|
||||
} catch (error) {
|
||||
stats.failed += 1;
|
||||
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
|
||||
@@ -182,6 +212,8 @@ async function handleBatchAsync({ added, total, reason }) {
|
||||
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
|
||||
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
|
||||
if (!added.length) return;
|
||||
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
|
||||
loadFolderPaths();
|
||||
stats.batches += 1;
|
||||
stats.added += added.length;
|
||||
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
|
||||
@@ -278,6 +310,8 @@ async function loadSettings() {
|
||||
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 () => {
|
||||
@@ -302,6 +336,30 @@ clearEl.addEventListener('click', () => {
|
||||
logEl.textContent = '';
|
||||
});
|
||||
|
||||
modeEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log('保存设置失败:' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
log(settings.mode === 'static'
|
||||
? '生成方式:静帧(始终 setup pose,PNG)'
|
||||
: '生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)', 'dim');
|
||||
});
|
||||
|
||||
strategyEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log('保存设置失败:' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
log(settings.strategy === 'speed'
|
||||
? '生成策略:最优速度(粗估挑档,只生成最小的一两档)'
|
||||
: '生成策略:最优体积(十档全生成,取实际体积最小的那一档)', 'dim');
|
||||
});
|
||||
|
||||
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
|
||||
filtersEl.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-filter]');
|
||||
|
||||
Reference in new issue
Block a user