资源组更新缩略图
This commit is contained in:
1 parent
05c4fc4b90
commit
2a5fe0ce1f
27 files changed
+562
-161
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user