203 lines
12 KiB
JavaScript
203 lines
12 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,
|
||
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');
|