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 @@