动态缩略图生成

This commit is contained in:
tianmo committed 2026-09-18 19:23:40 +08:00
1 parent 52bdbc9f3c
commit c113089e2d
23 files changed
+1196

No files matched your search

+10
View File
@@ -112,6 +112,16 @@
}
.thumb-dialog-input.hidden { display: none; }
.thumb-dialog-error { color: var(--error); min-height: 18px; }
.animated-thumb-card { width: min(520px, 92vw); }
.thumb-dialog-note.warning { color: #e6b35c; }
.animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; }
.animated-thumb-label { color: var(--text-dim); }
.animated-thumb-range { display: flex; align-items: center; gap: 8px; }
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
.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-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
.animated-thumb-size input { width: 108px; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
+289
View File
@@ -87,6 +87,29 @@
</div>
</div>
</div>
<div id="animatedThumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedThumbDialogTitle">
<div class="thumb-dialog-card animated-thumb-card">
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动画缩略图</div>
<div class="thumb-dialog-note warning" data-i18n="viewer.animatedThumbnailNote">生成后将替换当前素材缩略图,插件无法自动恢复原图。长动画会自动降低帧率以控制文件大小。</div>
<div class="animated-thumb-form">
<div class="animated-thumb-label" data-i18n="viewer.animatedRange">动画范围</div>
<div class="animated-thumb-range">
<label><span data-i18n="viewer.animatedStart">开始</span><input id="animatedThumbStart" type="number" min="0" step="1"></label>
<span>–</span>
<label><span data-i18n="viewer.animatedEnd">结束</span><input id="animatedThumbEnd" type="number" min="0" step="1"></label>
<span data-i18n="viewer.framesUnit">帧</span>
</div>
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">文件大小上限</span>
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="50" max="10240" step="50" value="100"><span>KB</span></span>
</label>
</div>
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
<div class="thumb-dialog-actions">
<button id="animatedThumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="animatedThumbDialogConfirm" class="primary" data-i18n="viewer.animatedConfirm">生成并替换</button>
</div>
</div>
</div>
</div>
<script>
@@ -100,6 +123,7 @@
const clippingRenderer = require('../js/spine-canvas-clipping.js');
const groupThumbnails = require('../js/spine-group-thumbnails.js');
const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -757,6 +781,267 @@
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
}
function confirmAnimatedThumbnail(animation) {
const dialog = document.getElementById('animatedThumbDialog');
const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd');
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm');
const cancel = document.getElementById('animatedThumbDialogCancel');
const animationLength = Math.max(0, animationDuration(animation));
const totalFrames = Math.max(1, frameOf(animationLength));
startInput.value = '0';
startInput.max = String(totalFrames);
endInput.value = String(totalFrames);
endInput.max = String(totalFrames);
maxSizeInput.value = '100';
errorEl.textContent = '';
dialog.classList.remove('hidden');
return new Promise((resolve) => {
const finish = (value) => {
dialog.classList.add('hidden');
confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const accept = () => {
const startFrame = Number(startInput.value);
const endFrame = Number(endInput.value);
const maxKilobytes = Number(maxSizeInput.value);
if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame) || startFrame < 0 || endFrame <= startFrame || endFrame > totalFrames) {
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
return;
}
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 50 || maxKilobytes > 10240) {
errorEl.textContent = t('viewer.animatedInvalidSize');
return;
}
finish({
start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength),
maxKilobytes,
});
};
const reject = () => finish(null);
const keydown = (event) => { if (event.key === 'Escape') reject(); };
confirm.addEventListener('click', accept);
cancel.addEventListener('click', reject);
document.addEventListener('keydown', keydown);
startInput.focus();
startInput.select();
});
}
function applyAnimationPose(targetSkeleton, targetState, animation, time) {
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
const entry = targetState.setAnimation(0, animation.name, false);
if (entry) entry.loop = false;
targetState.update(Math.max(0, time));
targetState.apply(targetSkeleton);
updateWorld(targetSkeleton);
}
function mergeBounds(total, next) {
if (!next || next.width <= 0 || next.height <= 0) return total;
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
const left = Math.min(total.x, next.x);
const bottom = Math.min(total.y, next.y);
const right = Math.max(total.x + total.width, next.x + next.width);
const top = Math.max(total.y + total.height, next.y + next.height);
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
function quantizeFrame(data, bits) {
if (bits >= 8) return new Uint8Array(data);
const levels = (1 << bits) - 1;
const lookup = new Uint8Array(256);
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
return output;
}
function compressFramePixels(data, size, bits, blockSize) {
if (blockSize <= 1) return quantizeFrame(data, bits);
const levels = (1 << bits) - 1;
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let top = 0; top < size; top += blockSize) {
for (let left = 0; left < size; left += blockSize) {
const source = (top * size + left) * 4;
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
for (let y = top; y < Math.min(size, top + blockSize); y++) {
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
}
}
}
return output;
}
function resizeFramePixels(data, sourceSize, targetSize) {
if (sourceSize === targetSize) return data;
const output = new Uint8Array(targetSize * targetSize * 4);
for (let y = 0; y < targetSize; y++) {
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
for (let x = 0; x < targetSize; x++) {
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
const source = (sourceY * sourceSize + sourceX) * 4;
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
}
}
return output;
}
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, bits: 8, block: 1 },
{ ratio: 0.75, bits: 7, block: 1 },
{ ratio: 0.5, bits: 7, block: 1 },
{ ratio: 0.5, bits: 6, block: 2 },
{ ratio: 0.33, bits: 6, block: 2 },
{ ratio: 0.25, bits: 5, block: 3 },
{ ratio: 0.16, bits: 5, block: 4 },
{ count: 3, bits: 4, block: 8 },
{ count: 2, bits: 3, block: 12 },
{ count: 3, bits: 5, block: 1, size: 320 },
{ count: 3, bits: 5, block: 1, size: 256 },
{ count: 2, bits: 4, block: 1, size: 192 },
{ count: 2, bits: 4, block: 1, size: 128 },
{ count: 2, bits: 3, block: 1, size: 96 },
{ count: 2, bits: 3, block: 1, size: 64 },
{ count: 2, bits: 2, block: 1, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const delayMs = Math.max(1, Math.round(range * 1000 / count));
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
return {
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
delayNumerator: delayMs,
delayDenominator: 1000,
};
};
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
if (count > 3) {
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
}
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((resolve) => setTimeout(resolve, 0));
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400;
const range = options.end - options.start;
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
const frameDuration = range / frameCount;
const sampleTimes = Array.from({ length: frameCount }, (_, index) => options.start + index * frameDuration);
const previewSkeleton = new spine.Skeleton(skeletonData);
utils.applySkin(previewSkeleton, currentSkin || utils.preferredSkin(skeletonData));
const previewStateData = new spine.AnimationStateData(skeletonData);
previewStateData.defaultMix = 0;
const previewState = new spine.AnimationState(previewStateData);
let bounds = null;
for (const time of sampleTimes) {
applyAnimationPose(previewSkeleton, previewState, animation, time);
bounds = mergeBounds(bounds, getBounds(previewSkeleton));
}
bounds = bounds || stableFitBounds;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
let offRenderer = null;
const sourceFrames = [];
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.clearRect(0, 0, size, size);
octx.fillStyle = '#1e1e1e';
octx.fillRect(0, 0, size, size);
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-centerX, -centerY);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
offRenderer.draw(previewSkeleton);
octx.restore();
await drawSpineBadge(octx, size, size);
spineBadge.drawVersion(octx, size, size, skeletonData.version || activeVersion);
const rgba = octx.getImageData(0, 0, size, size).data;
sourceFrames.push(new Uint8Array(rgba));
setStatus(t('viewer.animatedGenerating', { current: index + 1, total: frameCount }), Infinity);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
}
const encoded = await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: '.png', frames: encoded.frames, size: encoded.size };
}
async function generateAnimatedThumbnail() {
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
if (isStaticAnimation || currentAnim === SETUP_POSE_INDEX) {
await generateThumbnail();
return;
}
const animation = (skeletonData.animations || [])[currentAnim];
if (!animation || animationDuration(animation) <= STATIC_EPSILON) {
await generateThumbnail();
return;
}
const options = await confirmAnimatedThumbnail(animation);
if (!options) return;
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
let tempPath = null;
try {
const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + '.png');
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', {
frames: rendered.frames,
size: Math.ceil(rendered.buffer.length / 1024),
}), 5000);
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
} catch (error) {
setStatus(t('viewer.thumbnailFailed', { error: (error && error.message) || String(error) }), 7000);
log('生成当前素材动画缩略图失败: ' + ((error && error.stack) || error));
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
thumbBtn.disabled = false;
}
}
async function generateGroupThumbnails() {
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn');
@@ -1575,6 +1860,10 @@
});
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault();
generateAnimatedThumbnail();
});
function seekTimelineToNearestFrame() {
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
timeline.value = String(snappedTime);