优化缩略图

This commit is contained in:
Yeomai committed 2026-09-20 01:14:46 +08:00
1 parent e94dfb9e35
commit 081520dbd9
5 files changed
+259 -15

No files matched your search

@@ -1017,8 +1017,82 @@
+ '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb';
}
// 结果画廊:逐档生成,每档一出来就填进两行四列的格子,点哪个用哪个,关掉就全部丢弃。
// 帧编码走 canvas.toBlob(异步),生成期间界面不会像 toDataURL 那样被卡住。
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
async function estimateAnimatedBytes(profile, sourceFrames, size, range, options, encodeWebpFrame) {
const count = profileFrameCount(sourceFrames, range, profile.fps);
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
let total = 0;
for (let slot = 0; slot < indexes.length; slot++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
if (options.format === 'apng') {
total += apngEncoder.encode({
width: size, height: size, plays: 0, compressionLevel: 9,
frames: [{ rgba: pixels, delayNumerator: 100, delayDenominator: 1000 }],
}).length;
} else {
total += (await encodeWebpFrame(slot, pixels, profile.quality)).length;
}
}
return (total / indexes.length) * count;
}
// 沿整条路径估算体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」挑候选。
// 对数取点能让画廊里的档位从明显清晰一路铺到明显省体积,而不是全挤在目标附近;
// 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以整条路径不假设单调。
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, options, targetBytes, count, encodeWebpFrame) {
// 估算点按步长抽样,避免整条路径都各编两帧;路径首尾一定纳入。
const stride = Math.max(1, Math.ceil(searchPath.length / 24));
const samples = [];
for (let index = 0; index < searchPath.length; index += stride) {
samples.push({
index,
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, options, encodeWebpFrame),
});
}
const lastIndex = searchPath.length - 1;
if (samples[samples.length - 1].index !== lastIndex) {
samples.push({
index: lastIndex,
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, options, encodeWebpFrame),
});
}
// 先按体积从小到大排:配置允许分段(分辨率优先的 400 / 200 两段体积曲线会重叠),
// 排序后再取点才能保证候选体积单调递增、也不会挑出两张几乎一样的结果。
samples.sort((left, right) => left.bytes - right.bytes);
// 体积 → 参数:在「体积相邻」的两个采样点之间插值。候选因此可以落在配置表之外,
// 出现 234×234@13fps/q0.22 这类值;按体积相邻插值也能跨过分段,保证候选体积递增。
const profileForBytes = (wantedBytes) => {
let upper = samples.length - 1;
for (let index = 0; index < samples.length; index++) {
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
}
const lower = Math.max(0, upper - 1);
const before = searchPath[samples[lower].index];
const after = searchPath[samples[upper].index];
const gap = samples[upper].bytes - samples[lower].bytes;
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
const mix = (start, end) => start + (end - start) * ratio;
return {
size: Math.max(1, Math.round(mix(before.size, after.size))),
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
};
};
// 目标区间夹在样本的实际体积范围内,免得目标远小于最小候选时空转。
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
const span = Math.max(1, count - 1);
const picked = [];
for (let slot = 0; slot < count; slot++) {
const wanted = Math.exp(low + (high - low) * slot / span);
picked.push(profileForBytes(wanted));
}
return picked;
}
// 结果画廊:先按目标体积定位,再逐档生成,每档一出来就填进两行五列的格子,
// 点哪个用哪个,关掉就全部丢弃。帧编码走 canvas.toBlob(异步),界面不会被卡住。
async function pickAnimatedThumbnail(sourceFrames, size, range, options) {
const dialog = document.getElementById('animatedPickDialog');
const grid = document.getElementById('animatedPickGrid');
@@ -1026,7 +1100,9 @@
const cancelButton = document.getElementById('animatedPickCancel');
const confirmButton = document.getElementById('animatedPickConfirm');
const targetBytes = options.maxKilobytes * 1024;
const profiles = animatedProfiles(options.priority);
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority);
const candidateCount = animatedProfiles(options.priority).length;
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
const pool = Array.from({ length: 4 }, () => {
const off = document.createElement('canvas');
@@ -1100,6 +1176,12 @@
document.addEventListener('keydown', keydown);
dialog.classList.remove('hidden');
(async () => {
// 先沿搜索路径估算体积,再按对数间隔挑出候选,最后完整生成选中的那几档,
// 这样画廊里的候选会围绕用户填写的体积铺开,而不是固定的那几张。
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const profiles = await pickAnimatedCandidates(
searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame,
);
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
if (settled) return;
const profile = profiles[profileIndex];