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

156 lines
9.2 KiB
JavaScript

'use strict';
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 = [];
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
result.push({ type, data: buffer.subarray(offset + 8, offset + 8 + length) });
offset += length + 12;
}
return result;
}
const red = new Uint8Array(16);
const blue = new Uint8Array(16);
for (let index = 0; index < 16; index += 4) {
red.set([255, 0, 0, 255], index);
blue.set([0, 0, 255, 255], index);
}
const png = encode({
width: 2,
height: 2,
frames: [
{ rgba: red, delayNumerator: 125, delayDenominator: 1000 },
{ rgba: blue, delayNumerator: 125, delayDenominator: 1000 },
],
});
assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
const parsed = chunks(png);
assert.deepEqual(parsed.map((entry) => entry.type), ['IHDR', 'acTL', 'fcTL', 'IDAT', 'fcTL', 'fdAT', 'IEND']);
assert.equal(parsed.find((entry) => entry.type === 'acTL').data.readUInt32BE(0), 2);
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');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
new vm.Script(script);
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]*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 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/);
// 帧编码必须走异步 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/);
assert.match(html, /id="animatedThumbStart" type="number" min="0" step="1"/);
assert.match(html, /id="animatedThumbEnd" type="number" min="0" step="1"/);
assert.match(html, /data-i18n="viewer\.framesUnit"/);
assert.match(html, /id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"/);
assert.match(html, /class="animated-thumb-danger" data-i18n="viewer\.animatedThumbnailDanger"/);
assert.match(html, /class="animated-thumb-warning-title" data-i18n="viewer\.animatedThumbnailAttention"/);
assert.match(css, /\.animated-thumb-warning-title \{ display: block; \}/);
assert.match(script, /maxSizeInput\.value = '30'/);
assert.match(script, /maxKilobytes < 10 \|\| maxKilobytes > 1024/);
assert.doesNotMatch(script, /startInput\.(?:focus|select)\(\)/);
assert.match(script, /document\.activeElement\.blur\(\)/);
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>/);
assert.match(css, /\.animated-thumb-form \{[^}]*grid-template-columns: 96px minmax\(0, 1fr\)/);
assert.match(css, /\.animated-thumb-form input\[type="number"\]/);
}
console.log('PASS: APNG structure, compact dialog, size fallback and single/group right-click wiring');