优化缩略图
This commit is contained in:
1 parent
e94dfb9e35
commit
081520dbd9
5 files changed
+259
-15
No files matched your search
+33
-4
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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];
|
||||
|
||||
@@ -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/);
|
||||
|
||||
+85
-3
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user