缩略图二分定位 + 邻域采样

B. 二分定位 + 邻域采样(我推荐)
把配置的语义从"这就是要生成的 10 档"改成"允许搜索的参数梯度"(比如分辨率梯度、帧率梯度、质量梯度三张表)。运行时:
1. 用每个组合只编 2 帧的方式粗估体积(很便宜);
2. 靠单调性做二分,几轮就找到"体积刚好不超过目标"的参数点;
3. 在这个点周围取 10 个组合(略高、略低各几个),完整生成。
成本大概是十几次估算编码 + 10 档完整编码,和现在基本持平甚至更省(估算只编 2 帧)。效果是候选真正围绕你输入的目标分布——比如你填 30 KB,出来的就是 22、26、29、31、35 KB 这种,而不是 5 KB 和 300 KB 各一个。
代价是你现在这套配置格式要推翻重做——从"精确档位表"变成"参数范围表"
This commit is contained in:
Yeomai committed 2026-09-20 00:39:38 +08:00
1 parent 5808e3dfce
commit e94dfb9e35
27 files changed
+710 -299

No files matched your search

+74 -11
View File
@@ -4,6 +4,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { encode } = require('../js/apng-encoder');
const { encode: encodeWebp } = require('../js/animated-webp-encoder');
const profilesModule = require('../js/animated-profiles');
function chunks(buffer) {
const result = [];
@@ -39,6 +41,39 @@ assert.equal(parsed[2].data.readUInt32BE(0), 0);
assert.equal(parsed[4].data.readUInt32BE(0), 1);
assert.equal(parsed[5].data.readUInt32BE(0), 2);
// WebP 帧必须是 { webp, duration } 结构:直接塞 Buffer 会让编码器读到 undefined 后报错。
assert.throws(
() => encodeWebp({ width: 2, height: 2, plays: 0, frames: [Buffer.alloc(4), Buffer.alloc(4)] }),
/WebP 帧数据缺失/,
);
// 档位表是可以自由增删的配置:只校验结构约束,不锁死具体数值,
// 否则每次调档位都要连着改测试。
const priorities = profilesModule.ANIMATED_PRIORITIES;
assert.ok(priorities[profilesModule.DEFAULT_PRIORITY], '默认优先级必须存在于配置里');
for (const [name, plan] of Object.entries(priorities)) {
assert.ok(Array.isArray(plan) && plan.length >= 2, name + ' 至少要有两档');
for (const item of plan) {
assert.ok(Array.isArray(item) && item.length === 3, name + ' 每项必须是 [细节分辨率, 帧率, 质量]');
const [detailSize, fps, quality] = item;
assert.ok(Number.isInteger(detailSize) && detailSize > 0 && detailSize <= 400, name + ' 细节分辨率必须是 1-400 的整数');
assert.ok(Number.isFinite(fps) && fps > 0, name + ' 帧率必须是正数');
assert.ok(quality > 0 && quality <= 1, name + ' 编码质量必须落在 (0,1] 内');
}
const list = profilesModule.animatedProfiles(name);
assert.equal(list.length, plan.length, name + ' 档位数必须跟随配置长度');
list.forEach((profile, index) => {
assert.equal(profile.size, plan[index][0], name + ' 第 ' + (index + 1) + ' 档分辨率');
assert.equal(profile.fps, plan[index][1], name + ' 第 ' + (index + 1) + ' 档帧率');
assert.equal(profile.quality, plan[index][2], name + ' 第 ' + (index + 1) + ' 档质量');
});
}
// 未知优先级回落到默认档位表,不能抛错。
assert.deepEqual(
profilesModule.animatedProfiles('unknown'),
profilesModule.animatedProfiles(profilesModule.DEFAULT_PRIORITY),
);
for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8');
const css = fs.readFileSync(root + '/viewer/spine.css', 'utf8');
@@ -47,24 +82,39 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
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, /if \(animated[\s\S]*renderAnimatedSourceFrames\(animation, options\)[\s\S]*pickAnimatedThumbnail\(source\.sourceFrames, source\.size, source\.range, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/);
assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
assert.match(script, /async function encodeAnimatedThumbnailToLimit/);
assert.match(script, /const targetBytes = maxKilobytes \* 1024/);
assert.match(script, /if \(buffer\.length <= targetBytes\) return/);
assert.match(script, /count: 2, bits: 3, block: 12/);
assert.match(script, /count: 2, bits: 2, block: 1, size: 32/);
assert.match(script, /const outputSize = profile\.size \|\| size/);
assert.match(script, /return smallest;/);
assert.match(script, /async function renderAnimatedSourceFrames\(animation, options\)/);
assert.match(script, /async function pickAnimatedThumbnail\(sourceFrames, size, range, options\)/);
assert.match(script, /const targetBytes = options\.maxKilobytes \* 1024/);
// 档位表来自独立配置文件,三种优先级都走画廊手动挑,不会自动应用。
assert.match(script, /require\('\.\.\/js\/animated-profiles\.js'\)/);
assert.match(script, /const profiles = animatedProfiles\(options\.priority\)/);
assert.match(html, /id="animatedThumbPriority"/);
assert.match(script, /priorityInput\.value = animatedProfileConfig\.DEFAULT_PRIORITY/);
assert.match(script, /priority: priorityInput\.value/);
assert.match(script, /resizeFramePixels\(resizeFramePixels\(data, size, detailSize\), detailSize, size\)/);
assert.match(script, /apngEncoder\.encode\(\{ width: size, height: size/);
assert.match(script, /frames\.push\(\{ webp, duration \}\)/);
// 每档生成完立刻填卡片,生成中可取消。
assert.match(script, /addCard\(candidate\)/);
assert.match(script, /if \(settled\) return;/);
assert.match(script, /URL\.revokeObjectURL\(url\)/);
assert.match(script, /candidate\.detail \+ '\*' \+ candidate\.detail \+ '@' \+ candidate\.fps \+ 'fps\/' \+ quality/);
assert.match(script, /t\('viewer\.animatedCannotFit', \{ target: options\.maxKilobytes, actual: Math\.ceil\(smallest \/ 1024\) \}\)/);
assert.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/);
assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/);
assert.match(script, /require\('\.\.\/js\/spine-visible-bounds\.js'\)/);
assert.match(script, /async function measureVisibleBounds/);
assert.match(script, /const bounds = await measureVisibleBounds\(skeleton, geometryBounds, 1/);
assert.match(script, /measureVisibleBounds\(previewSkeleton, bounds, sampleTimes\.length/);
assert.match(script, /async function encodeAnimatedWebpToLimit/);
assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/);
assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/);
// 帧编码必须走异步 toBlob,否则同步 toDataURL 会把界面卡住。
assert.match(script, /const pool = Array\.from\(\{ length: 4 \}/);
assert.match(script, /Promise\.all\(batch\.map/);
assert.match(script, /unit\.canvas\.toBlob\(\(blob\) => \{/);
assert.match(script, /\}, 'image\/webp', quality\);/);
assert.doesNotMatch(script, /toDataURL\('image\/webp',\s*profile\.quality\)/);
assert.match(script, /const extension = options\.format === 'apng' \? '\.png' : '\.webp'/);
assert.match(script, /for \(let alpha = 3; alpha < rgba\.length; alpha \+= 4\) rgba\[alpha\] = 255/);
assert.match(script, /const totalFrames = Math\.max\(1, frameOf\(animationLength\)\)/);
assert.match(script, /start: startFrame \/ FPS/);
@@ -82,6 +132,19 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.doesNotMatch(html, /id="animatedThumbQuality"/);
assert.doesNotMatch(html, /viewer\.secondsUnit/);
assert.match(html, /id="animatedThumbDialog"/);
assert.match(html, /id="animatedPickDialog"/);
assert.match(html, /id="animatedPickGrid"/);
assert.match(css, /grid-template-columns: repeat\(5, minmax\(0, 1fr\)\)/);
// 选中用绿色对勾,不能用半透明蒙版把缩略图压暗;确认按钮负责真正替换。
assert.match(html, /id="animatedPickConfirm"[^>]*disabled/);
assert.match(css, /\.animated-pick-item\.selected::after \{/);
assert.match(css, /\.animated-pick-notice \{ color: var\(--text\); text-align: center;/);
assert.match(css, /\.animated-pick-card \.thumb-dialog-note \{[^}]*text-align: center/);
assert.doesNotMatch(css, /\.animated-pick-item\.over/);
assert.match(script, /const confirm = \(\) => \{ if \(selected\) close\(selected\); \}/);
// 用户没挑过时默认选中体积最小的一档,挑过之后就不再自动改。
assert.match(script, /if \(!userPicked && \(!defaultPick \|\| buffer\.length < defaultPick\.buffer\.length\)\)/);
assert.match(script, /item\.addEventListener\('click', \(\) => select\(candidate, true\)\)/);
assert.match(html, /id="animatedThumbDialogTitle"[^>]*>生成动态缩略图<\/div>/);
assert.match(css, /\.animated-thumb-card \.thumb-dialog-title \{ text-align: center; \}/);
assert.match(html, /id="animatedThumbFormat"><option value="webp">AWebP \(\.webp\)<\/option><option value="apng">APNG \(\.png\)<\/option>/);