diff --git a/_locales/de_DE.json b/_locales/de_DE.json index 6668042..32393f6 100644 --- a/_locales/de_DE.json +++ b/_locales/de_DE.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt", "thumbnail": "Als aktuelles Vorschaubild setzen", "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", + "animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen", + "animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.", + "animatedRange": "Animationsbereich", + "animatedStart": "Start", + "animatedEnd": "Ende", + "framesUnit": "Frames", + "animatedQuality": "Vorschaubildqualität", + "animatedMaxSize": "Dateigrößenlimit", + "animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein", + "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", + "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.", + "qualityLow": "Niedrig", + "qualityMedium": "Mittel", + "qualityHigh": "Hoch", + "animatedConfirm": "Erzeugen und ersetzen", + "animatedInvalidRange": "Geben Sie einen gültigen ganzzahligen Framebereich zwischen 0 und {duration} ein", + "animatedGenerating": "Animiertes Vorschaubild wird erzeugt {current}/{total}…", + "animatedTooLarge": "Das animierte Vorschaubild ist größer als 50 MB. Verkürzen Sie den Bereich oder wählen Sie eine niedrigere Qualität.", + "animatedThumbUpdated": "Animiertes Vorschaubild aktualisiert · {frames} Frames · {size} KB", "groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen", "groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen", "groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen", diff --git a/_locales/en.json b/_locales/en.json index 2b57ea1..6eb74be 100644 --- a/_locales/en.json +++ b/_locales/en.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes", "thumbnail": "Set current item thumbnail", "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", + "animatedThumbnailTitle": "Generate animated thumbnail", + "animatedThumbnailNote": "This replaces the current item thumbnail and the plugin cannot restore the previous image. Frame count, color precision and internal detail are reduced automatically to meet the size limit.", + "animatedRange": "Animation range", + "animatedStart": "Start", + "animatedEnd": "End", + "framesUnit": "frames", + "animatedQuality": "Thumbnail quality", + "animatedMaxSize": "File size limit", + "animatedInvalidSize": "Enter a whole number between 50 and 10240 KB", + "animatedCompressing": "Compressing animated thumbnail {current}/{total}…", + "animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.", + "qualityLow": "Low", + "qualityMedium": "Medium", + "qualityHigh": "High", + "animatedConfirm": "Generate and replace", + "animatedInvalidRange": "Enter a valid whole-frame range between 0 and {duration}", + "animatedGenerating": "Generating animated thumbnail {current}/{total}…", + "animatedTooLarge": "The animated thumbnail exceeds 50 MB. Shorten the range or select a lower quality.", + "animatedThumbUpdated": "Animated thumbnail updated · {frames} frames · {size} KB", "groupThumbnail": "Generate thumbnails for related assets", "groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch", "groupDialogTitle": "Generate thumbnails for related assets", diff --git a/_locales/es_ES.json b/_locales/es_ES.json index ef1444f..bda3d04 100644 --- a/_locales/es_ES.json +++ b/_locales/es_ES.json @@ -20,6 +20,25 @@ "backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión", "thumbnail": "Usar como miniatura actual", "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", + "animatedThumbnailTitle": "Generar miniatura animada", + "animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.", + "animatedRange": "Rango de animación", + "animatedStart": "Inicio", + "animatedEnd": "Fin", + "framesUnit": "fotogramas", + "animatedQuality": "Calidad de miniatura", + "animatedMaxSize": "Límite de tamaño", + "animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB", + "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", + "animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.", + "qualityLow": "Baja", + "qualityMedium": "Media", + "qualityHigh": "Alta", + "animatedConfirm": "Generar y reemplazar", + "animatedInvalidRange": "Introduce un rango de fotogramas enteros entre 0 y {duration}", + "animatedGenerating": "Generando miniatura animada {current}/{total}…", + "animatedTooLarge": "La miniatura animada supera los 50 MB. Acorta el rango o elige una calidad inferior.", + "animatedThumbUpdated": "Miniatura animada actualizada · {frames} fotogramas · {size} KB", "groupThumbnail": "Generar miniaturas de recursos relacionados", "groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes", "groupDialogTitle": "Generar miniaturas de recursos relacionados", diff --git a/_locales/ja_JP.json b/_locales/ja_JP.json index 1990da3..ea53d01 100644 --- a/_locales/ja_JP.json +++ b/_locales/ja_JP.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", "thumbnail": "現在の項目のサムネイルに設定", "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", + "animatedThumbnailTitle": "アニメーションサムネイルを生成", + "animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。", + "animatedRange": "アニメーション範囲", + "animatedStart": "開始", + "animatedEnd": "終了", + "framesUnit": "フレーム", + "animatedQuality": "サムネイル品質", + "animatedMaxSize": "ファイルサイズ上限", + "animatedInvalidSize": "50 から 10240 KB までの整数を入力してください", + "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", + "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "生成して置き換え", + "animatedInvalidRange": "0 から {duration} フレームまでの有効な整数フレーム範囲を入力してください", + "animatedGenerating": "アニメーションサムネイルを生成中 {current}/{total}…", + "animatedTooLarge": "アニメーションサムネイルが 50 MB を超えます。範囲を短くするか、低い品質を選択してください。", + "animatedThumbUpdated": "アニメーションサムネイルを更新 · {frames} フレーム · {size} KB", "groupThumbnail": "関連アセットのサムネイルを生成", "groupThumbnailTitle": "関連する Spine アセットを確認して一括生成", "groupDialogTitle": "関連アセットのサムネイルを生成", diff --git a/_locales/ko_KR.json b/_locales/ko_KR.json index 1b5ef3c..f487723 100644 --- a/_locales/ko_KR.json +++ b/_locales/ko_KR.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", "thumbnail": "현재 항목 썸네일로 설정", "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", + "animatedThumbnailTitle": "애니메이션 썸네일 생성", + "animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.", + "animatedRange": "애니메이션 범위", + "animatedStart": "시작", + "animatedEnd": "종료", + "framesUnit": "프레임", + "animatedQuality": "썸네일 품질", + "animatedMaxSize": "파일 크기 제한", + "animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요", + "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", + "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", + "qualityLow": "낮음", + "qualityMedium": "보통", + "qualityHigh": "높음", + "animatedConfirm": "생성 및 교체", + "animatedInvalidRange": "0에서 {duration}프레임 사이의 올바른 정수 프레임 범위를 입력하세요", + "animatedGenerating": "애니메이션 썸네일 생성 중 {current}/{total}…", + "animatedTooLarge": "애니메이션 썸네일이 50MB를 초과합니다. 범위를 줄이거나 더 낮은 품질을 선택하세요.", + "animatedThumbUpdated": "애니메이션 썸네일 업데이트 · {frames}프레임 · {size}KB", "groupThumbnail": "관련 자산 썸네일 생성", "groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성", "groupDialogTitle": "관련 자산 썸네일 생성", diff --git a/_locales/ru_RU.json b/_locales/ru_RU.json index 7060822..1e7dcad 100644 --- a/_locales/ru_RU.json +++ b/_locales/ru_RU.json @@ -20,6 +20,25 @@ "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", "thumbnail": "Сделать миниатюрой элемента", "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", + "animatedThumbnailTitle": "Создать анимированную миниатюру", + "animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.", + "animatedRange": "Диапазон анимации", + "animatedStart": "Начало", + "animatedEnd": "Конец", + "framesUnit": "кадров", + "animatedQuality": "Качество миниатюры", + "animatedMaxSize": "Лимит размера файла", + "animatedInvalidSize": "Введите целое число от 50 до 10240 КБ", + "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", + "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", + "qualityLow": "Низкое", + "qualityMedium": "Среднее", + "qualityHigh": "Высокое", + "animatedConfirm": "Создать и заменить", + "animatedInvalidRange": "Введите целый диапазон кадров от 0 до {duration}", + "animatedGenerating": "Создание анимированной миниатюры {current}/{total}…", + "animatedTooLarge": "Миниатюра превышает 50 МБ. Сократите диапазон или выберите более низкое качество.", + "animatedThumbUpdated": "Миниатюра обновлена · {frames} кадров · {size} КБ", "groupThumbnail": "Создать миниатюры для связанных ресурсов", "groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры", "groupDialogTitle": "Создать миниатюры для связанных ресурсов", diff --git a/_locales/zh_CN.json b/_locales/zh_CN.json index fac532b..aad68f1 100644 --- a/_locales/zh_CN.json +++ b/_locales/zh_CN.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", "thumbnail": "设为当前素材缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", + "animatedThumbnailTitle": "生成动画缩略图", + "animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节以满足体积上限。", + "animatedRange": "动画范围", + "animatedStart": "开始", + "animatedEnd": "结束", + "framesUnit": "帧", + "animatedQuality": "缩略图质量", + "animatedMaxSize": "文件大小上限", + "animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数", + "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", + "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "生成并替换", + "animatedInvalidRange": "请输入 0 到 {duration} 帧之间的有效整数帧范围", + "animatedGenerating": "正在生成动画缩略图 {current}/{total}…", + "animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量", + "animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB", "groupThumbnail": "为关联资源生成缩略图", "groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图", "groupDialogTitle": "为关联资源生成缩略图", diff --git a/_locales/zh_TW.json b/_locales/zh_TW.json index 20392a9..c31590c 100644 --- a/_locales/zh_TW.json +++ b/_locales/zh_TW.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", "thumbnail": "設為目前素材縮圖", "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", + "animatedThumbnailTitle": "產生動畫縮圖", + "animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節以符合檔案上限。", + "animatedRange": "動畫範圍", + "animatedStart": "開始", + "animatedEnd": "結束", + "framesUnit": "影格", + "animatedQuality": "縮圖品質", + "animatedMaxSize": "檔案大小上限", + "animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數", + "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", + "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "產生並取代", + "animatedInvalidRange": "請輸入 0 到 {duration} 影格之間的有效整數影格範圍", + "animatedGenerating": "正在產生動畫縮圖 {current}/{total}…", + "animatedTooLarge": "動畫縮圖超過 50 MB,請縮短範圍或選擇較低品質", + "animatedThumbUpdated": "動畫縮圖已更新 · {frames} 影格 · {size} KB", "groupThumbnail": "為關聯資源產生縮圖", "groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖", "groupDialogTitle": "為關聯資源產生縮圖", diff --git a/js/apng-encoder.js b/js/apng-encoder.js new file mode 100644 index 0000000..81c704f --- /dev/null +++ b/js/apng-encoder.js @@ -0,0 +1,115 @@ +'use strict'; + +const zlib = require('zlib'); + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const crcTable = new Uint32Array(256); +for (let n = 0; n < 256; n++) { + let value = n; + for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); + crcTable[n] = value >>> 0; +} + +function crc32(buffer) { + let value = 0xffffffff; + for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const name = Buffer.from(type, 'ascii'); + const body = Buffer.from(data || []); + const out = Buffer.allocUnsafe(12 + body.length); + out.writeUInt32BE(body.length, 0); + name.copy(out, 4); + body.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length); + return out; +} + +function paeth(left, above, upperLeft) { + const estimate = left + above - upperLeft; + const leftDistance = Math.abs(estimate - left); + const aboveDistance = Math.abs(estimate - above); + const cornerDistance = Math.abs(estimate - upperLeft); + return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left + : aboveDistance <= cornerDistance ? above : upperLeft; +} + +function filterFrame(rgba, width, height) { + const stride = width * 4; + const result = Buffer.allocUnsafe((stride + 1) * height); + const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride)); + for (let y = 0; y < height; y++) { + const rowStart = y * stride; + let bestType = 0; + let bestScore = Infinity; + for (let type = 0; type < 5; type++) { + const candidate = candidates[type]; + let score = 0; + for (let x = 0; x < stride; x++) { + const raw = rgba[rowStart + x]; + const left = x >= 4 ? rgba[rowStart + x - 4] : 0; + const above = y > 0 ? rgba[rowStart - stride + x] : 0; + const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0; + let predicted = 0; + if (type === 1) predicted = left; + else if (type === 2) predicted = above; + else if (type === 3) predicted = Math.floor((left + above) / 2); + else if (type === 4) predicted = paeth(left, above, upperLeft); + const filtered = (raw - predicted + 256) & 0xff; + candidate[x] = filtered; + score += Math.abs(filtered < 128 ? filtered : filtered - 256); + } + if (score < bestScore) { bestScore = score; bestType = type; } + } + const outputStart = y * (stride + 1); + result[outputStart] = bestType; + candidates[bestType].copy(result, outputStart + 1); + } + return result; +} + +function encode(options) { + const width = Number(options.width); + const height = Number(options.height); + const frames = options.frames || []; + if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效'); + if (!frames.length) throw new TypeError('APNG 至少需要一帧'); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA + const animationControl = Buffer.alloc(8); + animationControl.writeUInt32BE(frames.length, 0); + animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4); + const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)]; + let sequence = 0; + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]; + const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength); + if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效'); + const control = Buffer.alloc(26); + control.writeUInt32BE(sequence++, 0); + control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8); + control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16); + control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20); + control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22); + control[24] = 0; // keep the previous frame + control[25] = 0; // replace the full canvas + chunks.push(chunk('fcTL', control)); + const compressed = zlib.deflateSync(filterFrame(rgba, width, height), { + level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)), + }); + if (index === 0) chunks.push(chunk('IDAT', compressed)); + else { + const frameData = Buffer.allocUnsafe(4 + compressed.length); + frameData.writeUInt32BE(sequence++, 0); + compressed.copy(frameData, 4); + chunks.push(chunk('fdAT', frameData)); + } + } + chunks.push(chunk('IEND')); + return Buffer.concat(chunks); +} + +module.exports = { encode }; diff --git a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json index 6668042..8434ad5 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json +++ b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt", "thumbnail": "Als aktuelles Vorschaubild setzen", "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", + "animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen", + "animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.", + "animatedRange": "Animationsbereich", + "animatedStart": "Start", + "animatedEnd": "Ende", + "framesUnit": "Frames", + "animatedQuality": "Vorschaubildqualität", + "animatedMaxSize": "Dateigrößenlimit", + "animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein", + "animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…", + "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.", + "qualityLow": "Niedrig", + "qualityMedium": "Mittel", + "qualityHigh": "Hoch", + "animatedConfirm": "Erzeugen und ersetzen", + "animatedInvalidRange": "Geben Sie einen gültigen ganzzahligen Framebereich zwischen 0 und {duration} ein", + "animatedGenerating": "Animiertes Vorschaubild wird erzeugt {current}/{total}…", + "animatedTooLarge": "Das animierte Vorschaubild ist größer als 50 MB. Verkürzen Sie den Bereich oder wählen Sie eine niedrigere Qualität.", + "animatedThumbUpdated": "Animiertes Vorschaubild aktualisiert · {frames} Frames · {size} MB", "groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen", "groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen", "groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen", diff --git a/release/UNIVERSAL_SpineViewer/_locales/en.json b/release/UNIVERSAL_SpineViewer/_locales/en.json index 2b57ea1..3057fb0 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/en.json +++ b/release/UNIVERSAL_SpineViewer/_locales/en.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes", "thumbnail": "Set current item thumbnail", "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", + "animatedThumbnailTitle": "Generate animated thumbnail", + "animatedThumbnailNote": "This replaces the current item thumbnail and the plugin cannot restore the previous image. Frame count, color precision and internal detail are reduced automatically to meet the size limit.", + "animatedRange": "Animation range", + "animatedStart": "Start", + "animatedEnd": "End", + "framesUnit": "frames", + "animatedQuality": "Thumbnail quality", + "animatedMaxSize": "File size limit", + "animatedInvalidSize": "Enter a whole number between 50 and 10240 KB", + "animatedCompressing": "Compressing animated thumbnail {current}/{total}…", + "animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.", + "qualityLow": "Low", + "qualityMedium": "Medium", + "qualityHigh": "High", + "animatedConfirm": "Generate and replace", + "animatedInvalidRange": "Enter a valid whole-frame range between 0 and {duration}", + "animatedGenerating": "Generating animated thumbnail {current}/{total}…", + "animatedTooLarge": "The animated thumbnail exceeds 50 MB. Shorten the range or select a lower quality.", + "animatedThumbUpdated": "Animated thumbnail updated · {frames} frames · {size} MB", "groupThumbnail": "Generate thumbnails for related assets", "groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch", "groupDialogTitle": "Generate thumbnails for related assets", diff --git a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json index ef1444f..e1ab6dd 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json +++ b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json @@ -20,6 +20,25 @@ "backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión", "thumbnail": "Usar como miniatura actual", "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", + "animatedThumbnailTitle": "Generar miniatura animada", + "animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.", + "animatedRange": "Rango de animación", + "animatedStart": "Inicio", + "animatedEnd": "Fin", + "framesUnit": "fotogramas", + "animatedQuality": "Calidad de miniatura", + "animatedMaxSize": "Límite de tamaño", + "animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB", + "animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…", + "animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.", + "qualityLow": "Baja", + "qualityMedium": "Media", + "qualityHigh": "Alta", + "animatedConfirm": "Generar y reemplazar", + "animatedInvalidRange": "Introduce un rango de fotogramas enteros entre 0 y {duration}", + "animatedGenerating": "Generando miniatura animada {current}/{total}…", + "animatedTooLarge": "La miniatura animada supera los 50 MB. Acorta el rango o elige una calidad inferior.", + "animatedThumbUpdated": "Miniatura animada actualizada · {frames} fotogramas · {size} MB", "groupThumbnail": "Generar miniaturas de recursos relacionados", "groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes", "groupDialogTitle": "Generar miniaturas de recursos relacionados", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json index 1990da3..c2e8b52 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", "thumbnail": "現在の項目のサムネイルに設定", "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", + "animatedThumbnailTitle": "アニメーションサムネイルを生成", + "animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。", + "animatedRange": "アニメーション範囲", + "animatedStart": "開始", + "animatedEnd": "終了", + "framesUnit": "フレーム", + "animatedQuality": "サムネイル品質", + "animatedMaxSize": "ファイルサイズ上限", + "animatedInvalidSize": "50 から 10240 KB までの整数を入力してください", + "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", + "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "生成して置き換え", + "animatedInvalidRange": "0 から {duration} フレームまでの有効な整数フレーム範囲を入力してください", + "animatedGenerating": "アニメーションサムネイルを生成中 {current}/{total}…", + "animatedTooLarge": "アニメーションサムネイルが 50 MB を超えます。範囲を短くするか、低い品質を選択してください。", + "animatedThumbUpdated": "アニメーションサムネイルを更新 · {frames} フレーム · {size} MB", "groupThumbnail": "関連アセットのサムネイルを生成", "groupThumbnailTitle": "関連する Spine アセットを確認して一括生成", "groupDialogTitle": "関連アセットのサムネイルを生成", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json index 1b5ef3c..ea3d774 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", "thumbnail": "현재 항목 썸네일로 설정", "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", + "animatedThumbnailTitle": "애니메이션 썸네일 생성", + "animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.", + "animatedRange": "애니메이션 범위", + "animatedStart": "시작", + "animatedEnd": "종료", + "framesUnit": "프레임", + "animatedQuality": "썸네일 품질", + "animatedMaxSize": "파일 크기 제한", + "animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요", + "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", + "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", + "qualityLow": "낮음", + "qualityMedium": "보통", + "qualityHigh": "높음", + "animatedConfirm": "생성 및 교체", + "animatedInvalidRange": "0에서 {duration}프레임 사이의 올바른 정수 프레임 범위를 입력하세요", + "animatedGenerating": "애니메이션 썸네일 생성 중 {current}/{total}…", + "animatedTooLarge": "애니메이션 썸네일이 50MB를 초과합니다. 범위를 줄이거나 더 낮은 품질을 선택하세요.", + "animatedThumbUpdated": "애니메이션 썸네일 업데이트 · {frames}프레임 · {size}MB", "groupThumbnail": "관련 자산 썸네일 생성", "groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성", "groupDialogTitle": "관련 자산 썸네일 생성", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json index 7060822..16b9e5f 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json @@ -20,6 +20,25 @@ "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", "thumbnail": "Сделать миниатюрой элемента", "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", + "animatedThumbnailTitle": "Создать анимированную миниатюру", + "animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.", + "animatedRange": "Диапазон анимации", + "animatedStart": "Начало", + "animatedEnd": "Конец", + "framesUnit": "кадров", + "animatedQuality": "Качество миниатюры", + "animatedMaxSize": "Лимит размера файла", + "animatedInvalidSize": "Введите целое число от 50 до 10240 КБ", + "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", + "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", + "qualityLow": "Низкое", + "qualityMedium": "Среднее", + "qualityHigh": "Высокое", + "animatedConfirm": "Создать и заменить", + "animatedInvalidRange": "Введите целый диапазон кадров от 0 до {duration}", + "animatedGenerating": "Создание анимированной миниатюры {current}/{total}…", + "animatedTooLarge": "Миниатюра превышает 50 МБ. Сократите диапазон или выберите более низкое качество.", + "animatedThumbUpdated": "Миниатюра обновлена · {frames} кадров · {size} МБ", "groupThumbnail": "Создать миниатюры для связанных ресурсов", "groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры", "groupDialogTitle": "Создать миниатюры для связанных ресурсов", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json index fac532b..12e3253 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", "thumbnail": "设为当前素材缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", + "animatedThumbnailTitle": "生成动画缩略图", + "animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节以满足体积上限。", + "animatedRange": "动画范围", + "animatedStart": "开始", + "animatedEnd": "结束", + "framesUnit": "帧", + "animatedQuality": "缩略图质量", + "animatedMaxSize": "文件大小上限", + "animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数", + "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", + "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "生成并替换", + "animatedInvalidRange": "请输入 0 到 {duration} 帧之间的有效整数帧范围", + "animatedGenerating": "正在生成动画缩略图 {current}/{total}…", + "animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量", + "animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} MB", "groupThumbnail": "为关联资源生成缩略图", "groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图", "groupDialogTitle": "为关联资源生成缩略图", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json index 20392a9..26d15ae 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json @@ -20,6 +20,25 @@ "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", "thumbnail": "設為目前素材縮圖", "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", + "animatedThumbnailTitle": "產生動畫縮圖", + "animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節以符合檔案上限。", + "animatedRange": "動畫範圍", + "animatedStart": "開始", + "animatedEnd": "結束", + "framesUnit": "影格", + "animatedQuality": "縮圖品質", + "animatedMaxSize": "檔案大小上限", + "animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數", + "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", + "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", + "qualityLow": "低", + "qualityMedium": "中", + "qualityHigh": "高", + "animatedConfirm": "產生並取代", + "animatedInvalidRange": "請輸入 0 到 {duration} 影格之間的有效整數影格範圍", + "animatedGenerating": "正在產生動畫縮圖 {current}/{total}…", + "animatedTooLarge": "動畫縮圖超過 50 MB,請縮短範圍或選擇較低品質", + "animatedThumbUpdated": "動畫縮圖已更新 · {frames} 影格 · {size} MB", "groupThumbnail": "為關聯資源產生縮圖", "groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖", "groupDialogTitle": "為關聯資源產生縮圖", diff --git a/release/UNIVERSAL_SpineViewer/js/apng-encoder.js b/release/UNIVERSAL_SpineViewer/js/apng-encoder.js new file mode 100644 index 0000000..81c704f --- /dev/null +++ b/release/UNIVERSAL_SpineViewer/js/apng-encoder.js @@ -0,0 +1,115 @@ +'use strict'; + +const zlib = require('zlib'); + +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const crcTable = new Uint32Array(256); +for (let n = 0; n < 256; n++) { + let value = n; + for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1); + crcTable[n] = value >>> 0; +} + +function crc32(buffer) { + let value = 0xffffffff; + for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8); + return (value ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const name = Buffer.from(type, 'ascii'); + const body = Buffer.from(data || []); + const out = Buffer.allocUnsafe(12 + body.length); + out.writeUInt32BE(body.length, 0); + name.copy(out, 4); + body.copy(out, 8); + out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length); + return out; +} + +function paeth(left, above, upperLeft) { + const estimate = left + above - upperLeft; + const leftDistance = Math.abs(estimate - left); + const aboveDistance = Math.abs(estimate - above); + const cornerDistance = Math.abs(estimate - upperLeft); + return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left + : aboveDistance <= cornerDistance ? above : upperLeft; +} + +function filterFrame(rgba, width, height) { + const stride = width * 4; + const result = Buffer.allocUnsafe((stride + 1) * height); + const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride)); + for (let y = 0; y < height; y++) { + const rowStart = y * stride; + let bestType = 0; + let bestScore = Infinity; + for (let type = 0; type < 5; type++) { + const candidate = candidates[type]; + let score = 0; + for (let x = 0; x < stride; x++) { + const raw = rgba[rowStart + x]; + const left = x >= 4 ? rgba[rowStart + x - 4] : 0; + const above = y > 0 ? rgba[rowStart - stride + x] : 0; + const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0; + let predicted = 0; + if (type === 1) predicted = left; + else if (type === 2) predicted = above; + else if (type === 3) predicted = Math.floor((left + above) / 2); + else if (type === 4) predicted = paeth(left, above, upperLeft); + const filtered = (raw - predicted + 256) & 0xff; + candidate[x] = filtered; + score += Math.abs(filtered < 128 ? filtered : filtered - 256); + } + if (score < bestScore) { bestScore = score; bestType = type; } + } + const outputStart = y * (stride + 1); + result[outputStart] = bestType; + candidates[bestType].copy(result, outputStart + 1); + } + return result; +} + +function encode(options) { + const width = Number(options.width); + const height = Number(options.height); + const frames = options.frames || []; + if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效'); + if (!frames.length) throw new TypeError('APNG 至少需要一帧'); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA + const animationControl = Buffer.alloc(8); + animationControl.writeUInt32BE(frames.length, 0); + animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4); + const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)]; + let sequence = 0; + for (let index = 0; index < frames.length; index++) { + const frame = frames[index]; + const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength); + if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效'); + const control = Buffer.alloc(26); + control.writeUInt32BE(sequence++, 0); + control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8); + control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16); + control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20); + control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22); + control[24] = 0; // keep the previous frame + control[25] = 0; // replace the full canvas + chunks.push(chunk('fcTL', control)); + const compressed = zlib.deflateSync(filterFrame(rgba, width, height), { + level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)), + }); + if (index === 0) chunks.push(chunk('IDAT', compressed)); + else { + const frameData = Buffer.allocUnsafe(4 + compressed.length); + frameData.writeUInt32BE(sequence++, 0); + compressed.copy(frameData, 4); + chunks.push(chunk('fdAT', frameData)); + } + } + chunks.push(chunk('IEND')); + return Buffer.concat(chunks); +} + +module.exports = { encode }; diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.css b/release/UNIVERSAL_SpineViewer/viewer/spine.css index 175fb73..1f1852a 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.css +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.css @@ -112,6 +112,16 @@ } .thumb-dialog-input.hidden { display: none; } .thumb-dialog-error { color: var(--error); min-height: 18px; } + .animated-thumb-card { width: min(520px, 92vw); } + .thumb-dialog-note.warning { color: #e6b35c; } + .animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; } + .animated-thumb-label { color: var(--text-dim); } + .animated-thumb-range { display: flex; align-items: center; gap: 8px; } + .animated-thumb-range label { display: flex; align-items: center; gap: 6px; } + .animated-thumb-range input { width: 88px; } + .animated-thumb-quality { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-dim); } + .animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); } + .animated-thumb-size input { width: 108px; } /* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/ #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 934033a..f3c0312 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -87,6 +87,29 @@ +