缩略图生成修改
This commit is contained in:
1 parent
fe71d2d6ce
commit
05c4fc4b90
24 files changed
+673
-130
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user