加入awebp编码支持

This commit is contained in:
tianmo committed 2026-09-19 10:36:32 +08:00
1 parent eb2a7f7d91
commit 081d06c70e
22 files changed
+392 -11

No files matched your search

+74 -5
View File
@@ -92,6 +92,9 @@
<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">
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedFormat">输出格式</span>
<select id="animatedThumbFormat"><option value="webp">AWebP (.webp)</option><option value="apng">APNG (.png)</option></select>
</label>
<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>
@@ -124,6 +127,7 @@
const groupThumbnails = require('../js/spine-group-thumbnails.js');
const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -785,6 +789,7 @@
const dialog = document.getElementById('animatedThumbDialog');
const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat');
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm');
@@ -795,6 +800,7 @@
startInput.max = String(totalFrames);
endInput.value = String(totalFrames);
endInput.max = String(totalFrames);
formatInput.value = 'webp';
maxSizeInput.value = '100';
errorEl.textContent = '';
dialog.classList.remove('hidden');
@@ -821,6 +827,7 @@
finish({
start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp',
maxKilobytes,
});
};
@@ -947,6 +954,65 @@
return smallest;
}
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, 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 duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
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;
@@ -993,16 +1059,19 @@
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));
const rgba = new Uint8Array(octx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
sourceFrames.push(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 };
const encoded = options.format === 'apng'
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes)
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size };
}
async function generateAnimatedThumbnail() {
@@ -1025,7 +1094,7 @@
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');
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', {