缩略图生成修改

This commit is contained in:
tianmo committed 2026-09-17 14:52:18 +08:00
1 parent fe71d2d6ce
commit 05c4fc4b90
24 files changed
+673 -130

No files matched your search

+30
View File
@@ -75,6 +75,36 @@
}
button:hover { background: var(--border); }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button:disabled { cursor: default; opacity: .55; }
.thumb-dialog {
position: absolute; inset: 0; z-index: 40; display: flex;
align-items: center; justify-content: center; padding: 24px;
background: rgba(0, 0, 0, .58);
}
.thumb-dialog.hidden { display: none; }
.thumb-dialog-card {
width: min(620px, 92vw); max-height: min(680px, 88vh); display: flex;
flex-direction: column; gap: 12px; padding: 18px;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, .45);
}
.thumb-dialog-title { font-size: 16px; font-weight: 600; }
.thumb-dialog-summary, .thumb-dialog-note { color: var(--text-dim); line-height: 1.5; }
.thumb-dialog-list {
min-height: 60px; overflow: auto; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg);
}
.thumb-dialog-row {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.thumb-dialog-row:last-child { border-bottom: 0; }
.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,
.thumb-dialog-row.skipped .thumb-dialog-state { color: var(--text-dim); }
.thumb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
#animationPanel {
position: absolute;
+199 -63
View File
@@ -25,6 +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="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>
@@ -58,6 +59,18 @@
</div>
<span id="timeLabel" data-i18n="viewer.timePlaceholder">0.00 / 0.00 秒 · 0 / 0 帧</span>
</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="thumbDialogList" class="thumb-dialog-list"></div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div>
<div class="thumb-dialog-actions">
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
</div>
</div>
</div>
</div>
<script>
@@ -215,6 +228,7 @@
// ---------- 全局状态 ----------
let skeleton = null, state = null, stateData = null, skeletonData = null;
let activeAssets = null;
let renderer = null;
let activeRuntimeDir = null;
let activeVersion = null;
@@ -482,19 +496,26 @@
return match ? match[1] : null;
}
async function getCurrentEagleItem() {
const pathItemId = eagleItemIdFromPath(filePath);
const urlItemId = urlParams.get('id');
if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch'));
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId));
async function getEagleItemForPath(sourcePath) {
const itemId = eagleItemIdFromPath(sourcePath);
if (!itemId || !/^[A-Za-z0-9_-]+$/.test(itemId)) throw new Error(t('viewer.itemMismatch'));
const currentLibrary = utils.findLibraryRoot(filePath);
const itemLibrary = utils.findLibraryRoot(sourcePath);
if (!currentLibrary || !itemLibrary || path.resolve(currentLibrary) !== path.resolve(itemLibrary)) {
throw new Error(t('viewer.itemMismatch'));
}
if (sourcePath === filePath) {
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));
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) {
throw new Error(t('viewer.apiHttp', { status: response.status }));
}
if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch'));
if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered'));
return { id: pathItemId, data: result.data };
if (result.data.name + '.' + result.data.ext !== path.basename(sourcePath)) throw new Error(t('viewer.itemMismatch'));
return { id: itemId, data: result.data, sourcePath };
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
@@ -512,71 +533,183 @@
return result;
}
function currentResourcePaths() {
if (!activeAssets) return [];
return [...new Set([
activeAssets.skeletonPath,
activeAssets.atlasPath,
...Object.values(activeAssets.texturePaths || {}),
].filter(Boolean).map((file) => path.resolve(file)))];
}
async function collectGroupThumbnailItems() {
const rows = [];
for (const sourcePath of currentResourcePaths()) {
try {
const item = await getEagleItemForPath(sourcePath);
rows.push({
...item,
name: path.basename(sourcePath),
state: item.data.customThumbnail === true ? 'custom' : 'ready',
});
} catch (error) {
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
}
}
return rows;
}
function confirmGroupThumbnail(rows) {
const dialog = document.getElementById('thumbDialog');
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,
});
list.textContent = '';
for (const row of rows) {
const entry = document.createElement('div');
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped');
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);
list.appendChild(entry);
}
confirm.disabled = ready.length === 0;
dialog.classList.remove('hidden');
return new Promise((resolve) => {
const finish = (accepted) => {
dialog.classList.add('hidden');
confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown);
resolve(accepted ? ready : []);
};
const accept = () => finish(true);
const reject = () => finish(false);
const keydown = (event) => { if (event.key === 'Escape') reject(); };
confirm.addEventListener('click', accept);
cancel.addEventListener('click', reject);
document.addEventListener('keydown', keydown);
cancel.focus();
});
}
async function renderCurrentFrameThumbnail() {
playing = false;
updatePlayButton();
if (state) state.apply(skeleton);
updateWorld(skeleton);
const size = 400;
const bounds = getBounds(skeleton);
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.getContext('2d');
octx.fillStyle = '#1e1e1e';
octx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-cx, -cy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
let offRenderer = null;
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
offRenderer.draw(skeleton);
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
octx.restore();
}
}
await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
let out = off.toDataURL('image/webp', 0.9);
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
}
async function generateGroupThumbnails() {
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);
if (targets.length === 0) {
if (rows.length && rows.every((row) => row.state !== 'ready')) 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);
} 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;
}
}
async function generateThumbnail() {
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
let tempPath = null;
try {
const currentItem = await getCurrentEagleItem();
const currentItem = await getEagleItemForPath(filePath);
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
setStatus(t('viewer.generating'), Infinity);
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
updatePlayButton();
if (state) state.apply(skeleton);
updateWorld(skeleton);
const size = 400;
const bounds = getBounds(skeleton);
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.getContext('2d');
octx.fillStyle = '#1e1e1e';
octx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-cx, -cy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
let offRenderer = null;
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
offRenderer.draw(skeleton);
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
octx.restore();
}
}
await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
let out = off.toDataURL('image/webp', 0.9);
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
const buf = Buffer.from(out.split(',')[1], 'base64');
const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png'));
try {
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)');
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
const rendered = await renderCurrentFrameThumbnail();
tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.thumbUpdated', {
format: rendered.extension === '.webp' ? 'WebP' : 'PNG',
size: (rendered.buffer.length / 1024).toFixed(1),
}), 3500);
log('用户主动更新当前素材缩略图: item=' + currentItem.id);
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
log('生成当前素材缩略图失败: ' + ((e && e.stack) || e));
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
thumbBtn.disabled = false;
}
}
@@ -925,6 +1058,7 @@
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
activeAssets = null;
if (!utils.extOf(path.basename(filePath))) {
// Registering txt/bytes is required by Eagle's extension dispatch.
// Ordinary text remains readable and never starts Spine discovery.
@@ -970,6 +1104,7 @@
}
return;
}
activeAssets = assets;
let savedPma;
try {
const settings = require('../js/spine-preview-settings.js');
@@ -1131,6 +1266,7 @@
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
function seekTimelineToNearestFrame() {
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));