资源组更新缩略图

This commit is contained in:
tianmo committed 2026-09-17 18:40:29 +08:00
1 parent 05c4fc4b90
commit 2a5fe0ce1f
27 files changed
+562 -161

No files matched your search

+1
View File
@@ -100,6 +100,7 @@
border-bottom: 1px solid var(--border);
}
.thumb-dialog-row:last-child { border-bottom: 0; }
.thumb-dialog-check { width: 17px; height: 17px; margin: 0; flex: none; accent-color: var(--accent); }
.thumb-dialog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thumb-dialog-state { flex: none; color: var(--accent); font-size: 12px; }
.thumb-dialog-row.skipped .thumb-dialog-name,
+89 -41
View File
@@ -25,7 +25,7 @@
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为当前资源组生成缩略图">为当前资源组生成缩略图…</button>
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为关联资源生成缩略图">为关联资源生成缩略图</button>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
@@ -61,10 +61,10 @@
</div>
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
<div class="thumb-dialog-card">
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为当前资源组生成缩略图</div>
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为关联资源生成缩略图</div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。</div>
<div id="thumbDialogList" class="thumb-dialog-list"></div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div>
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
<div class="thumb-dialog-actions">
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
@@ -82,6 +82,7 @@
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
const groupThumbnails = require('../js/spine-group-thumbnails.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -508,7 +509,9 @@
const urlItemId = urlParams.get('id');
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
}
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId) + '&_=' + Date.now(), {
cache: 'no-store',
});
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) {
@@ -526,18 +529,40 @@
});
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) {
if (!response.ok || !result || result.status !== 'success' || result.data !== true) {
const reason = result && (result.message || result.error);
throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
}
return result;
return true;
}
async function waitForCustomThumbnail(sourcePath, attempts = 8, delayMs = 120) {
for (let attempt = 0; attempt < attempts; attempt++) {
const item = await getEagleItemForPath(sourcePath);
if (item.data.customThumbnail === true) return true;
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
}
function currentResourcePaths() {
if (!activeAssets) return [];
const groupStems = new Set([
activeAssets.base,
activeAssets.skeletonPath && utils.normalizeStem(path.basename(activeAssets.skeletonPath)),
activeAssets.atlasPath && utils.normalizeStem(path.basename(activeAssets.atlasPath)),
].filter(Boolean));
const relatedDataPaths = [
...(activeAssets.skeletonCandidatePaths || []),
...(activeAssets.atlasCandidatePaths || []),
].filter((file) => {
const name = path.basename(file);
return (utils.isSkeletonFile(name) || utils.isAtlasFile(name)) && groupStems.has(utils.normalizeStem(name));
});
return [...new Set([
activeAssets.skeletonPath,
activeAssets.atlasPath,
...relatedDataPaths,
...Object.values(activeAssets.texturePaths || {}),
].filter(Boolean).map((file) => path.resolve(file)))];
}
@@ -551,6 +576,8 @@
...item,
name: path.basename(sourcePath),
state: item.data.customThumbnail === true ? 'custom' : 'ready',
selected: item.data.customThumbnail !== true && !utils.isTextureFile(sourcePath),
allowOverwrite: item.data.customThumbnail === true,
});
} catch (error) {
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
@@ -564,28 +591,53 @@
const list = document.getElementById('thumbDialogList');
const confirm = document.getElementById('thumbDialogConfirm');
const cancel = document.getElementById('thumbDialogCancel');
const ready = rows.filter((row) => row.state === 'ready');
const skipped = rows.length - ready.length;
document.getElementById('thumbDialogSummary').textContent = t('viewer.groupDialogSummary', {
generate: ready.length,
skip: skipped,
});
const summary = document.getElementById('thumbDialogSummary');
const selectable = rows.filter((row) => row.state !== 'unavailable');
list.textContent = '';
for (const row of rows) {
const entry = document.createElement('div');
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped');
entry.className = 'thumb-dialog-row' + (row.state === 'unavailable' ? ' skipped' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'thumb-dialog-check';
checkbox.checked = row.state !== 'unavailable' && row.selected === true;
checkbox.disabled = row.state === 'unavailable';
row.checkbox = checkbox;
const name = document.createElement('span');
name.className = 'thumb-dialog-name';
name.textContent = row.name;
name.title = row.sourcePath;
const state = document.createElement('span');
state.className = 'thumb-dialog-state';
state.textContent = t(row.state === 'ready' ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupSkipCustom' : 'viewer.groupUnavailable');
entry.append(name, state);
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected'
: row.state === 'ready' ? 'viewer.groupNotSelected' : 'viewer.groupUnavailable');
row.stateElement = state;
entry.append(checkbox, name, state);
list.appendChild(entry);
if (row.state !== 'unavailable') {
checkbox.addEventListener('change', () => {
row.selected = checkbox.checked;
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected' : 'viewer.groupNotSelected');
updateSelection();
});
entry.addEventListener('click', (event) => {
if (event.target !== checkbox) checkbox.click();
});
}
}
confirm.disabled = ready.length === 0;
function updateSelection() {
const selected = selectable.filter((row) => row.selected);
summary.textContent = t('viewer.groupDialogSummary', {
generate: selected.length,
skip: rows.length - selected.length,
});
confirm.disabled = selected.length === 0;
}
updateSelection();
dialog.classList.remove('hidden');
return new Promise((resolve) => {
const finish = (accepted) => {
@@ -593,7 +645,7 @@
confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown);
resolve(accepted ? ready : []);
resolve(accepted ? selectable.filter((row) => row.selected) : []);
};
const accept = () => finish(true);
const reject = () => finish(false);
@@ -650,39 +702,35 @@
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true;
let tempPath = null;
try {
const rows = await collectGroupThumbnailItems();
const targets = await confirmGroupThumbnail(rows);
const selectedTargets = await confirmGroupThumbnail(rows);
// Updating the item that owns this preview causes Eagle to close/reload the
// viewer. Always do that item last so its teardown cannot abort the batch.
const targets = groupThumbnails.orderCurrentTargetLast(selectedTargets, filePath);
if (targets.length === 0) {
if (rows.length && rows.every((row) => row.state !== 'ready')) setStatus(t('viewer.groupNoTargets'), 4000);
if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return;
}
setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail();
tempPath = path.join(os.tmpdir(), 'spineviewer-group-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
let success = 0, skipped = rows.filter((row) => row.state !== 'ready').length, failed = 0;
for (let index = 0; index < targets.length; index++) {
setStatus(t('viewer.groupGenerating', { current: index + 1, total: targets.length }), Infinity);
try {
// 确认后再读一次,避免在对话框打开期间覆盖用户新设的缩略图。
const fresh = await getEagleItemForPath(targets[index].sourcePath);
if (fresh.data.customThumbnail === true) { skipped++; continue; }
await setEagleCustomThumbnail(fresh.id, tempPath);
success++;
log('用户确认批量更新资源组缩略图: item=' + fresh.id);
} catch (error) {
failed++;
log('资源组缩略图失败: ' + ((error && error.stack) || error));
}
}
setStatus(t('viewer.groupComplete', { success, skip: skipped, failed }), 6000);
const result = await groupThumbnails.applyThumbnailTargets({
targets,
initialSkipped: rows.length - targets.length,
rendered,
getItem: getEagleItemForPath,
setThumbnail: setEagleCustomThumbnail,
waitForApplied: waitForCustomThumbnail,
notConfirmedMessage: t('viewer.groupApplyNotConfirmed'),
onProgress: (current, total) => setStatus(t('viewer.groupGenerating', { current, total }), Infinity),
onSuccess: (fresh) => log('用户确认批量更新关联资源缩略图: item=' + fresh.id),
onError: (error) => log('关联资源缩略图失败: ' + ((error && error.stack) || error)),
});
setStatus(t('viewer.groupComplete', { success: result.success, skip: result.skipped, failed: result.failed }), 6000);
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
groupThumbBtn.disabled = false;
}
}