优化缩略图
This commit is contained in:
1 parent
e94dfb9e35
commit
081520dbd9
5 files changed
+259
-15
No files matched your search
+85
-3
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user