diff --git a/_locales/de_DE.json b/_locales/de_DE.json index 32393f6..ac09909 100644 --- a/_locales/de_DE.json +++ b/_locales/de_DE.json @@ -22,6 +22,7 @@ "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.", + "animatedFormat": "Ausgabeformat", "animatedRange": "Animationsbereich", "animatedStart": "Start", "animatedEnd": "Ende", @@ -30,6 +31,7 @@ "animatedMaxSize": "Dateigrößenlimit", "animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein", "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.", "qualityLow": "Niedrig", "qualityMedium": "Mittel", diff --git a/_locales/en.json b/_locales/en.json index 89901aa..12b3bb3 100644 --- a/_locales/en.json +++ b/_locales/en.json @@ -22,6 +22,7 @@ "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "animatedThumbnailTitle": "Generate animated thumbnail", "animatedThumbnailNote": "This replaces the current thumbnail and cannot be undone by the plugin. Frame count, color precision and detail are reduced first; if needed, dimensions are reduced progressively from 400×400.", + "animatedFormat": "Output format", "animatedRange": "Animation range", "animatedStart": "Start", "animatedEnd": "End", @@ -30,6 +31,7 @@ "animatedMaxSize": "File size limit", "animatedInvalidSize": "Enter a whole number between 50 and 10240 KB", "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.", "qualityLow": "Low", "qualityMedium": "Medium", diff --git a/_locales/es_ES.json b/_locales/es_ES.json index bda3d04..893d685 100644 --- a/_locales/es_ES.json +++ b/_locales/es_ES.json @@ -22,6 +22,7 @@ "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.", + "animatedFormat": "Formato de salida", "animatedRange": "Rango de animación", "animatedStart": "Inicio", "animatedEnd": "Fin", @@ -30,6 +31,7 @@ "animatedMaxSize": "Límite de tamaño", "animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB", "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.", "qualityLow": "Baja", "qualityMedium": "Media", diff --git a/_locales/ja_JP.json b/_locales/ja_JP.json index ea53d01..fcb391d 100644 --- a/_locales/ja_JP.json +++ b/_locales/ja_JP.json @@ -22,6 +22,7 @@ "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "animatedThumbnailTitle": "アニメーションサムネイルを生成", "animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。", + "animatedFormat": "出力形式", "animatedRange": "アニメーション範囲", "animatedStart": "開始", "animatedEnd": "終了", @@ -30,6 +31,7 @@ "animatedMaxSize": "ファイルサイズ上限", "animatedInvalidSize": "50 から 10240 KB までの整数を入力してください", "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", + "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", "qualityLow": "低", "qualityMedium": "中", diff --git a/_locales/ko_KR.json b/_locales/ko_KR.json index f487723..be202c0 100644 --- a/_locales/ko_KR.json +++ b/_locales/ko_KR.json @@ -22,6 +22,7 @@ "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "animatedThumbnailTitle": "애니메이션 썸네일 생성", "animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.", + "animatedFormat": "출력 형식", "animatedRange": "애니메이션 범위", "animatedStart": "시작", "animatedEnd": "종료", @@ -30,6 +31,7 @@ "animatedMaxSize": "파일 크기 제한", "animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요", "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", + "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", "qualityLow": "낮음", "qualityMedium": "보통", diff --git a/_locales/ru_RU.json b/_locales/ru_RU.json index 1e7dcad..43cfe6c 100644 --- a/_locales/ru_RU.json +++ b/_locales/ru_RU.json @@ -22,6 +22,7 @@ "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "animatedThumbnailTitle": "Создать анимированную миниатюру", "animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.", + "animatedFormat": "Формат вывода", "animatedRange": "Диапазон анимации", "animatedStart": "Начало", "animatedEnd": "Конец", @@ -30,6 +31,7 @@ "animatedMaxSize": "Лимит размера файла", "animatedInvalidSize": "Введите целое число от 50 до 10240 КБ", "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", + "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", "qualityLow": "Низкое", "qualityMedium": "Среднее", diff --git a/_locales/zh_CN.json b/_locales/zh_CN.json index 86b3337..4970cb1 100644 --- a/_locales/zh_CN.json +++ b/_locales/zh_CN.json @@ -22,6 +22,7 @@ "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "animatedThumbnailTitle": "生成动画缩略图", "animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。", + "animatedFormat": "输出格式", "animatedRange": "动画范围", "animatedStart": "开始", "animatedEnd": "结束", @@ -30,6 +31,7 @@ "animatedMaxSize": "文件大小上限", "animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数", "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", + "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", "qualityLow": "低", "qualityMedium": "中", diff --git a/_locales/zh_TW.json b/_locales/zh_TW.json index c96520b..17bc692 100644 --- a/_locales/zh_TW.json +++ b/_locales/zh_TW.json @@ -22,6 +22,7 @@ "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "animatedThumbnailTitle": "產生動畫縮圖", "animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。", + "animatedFormat": "輸出格式", "animatedRange": "動畫範圍", "animatedStart": "開始", "animatedEnd": "結束", @@ -30,6 +31,7 @@ "animatedMaxSize": "檔案大小上限", "animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數", "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", + "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", "qualityLow": "低", "qualityMedium": "中", diff --git a/js/animated-webp-encoder.js b/js/animated-webp-encoder.js new file mode 100644 index 0000000..8a7da01 --- /dev/null +++ b/js/animated-webp-encoder.js @@ -0,0 +1,77 @@ +'use strict'; + +function writeUint24LE(buffer, offset, value) { + buffer[offset] = value & 0xff; + buffer[offset + 1] = (value >>> 8) & 0xff; + buffer[offset + 2] = (value >>> 16) & 0xff; +} + +function chunk(type, payload) { + const padding = payload.length & 1; + const output = Buffer.alloc(8 + payload.length + padding); + output.write(type, 0, 4, 'ascii'); + output.writeUInt32LE(payload.length, 4); + payload.copy(output, 8); + return output; +} + +function frameData(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'); + } + const parts = []; + let hasAlpha = false; + let offset = 12; + while (offset + 8 <= input.length) { + const type = input.toString('ascii', offset, offset + 4); + const length = input.readUInt32LE(offset + 4); + const end = offset + 8 + length + (length & 1); + if (end > input.length) throw new Error('Truncated WebP frame'); + if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end)); + if (type === 'ALPH') hasAlpha = true; + offset = end; + } + if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) { + throw new Error('WebP frame has no image data'); + } + return { data: Buffer.concat(parts), hasAlpha }; +} + +function encode(options) { + const width = Number(options && options.width); + const height = Number(options && options.height); + const frames = options && options.frames; + if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) { + throw new RangeError('Animated WebP dimensions are invalid'); + } + if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames'); + const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) })); + + const vp8x = Buffer.alloc(10); + vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0); + writeUint24LE(vp8x, 4, width - 1); + writeUint24LE(vp8x, 7, height - 1); + const anim = Buffer.alloc(6); + const background = options.background || [30, 30, 30, 255]; + anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0); + anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4); + + const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)]; + for (const { frame, data } of preparedFrames) { + const header = Buffer.alloc(16); + writeUint24LE(header, 6, width - 1); + writeUint24LE(header, 9, height - 1); + writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration)))); + header[15] = 0x02; + chunks.push(chunk('ANMF', Buffer.concat([header, data]))); + } + const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]); + const output = Buffer.alloc(8 + body.length); + output.write('RIFF', 0, 4, 'ascii'); + output.writeUInt32LE(body.length, 4); + body.copy(output, 8); + return output; +} + +module.exports = { encode }; diff --git a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json index 32393f6..ac09909 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/de_DE.json +++ b/release/UNIVERSAL_SpineViewer/_locales/de_DE.json @@ -22,6 +22,7 @@ "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.", + "animatedFormat": "Ausgabeformat", "animatedRange": "Animationsbereich", "animatedStart": "Start", "animatedEnd": "Ende", @@ -30,6 +31,7 @@ "animatedMaxSize": "Dateigrößenlimit", "animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein", "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.", "qualityLow": "Niedrig", "qualityMedium": "Mittel", diff --git a/release/UNIVERSAL_SpineViewer/_locales/en.json b/release/UNIVERSAL_SpineViewer/_locales/en.json index 89901aa..12b3bb3 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/en.json +++ b/release/UNIVERSAL_SpineViewer/_locales/en.json @@ -22,6 +22,7 @@ "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "animatedThumbnailTitle": "Generate animated thumbnail", "animatedThumbnailNote": "This replaces the current thumbnail and cannot be undone by the plugin. Frame count, color precision and detail are reduced first; if needed, dimensions are reduced progressively from 400×400.", + "animatedFormat": "Output format", "animatedRange": "Animation range", "animatedStart": "Start", "animatedEnd": "End", @@ -30,6 +31,7 @@ "animatedMaxSize": "File size limit", "animatedInvalidSize": "Enter a whole number between 50 and 10240 KB", "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.", "qualityLow": "Low", "qualityMedium": "Medium", diff --git a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json index bda3d04..893d685 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/es_ES.json +++ b/release/UNIVERSAL_SpineViewer/_locales/es_ES.json @@ -22,6 +22,7 @@ "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.", + "animatedFormat": "Formato de salida", "animatedRange": "Rango de animación", "animatedStart": "Inicio", "animatedEnd": "Fin", @@ -30,6 +31,7 @@ "animatedMaxSize": "Límite de tamaño", "animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB", "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.", "qualityLow": "Baja", "qualityMedium": "Media", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json index ea53d01..fcb391d 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ja_JP.json @@ -22,6 +22,7 @@ "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "animatedThumbnailTitle": "アニメーションサムネイルを生成", "animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。", + "animatedFormat": "出力形式", "animatedRange": "アニメーション範囲", "animatedStart": "開始", "animatedEnd": "終了", @@ -30,6 +31,7 @@ "animatedMaxSize": "ファイルサイズ上限", "animatedInvalidSize": "50 から 10240 KB までの整数を入力してください", "animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…", + "animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。", "animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。", "qualityLow": "低", "qualityMedium": "中", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json index f487723..be202c0 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ko_KR.json @@ -22,6 +22,7 @@ "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "animatedThumbnailTitle": "애니메이션 썸네일 생성", "animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.", + "animatedFormat": "출력 형식", "animatedRange": "애니메이션 범위", "animatedStart": "시작", "animatedEnd": "종료", @@ -30,6 +31,7 @@ "animatedMaxSize": "파일 크기 제한", "animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요", "animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…", + "animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.", "animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.", "qualityLow": "낮음", "qualityMedium": "보통", diff --git a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json index 1e7dcad..43cfe6c 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json +++ b/release/UNIVERSAL_SpineViewer/_locales/ru_RU.json @@ -22,6 +22,7 @@ "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "animatedThumbnailTitle": "Создать анимированную миниатюру", "animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.", + "animatedFormat": "Формат вывода", "animatedRange": "Диапазон анимации", "animatedStart": "Начало", "animatedEnd": "Конец", @@ -30,6 +31,7 @@ "animatedMaxSize": "Лимит размера файла", "animatedInvalidSize": "Введите целое число от 50 до 10240 КБ", "animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…", + "animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.", "animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.", "qualityLow": "Низкое", "qualityMedium": "Среднее", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json index 86b3337..4970cb1 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_CN.json @@ -22,6 +22,7 @@ "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "animatedThumbnailTitle": "生成动画缩略图", "animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。", + "animatedFormat": "输出格式", "animatedRange": "动画范围", "animatedStart": "开始", "animatedEnd": "结束", @@ -30,6 +31,7 @@ "animatedMaxSize": "文件大小上限", "animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数", "animatedCompressing": "正在压缩动画缩略图 {current}/{total}…", + "animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG", "animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围", "qualityLow": "低", "qualityMedium": "中", diff --git a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json index c96520b..17bc692 100644 --- a/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json +++ b/release/UNIVERSAL_SpineViewer/_locales/zh_TW.json @@ -22,6 +22,7 @@ "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "animatedThumbnailTitle": "產生動畫縮圖", "animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。", + "animatedFormat": "輸出格式", "animatedRange": "動畫範圍", "animatedStart": "開始", "animatedEnd": "結束", @@ -30,6 +31,7 @@ "animatedMaxSize": "檔案大小上限", "animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數", "animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…", + "animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG", "animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍", "qualityLow": "低", "qualityMedium": "中", diff --git a/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js b/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js new file mode 100644 index 0000000..8a7da01 --- /dev/null +++ b/release/UNIVERSAL_SpineViewer/js/animated-webp-encoder.js @@ -0,0 +1,77 @@ +'use strict'; + +function writeUint24LE(buffer, offset, value) { + buffer[offset] = value & 0xff; + buffer[offset + 1] = (value >>> 8) & 0xff; + buffer[offset + 2] = (value >>> 16) & 0xff; +} + +function chunk(type, payload) { + const padding = payload.length & 1; + const output = Buffer.alloc(8 + payload.length + padding); + output.write(type, 0, 4, 'ascii'); + output.writeUInt32LE(payload.length, 4); + payload.copy(output, 8); + return output; +} + +function frameData(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'); + } + const parts = []; + let hasAlpha = false; + let offset = 12; + while (offset + 8 <= input.length) { + const type = input.toString('ascii', offset, offset + 4); + const length = input.readUInt32LE(offset + 4); + const end = offset + 8 + length + (length & 1); + if (end > input.length) throw new Error('Truncated WebP frame'); + if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end)); + if (type === 'ALPH') hasAlpha = true; + offset = end; + } + if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) { + throw new Error('WebP frame has no image data'); + } + return { data: Buffer.concat(parts), hasAlpha }; +} + +function encode(options) { + const width = Number(options && options.width); + const height = Number(options && options.height); + const frames = options && options.frames; + if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) { + throw new RangeError('Animated WebP dimensions are invalid'); + } + if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames'); + const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) })); + + const vp8x = Buffer.alloc(10); + vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0); + writeUint24LE(vp8x, 4, width - 1); + writeUint24LE(vp8x, 7, height - 1); + const anim = Buffer.alloc(6); + const background = options.background || [30, 30, 30, 255]; + anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0); + anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4); + + const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)]; + for (const { frame, data } of preparedFrames) { + const header = Buffer.alloc(16); + writeUint24LE(header, 6, width - 1); + writeUint24LE(header, 9, height - 1); + writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration)))); + header[15] = 0x02; + chunks.push(chunk('ANMF', Buffer.concat([header, data]))); + } + const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]); + const output = Buffer.alloc(8 + body.length); + output.write('RIFF', 0, 4, 'ascii'); + output.writeUInt32LE(body.length, 4); + body.copy(output, 8); + return output; +} + +module.exports = { encode }; diff --git a/release/UNIVERSAL_SpineViewer/viewer/spine.html b/release/UNIVERSAL_SpineViewer/viewer/spine.html index adb5a82..f1d3060 100644 --- a/release/UNIVERSAL_SpineViewer/viewer/spine.html +++ b/release/UNIVERSAL_SpineViewer/viewer/spine.html @@ -92,6 +92,9 @@
生成动画缩略图
生成后将替换当前素材缩略图,插件无法自动恢复原图。长动画会自动降低帧率以控制文件大小。
+
动画范围
@@ -124,6 +127,7 @@ const groupThumbnails = require('../js/spine-group-thumbnails.js'); const previewSettingsModule = require('../js/spine-preview-settings.js'); const apngEncoder = require('../js/apng-encoder.js'); + const animatedWebpEncoder = require('../js/animated-webp-encoder.js'); let spineBadgeImagePromise = null; function loadSpineBadgeImage() { @@ -785,6 +789,7 @@ const dialog = document.getElementById('animatedThumbDialog'); const startInput = document.getElementById('animatedThumbStart'); const endInput = document.getElementById('animatedThumbEnd'); + const formatInput = document.getElementById('animatedThumbFormat'); const maxSizeInput = document.getElementById('animatedThumbMaxSize'); const errorEl = document.getElementById('animatedThumbDialogError'); const confirm = document.getElementById('animatedThumbDialogConfirm'); @@ -795,6 +800,7 @@ startInput.max = String(totalFrames); endInput.value = String(totalFrames); endInput.max = String(totalFrames); + formatInput.value = 'webp'; maxSizeInput.value = '100'; errorEl.textContent = ''; dialog.classList.remove('hidden'); @@ -821,6 +827,7 @@ finish({ start: startFrame / FPS, end: Math.min(endFrame / FPS, animationLength), + format: formatInput.value === 'apng' ? 'apng' : 'webp', maxKilobytes, }); }; @@ -947,6 +954,65 @@ return smallest; } + async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) { + 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 range = options.end - options.start; @@ -993,16 +1059,19 @@ octx.restore(); await drawSpineBadge(octx, size, size); spineBadge.drawVersion(octx, size, size, skeletonData.version || activeVersion); - const rgba = octx.getImageData(0, 0, size, size).data; - sourceFrames.push(new Uint8Array(rgba)); + const rgba = new Uint8Array(octx.getImageData(0, 0, size, size).data); + for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255; + sourceFrames.push(rgba); setStatus(t('viewer.animatedGenerating', { current: index + 1, total: frameCount }), Infinity); if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0)); } } finally { if (offRenderer && offRenderer.dispose) offRenderer.dispose(); } - const encoded = await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes); - return { buffer: encoded.buffer, extension: '.png', frames: encoded.frames, size: encoded.size }; + const encoded = options.format === 'apng' + ? 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 }; } async function generateAnimatedThumbnail() { @@ -1025,7 +1094,7 @@ const currentItem = await getEagleItemForPath(filePath); setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity); const rendered = await renderAnimatedThumbnail(animation, options); - tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + '.png'); + tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension); fs.writeFileSync(tempPath, rendered.buffer); await setEagleCustomThumbnail(currentItem.id, tempPath); setStatus(t('viewer.animatedThumbUpdated', { diff --git a/tests/animated-webp-encoder.test.js b/tests/animated-webp-encoder.test.js new file mode 100644 index 0000000..ffdca2f --- /dev/null +++ b/tests/animated-webp-encoder.test.js @@ -0,0 +1,52 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { encode } = require('../js/animated-webp-encoder'); + +function chunk(type, payload) { + const output = Buffer.alloc(8 + payload.length + (payload.length & 1)); + output.write(type, 0, 4, 'ascii'); + output.writeUInt32LE(payload.length, 4); + payload.copy(output, 8); + return output; +} + +function stillWebp() { + const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]); + const output = Buffer.alloc(8 + body.length); + output.write('RIFF', 0, 4, 'ascii'); + output.writeUInt32LE(body.length, 4); + body.copy(output, 8); + return output; +} + +function chunks(buffer) { + const result = []; + for (let offset = 12; offset + 8 <= buffer.length;) { + const size = buffer.readUInt32LE(offset + 4); + result.push({ type: buffer.toString('ascii', offset, offset + 4), payload: buffer.subarray(offset + 8, offset + 8 + size) }); + offset += 8 + size + (size & 1); + } + return result; +} + +const output = encode({ + width: 400, + height: 300, + frames: [{ webp: stillWebp(), duration: 50 }, { webp: stillWebp(), duration: 75 }], +}); +assert.equal(output.toString('ascii', 0, 4), 'RIFF'); +assert.equal(output.readUInt32LE(4), output.length - 8); +assert.equal(output.toString('ascii', 8, 12), 'WEBP'); +const parsed = chunks(output); +assert.deepEqual(parsed.map((entry) => entry.type), ['VP8X', 'ANIM', 'ANMF', 'ANMF']); +assert.equal(parsed[0].payload[0], 0x02, 'VP8X must declare animation without alpha'); +assert.deepEqual([...parsed[0].payload.subarray(4, 7)], [143, 1, 0]); +assert.deepEqual([...parsed[0].payload.subarray(7, 10)], [43, 1, 0]); +assert.equal(parsed[1].payload[3], 255, 'animation background must be opaque'); +assert.deepEqual([...parsed[2].payload.subarray(12, 15)], [50, 0, 0]); +assert.equal(parsed[2].payload[15], 0x02, 'full frames must replace without blending'); +assert.equal(parsed[2].payload.toString('ascii', 16, 20), 'VP8 '); +assert.throws(() => encode({ width: 1, height: 1, frames: [{ webp: Buffer.alloc(0), duration: 50 }] }), /at least two frames/); + +console.log('PASS: animated WebP RIFF structure, timing and opaque full-frame mode'); diff --git a/tests/apng-thumbnail.test.js b/tests/apng-thumbnail.test.js index 37a72a4..fe921a7 100644 --- a/tests/apng-thumbnail.test.js +++ b/tests/apng-thumbnail.test.js @@ -57,7 +57,11 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) { assert.match(script, /const outputSize = profile\.size \|\| size/); assert.match(script, /return smallest;/); assert.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/); - assert.match(script, /extension: '\.png'/); + assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/); + assert.match(script, /async function encodeAnimatedWebpToLimit/); + assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/); + assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/); + 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, /start: startFrame \/ FPS/); assert.match(html, /id="animatedThumbStart" type="number" min="0" step="1"/); @@ -67,6 +71,7 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) { assert.doesNotMatch(html, /id="animatedThumbQuality"/); assert.doesNotMatch(html, /viewer\.secondsUnit/); assert.match(html, /id="animatedThumbDialog"/); + assert.match(html, /id="animatedThumbFormat">