动态缩略图生成

This commit is contained in:
tianmo committed 2026-09-19 10:15:44 +08:00
1 parent 4ff35ce352
commit eb2a7f7d91
7 files changed
+71 -23

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@
"thumbnail": "设为当前素材缩略图", "thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"animatedThumbnailTitle": "生成动画缩略图", "animatedThumbnailTitle": "生成动画缩略图",
"animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。", "animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
"animatedRange": "动画范围", "animatedRange": "动画范围",
"animatedStart": "开始", "animatedStart": "开始",
"animatedEnd": "结束", "animatedEnd": "结束",
@@ -40,7 +40,7 @@
"animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量", "animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量",
"animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB", "animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB",
"groupThumbnail": "为关联资源生成缩略图", "groupThumbnail": "为关联资源生成缩略图",
"groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图", "groupThumbnailTitle": "左键为关联资源生成当前帧缩略图,右键生成当前动画缩略图",
"groupDialogTitle": "为关联资源生成缩略图", "groupDialogTitle": "为关联资源生成缩略图",
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。", "groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
"groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。", "groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
@@ -21,7 +21,7 @@
"thumbnail": "设为当前素材缩略图", "thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"animatedThumbnailTitle": "生成动画缩略图", "animatedThumbnailTitle": "生成动画缩略图",
"animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。", "animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
"animatedRange": "动画范围", "animatedRange": "动画范围",
"animatedStart": "开始", "animatedStart": "开始",
"animatedEnd": "结束", "animatedEnd": "结束",
@@ -40,7 +40,7 @@
"animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量", "animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量",
"animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB", "animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB",
"groupThumbnail": "为关联资源生成缩略图", "groupThumbnail": "为关联资源生成缩略图",
"groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图", "groupThumbnailTitle": "左键为关联资源生成当前帧缩略图,右键生成当前动画缩略图",
"groupDialogTitle": "为关联资源生成缩略图", "groupDialogTitle": "为关联资源生成缩略图",
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。", "groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
"groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。", "groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
+13 -5
View File
@@ -112,16 +112,24 @@
} }
.thumb-dialog-input.hidden { display: none; } .thumb-dialog-input.hidden { display: none; }
.thumb-dialog-error { color: var(--error); min-height: 18px; } .thumb-dialog-error { color: var(--error); min-height: 18px; }
.animated-thumb-card { width: min(520px, 92vw); } .animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
.thumb-dialog-note.warning { color: #e6b35c; } .thumb-dialog-note.warning { color: #e6b35c; }
.animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; } .animated-thumb-card .thumb-dialog-note { font-size: 13px; }
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
.animated-thumb-label { color: var(--text-dim); } .animated-thumb-label { color: var(--text-dim); }
.animated-thumb-range { display: flex; align-items: center; gap: 8px; } .animated-thumb-range { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; } .animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
.animated-thumb-range input { width: 88px; } .animated-thumb-range input { width: 88px; }
.animated-thumb-quality { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-dim); } .animated-thumb-quality { grid-column: 1 / -1; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 14px; color: var(--text-dim); }
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); } .animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
.animated-thumb-size input { width: 108px; } .animated-thumb-size input { width: 88px; }
.animated-thumb-form input[type="number"] {
box-sizing: border-box; height: 32px; padding: 4px 8px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px;
}
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/ /* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; } #toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
@@ -29,7 +29,7 @@
<span data-i18n="viewer.suffix">尾缀</span> <span data-i18n="viewer.suffix">尾缀</span>
<select id="suffixSelect"></select> <select id="suffixSelect"></select>
</label> </label>
<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> <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> <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> <button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
@@ -1042,7 +1042,7 @@
} }
} }
async function generateGroupThumbnails() { async function generateGroupThumbnails(animated = false) {
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; } if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn'); const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true; groupThumbBtn.disabled = true;
@@ -1056,8 +1056,17 @@
if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000); if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return; return;
} }
let rendered;
const animation = (skeletonData.animations || [])[currentAnim];
if (animated && !isStaticAnimation && currentAnim !== SETUP_POSE_INDEX && animation && animationDuration(animation) > STATIC_EPSILON) {
const options = await confirmAnimatedThumbnail(animation);
if (!options) return;
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
rendered = await renderAnimatedThumbnail(animation, options);
} else {
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail(); rendered = await renderCurrentFrameThumbnail();
}
const result = await groupThumbnails.applyThumbnailTargets({ const result = await groupThumbnails.applyThumbnailTargets({
targets, targets,
initialSkipped: rows.length - targets.length, initialSkipped: rows.length - targets.length,
@@ -1858,7 +1867,11 @@
document.getElementById('reloadBtn').addEventListener('click', () => { document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload(); window.location.reload();
}); });
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails); document.getElementById('groupThumbBtn').addEventListener('click', () => generateGroupThumbnails());
document.getElementById('groupThumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault();
generateGroupThumbnails(true);
});
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail); document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => { document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault(); event.preventDefault();
+7 -1
View File
@@ -41,9 +41,13 @@ assert.equal(parsed[5].data.readUInt32BE(0), 2);
for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) { for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8'); const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8');
const css = fs.readFileSync(root + '/viewer/spine.css', 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]; const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
new vm.Script(script); new vm.Script(script);
assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/); assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/);
assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/);
assert.match(script, /async function generateGroupThumbnails\(animated = false\)/);
assert.match(script, /if \(animated[\s\S]*renderAnimatedThumbnail\(animation, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/);
assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400'); assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
assert.match(script, /async function encodeAnimatedThumbnailToLimit/); assert.match(script, /async function encodeAnimatedThumbnailToLimit/);
assert.match(script, /const targetBytes = maxKilobytes \* 1024/); assert.match(script, /const targetBytes = maxKilobytes \* 1024/);
@@ -63,6 +67,8 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.doesNotMatch(html, /id="animatedThumbQuality"/); assert.doesNotMatch(html, /id="animatedThumbQuality"/);
assert.doesNotMatch(html, /viewer\.secondsUnit/); assert.doesNotMatch(html, /viewer\.secondsUnit/);
assert.match(html, /id="animatedThumbDialog"/); assert.match(html, /id="animatedThumbDialog"/);
assert.match(css, /\.animated-thumb-form \{[^}]*grid-template-columns: 96px minmax\(0, 1fr\)/);
assert.match(css, /\.animated-thumb-form input\[type="number"\]/);
} }
console.log('PASS: APNG structure, 400px maximum with size fallback, byte limit and right-click wiring'); console.log('PASS: APNG structure, compact dialog, size fallback and single/group right-click wiring');
+13 -5
View File
@@ -112,16 +112,24 @@
} }
.thumb-dialog-input.hidden { display: none; } .thumb-dialog-input.hidden { display: none; }
.thumb-dialog-error { color: var(--error); min-height: 18px; } .thumb-dialog-error { color: var(--error); min-height: 18px; }
.animated-thumb-card { width: min(520px, 92vw); } .animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
.thumb-dialog-note.warning { color: #e6b35c; } .thumb-dialog-note.warning { color: #e6b35c; }
.animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; } .animated-thumb-card .thumb-dialog-note { font-size: 13px; }
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
.animated-thumb-label { color: var(--text-dim); } .animated-thumb-label { color: var(--text-dim); }
.animated-thumb-range { display: flex; align-items: center; gap: 8px; } .animated-thumb-range { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; } .animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
.animated-thumb-range input { width: 88px; } .animated-thumb-range input { width: 88px; }
.animated-thumb-quality { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-dim); } .animated-thumb-quality { grid-column: 1 / -1; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 14px; color: var(--text-dim); }
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); } .animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
.animated-thumb-size input { width: 108px; } .animated-thumb-size input { width: 88px; }
.animated-thumb-form input[type="number"] {
box-sizing: border-box; height: 32px; padding: 4px 8px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px;
}
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/ /* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; } #toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
+17 -4
View File
@@ -29,7 +29,7 @@
<span data-i18n="viewer.suffix">尾缀</span> <span data-i18n="viewer.suffix">尾缀</span>
<select id="suffixSelect"></select> <select id="suffixSelect"></select>
</label> </label>
<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> <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> <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> <button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
@@ -1042,7 +1042,7 @@
} }
} }
async function generateGroupThumbnails() { async function generateGroupThumbnails(animated = false) {
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; } if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn'); const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true; groupThumbBtn.disabled = true;
@@ -1056,8 +1056,17 @@
if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000); if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return; return;
} }
let rendered;
const animation = (skeletonData.animations || [])[currentAnim];
if (animated && !isStaticAnimation && currentAnim !== SETUP_POSE_INDEX && animation && animationDuration(animation) > STATIC_EPSILON) {
const options = await confirmAnimatedThumbnail(animation);
if (!options) return;
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
rendered = await renderAnimatedThumbnail(animation, options);
} else {
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail(); rendered = await renderCurrentFrameThumbnail();
}
const result = await groupThumbnails.applyThumbnailTargets({ const result = await groupThumbnails.applyThumbnailTargets({
targets, targets,
initialSkipped: rows.length - targets.length, initialSkipped: rows.length - targets.length,
@@ -1858,7 +1867,11 @@
document.getElementById('reloadBtn').addEventListener('click', () => { document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload(); window.location.reload();
}); });
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails); document.getElementById('groupThumbBtn').addEventListener('click', () => generateGroupThumbnails());
document.getElementById('groupThumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault();
generateGroupThumbnails(true);
});
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail); document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => { document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault(); event.preventDefault();