From e94dfb9e35b9ae7d11eb3592d9309bab4a5aa5bb Mon Sep 17 00:00:00 2001 From: YeomaiPC Date: Sun, 20 Sep 2026 00:39:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=A9=E7=95=A5=E5=9B=BE=E4=BA=8C=E5=88=86?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=20+=20=E9=82=BB=E5=9F=9F=E9=87=87=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B. 二分定位 + 邻域采样(我推荐) 把配置的语义从"这就是要生成的 10 档"改成"允许搜索的参数梯度"(比如分辨率梯度、帧率梯度、质量梯度三张表)。运行时: 1. 用每个组合只编 2 帧的方式粗估体积(很便宜); 2. 靠单调性做二分,几轮就找到"体积刚好不超过目标"的参数点; 3. 在这个点周围取 10 个组合(略高、略低各几个),完整生成。 成本大概是十几次估算编码 + 10 档完整编码,和现在基本持平甚至更省(估算只编 2 帧)。效果是候选真正围绕你输入的目标分布——比如你填 30 KB,出来的就是 22、26、29、31、35 KB 这种,而不是 5 KB 和 300 KB 各一个。 代价是你现在这套配置格式要推翻重做——从"精确档位表"变成"参数范围表" --- _locales/de_DE.json | 7 + _locales/en.json | 7 + _locales/es_ES.json | 7 + _locales/ja_JP.json | 7 + _locales/ko_KR.json | 7 + _locales/ru_RU.json | 7 + _locales/zh_CN.json | 7 + _locales/zh_TW.json | 7 + js/animated-profiles.js | 39 +++ js/animated-webp-encoder.js | 1 + js/spine-group-thumbnails.js | 2 + .../UNIVERSAL_SpineViewer/_locales/de_DE.json | 7 + .../UNIVERSAL_SpineViewer/_locales/en.json | 7 + .../UNIVERSAL_SpineViewer/_locales/es_ES.json | 7 + .../UNIVERSAL_SpineViewer/_locales/ja_JP.json | 7 + .../UNIVERSAL_SpineViewer/_locales/ko_KR.json | 7 + .../UNIVERSAL_SpineViewer/_locales/ru_RU.json | 7 + .../UNIVERSAL_SpineViewer/_locales/zh_CN.json | 7 + .../UNIVERSAL_SpineViewer/_locales/zh_TW.json | 7 + .../js/animated-profiles.js | 39 +++ .../js/animated-webp-encoder.js | 1 + .../js/spine-group-thumbnails.js | 2 + .../UNIVERSAL_SpineViewer/viewer/spine.css | 34 ++ .../UNIVERSAL_SpineViewer/viewer/spine.html | 330 ++++++++++-------- tests/apng-thumbnail.test.js | 85 ++++- viewer/spine.css | 34 ++ viewer/spine.html | 330 ++++++++++-------- 27 files changed, 710 insertions(+), 299 deletions(-) create mode 100644 js/animated-profiles.js create mode 100644 release/UNIVERSAL_SpineViewer/js/animated-profiles.js diff --git a/_locales/de_DE.json b/_locales/de_DE.json index 8a609f7..d65be9c 100644 --- a/_locales/de_DE.json +++ b/_locales/de_DE.json @@ -36,6 +36,13 @@ "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", "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.", + "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", "qualityMedium": "Mittel", "qualityHigh": "Hoch", diff --git a/_locales/en.json b/_locales/en.json index 614cc5c..4d88e6b 100644 --- a/_locales/en.json +++ b/_locales/en.json @@ -36,6 +36,13 @@ "animatedCompressing": "Compressing animated thumbnail {current}/{total}…", "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.", + "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", "qualityMedium": "Medium", "qualityHigh": "High", diff --git a/_locales/es_ES.json b/_locales/es_ES.json index cb0897b..4b40248 100644 --- a/_locales/es_ES.json +++ b/_locales/es_ES.json @@ -36,6 +36,13 @@ "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", "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.", + "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", "qualityMedium": "Media", "qualityHigh": "Alta", diff --git a/_locales/ja_JP.json b/_locales/ja_JP.json index e9a31a3..582b6e9 100644 --- a/_locales/ja_JP.json +++ b/_locales/ja_JP.json @@ -36,6 +36,13 @@ "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", + "animatedPickTitle": "サムネイルを選択", + "animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。", + "animatedPickConfirm": "確認", + "animatedPriority": "優先度", + "priorityResolution": "解像度優先", + "priorityFramerate": "フレームレート優先", + "priorityBalanced": "解像度とフレームレートを交互に", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/_locales/ko_KR.json b/_locales/ko_KR.json index de0af00..1bd188d 100644 --- a/_locales/ko_KR.json +++ b/_locales/ko_KR.json @@ -36,6 +36,13 @@ "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", + "animatedPickTitle": "썸네일 선택", + "animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.", + "animatedPickConfirm": "확인", + "animatedPriority": "우선 순위", + "priorityResolution": "해상도 우선", + "priorityFramerate": "프레임률 우선", + "priorityBalanced": "해상도와 프레임률 교차", "qualityLow": "낮음", "qualityMedium": "보통", "qualityHigh": "높음", diff --git a/_locales/ru_RU.json b/_locales/ru_RU.json index 8be0cb4..45c68dd 100644 --- a/_locales/ru_RU.json +++ b/_locales/ru_RU.json @@ -36,6 +36,13 @@ "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", + "animatedPickTitle": "Выберите миниатюру", + "animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.", + "animatedPickConfirm": "Подтвердить", + "animatedPriority": "Приоритет", + "priorityResolution": "Сначала разрешение", + "priorityFramerate": "Сначала частота кадров", + "priorityBalanced": "Чередовать разрешение и частоту", "qualityLow": "Низкое", "qualityMedium": "Среднее", "qualityHigh": "Высокое", diff --git a/_locales/zh_CN.json b/_locales/zh_CN.json index eb978c1..842f2b9 100644 --- a/_locales/zh_CN.json +++ b/_locales/zh_CN.json @@ -36,6 +36,13 @@ "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", + "animatedPickTitle": "选择一个缩略图", + "animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。", + "animatedPickConfirm": "确认", + "animatedPriority": "优先级别", + "priorityResolution": "分辨率优先", + "priorityFramerate": "帧率优先", + "priorityBalanced": "分辨率和帧率交替进行", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/_locales/zh_TW.json b/_locales/zh_TW.json index 6a55ec6..a474add 100644 --- a/_locales/zh_TW.json +++ b/_locales/zh_TW.json @@ -36,6 +36,13 @@ "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", + "animatedPickTitle": "選擇一個縮圖", + "animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。", + "animatedPickConfirm": "確認", + "animatedPriority": "優先級別", + "priorityResolution": "解析度優先", + "priorityFramerate": "影格率優先", + "priorityBalanced": "解析度與影格率交替進行", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/js/animated-profiles.js b/js/animated-profiles.js new file mode 100644 index 0000000..2ed914f --- /dev/null +++ b/js/animated-profiles.js @@ -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 }; diff --git a/js/animated-webp-encoder.js b/js/animated-webp-encoder.js index 8a7da01..0750347 100644 --- a/js/animated-webp-encoder.js +++ b/js/animated-webp-encoder.js @@ -16,6 +16,7 @@ function chunk(type, payload) { } function frameData(webp) { + if (!webp) throw new Error('WebP 帧数据缺失'); const input = Buffer.from(webp); if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') { throw new Error('Invalid WebP frame'); diff --git a/js/spine-group-thumbnails.js b/js/spine-group-thumbnails.js index 822e4b2..31fd253 100644 --- a/js/spine-group-thumbnails.js +++ b/js/spine-group-thumbnails.js @@ -22,6 +22,8 @@ function orderCurrentTargetLast(targets, currentSourcePath) { */ async function applyThumbnailTargets(options) { const targets = options.targets || []; + // 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。 + if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空'); const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs; const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs; const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts; diff --git a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json index 8a609f7..d65be9c 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json +++ b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json @@ -36,6 +36,13 @@ "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", "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.", + "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", "qualityMedium": "Mittel", "qualityHigh": "Hoch", diff --git a/release/UNIVERSAL_SpineViewer/_locales/en.json b/release/UNIVERSAL_SpineViewer/_locales/en.json index 614cc5c..4d88e6b 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/en.json +++ b/release/UNIVERSAL_SpineViewer/_locales/en.json @@ -36,6 +36,13 @@ "animatedCompressing": "Compressing animated thumbnail {current}/{total}…", "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.", + "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", "qualityMedium": "Medium", "qualityHigh": "High", diff --git a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json index cb0897b..4b40248 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json +++ b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json @@ -36,6 +36,13 @@ "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", "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.", + "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", "qualityMedium": "Media", "qualityHigh": "Alta", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json index e9a31a3..582b6e9 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json @@ -36,6 +36,13 @@ "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", + "animatedPickTitle": "サムネイルを選択", + "animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。", + "animatedPickConfirm": "確認", + "animatedPriority": "優先度", + "priorityResolution": "解像度優先", + "priorityFramerate": "フレームレート優先", + "priorityBalanced": "解像度とフレームレートを交互に", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json index de0af00..1bd188d 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json @@ -36,6 +36,13 @@ "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", + "animatedPickTitle": "썸네일 선택", + "animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.", + "animatedPickConfirm": "확인", + "animatedPriority": "우선 순위", + "priorityResolution": "해상도 우선", + "priorityFramerate": "프레임률 우선", + "priorityBalanced": "해상도와 프레임률 교차", "qualityLow": "낮음", "qualityMedium": "보통", "qualityHigh": "높음", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json index 8be0cb4..45c68dd 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json @@ -36,6 +36,13 @@ "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", + "animatedPickTitle": "Выберите миниатюру", + "animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.", + "animatedPickConfirm": "Подтвердить", + "animatedPriority": "Приоритет", + "priorityResolution": "Сначала разрешение", + "priorityFramerate": "Сначала частота кадров", + "priorityBalanced": "Чередовать разрешение и частоту", "qualityLow": "Низкое", "qualityMedium": "Среднее", "qualityHigh": "Высокое", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json index eb978c1..842f2b9 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json @@ -36,6 +36,13 @@ "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", + "animatedPickTitle": "选择一个缩略图", + "animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。", + "animatedPickConfirm": "确认", + "animatedPriority": "优先级别", + "priorityResolution": "分辨率优先", + "priorityFramerate": "帧率优先", + "priorityBalanced": "分辨率和帧率交替进行", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json index 6a55ec6..a474add 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json @@ -36,6 +36,13 @@ "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", + "animatedPickTitle": "選擇一個縮圖", + "animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。", + "animatedPickConfirm": "確認", + "animatedPriority": "優先級別", + "priorityResolution": "解析度優先", + "priorityFramerate": "影格率優先", + "priorityBalanced": "解析度與影格率交替進行", "qualityLow": "低", "qualityMedium": "中", "qualityHigh": "高", diff --git a/release/UNIVERSAL_SpineViewer/js/animated-profiles.js b/release/UNIVERSAL_SpineViewer/js/animated-profiles.js new file mode 100644 index 0000000..2ed914f --- /dev/null +++ b/release/UNIVERSAL_SpineViewer/js/animated-profiles.js @@ -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 }; diff --git a/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js b/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js index 8a7da01..0750347 100644 --- a/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js +++ b/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js @@ -16,6 +16,7 @@ function chunk(type, payload) { } function frameData(webp) { + if (!webp) throw new Error('WebP 帧数据缺失'); const input = Buffer.from(webp); if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') { throw new Error('Invalid WebP frame'); diff --git a/release/UNIVERSAL_SpineViewer/js/spine-group-thumbnails.js b/release/UNIVERSAL_SpineViewer/js/spine-group-thumbnails.js index 822e4b2..31fd253 100644 --- a/release/UNIVERSAL_SpineViewer/js/spine-group-thumbnails.js +++ b/release/UNIVERSAL_SpineViewer/js/spine-group-thumbnails.js @@ -22,6 +22,8 @@ function orderCurrentTargetLast(targets, currentSourcePath) { */ async function applyThumbnailTargets(options) { const targets = options.targets || []; + // 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。 + if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空'); const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs; const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs; const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts; diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.css b/release/UNIVERSAL_SpineViewer/viewer/spine.css index 8afc975..9ff16f4 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.css +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.css @@ -7,6 +7,7 @@ --accent: #4fc1ff; --border: #3c3c3c; --error: #f14c4c; + --pick-ok: #3fb950; /* 悬浮层填充与滚动条配色。Eagle 内置 Electron 22 / Chromium 108 不支持 color-mix(), 这些必须写成 rgba(),否则整条颜色声明会被丢弃。 */ --hover-fill: rgba(255, 255, 255, .07); @@ -28,6 +29,7 @@ --accent: #0078d4; --border: #ddd; --error: #d13438; + --pick-ok: #1a7f37; --hover-fill: rgba(0, 0, 0, .06); --scroll-thumb: rgba(0, 0, 0, .30); --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-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; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; } diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index 6efd566..f749a68 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -105,6 +105,13 @@ +
@@ -113,6 +120,18 @@
+