缩略图二分定位 + 邻域采样

B. 二分定位 + 邻域采样(我推荐)
把配置的语义从"这就是要生成的 10 档"改成"允许搜索的参数梯度"(比如分辨率梯度、帧率梯度、质量梯度三张表)。运行时:
1. 用每个组合只编 2 帧的方式粗估体积(很便宜);
2. 靠单调性做二分,几轮就找到"体积刚好不超过目标"的参数点;
3. 在这个点周围取 10 个组合(略高、略低各几个),完整生成。
成本大概是十几次估算编码 + 10 档完整编码,和现在基本持平甚至更省(估算只编 2 帧)。效果是候选真正围绕你输入的目标分布——比如你填 30 KB,出来的就是 22、26、29、31、35 KB 这种,而不是 5 KB 和 300 KB 各一个。
代价是你现在这套配置格式要推翻重做——从"精确档位表"变成"参数范围表"
This commit is contained in:
Yeomai committed 2026-09-20 00:39:38 +08:00
1 parent 5808e3dfce
commit e94dfb9e35
27 files changed
+710 -299

No files matched your search

+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.", "animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.", "animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
"animatedPickTitle": "Miniaturbild auswählen",
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
"animatedPickConfirm": "Bestätigen",
"animatedPriority": "Priorität",
"priorityResolution": "Auflösung zuerst",
"priorityFramerate": "Bildrate zuerst",
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
"qualityLow": "Niedrig", "qualityLow": "Niedrig",
"qualityMedium": "Mittel", "qualityMedium": "Mittel",
"qualityHigh": "Hoch", "qualityHigh": "Hoch",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…", "animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.", "animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.", "animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
"animatedPickTitle": "Pick a thumbnail",
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
"animatedPickConfirm": "Confirm",
"animatedPriority": "Priority",
"priorityResolution": "Resolution first",
"priorityFramerate": "Frame rate first",
"priorityBalanced": "Alternate resolution and frame rate",
"qualityLow": "Low", "qualityLow": "Low",
"qualityMedium": "Medium", "qualityMedium": "Medium",
"qualityHigh": "High", "qualityHigh": "High",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.", "animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.", "animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
"animatedPickTitle": "Elige una miniatura",
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
"animatedPickConfirm": "Confirmar",
"animatedPriority": "Prioridad",
"priorityResolution": "Resolución primero",
"priorityFramerate": "Frecuencia primero",
"priorityBalanced": "Alternar resolución y frecuencia",
"qualityLow": "Baja", "qualityLow": "Baja",
"qualityMedium": "Media", "qualityMedium": "Media",
"qualityHigh": "Alta", "qualityHigh": "Alta",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
"animatedPickTitle": "サムネイルを選択",
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先度",
"priorityResolution": "解像度優先",
"priorityFramerate": "フレームレート優先",
"priorityBalanced": "解像度とフレームレートを交互に",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
"animatedPickTitle": "썸네일 선택",
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
"animatedPickConfirm": "확인",
"animatedPriority": "우선 순위",
"priorityResolution": "해상도 우선",
"priorityFramerate": "프레임률 우선",
"priorityBalanced": "해상도와 프레임률 교차",
"qualityLow": "낮음", "qualityLow": "낮음",
"qualityMedium": "보통", "qualityMedium": "보통",
"qualityHigh": "높음", "qualityHigh": "높음",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
"animatedPickTitle": "Выберите миниатюру",
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
"animatedPickConfirm": "Подтвердить",
"animatedPriority": "Приоритет",
"priorityResolution": "Сначала разрешение",
"priorityFramerate": "Сначала частота кадров",
"priorityBalanced": "Чередовать разрешение и частоту",
"qualityLow": "Низкое", "qualityLow": "Низкое",
"qualityMedium": "Среднее", "qualityMedium": "Среднее",
"qualityHigh": "Высокое", "qualityHigh": "Высокое",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
"animatedPickTitle": "选择一个缩略图",
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
"animatedPickConfirm": "确认",
"animatedPriority": "优先级别",
"priorityResolution": "分辨率优先",
"priorityFramerate": "帧率优先",
"priorityBalanced": "分辨率和帧率交替进行",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
+7
View File
@@ -36,6 +36,13 @@
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
"animatedPickTitle": "選擇一個縮圖",
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先級別",
"priorityResolution": "解析度優先",
"priorityFramerate": "影格率優先",
"priorityBalanced": "解析度與影格率交替進行",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
+39
View File
@@ -0,0 +1,39 @@
'use strict';
/**
* 动态缩略图的档位配置。
*
* 每项是 [细节分辨率, 帧率, 编码质量]:
* - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满);
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
* - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。
* 数组顺序就是生成顺序,也是画廊里的展示顺序。
*
* 想调整压缩策略,直接改下面的数字即可;增删档位也支持,每档自带质量。
*/
const ANIMATED_PRIORITIES = {
// 分辨率优先:先把帧率压到底,再降分辨率。
resolution: [
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
],
// 帧率优先:先把分辨率压到底,再降帧率。
framerate: [
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
],
// 交替进行:分辨率和帧率交替下降。
balanced: [
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
],
};
const DEFAULT_PRIORITY = 'resolution';
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
View File
@@ -16,6 +16,7 @@ function chunk(type, payload) {
} }
function frameData(webp) { function frameData(webp) {
if (!webp) throw new Error('WebP 帧数据缺失');
const input = Buffer.from(webp); const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') { if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame'); throw new Error('Invalid WebP frame');
+2
View File
@@ -22,6 +22,8 @@ function orderCurrentTargetLast(targets, currentSourcePath) {
*/ */
async function applyThumbnailTargets(options) { async function applyThumbnailTargets(options) {
const targets = options.targets || []; const targets = options.targets || [];
// 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。
if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空');
const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs; const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs;
const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs; const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs;
const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts; const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts;
@@ -36,6 +36,13 @@
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.", "animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.", "animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
"animatedPickTitle": "Miniaturbild auswählen",
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
"animatedPickConfirm": "Bestätigen",
"animatedPriority": "Priorität",
"priorityResolution": "Auflösung zuerst",
"priorityFramerate": "Bildrate zuerst",
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
"qualityLow": "Niedrig", "qualityLow": "Niedrig",
"qualityMedium": "Mittel", "qualityMedium": "Mittel",
"qualityHigh": "Hoch", "qualityHigh": "Hoch",
@@ -36,6 +36,13 @@
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…", "animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.", "animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.", "animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
"animatedPickTitle": "Pick a thumbnail",
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
"animatedPickConfirm": "Confirm",
"animatedPriority": "Priority",
"priorityResolution": "Resolution first",
"priorityFramerate": "Frame rate first",
"priorityBalanced": "Alternate resolution and frame rate",
"qualityLow": "Low", "qualityLow": "Low",
"qualityMedium": "Medium", "qualityMedium": "Medium",
"qualityHigh": "High", "qualityHigh": "High",
@@ -36,6 +36,13 @@
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.", "animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.", "animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
"animatedPickTitle": "Elige una miniatura",
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
"animatedPickConfirm": "Confirmar",
"animatedPriority": "Prioridad",
"priorityResolution": "Resolución primero",
"priorityFramerate": "Frecuencia primero",
"priorityBalanced": "Alternar resolución y frecuencia",
"qualityLow": "Baja", "qualityLow": "Baja",
"qualityMedium": "Media", "qualityMedium": "Media",
"qualityHigh": "Alta", "qualityHigh": "Alta",
@@ -36,6 +36,13 @@
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
"animatedPickTitle": "サムネイルを選択",
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先度",
"priorityResolution": "解像度優先",
"priorityFramerate": "フレームレート優先",
"priorityBalanced": "解像度とフレームレートを交互に",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
@@ -36,6 +36,13 @@
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
"animatedPickTitle": "썸네일 선택",
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
"animatedPickConfirm": "확인",
"animatedPriority": "우선 순위",
"priorityResolution": "해상도 우선",
"priorityFramerate": "프레임률 우선",
"priorityBalanced": "해상도와 프레임률 교차",
"qualityLow": "낮음", "qualityLow": "낮음",
"qualityMedium": "보통", "qualityMedium": "보통",
"qualityHigh": "높음", "qualityHigh": "높음",
@@ -36,6 +36,13 @@
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
"animatedPickTitle": "Выберите миниатюру",
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
"animatedPickConfirm": "Подтвердить",
"animatedPriority": "Приоритет",
"priorityResolution": "Сначала разрешение",
"priorityFramerate": "Сначала частота кадров",
"priorityBalanced": "Чередовать разрешение и частоту",
"qualityLow": "Низкое", "qualityLow": "Низкое",
"qualityMedium": "Среднее", "qualityMedium": "Среднее",
"qualityHigh": "Высокое", "qualityHigh": "Высокое",
@@ -36,6 +36,13 @@
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
"animatedPickTitle": "选择一个缩略图",
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
"animatedPickConfirm": "确认",
"animatedPriority": "优先级别",
"priorityResolution": "分辨率优先",
"priorityFramerate": "帧率优先",
"priorityBalanced": "分辨率和帧率交替进行",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
@@ -36,6 +36,13 @@
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
"animatedPickTitle": "選擇一個縮圖",
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先級別",
"priorityResolution": "解析度優先",
"priorityFramerate": "影格率優先",
"priorityBalanced": "解析度與影格率交替進行",
"qualityLow": "低", "qualityLow": "低",
"qualityMedium": "中", "qualityMedium": "中",
"qualityHigh": "高", "qualityHigh": "高",
@@ -0,0 +1,39 @@
'use strict';
/**
* 动态缩略图的档位配置。
*
* 每项是 [细节分辨率, 帧率, 编码质量]:
* - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满);
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
* - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。
* 数组顺序就是生成顺序,也是画廊里的展示顺序。
*
* 想调整压缩策略,直接改下面的数字即可;增删档位也支持,每档自带质量。
*/
const ANIMATED_PRIORITIES = {
// 分辨率优先:先把帧率压到底,再降分辨率。
resolution: [
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
],
// 帧率优先:先把分辨率压到底,再降帧率。
framerate: [
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
],
// 交替进行:分辨率和帧率交替下降。
balanced: [
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
],
};
const DEFAULT_PRIORITY = 'resolution';
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 };
@@ -16,6 +16,7 @@ function chunk(type, payload) {
} }
function frameData(webp) { function frameData(webp) {
if (!webp) throw new Error('WebP 帧数据缺失');
const input = Buffer.from(webp); const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') { if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame'); throw new Error('Invalid WebP frame');
@@ -22,6 +22,8 @@ function orderCurrentTargetLast(targets, currentSourcePath) {
*/ */
async function applyThumbnailTargets(options) { async function applyThumbnailTargets(options) {
const targets = options.targets || []; const targets = options.targets || [];
// 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。
if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空');
const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs; const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs;
const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs; const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs;
const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts; const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts;
@@ -7,6 +7,7 @@
--accent: #4fc1ff; --accent: #4fc1ff;
--border: #3c3c3c; --border: #3c3c3c;
--error: #f14c4c; --error: #f14c4c;
--pick-ok: #3fb950;
/* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(), /* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(),
这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */ 这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */
--hover-fill: rgba(255, 255, 255, .07); --hover-fill: rgba(255, 255, 255, .07);
@@ -28,6 +29,7 @@
--accent: #0078d4; --accent: #0078d4;
--border: #ddd; --border: #ddd;
--error: #d13438; --error: #d13438;
--pick-ok: #1a7f37;
--hover-fill: rgba(0, 0, 0, .06); --hover-fill: rgba(0, 0, 0, .06);
--scroll-thumb: rgba(0, 0, 0, .30); --scroll-thumb: rgba(0, 0, 0, .30);
--scroll-thumb-hover: rgba(0, 0, 0, .46); --scroll-thumb-hover: rgba(0, 0, 0, .46);
@@ -148,6 +150,38 @@
} }
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); } .animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; } .animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
.animated-pick-card { width: min(920px, 94vw); gap: 10px; padding: 16px; }
.animated-pick-card .thumb-dialog-title { text-align: center; }
.animated-pick-card .thumb-dialog-note { font-size: 12px; text-align: center; }
.animated-pick-notice { color: var(--text); text-align: center; min-height: 18px; }
/* 档位数量由配置决定,行数不固定:格子超高时内部滚动,按钮区始终留在面板底部。 */
.animated-pick-grid {
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px;
max-height: 58vh; overflow-y: auto; padding-right: 2px;
}
.animated-pick-item {
position: relative;
display: flex; flex-direction: column; gap: 6px; width: 100%;
padding: 6px; border: 1px solid var(--border); border-radius: 8px;
background: var(--panel-2); color: var(--text-dim); cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px; line-height: 1.4;
}
.animated-pick-item:hover { border-color: var(--accent); color: var(--text); }
.animated-pick-item img {
display: block; width: 100%; aspect-ratio: 1 / 1;
border-radius: 5px; background: var(--bg);
}
.animated-pick-item.selected {
border-color: var(--pick-ok); box-shadow: 0 0 0 1px var(--pick-ok); color: var(--text);
}
.animated-pick-item.selected::after {
content: '✓'; position: absolute; top: 10px; right: 10px;
width: 20px; height: 20px; border-radius: 50%;
background: var(--pick-ok); color: #fff;
font-size: 13px; line-height: 20px; text-align: center; font-weight: 700;
}
.animated-pick-params { display: block; word-break: break-all; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/ /* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; } #toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
+186 -144
View File
@@ -105,6 +105,13 @@
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span> <label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span> <span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
</label> </label>
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
<select id="animatedThumbPriority">
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
<option value="framerate" data-i18n="viewer.priorityFramerate">帧率优先</option>
<option value="balanced" data-i18n="viewer.priorityBalanced">分辨率和帧率交替进行</option>
</select>
</label>
</div> </div>
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div> <div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
<div class="thumb-dialog-actions"> <div class="thumb-dialog-actions">
@@ -113,6 +120,18 @@
</div> </div>
</div> </div>
</div> </div>
<div id="animatedPickDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedPickTitle">
<div class="thumb-dialog-card animated-pick-card">
<div id="animatedPickTitle" class="thumb-dialog-title" data-i18n="viewer.animatedPickTitle">选择一个缩略图</div>
<div class="thumb-dialog-note" data-i18n="viewer.animatedPickHint">所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点击一个结果即替换缩略图,关闭则全部放弃。</div>
<div id="animatedPickGrid" class="animated-pick-grid"></div>
<div id="animatedPickNotice" class="animated-pick-notice"></div>
<div class="thumb-dialog-actions">
<button id="animatedPickCancel" data-i18n="viewer.cancel">取消</button>
<button id="animatedPickConfirm" class="primary" data-i18n="viewer.animatedPickConfirm" disabled>确认</button>
</div>
</div>
</div>
</div> </div>
<script> <script>
@@ -128,6 +147,8 @@
const previewSettingsModule = require('../js/spine-preview-settings.js'); const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js'); const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js'); const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
const animatedProfileConfig = require('../js/animated-profiles.js');
const animatedProfiles = animatedProfileConfig.animatedProfiles;
const visibleBounds = require('../js/spine-visible-bounds.js'); const visibleBounds = require('../js/spine-visible-bounds.js');
let spineBadgeImagePromise = null; let spineBadgeImagePromise = null;
@@ -796,6 +817,7 @@
const startInput = document.getElementById('animatedThumbStart'); const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd'); const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat'); const formatInput = document.getElementById('animatedThumbFormat');
const priorityInput = document.getElementById('animatedThumbPriority');
const maxSizeInput = document.getElementById('animatedThumbMaxSize'); const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError'); const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm'); const confirm = document.getElementById('animatedThumbDialogConfirm');
@@ -807,6 +829,7 @@
endInput.value = String(totalFrames); endInput.value = String(totalFrames);
endInput.max = String(totalFrames); endInput.max = String(totalFrames);
formatInput.value = 'webp'; formatInput.value = 'webp';
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
maxSizeInput.value = '30'; maxSizeInput.value = '30';
errorEl.textContent = ''; errorEl.textContent = '';
dialog.classList.remove('hidden'); dialog.classList.remove('hidden');
@@ -834,6 +857,7 @@
start: startFrame / FPS, start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength), end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp', format: formatInput.value === 'apng' ? 'apng' : 'webp',
priority: priorityInput.value,
maxKilobytes, maxKilobytes,
}); });
}; };
@@ -901,31 +925,8 @@
return measured; return measured;
} }
function quantizeFrame(data, bits) { function profileFrameCount(sourceFrames, range, fps) {
if (bits >= 8) return new Uint8Array(data); return Math.max(2, Math.min(sourceFrames.length, Math.ceil(range * fps)));
const levels = (1 << bits) - 1;
const lookup = new Uint8Array(256);
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
return output;
}
function compressFramePixels(data, size, bits, blockSize) {
if (blockSize <= 1) return quantizeFrame(data, bits);
const levels = (1 << bits) - 1;
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let top = 0; top < size; top += blockSize) {
for (let left = 0; left < size; left += blockSize) {
const source = (top * size + left) * 4;
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
for (let y = top; y < Math.min(size, top + blockSize); y++) {
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
}
}
}
return output;
} }
function resizeFramePixels(data, sourceSize, targetSize) { function resizeFramePixels(data, sourceSize, targetSize) {
@@ -942,117 +943,13 @@
return output; return output;
} }
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) { // 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
const targetBytes = maxKilobytes * 1024; // 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
const profiles = [ function scaledFramePixels(data, size, detailSize) {
{ ratio: 1, bits: 8, block: 1 }, return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
{ ratio: 0.75, bits: 7, block: 1 },
{ ratio: 0.5, bits: 7, block: 1 },
{ ratio: 0.5, bits: 6, block: 2 },
{ ratio: 0.33, bits: 6, block: 2 },
{ ratio: 0.25, bits: 5, block: 3 },
{ ratio: 0.16, bits: 5, block: 4 },
{ count: 3, bits: 4, block: 8 },
{ count: 2, bits: 3, block: 12 },
{ count: 3, bits: 5, block: 1, size: 320 },
{ count: 3, bits: 5, block: 1, size: 256 },
{ count: 2, bits: 4, block: 1, size: 192 },
{ count: 2, bits: 4, block: 1, size: 128 },
{ count: 2, bits: 3, block: 1, size: 96 },
{ count: 2, bits: 3, block: 1, size: 64 },
{ count: 2, bits: 2, block: 1, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const delayMs = Math.max(1, Math.round(range * 1000 / count));
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
return {
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
delayNumerator: delayMs,
delayDenominator: 1000,
};
};
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
if (count > 3) {
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
}
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((resolve) => setTimeout(resolve, 0));
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
} }
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) { async function renderAnimatedSourceFrames(animation, options) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400; const size = 400;
const range = options.end - options.start; const range = options.end - options.start;
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。 // 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
@@ -1110,10 +1007,145 @@
} finally { } finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose(); if (offRenderer && offRenderer.dispose) offRenderer.dispose();
} }
const encoded = options.format === 'apng' return { sourceFrames, size, range };
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes) }
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size }; // 缩略图参数标签,不走本地化:细节分辨率*细节分辨率@帧率/质量/文件大小。
function candidateParams(candidate) {
const quality = candidate.quality === null ? 'lossless' : 'q' + candidate.quality;
return candidate.detail + '*' + candidate.detail + '@' + candidate.fps + 'fps/' + quality
+ '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb';
}
// 结果画廊:逐档生成,每档一出来就填进两行四列的格子,点哪个用哪个,关掉就全部丢弃。
// 帧编码走 canvas.toBlob(异步),生成期间界面不会像 toDataURL 那样被卡住。
async function pickAnimatedThumbnail(sourceFrames, size, range, options) {
const dialog = document.getElementById('animatedPickDialog');
const grid = document.getElementById('animatedPickGrid');
const notice = document.getElementById('animatedPickNotice');
const cancelButton = document.getElementById('animatedPickCancel');
const confirmButton = document.getElementById('animatedPickConfirm');
const targetBytes = options.maxKilobytes * 1024;
const profiles = animatedProfiles(options.priority);
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
const pool = Array.from({ length: 4 }, () => {
const off = document.createElement('canvas');
off.width = size; off.height = size;
const context = off.getContext('2d');
return { canvas: off, context, image: context.createImageData(size, size) };
});
const previewUrls = [];
grid.textContent = '';
notice.textContent = '';
confirmButton.disabled = true;
let selected = null;
let defaultPick = null;
let userPicked = false;
let hasFit = false;
let smallest = Infinity;
let settled = false;
const encodeFrame = (slot, pixels, quality) => new Promise((resolve, reject) => {
const unit = pool[slot];
unit.image.data.set(pixels);
unit.context.putImageData(unit.image, 0, 0);
unit.canvas.toBlob((blob) => {
if (!blob || blob.type !== 'image/webp') { reject(new Error(t('viewer.animatedWebpUnavailable'))); return; }
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
}, 'image/webp', quality);
});
return new Promise((resolve, reject) => {
const cards = new Map();
const close = (value) => {
if (settled) return;
settled = true;
dialog.classList.add('hidden');
cancelButton.removeEventListener('click', cancel);
confirmButton.removeEventListener('click', confirm);
document.removeEventListener('keydown', keydown);
for (const url of previewUrls) URL.revokeObjectURL(url);
previewUrls.length = 0;
resolve(value);
};
const cancel = () => close(null);
const confirm = () => { if (selected) close(selected); };
const keydown = (event) => { if (event.key === 'Escape') cancel(); };
// 点卡片只是选中(右上角打勾),真正替换缩略图要再按确认。
const select = (candidate, manual) => {
if (manual) userPicked = true;
selected = candidate;
for (const [other, node] of cards) node.classList.toggle('selected', other === candidate);
confirmButton.disabled = false;
};
const addCard = (candidate) => {
const url = URL.createObjectURL(new Blob([candidate.buffer], {
type: options.format === 'apng' ? 'image/png' : 'image/webp',
}));
previewUrls.push(url);
const item = document.createElement('button');
item.type = 'button';
item.className = 'animated-pick-item';
const preview = document.createElement('img');
preview.src = url;
preview.alt = '';
const params = document.createElement('span');
params.className = 'animated-pick-params';
params.textContent = candidateParams(candidate);
item.append(preview, params);
item.addEventListener('click', () => select(candidate, true));
cards.set(candidate, item);
grid.appendChild(item);
};
cancelButton.addEventListener('click', cancel);
confirmButton.addEventListener('click', confirm);
document.addEventListener('keydown', keydown);
dialog.classList.remove('hidden');
(async () => {
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
if (settled) return;
const profile = profiles[profileIndex];
const count = profileFrameCount(sourceFrames, range, profile.fps);
const duration = Math.max(1, Math.round(range * 1000 / count));
const frames = [];
for (let start = 0; start < count; start += pool.length) {
if (settled) return;
const batch = [];
for (let index = start; index < Math.min(count, start + pool.length); index++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
batch.push(scaledFramePixels(sourceFrames[sourceIndex], size, profile.size));
}
if (options.format === 'apng') {
for (const pixels of batch) frames.push({ rgba: pixels, delayNumerator: duration, delayDenominator: 1000 });
} else {
const buffers = await Promise.all(batch.map((pixels, offset) =>
encodeFrame((start + offset) % pool.length, pixels, profile.quality)));
for (const webp of buffers) frames.push({ webp, duration });
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((next) => setTimeout(next, 0));
}
if (settled) return;
const buffer = options.format === 'apng'
? apngEncoder.encode({ width: size, height: size, frames, plays: 0, compressionLevel: 9 })
: animatedWebpEncoder.encode({ width: size, height: size, frames, plays: 0 });
const candidate = {
detail: profile.size,
fps: profile.fps,
quality: options.format === 'apng' ? null : profile.quality,
frames: count,
buffer,
};
smallest = Math.min(smallest, buffer.length);
if (buffer.length <= targetBytes) hasFit = true;
addCard(candidate);
// 用户没自己挑过时,默认选中当前体积最小的档位,省得在十个结果里纠结。
if (!userPicked && (!defaultPick || buffer.length < defaultPick.buffer.length)) {
defaultPick = candidate;
select(candidate, false);
}
}
if (!hasFit) notice.textContent = t('viewer.animatedCannotFit', { target: options.maxKilobytes, actual: Math.ceil(smallest / 1024) });
})().catch((error) => { close(null); reject(error); });
});
} }
async function generateAnimatedThumbnail() { async function generateAnimatedThumbnail() {
@@ -1135,13 +1167,17 @@
try { try {
const currentItem = await getEagleItemForPath(filePath); const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options); const source = await renderAnimatedSourceFrames(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension); const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
fs.writeFileSync(tempPath, rendered.buffer); // 用户没挑就直接结束:不替换缩略图,已生成的预览对象在上面统一释放。
if (!picked) { setStatus(''); return; }
const extension = options.format === 'apng' ? '.png' : '.webp';
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + extension);
fs.writeFileSync(tempPath, picked.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath); await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', { setStatus(t('viewer.animatedThumbUpdated', {
frames: rendered.frames, frames: picked.frames,
size: Math.ceil(rendered.buffer.length / 1024), size: Math.ceil(picked.buffer.length / 1024),
}), 5000); }), 5000);
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id); log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
} catch (error) { } catch (error) {
@@ -1173,7 +1209,13 @@
const options = await confirmAnimatedThumbnail(animation); const options = await confirmAnimatedThumbnail(animation);
if (!options) return; if (!options) return;
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
rendered = await renderAnimatedThumbnail(animation, options); const source = await renderAnimatedSourceFrames(animation, options);
const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
if (!picked) { setStatus(''); return; }
rendered = {
buffer: picked.buffer,
extension: options.format === 'apng' ? '.png' : '.webp',
};
} else { } else {
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
rendered = await renderCurrentFrameThumbnail(); rendered = await renderCurrentFrameThumbnail();
+74 -11
View File
@@ -4,6 +4,8 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const vm = require('node:vm'); const vm = require('node:vm');
const { encode } = require('../js/apng-encoder'); const { encode } = require('../js/apng-encoder');
const { encode: encodeWebp } = require('../js/animated-webp-encoder');
const profilesModule = require('../js/animated-profiles');
function chunks(buffer) { function chunks(buffer) {
const result = []; const result = [];
@@ -39,6 +41,39 @@ assert.equal(parsed[2].data.readUInt32BE(0), 0);
assert.equal(parsed[4].data.readUInt32BE(0), 1); assert.equal(parsed[4].data.readUInt32BE(0), 1);
assert.equal(parsed[5].data.readUInt32BE(0), 2); 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, plan[index][0], 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),
);
for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) { for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8'); const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8');
const css = fs.readFileSync(root + '/viewer/spine.css', 'utf8'); const css = fs.readFileSync(root + '/viewer/spine.css', 'utf8');
@@ -47,24 +82,39 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/); assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/);
assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/); assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/);
assert.match(script, /async function generateGroupThumbnails\(animated = false\)/); assert.match(script, /async function generateGroupThumbnails\(animated = false\)/);
assert.match(script, /if \(animated[\s\S]*renderAnimatedThumbnail\(animation, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/); 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 = 400;/, 'animated output must remain fixed at 400x400'); assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
assert.match(script, /async function encodeAnimatedThumbnailToLimit/); assert.match(script, /async function renderAnimatedSourceFrames\(animation, options\)/);
assert.match(script, /const targetBytes = maxKilobytes \* 1024/); assert.match(script, /async function pickAnimatedThumbnail\(sourceFrames, size, range, options\)/);
assert.match(script, /if \(buffer\.length <= targetBytes\) return/); assert.match(script, /const targetBytes = options\.maxKilobytes \* 1024/);
assert.match(script, /count: 2, bits: 3, block: 12/); // 档位表来自独立配置文件,三种优先级都走画廊手动挑,不会自动应用。
assert.match(script, /count: 2, bits: 2, block: 1, size: 32/); assert.match(script, /require\('\.\.\/js\/animated-profiles\.js'\)/);
assert.match(script, /const outputSize = profile\.size \|\| size/); assert.match(script, /const profiles = animatedProfiles\(options\.priority\)/);
assert.match(script, /return smallest;/); 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.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/);
assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/); assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/);
assert.match(script, /require\('\.\.\/js\/spine-visible-bounds\.js'\)/); assert.match(script, /require\('\.\.\/js\/spine-visible-bounds\.js'\)/);
assert.match(script, /async function measureVisibleBounds/); assert.match(script, /async function measureVisibleBounds/);
assert.match(script, /const bounds = await measureVisibleBounds\(skeleton, geometryBounds, 1/); assert.match(script, /const bounds = await measureVisibleBounds\(skeleton, geometryBounds, 1/);
assert.match(script, /measureVisibleBounds\(previewSkeleton, bounds, sampleTimes\.length/); assert.match(script, /measureVisibleBounds\(previewSkeleton, bounds, sampleTimes\.length/);
assert.match(script, /async function encodeAnimatedWebpToLimit/); // 帧编码必须走异步 toBlob,否则同步 toDataURL 会把界面卡住。
assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/); assert.match(script, /const pool = Array\.from\(\{ length: 4 \}/);
assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/); 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, /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, /const totalFrames = Math\.max\(1, frameOf\(animationLength\)\)/);
assert.match(script, /start: startFrame \/ FPS/); assert.match(script, /start: startFrame \/ FPS/);
@@ -82,6 +132,19 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.doesNotMatch(html, /id="animatedThumbQuality"/); assert.doesNotMatch(html, /id="animatedThumbQuality"/);
assert.doesNotMatch(html, /viewer\.secondsUnit/); assert.doesNotMatch(html, /viewer\.secondsUnit/);
assert.match(html, /id="animatedThumbDialog"/); 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(html, /id="animatedThumbDialogTitle"[^>]*>生成动态缩略图<\/div>/);
assert.match(css, /\.animated-thumb-card \.thumb-dialog-title \{ text-align: center; \}/); 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(html, /id="animatedThumbFormat"><option value="webp">AWebP \(\.webp\)<\/option><option value="apng">APNG \(\.png\)<\/option>/);
+34
View File
@@ -7,6 +7,7 @@
--accent: #4fc1ff; --accent: #4fc1ff;
--border: #3c3c3c; --border: #3c3c3c;
--error: #f14c4c; --error: #f14c4c;
--pick-ok: #3fb950;
/* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(), /* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(),
这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */ 这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */
--hover-fill: rgba(255, 255, 255, .07); --hover-fill: rgba(255, 255, 255, .07);
@@ -28,6 +29,7 @@
--accent: #0078d4; --accent: #0078d4;
--border: #ddd; --border: #ddd;
--error: #d13438; --error: #d13438;
--pick-ok: #1a7f37;
--hover-fill: rgba(0, 0, 0, .06); --hover-fill: rgba(0, 0, 0, .06);
--scroll-thumb: rgba(0, 0, 0, .30); --scroll-thumb: rgba(0, 0, 0, .30);
--scroll-thumb-hover: rgba(0, 0, 0, .46); --scroll-thumb-hover: rgba(0, 0, 0, .46);
@@ -148,6 +150,38 @@
} }
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); } .animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; } .animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
.animated-pick-card { width: min(920px, 94vw); gap: 10px; padding: 16px; }
.animated-pick-card .thumb-dialog-title { text-align: center; }
.animated-pick-card .thumb-dialog-note { font-size: 12px; text-align: center; }
.animated-pick-notice { color: var(--text); text-align: center; min-height: 18px; }
/* 档位数量由配置决定,行数不固定:格子超高时内部滚动,按钮区始终留在面板底部。 */
.animated-pick-grid {
display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px;
max-height: 58vh; overflow-y: auto; padding-right: 2px;
}
.animated-pick-item {
position: relative;
display: flex; flex-direction: column; gap: 6px; width: 100%;
padding: 6px; border: 1px solid var(--border); border-radius: 8px;
background: var(--panel-2); color: var(--text-dim); cursor: pointer;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px; line-height: 1.4;
}
.animated-pick-item:hover { border-color: var(--accent); color: var(--text); }
.animated-pick-item img {
display: block; width: 100%; aspect-ratio: 1 / 1;
border-radius: 5px; background: var(--bg);
}
.animated-pick-item.selected {
border-color: var(--pick-ok); box-shadow: 0 0 0 1px var(--pick-ok); color: var(--text);
}
.animated-pick-item.selected::after {
content: '✓'; position: absolute; top: 10px; right: 10px;
width: 20px; height: 20px; border-radius: 50%;
background: var(--pick-ok); color: #fff;
font-size: 13px; line-height: 20px; text-align: center; font-weight: 700;
}
.animated-pick-params { display: block; word-break: break-all; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/ /* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; } #toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
+186 -144
View File
@@ -105,6 +105,13 @@
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span> <label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span> <span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
</label> </label>
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
<select id="animatedThumbPriority">
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
<option value="framerate" data-i18n="viewer.priorityFramerate">帧率优先</option>
<option value="balanced" data-i18n="viewer.priorityBalanced">分辨率和帧率交替进行</option>
</select>
</label>
</div> </div>
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div> <div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
<div class="thumb-dialog-actions"> <div class="thumb-dialog-actions">
@@ -113,6 +120,18 @@
</div> </div>
</div> </div>
</div> </div>
<div id="animatedPickDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedPickTitle">
<div class="thumb-dialog-card animated-pick-card">
<div id="animatedPickTitle" class="thumb-dialog-title" data-i18n="viewer.animatedPickTitle">选择一个缩略图</div>
<div class="thumb-dialog-note" data-i18n="viewer.animatedPickHint">所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点击一个结果即替换缩略图,关闭则全部放弃。</div>
<div id="animatedPickGrid" class="animated-pick-grid"></div>
<div id="animatedPickNotice" class="animated-pick-notice"></div>
<div class="thumb-dialog-actions">
<button id="animatedPickCancel" data-i18n="viewer.cancel">取消</button>
<button id="animatedPickConfirm" class="primary" data-i18n="viewer.animatedPickConfirm" disabled>确认</button>
</div>
</div>
</div>
</div> </div>
<script> <script>
@@ -128,6 +147,8 @@
const previewSettingsModule = require('../js/spine-preview-settings.js'); const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js'); const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js'); const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
const animatedProfileConfig = require('../js/animated-profiles.js');
const animatedProfiles = animatedProfileConfig.animatedProfiles;
const visibleBounds = require('../js/spine-visible-bounds.js'); const visibleBounds = require('../js/spine-visible-bounds.js');
let spineBadgeImagePromise = null; let spineBadgeImagePromise = null;
@@ -796,6 +817,7 @@
const startInput = document.getElementById('animatedThumbStart'); const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd'); const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat'); const formatInput = document.getElementById('animatedThumbFormat');
const priorityInput = document.getElementById('animatedThumbPriority');
const maxSizeInput = document.getElementById('animatedThumbMaxSize'); const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError'); const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm'); const confirm = document.getElementById('animatedThumbDialogConfirm');
@@ -807,6 +829,7 @@
endInput.value = String(totalFrames); endInput.value = String(totalFrames);
endInput.max = String(totalFrames); endInput.max = String(totalFrames);
formatInput.value = 'webp'; formatInput.value = 'webp';
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
maxSizeInput.value = '30'; maxSizeInput.value = '30';
errorEl.textContent = ''; errorEl.textContent = '';
dialog.classList.remove('hidden'); dialog.classList.remove('hidden');
@@ -834,6 +857,7 @@
start: startFrame / FPS, start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength), end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp', format: formatInput.value === 'apng' ? 'apng' : 'webp',
priority: priorityInput.value,
maxKilobytes, maxKilobytes,
}); });
}; };
@@ -901,31 +925,8 @@
return measured; return measured;
} }
function quantizeFrame(data, bits) { function profileFrameCount(sourceFrames, range, fps) {
if (bits >= 8) return new Uint8Array(data); return Math.max(2, Math.min(sourceFrames.length, Math.ceil(range * fps)));
const levels = (1 << bits) - 1;
const lookup = new Uint8Array(256);
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
return output;
}
function compressFramePixels(data, size, bits, blockSize) {
if (blockSize <= 1) return quantizeFrame(data, bits);
const levels = (1 << bits) - 1;
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let top = 0; top < size; top += blockSize) {
for (let left = 0; left < size; left += blockSize) {
const source = (top * size + left) * 4;
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
for (let y = top; y < Math.min(size, top + blockSize); y++) {
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
}
}
}
return output;
} }
function resizeFramePixels(data, sourceSize, targetSize) { function resizeFramePixels(data, sourceSize, targetSize) {
@@ -942,117 +943,13 @@
return output; return output;
} }
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) { // 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
const targetBytes = maxKilobytes * 1024; // 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
const profiles = [ function scaledFramePixels(data, size, detailSize) {
{ ratio: 1, bits: 8, block: 1 }, return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
{ ratio: 0.75, bits: 7, block: 1 },
{ ratio: 0.5, bits: 7, block: 1 },
{ ratio: 0.5, bits: 6, block: 2 },
{ ratio: 0.33, bits: 6, block: 2 },
{ ratio: 0.25, bits: 5, block: 3 },
{ ratio: 0.16, bits: 5, block: 4 },
{ count: 3, bits: 4, block: 8 },
{ count: 2, bits: 3, block: 12 },
{ count: 3, bits: 5, block: 1, size: 320 },
{ count: 3, bits: 5, block: 1, size: 256 },
{ count: 2, bits: 4, block: 1, size: 192 },
{ count: 2, bits: 4, block: 1, size: 128 },
{ count: 2, bits: 3, block: 1, size: 96 },
{ count: 2, bits: 3, block: 1, size: 64 },
{ count: 2, bits: 2, block: 1, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const delayMs = Math.max(1, Math.round(range * 1000 / count));
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
return {
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
delayNumerator: delayMs,
delayDenominator: 1000,
};
};
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
if (count > 3) {
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
}
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((resolve) => setTimeout(resolve, 0));
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
} }
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) { async function renderAnimatedSourceFrames(animation, options) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400; const size = 400;
const range = options.end - options.start; const range = options.end - options.start;
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。 // 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
@@ -1110,10 +1007,145 @@
} finally { } finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose(); if (offRenderer && offRenderer.dispose) offRenderer.dispose();
} }
const encoded = options.format === 'apng' return { sourceFrames, size, range };
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes) }
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size }; // 缩略图参数标签,不走本地化:细节分辨率*细节分辨率@帧率/质量/文件大小。
function candidateParams(candidate) {
const quality = candidate.quality === null ? 'lossless' : 'q' + candidate.quality;
return candidate.detail + '*' + candidate.detail + '@' + candidate.fps + 'fps/' + quality
+ '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb';
}
// 结果画廊:逐档生成,每档一出来就填进两行四列的格子,点哪个用哪个,关掉就全部丢弃。
// 帧编码走 canvas.toBlob(异步),生成期间界面不会像 toDataURL 那样被卡住。
async function pickAnimatedThumbnail(sourceFrames, size, range, options) {
const dialog = document.getElementById('animatedPickDialog');
const grid = document.getElementById('animatedPickGrid');
const notice = document.getElementById('animatedPickNotice');
const cancelButton = document.getElementById('animatedPickCancel');
const confirmButton = document.getElementById('animatedPickConfirm');
const targetBytes = options.maxKilobytes * 1024;
const profiles = animatedProfiles(options.priority);
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
const pool = Array.from({ length: 4 }, () => {
const off = document.createElement('canvas');
off.width = size; off.height = size;
const context = off.getContext('2d');
return { canvas: off, context, image: context.createImageData(size, size) };
});
const previewUrls = [];
grid.textContent = '';
notice.textContent = '';
confirmButton.disabled = true;
let selected = null;
let defaultPick = null;
let userPicked = false;
let hasFit = false;
let smallest = Infinity;
let settled = false;
const encodeFrame = (slot, pixels, quality) => new Promise((resolve, reject) => {
const unit = pool[slot];
unit.image.data.set(pixels);
unit.context.putImageData(unit.image, 0, 0);
unit.canvas.toBlob((blob) => {
if (!blob || blob.type !== 'image/webp') { reject(new Error(t('viewer.animatedWebpUnavailable'))); return; }
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
}, 'image/webp', quality);
});
return new Promise((resolve, reject) => {
const cards = new Map();
const close = (value) => {
if (settled) return;
settled = true;
dialog.classList.add('hidden');
cancelButton.removeEventListener('click', cancel);
confirmButton.removeEventListener('click', confirm);
document.removeEventListener('keydown', keydown);
for (const url of previewUrls) URL.revokeObjectURL(url);
previewUrls.length = 0;
resolve(value);
};
const cancel = () => close(null);
const confirm = () => { if (selected) close(selected); };
const keydown = (event) => { if (event.key === 'Escape') cancel(); };
// 点卡片只是选中(右上角打勾),真正替换缩略图要再按确认。
const select = (candidate, manual) => {
if (manual) userPicked = true;
selected = candidate;
for (const [other, node] of cards) node.classList.toggle('selected', other === candidate);
confirmButton.disabled = false;
};
const addCard = (candidate) => {
const url = URL.createObjectURL(new Blob([candidate.buffer], {
type: options.format === 'apng' ? 'image/png' : 'image/webp',
}));
previewUrls.push(url);
const item = document.createElement('button');
item.type = 'button';
item.className = 'animated-pick-item';
const preview = document.createElement('img');
preview.src = url;
preview.alt = '';
const params = document.createElement('span');
params.className = 'animated-pick-params';
params.textContent = candidateParams(candidate);
item.append(preview, params);
item.addEventListener('click', () => select(candidate, true));
cards.set(candidate, item);
grid.appendChild(item);
};
cancelButton.addEventListener('click', cancel);
confirmButton.addEventListener('click', confirm);
document.addEventListener('keydown', keydown);
dialog.classList.remove('hidden');
(async () => {
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
if (settled) return;
const profile = profiles[profileIndex];
const count = profileFrameCount(sourceFrames, range, profile.fps);
const duration = Math.max(1, Math.round(range * 1000 / count));
const frames = [];
for (let start = 0; start < count; start += pool.length) {
if (settled) return;
const batch = [];
for (let index = start; index < Math.min(count, start + pool.length); index++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
batch.push(scaledFramePixels(sourceFrames[sourceIndex], size, profile.size));
}
if (options.format === 'apng') {
for (const pixels of batch) frames.push({ rgba: pixels, delayNumerator: duration, delayDenominator: 1000 });
} else {
const buffers = await Promise.all(batch.map((pixels, offset) =>
encodeFrame((start + offset) % pool.length, pixels, profile.quality)));
for (const webp of buffers) frames.push({ webp, duration });
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((next) => setTimeout(next, 0));
}
if (settled) return;
const buffer = options.format === 'apng'
? apngEncoder.encode({ width: size, height: size, frames, plays: 0, compressionLevel: 9 })
: animatedWebpEncoder.encode({ width: size, height: size, frames, plays: 0 });
const candidate = {
detail: profile.size,
fps: profile.fps,
quality: options.format === 'apng' ? null : profile.quality,
frames: count,
buffer,
};
smallest = Math.min(smallest, buffer.length);
if (buffer.length <= targetBytes) hasFit = true;
addCard(candidate);
// 用户没自己挑过时,默认选中当前体积最小的档位,省得在十个结果里纠结。
if (!userPicked && (!defaultPick || buffer.length < defaultPick.buffer.length)) {
defaultPick = candidate;
select(candidate, false);
}
}
if (!hasFit) notice.textContent = t('viewer.animatedCannotFit', { target: options.maxKilobytes, actual: Math.ceil(smallest / 1024) });
})().catch((error) => { close(null); reject(error); });
});
} }
async function generateAnimatedThumbnail() { async function generateAnimatedThumbnail() {
@@ -1135,13 +1167,17 @@
try { try {
const currentItem = await getEagleItemForPath(filePath); const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options); const source = await renderAnimatedSourceFrames(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension); const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
fs.writeFileSync(tempPath, rendered.buffer); // 用户没挑就直接结束:不替换缩略图,已生成的预览对象在上面统一释放。
if (!picked) { setStatus(''); return; }
const extension = options.format === 'apng' ? '.png' : '.webp';
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + extension);
fs.writeFileSync(tempPath, picked.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath); await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', { setStatus(t('viewer.animatedThumbUpdated', {
frames: rendered.frames, frames: picked.frames,
size: Math.ceil(rendered.buffer.length / 1024), size: Math.ceil(picked.buffer.length / 1024),
}), 5000); }), 5000);
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id); log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
} catch (error) { } catch (error) {
@@ -1173,7 +1209,13 @@
const options = await confirmAnimatedThumbnail(animation); const options = await confirmAnimatedThumbnail(animation);
if (!options) return; if (!options) return;
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
rendered = await renderAnimatedThumbnail(animation, options); const source = await renderAnimatedSourceFrames(animation, options);
const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
if (!picked) { setStatus(''); return; }
rendered = {
buffer: picked.buffer,
extension: options.format === 'apng' ? '.png' : '.webp',
};
} else { } else {
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
rendered = await renderCurrentFrameThumbnail(); rendered = await renderCurrentFrameThumbnail();