117 lines
6.1 KiB
JavaScript
117 lines
6.1 KiB
JavaScript
'use strict';
|
||
|
||
// 动态缩略图的回归测试。渲染本身要 DOM canvas,这里锁住所有能脱离浏览器验证的部分:
|
||
// 档位配置、目标体积默认值、帧数/尺寸换算、可见范围算法、WebP 封装、自动定档策略。
|
||
const assert = require('node:assert/strict');
|
||
const profiles = require('../js/animated-profiles.js');
|
||
const encoder = require('../js/animated-webp-encoder.js');
|
||
const visibleBounds = require('../js/spine-visible-bounds.js');
|
||
const renderer = require('../js/renderer.js');
|
||
|
||
// —— 目标体积:每秒 25 KB,夹在 25–100 KB(跟主插件的默认值一致)——
|
||
assert.equal(profiles.SIZE_PER_SECOND, 25);
|
||
assert.equal(profiles.DEFAULT_OUTPUT_SIZE, 200);
|
||
assert.equal(profiles.DEFAULT_PRIORITY, 'resolution');
|
||
assert.equal(profiles.suggestSize(0.2), 25);
|
||
assert.equal(profiles.suggestSize(1), 25);
|
||
assert.equal(profiles.suggestSize(2), 50);
|
||
assert.equal(profiles.suggestSize(10), 100);
|
||
|
||
// —— 档位表:细节分辨率永远不超过输出画布边长 ——
|
||
const ladder = profiles.animatedProfiles('resolution', 200);
|
||
assert.equal(ladder.length, 10);
|
||
assert.ok(ladder.every((profile) => profile.size <= 200), '细节分辨率不能超过输出画布');
|
||
assert.ok(ladder.every((profile) => profile.fps >= 1 && profile.quality >= 0 && profile.quality <= 1));
|
||
assert.deepEqual(profiles.animatedProfiles('resolution', 400).slice(0, 5).map((p) => p.size), [400, 400, 400, 400, 400]);
|
||
|
||
// —— 搜索路径:首尾保留锚点,中间是插值 ——
|
||
const searchPath = profiles.expandAnimatedPath('resolution', 200);
|
||
assert.deepEqual(searchPath[0], { size: 200, fps: 18, quality: 1 });
|
||
assert.equal(searchPath[searchPath.length - 1].fps, 6);
|
||
assert.ok(searchPath.every((point) => point.size <= 200));
|
||
|
||
// —— 帧数换算:至少 2 帧、不超过采样帧数、按 ceil(range × fps) ——
|
||
assert.equal(renderer.profileFrameCount(100, 1, 12), 12);
|
||
assert.equal(renderer.profileFrameCount(100, 1, 200), 100, '帧率再高也不能超过采样帧数');
|
||
assert.equal(renderer.profileFrameCount(100, 0.001, 1), 2);
|
||
|
||
// —— 细节分辨率:等于画布时原样返回,低于画布时先降后升、尺寸不变 ——
|
||
const pixels = new Uint8Array(4 * 4 * 4).fill(7);
|
||
assert.equal(renderer.scaledFramePixels(pixels, 4, 4), pixels);
|
||
const scaled = renderer.scaledFramePixels(pixels, 4, 2);
|
||
assert.equal(scaled.length, 16 * 4);
|
||
assert.equal(scaled[0], 7);
|
||
|
||
// —— 可见范围:全透明不算范围,单个像素能定位 ——
|
||
assert.equal(visibleBounds.findAlphaBounds(new Uint8Array(4 * 4 * 4), 4, 4), null);
|
||
const box = new Uint8Array(4 * 4 * 4);
|
||
box[(1 * 4 + 1) * 4 + 3] = 255;
|
||
assert.deepEqual(visibleBounds.findAlphaBounds(box, 4, 4), { left: 1, top: 1, right: 2, bottom: 2 });
|
||
assert.deepEqual(visibleBounds.toWorldBounds({ left: 0, top: 0, right: 8, bottom: 8 }, 8, 8, 2, 0, 0), {
|
||
x: -2, y: -2, width: 4, height: 4,
|
||
});
|
||
|
||
// —— 取骨骼里的第一个有效动画;时长为 0 的动画要跳过 ——
|
||
assert.equal(renderer.animationDurationOf({ duration: 1.5 }), 1.5);
|
||
assert.equal(renderer.animationDurationOf({ duration: 0, timelines: [{ frames: [[0.5], [1]] }] }), 1);
|
||
assert.equal(
|
||
renderer.firstAnimation({ animations: [{ name: 'empty', duration: 0 }, { name: 'idle', duration: 1 }] }).name,
|
||
'idle',
|
||
'时长为 0 的动画不能当成动态缩略图的素材',
|
||
);
|
||
assert.equal(renderer.firstAnimation({ animations: [] }), null);
|
||
|
||
// —— 自动定档:和主插件画廊默认打勾的一致,取实际体积最小的那一档 ——
|
||
(async () => {
|
||
const candidates = [60, 10, 120, 30].map((kb) => ({ buffer: Buffer.alloc(kb * 1024), kb }));
|
||
assert.equal(renderer.smallestCandidate(candidates).kb, 10, '要挑体积最小的那一档,跟顺序无关');
|
||
assert.equal(renderer.smallestCandidate([]), null);
|
||
|
||
// —— 候选插值:给定搜索路径应返回指定数量的合法档位 ——
|
||
const size = 8;
|
||
const frames = Array.from({ length: 3 }, () => new Uint8Array(size * size * 4).fill(9));
|
||
const fakeEncode = async (_slot, framePixels, quality) => Buffer.alloc(Math.max(1, Math.round(framePixels.length * quality / 16)));
|
||
const picked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode);
|
||
assert.equal(picked.length, 10);
|
||
// 最快策略只是少采样(第 8 个参数),候选数量不变,顺序仍是从预计体积最小到最大。
|
||
const fastPicked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode, 4);
|
||
assert.equal(fastPicked.length, 10, '最快策略只是少采样,候选数量不变');
|
||
for (const profile of picked) {
|
||
// 这条搜索路径是按 200 的画布展开的,细节分辨率必须落在 [1, 200]。
|
||
assert.ok(profile.size >= 1 && profile.size <= 200, '细节分辨率要落在画布范围内');
|
||
assert.ok(profile.fps >= 1);
|
||
assert.ok(profile.quality >= 0 && profile.quality <= 1);
|
||
}
|
||
|
||
// —— WebP 封装:两帧以上才合法,结构必须是 RIFF/WEBP + ANMF ——
|
||
const still = (() => {
|
||
const chunk = (type, payload) => {
|
||
const output = Buffer.alloc(8 + payload.length + (payload.length & 1));
|
||
output.write(type, 0, 4, 'ascii');
|
||
output.writeUInt32LE(payload.length, 4);
|
||
payload.copy(output, 8);
|
||
return output;
|
||
};
|
||
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]);
|
||
const output = Buffer.alloc(8 + body.length);
|
||
output.write('RIFF', 0, 4, 'ascii');
|
||
output.writeUInt32LE(body.length, 4);
|
||
body.copy(output, 8);
|
||
return output;
|
||
})();
|
||
const animated = encoder.encode({
|
||
width: 200,
|
||
height: 200,
|
||
frames: [{ webp: still, duration: 55 }, { webp: still, duration: 55 }],
|
||
});
|
||
assert.equal(animated.toString('ascii', 0, 4), 'RIFF');
|
||
assert.equal(animated.toString('ascii', 8, 12), 'WEBP');
|
||
assert.equal(animated.readUInt32LE(4), animated.length - 8);
|
||
assert.throws(() => encoder.encode({ width: 200, height: 200, frames: [{ webp: still, duration: 55 }] }), /at least two frames/);
|
||
|
||
console.log('PASS: 动态缩略图档位、目标体积、帧数换算、可见范围、自动定档、WebP 封装');
|
||
})().catch((error) => {
|
||
console.error(error);
|
||
process.exitCode = 1;
|
||
});
|