'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(/