Files
SpineViewer/tests/apng-thumbnail.test.js
T
2026-09-20 02:15:46 +08:00

203 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
Math.min(plan[index][0], profilesModule.DEFAULT_OUTPUT_SIZE),
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),
);
// 动态缩略图可选 200×200 或 400×400,默认 200(Eagle 缩略图格子里看不出差别,体积只有四分之一);
// 配置里超过所选尺寸的细节分辨率会被自动压下来,所以同一份档位表两种尺寸都能用。
assert.deepEqual(profilesModule.ANIMATED_OUTPUT_SIZES, [200, 400]);
assert.equal(profilesModule.DEFAULT_OUTPUT_SIZE, 200);
profilesModule.ANIMATED_PRIORITIES.__size = [[400, 15, 0.8], [100, 6, 0.2]];
assert.deepEqual(profilesModule.animatedProfiles('__size').map((item) => item.size), [200, 100], '默认按 200 裁剪');
assert.deepEqual(profilesModule.animatedProfiles('__size', 400).map((item) => item.size), [400, 100], '选 400 时保留 400');
assert.ok(
profilesModule.expandAnimatedPath('__size').every((item) => item.size <= profilesModule.DEFAULT_OUTPUT_SIZE),
'插值路径不能超过默认输出尺寸',
);
assert.ok(
profilesModule.expandAnimatedPath('__size', 400).every((item) => item.size <= 400),
'选 400 时插值路径上限跟着放宽',
);
delete profilesModule.ANIMATED_PRIORITIES.__size;
// 插值路径供按目标体积定位使用:比锚点更密,首尾与锚点一致。
// 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以不要求整条路径单调。
for (const name of Object.keys(priorities)) {
const anchors = profilesModule.animatedProfiles(name);
const path = profilesModule.expandAnimatedPath(name);
assert.ok(path.length > anchors.length, name + ' 插值后应比锚点更密');
assert.deepEqual(path[0], anchors[0], name + ' 路径首点必须等于首个锚点');
assert.deepEqual(path[path.length - 1], anchors[anchors.length - 1], name + ' 路径末点必须等于末尾锚点');
for (const profile of path) {
assert.ok(profile.size >= 1 && profile.size <= 400, name + ' 插值分辨率必须落在 1-400');
assert.ok(profile.fps >= 1, name + ' 插值帧率至少为 1');
assert.ok(profile.quality > 0 && profile.quality <= 1, name + ' 插值质量必须落在 (0,1]');
}
}
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 = options\.canvasSize \|\| animatedProfileConfig\.DEFAULT_OUTPUT_SIZE;/, '动态缩略图用界面选的输出尺寸');
assert.match(script, /const size = 400;/, '静态缩略图仍然是 400×400');
assert.match(html, /id="animatedThumbCanvas"/);
assert.match(html, /<option value="200" selected>200 × 200<\/option>/);
assert.match(script, /canvasSize: Number\(canvasInput\.value\) \|\| animatedProfileConfig\.DEFAULT_OUTPUT_SIZE/);
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'\)/);
// 候选先估算整条路径的体积,再按「目标 0.5× ~ 2× 对数间隔」挑,覆盖面比挤在目标附近更宽。
assert.match(script, /animatedProfileConfig\.expandAnimatedPath\(options\.priority, options\.canvasSize\)/);
assert.match(script, /pickAnimatedCandidates\(\s*searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame,?\s*\)/);
assert.match(script, /const low = Math\.log\(Math\.max\(samples\[0\]\.bytes, targetBytes \* 0\.5\)\)/);
assert.match(script, /const wanted = Math\.exp\(low \+ \(high - low\) \* slot \/ span\)/);
assert.match(script, /samples\.sort\(\(left, right\) => left\.bytes - right\.bytes\)/);
// 估算用动画 1/3 和 2/3 处的帧,避开首尾信息不全的画面。
assert.match(script, /const indexes = \[Math\.round\(count \/ 3\), Math\.round\(count \* 2 \/ 3\)\]/);
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');