diff --git a/js/animated-profiles.js b/js/animated-profiles.js index 2ed914f..f402aa6 100644 --- a/js/animated-profiles.js +++ b/js/animated-profiles.js @@ -3,13 +3,14 @@ /** * 动态缩略图的档位配置。 * - * 每项是 [细节分辨率, 帧率, 编码质量]: + * 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点: * - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满); * - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算; * - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。 - * 数组顺序就是生成顺序,也是画廊里的展示顺序。 * - * 想调整压缩策略,直接改下面的数字即可;增删档位也支持,每档自带质量。 + * 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的 + * 路径,再按用户填写的目标体积在路径上二分定位,取命中位置附近若干档完整生成, + * 所以锚点的疏密决定可搜索的粒度,锚点数量决定画廊里的候选数量。 */ const ANIMATED_PRIORITIES = { // 分辨率优先:先把帧率压到底,再降分辨率。 @@ -31,9 +32,37 @@ const ANIMATED_PRIORITIES = { const DEFAULT_PRIORITY = 'resolution'; +// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。 +const PATH_STEPS_PER_SEGMENT = 6; + function animatedProfiles(priority) { const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY]; return plan.map(([size, fps, quality]) => ({ size, fps, quality })); } -module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, animatedProfiles }; +/** + * 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。 + * 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。 + */ +function expandAnimatedPath(priority) { + const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY]; + const lerp = (from, to, t) => from + (to - from) * t; + const path = []; + for (let index = 0; index < anchors.length - 1; index++) { + const [fromSize, fromFps, fromQuality] = anchors[index]; + const [toSize, toFps, toQuality] = anchors[index + 1]; + for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) { + const t = step / PATH_STEPS_PER_SEGMENT; + path.push({ + size: Math.round(lerp(fromSize, toSize, t)), + fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))), + quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100, + }); + } + } + const [size, fps, quality] = anchors[anchors.length - 1]; + path.push({ size, fps, quality }); + return path; +} + +module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, PATH_STEPS_PER_SEGMENT, animatedProfiles, expandAnimatedPath }; diff --git a/release/UNIVERSAL_SpineViewer/js/animated-profiles.js b/release/UNIVERSAL_SpineViewer/js/animated-profiles.js index 2ed914f..f402aa6 100644 --- a/release/UNIVERSAL_SpineViewer/js/animated-profiles.js +++ b/release/UNIVERSAL_SpineViewer/js/animated-profiles.js @@ -3,13 +3,14 @@ /** * 动态缩略图的档位配置。 * - * 每项是 [细节分辨率, 帧率, 编码质量]: + * 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点: * - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满); * - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算; * - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。 - * 数组顺序就是生成顺序,也是画廊里的展示顺序。 * - * 想调整压缩策略,直接改下面的数字即可;增删档位也支持,每档自带质量。 + * 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的 + * 路径,再按用户填写的目标体积在路径上二分定位,取命中位置附近若干档完整生成, + * 所以锚点的疏密决定可搜索的粒度,锚点数量决定画廊里的候选数量。 */ const ANIMATED_PRIORITIES = { // 分辨率优先:先把帧率压到底,再降分辨率。 @@ -31,9 +32,37 @@ const ANIMATED_PRIORITIES = { const DEFAULT_PRIORITY = 'resolution'; +// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。 +const PATH_STEPS_PER_SEGMENT = 6; + function animatedProfiles(priority) { const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY]; return plan.map(([size, fps, quality]) => ({ size, fps, quality })); } -module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, animatedProfiles }; +/** + * 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。 + * 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。 + */ +function expandAnimatedPath(priority) { + const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY]; + const lerp = (from, to, t) => from + (to - from) * t; + const path = []; + for (let index = 0; index < anchors.length - 1; index++) { + const [fromSize, fromFps, fromQuality] = anchors[index]; + const [toSize, toFps, toQuality] = anchors[index + 1]; + for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) { + const t = step / PATH_STEPS_PER_SEGMENT; + path.push({ + size: Math.round(lerp(fromSize, toSize, t)), + fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))), + quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100, + }); + } + } + const [size, fps, quality] = anchors[anchors.length - 1]; + path.push({ size, fps, quality }); + return path; +} + +module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, PATH_STEPS_PER_SEGMENT, animatedProfiles, expandAnimatedPath }; diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index f749a68..22cf237 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -1017,8 +1017,82 @@ + '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb'; } - // 结果画廊:逐档生成,每档一出来就填进两行四列的格子,点哪个用哪个,关掉就全部丢弃。 - // 帧编码走 canvas.toBlob(异步),生成期间界面不会像 toDataURL 那样被卡住。 + // 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。 + async function estimateAnimatedBytes(profile, sourceFrames, size, range, options, encodeWebpFrame) { + const count = profileFrameCount(sourceFrames, range, profile.fps); + const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)]; + let total = 0; + for (let slot = 0; slot < indexes.length; slot++) { + const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count)); + const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size); + if (options.format === 'apng') { + total += apngEncoder.encode({ + width: size, height: size, plays: 0, compressionLevel: 9, + frames: [{ rgba: pixels, delayNumerator: 100, delayDenominator: 1000 }], + }).length; + } else { + total += (await encodeWebpFrame(slot, pixels, profile.quality)).length; + } + } + return (total / indexes.length) * count; + } + + // 沿整条路径估算体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」挑候选。 + // 对数取点能让画廊里的档位从明显清晰一路铺到明显省体积,而不是全挤在目标附近; + // 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以整条路径不假设单调。 + async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, options, targetBytes, count, encodeWebpFrame) { + // 估算点按步长抽样,避免整条路径都各编两帧;路径首尾一定纳入。 + const stride = Math.max(1, Math.ceil(searchPath.length / 24)); + const samples = []; + for (let index = 0; index < searchPath.length; index += stride) { + samples.push({ + index, + bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, options, encodeWebpFrame), + }); + } + const lastIndex = searchPath.length - 1; + if (samples[samples.length - 1].index !== lastIndex) { + samples.push({ + index: lastIndex, + bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, options, encodeWebpFrame), + }); + } + // 先按体积从小到大排:配置允许分段(分辨率优先的 400 / 200 两段体积曲线会重叠), + // 排序后再取点才能保证候选体积单调递增、也不会挑出两张几乎一样的结果。 + samples.sort((left, right) => left.bytes - right.bytes); + // 体积 → 参数:在「体积相邻」的两个采样点之间插值。候选因此可以落在配置表之外, + // 出现 234×234@13fps/q0.22 这类值;按体积相邻插值也能跨过分段,保证候选体积递增。 + const profileForBytes = (wantedBytes) => { + let upper = samples.length - 1; + for (let index = 0; index < samples.length; index++) { + if (samples[index].bytes >= wantedBytes) { upper = index; break; } + } + const lower = Math.max(0, upper - 1); + const before = searchPath[samples[lower].index]; + const after = searchPath[samples[upper].index]; + const gap = samples[upper].bytes - samples[lower].bytes; + const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0; + const mix = (start, end) => start + (end - start) * ratio; + return { + size: Math.max(1, Math.round(mix(before.size, after.size))), + fps: Math.max(1, Math.round(mix(before.fps, after.fps))), + quality: Math.round(mix(before.quality, after.quality) * 100) / 100, + }; + }; + // 目标区间夹在样本的实际体积范围内,免得目标远小于最小候选时空转。 + const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5)); + const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2)); + const span = Math.max(1, count - 1); + const picked = []; + for (let slot = 0; slot < count; slot++) { + const wanted = Math.exp(low + (high - low) * slot / span); + picked.push(profileForBytes(wanted)); + } + return picked; + } + + // 结果画廊:先按目标体积定位,再逐档生成,每档一出来就填进两行五列的格子, + // 点哪个用哪个,关掉就全部丢弃。帧编码走 canvas.toBlob(异步),界面不会被卡住。 async function pickAnimatedThumbnail(sourceFrames, size, range, options) { const dialog = document.getElementById('animatedPickDialog'); const grid = document.getElementById('animatedPickGrid'); @@ -1026,7 +1100,9 @@ const cancelButton = document.getElementById('animatedPickCancel'); const confirmButton = document.getElementById('animatedPickConfirm'); const targetBytes = options.maxKilobytes * 1024; - const profiles = animatedProfiles(options.priority); + // 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。 + const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority); + const candidateCount = animatedProfiles(options.priority).length; // 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。 const pool = Array.from({ length: 4 }, () => { const off = document.createElement('canvas'); @@ -1100,6 +1176,12 @@ document.addEventListener('keydown', keydown); dialog.classList.remove('hidden'); (async () => { + // 先沿搜索路径估算体积,再按对数间隔挑出候选,最后完整生成选中的那几档, + // 这样画廊里的候选会围绕用户填写的体积铺开,而不是固定的那几张。 + setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); + const profiles = await pickAnimatedCandidates( + searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame, + ); for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) { if (settled) return; const profile = profiles[profileIndex]; diff --git a/tests/apng-thumbnail.test.js b/tests/apng-thumbnail.test.js index 5c00d5d..1a75b2f 100644 --- a/tests/apng-thumbnail.test.js +++ b/tests/apng-thumbnail.test.js @@ -74,6 +74,21 @@ assert.deepEqual( profilesModule.animatedProfiles(profilesModule.DEFAULT_PRIORITY), ); +// 插值路径供按目标体积定位使用:比锚点更密,首尾与锚点一致。 +// 配置允许分段(分辨率优先就是「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'); @@ -89,7 +104,14 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) { assert.match(script, /const targetBytes = options\.maxKilobytes \* 1024/); // 档位表来自独立配置文件,三种优先级都走画廊手动挑,不会自动应用。 assert.match(script, /require\('\.\.\/js\/animated-profiles\.js'\)/); - assert.match(script, /const profiles = animatedProfiles\(options\.priority\)/); + // 候选先估算整条路径的体积,再按「目标 0.5× ~ 2× 对数间隔」挑,覆盖面比挤在目标附近更宽。 + assert.match(script, /animatedProfileConfig\.expandAnimatedPath\(options\.priority\)/); + 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/); diff --git a/viewer/spine.html b/viewer/spine.html index f749a68..22cf237 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -1017,8 +1017,82 @@ + '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb'; } - // 结果画廊:逐档生成,每档一出来就填进两行四列的格子,点哪个用哪个,关掉就全部丢弃。 - // 帧编码走 canvas.toBlob(异步),生成期间界面不会像 toDataURL 那样被卡住。 + // 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。 + async function estimateAnimatedBytes(profile, sourceFrames, size, range, options, encodeWebpFrame) { + const count = profileFrameCount(sourceFrames, range, profile.fps); + const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)]; + let total = 0; + for (let slot = 0; slot < indexes.length; slot++) { + const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count)); + const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size); + if (options.format === 'apng') { + total += apngEncoder.encode({ + width: size, height: size, plays: 0, compressionLevel: 9, + frames: [{ rgba: pixels, delayNumerator: 100, delayDenominator: 1000 }], + }).length; + } else { + total += (await encodeWebpFrame(slot, pixels, profile.quality)).length; + } + } + return (total / indexes.length) * count; + } + + // 沿整条路径估算体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」挑候选。 + // 对数取点能让画廊里的档位从明显清晰一路铺到明显省体积,而不是全挤在目标附近; + // 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以整条路径不假设单调。 + async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, options, targetBytes, count, encodeWebpFrame) { + // 估算点按步长抽样,避免整条路径都各编两帧;路径首尾一定纳入。 + const stride = Math.max(1, Math.ceil(searchPath.length / 24)); + const samples = []; + for (let index = 0; index < searchPath.length; index += stride) { + samples.push({ + index, + bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, options, encodeWebpFrame), + }); + } + const lastIndex = searchPath.length - 1; + if (samples[samples.length - 1].index !== lastIndex) { + samples.push({ + index: lastIndex, + bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, options, encodeWebpFrame), + }); + } + // 先按体积从小到大排:配置允许分段(分辨率优先的 400 / 200 两段体积曲线会重叠), + // 排序后再取点才能保证候选体积单调递增、也不会挑出两张几乎一样的结果。 + samples.sort((left, right) => left.bytes - right.bytes); + // 体积 → 参数:在「体积相邻」的两个采样点之间插值。候选因此可以落在配置表之外, + // 出现 234×234@13fps/q0.22 这类值;按体积相邻插值也能跨过分段,保证候选体积递增。 + const profileForBytes = (wantedBytes) => { + let upper = samples.length - 1; + for (let index = 0; index < samples.length; index++) { + if (samples[index].bytes >= wantedBytes) { upper = index; break; } + } + const lower = Math.max(0, upper - 1); + const before = searchPath[samples[lower].index]; + const after = searchPath[samples[upper].index]; + const gap = samples[upper].bytes - samples[lower].bytes; + const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0; + const mix = (start, end) => start + (end - start) * ratio; + return { + size: Math.max(1, Math.round(mix(before.size, after.size))), + fps: Math.max(1, Math.round(mix(before.fps, after.fps))), + quality: Math.round(mix(before.quality, after.quality) * 100) / 100, + }; + }; + // 目标区间夹在样本的实际体积范围内,免得目标远小于最小候选时空转。 + const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5)); + const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2)); + const span = Math.max(1, count - 1); + const picked = []; + for (let slot = 0; slot < count; slot++) { + const wanted = Math.exp(low + (high - low) * slot / span); + picked.push(profileForBytes(wanted)); + } + return picked; + } + + // 结果画廊:先按目标体积定位,再逐档生成,每档一出来就填进两行五列的格子, + // 点哪个用哪个,关掉就全部丢弃。帧编码走 canvas.toBlob(异步),界面不会被卡住。 async function pickAnimatedThumbnail(sourceFrames, size, range, options) { const dialog = document.getElementById('animatedPickDialog'); const grid = document.getElementById('animatedPickGrid'); @@ -1026,7 +1100,9 @@ const cancelButton = document.getElementById('animatedPickCancel'); const confirmButton = document.getElementById('animatedPickConfirm'); const targetBytes = options.maxKilobytes * 1024; - const profiles = animatedProfiles(options.priority); + // 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。 + const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority); + const candidateCount = animatedProfiles(options.priority).length; // 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。 const pool = Array.from({ length: 4 }, () => { const off = document.createElement('canvas'); @@ -1100,6 +1176,12 @@ document.addEventListener('keydown', keydown); dialog.classList.remove('hidden'); (async () => { + // 先沿搜索路径估算体积,再按对数间隔挑出候选,最后完整生成选中的那几档, + // 这样画廊里的候选会围绕用户填写的体积铺开,而不是固定的那几张。 + setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); + const profiles = await pickAnimatedCandidates( + searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame, + ); for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) { if (settled) return; const profile = profiles[profileIndex];