显示调整
This commit is contained in:
1 parent
081d06c70e
commit
e2df519d2e
25 files changed
+291
-73
No files matched your search
+6
-3
@@ -21,15 +21,18 @@
|
||||
"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.",
|
||||
"animatedThumbnailAttention": "Achtung:",
|
||||
"animatedThumbnailNote": "Das erzeugte Vorschaubild ersetzt das vorhandene. Dieser Vorgang kann nicht rückgängig gemacht werden und ",
|
||||
"animatedThumbnailDanger": "das ursprüngliche Vorschaubild kann nicht wiederhergestellt werden",
|
||||
"animatedThumbnailSizeNote": ". Damit Animationsdaten nicht zu groß werden, wird die Datei unter die eingestellte Größe reduziert.",
|
||||
"animatedFormat": "Ausgabeformat",
|
||||
"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",
|
||||
"animatedMaxSize": "Vorschaubildgröße",
|
||||
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 10 und 1024 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.",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"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 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.",
|
||||
"animatedThumbnailAttention": "Warning:",
|
||||
"animatedThumbnailNote": "The generated thumbnail replaces the existing thumbnail. This cannot be undone and ",
|
||||
"animatedThumbnailDanger": "the original thumbnail cannot be restored",
|
||||
"animatedThumbnailSizeNote": ". To prevent animated thumbnail data from becoming too large, the file will be reduced below the configured size.",
|
||||
"animatedFormat": "Output format",
|
||||
"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",
|
||||
"animatedMaxSize": "Thumbnail size",
|
||||
"animatedInvalidSize": "Enter a whole number between 10 and 1024 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.",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"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.",
|
||||
"animatedThumbnailAttention": "Atención:",
|
||||
"animatedThumbnailNote": "La miniatura generada reemplaza la existente. Esta acción no se puede deshacer y ",
|
||||
"animatedThumbnailDanger": "la miniatura original no se puede restaurar",
|
||||
"animatedThumbnailSizeNote": ". Para evitar datos de animación demasiado grandes, el archivo se ajustará por debajo del tamaño configurado.",
|
||||
"animatedFormat": "Formato de salida",
|
||||
"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",
|
||||
"animatedMaxSize": "Tamaño de miniatura",
|
||||
"animatedInvalidSize": "Introduce un número entero entre 10 y 1024 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.",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
|
||||
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "生成されたサムネイルは既存のサムネイルを置き換えます。この操作は元に戻せず、",
|
||||
"animatedThumbnailDanger": "元のサムネイルは復元できません",
|
||||
"animatedThumbnailSizeNote": "。データが大きくなりすぎないよう、ファイルは設定サイズ以下に調整されます。",
|
||||
"animatedFormat": "出力形式",
|
||||
"animatedRange": "アニメーション範囲",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "終了",
|
||||
"framesUnit": "フレーム",
|
||||
"animatedQuality": "サムネイル品質",
|
||||
"animatedMaxSize": "ファイルサイズ上限",
|
||||
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
|
||||
"animatedMaxSize": "サムネイルサイズ",
|
||||
"animatedInvalidSize": "10 から 1024 KB までの整数を入力してください",
|
||||
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
|
||||
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
|
||||
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
|
||||
"animatedThumbnailAttention": "주의:",
|
||||
"animatedThumbnailNote": "생성된 썸네일은 기존 썸네일을 교체합니다. 이 작업은 취소할 수 없으며 ",
|
||||
"animatedThumbnailDanger": "기존 썸네일을 복원할 수 없습니다",
|
||||
"animatedThumbnailSizeNote": ". 데이터가 너무 커지지 않도록 파일은 설정한 크기 이하로 조정됩니다.",
|
||||
"animatedFormat": "출력 형식",
|
||||
"animatedRange": "애니메이션 범위",
|
||||
"animatedStart": "시작",
|
||||
"animatedEnd": "종료",
|
||||
"framesUnit": "프레임",
|
||||
"animatedQuality": "썸네일 품질",
|
||||
"animatedMaxSize": "파일 크기 제한",
|
||||
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
|
||||
"animatedMaxSize": "썸네일 크기",
|
||||
"animatedInvalidSize": "10~1024KB 사이의 정수를 입력하세요",
|
||||
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
|
||||
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"animatedThumbnailTitle": "Создать анимированную миниатюру",
|
||||
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
|
||||
"animatedThumbnailAttention": "Внимание:",
|
||||
"animatedThumbnailNote": "Созданная миниатюра заменит существующую. Это действие нельзя отменить, и ",
|
||||
"animatedThumbnailDanger": "исходную миниатюру нельзя восстановить",
|
||||
"animatedThumbnailSizeNote": ". Чтобы данные анимации не были слишком большими, файл будет уменьшен до размера ниже заданного.",
|
||||
"animatedFormat": "Формат вывода",
|
||||
"animatedRange": "Диапазон анимации",
|
||||
"animatedStart": "Начало",
|
||||
"animatedEnd": "Конец",
|
||||
"framesUnit": "кадров",
|
||||
"animatedQuality": "Качество миниатюры",
|
||||
"animatedMaxSize": "Лимит размера файла",
|
||||
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
|
||||
"animatedMaxSize": "Размер миниатюры",
|
||||
"animatedInvalidSize": "Введите целое число от 10 до 1024 КБ",
|
||||
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
|
||||
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
|
||||
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
|
||||
|
||||
+7
-4
@@ -20,16 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"animatedThumbnailTitle": "生成动画缩略图",
|
||||
"animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
|
||||
"animatedThumbnailTitle": "生成动态缩略图",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "生成的缩略图会替换原有缩略图,该操作不可撤销且",
|
||||
"animatedThumbnailDanger": "无法恢复原有缩略图",
|
||||
"animatedThumbnailSizeNote": ",为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下",
|
||||
"animatedFormat": "输出格式",
|
||||
"animatedRange": "动画范围",
|
||||
"animatedStart": "开始",
|
||||
"animatedEnd": "结束",
|
||||
"framesUnit": "帧",
|
||||
"animatedQuality": "缩略图质量",
|
||||
"animatedMaxSize": "文件大小上限",
|
||||
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
|
||||
"animatedMaxSize": "缩略图大小",
|
||||
"animatedInvalidSize": "请输入 10 到 1024 KB 之间的整数",
|
||||
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
|
||||
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
|
||||
|
||||
+6
-3
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"animatedThumbnailTitle": "產生動畫縮圖",
|
||||
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "產生的縮圖會取代原有縮圖,該操作無法復原且",
|
||||
"animatedThumbnailDanger": "無法恢復原有縮圖",
|
||||
"animatedThumbnailSizeNote": ",為避免動態縮圖資料過大,檔案尺寸將調整至設定大小以下",
|
||||
"animatedFormat": "輸出格式",
|
||||
"animatedRange": "動畫範圍",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "結束",
|
||||
"framesUnit": "影格",
|
||||
"animatedQuality": "縮圖品質",
|
||||
"animatedMaxSize": "檔案大小上限",
|
||||
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
|
||||
"animatedMaxSize": "縮圖大小",
|
||||
"animatedInvalidSize": "請輸入 10 到 1024 KB 之間的整數",
|
||||
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
|
||||
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
function findAlphaBounds(rgba, width, height, threshold = 4) {
|
||||
let left = width, top = height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1);
|
||||
bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
}
|
||||
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
||||
}
|
||||
|
||||
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
|
||||
if (!pixels || !isFinite(scale) || scale <= 0) return null;
|
||||
const left = centerX + (pixels.left - width / 2) / scale;
|
||||
const right = centerX + (pixels.right - width / 2) / scale;
|
||||
const top = centerY - (pixels.top - height / 2) / scale;
|
||||
const bottom = centerY - (pixels.bottom - height / 2) / scale;
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
module.exports = { findAlphaBounds, toWorldBounds };
|
||||
@@ -21,15 +21,18 @@
|
||||
"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.",
|
||||
"animatedThumbnailAttention": "Achtung:",
|
||||
"animatedThumbnailNote": "Das erzeugte Vorschaubild ersetzt das vorhandene. Dieser Vorgang kann nicht rückgängig gemacht werden und ",
|
||||
"animatedThumbnailDanger": "das ursprüngliche Vorschaubild kann nicht wiederhergestellt werden",
|
||||
"animatedThumbnailSizeNote": ". Damit Animationsdaten nicht zu groß werden, wird die Datei unter die eingestellte Größe reduziert.",
|
||||
"animatedFormat": "Ausgabeformat",
|
||||
"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",
|
||||
"animatedMaxSize": "Vorschaubildgröße",
|
||||
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 10 und 1024 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.",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"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 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.",
|
||||
"animatedThumbnailAttention": "Warning:",
|
||||
"animatedThumbnailNote": "The generated thumbnail replaces the existing thumbnail. This cannot be undone and ",
|
||||
"animatedThumbnailDanger": "the original thumbnail cannot be restored",
|
||||
"animatedThumbnailSizeNote": ". To prevent animated thumbnail data from becoming too large, the file will be reduced below the configured size.",
|
||||
"animatedFormat": "Output format",
|
||||
"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",
|
||||
"animatedMaxSize": "Thumbnail size",
|
||||
"animatedInvalidSize": "Enter a whole number between 10 and 1024 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.",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"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.",
|
||||
"animatedThumbnailAttention": "Atención:",
|
||||
"animatedThumbnailNote": "La miniatura generada reemplaza la existente. Esta acción no se puede deshacer y ",
|
||||
"animatedThumbnailDanger": "la miniatura original no se puede restaurar",
|
||||
"animatedThumbnailSizeNote": ". Para evitar datos de animación demasiado grandes, el archivo se ajustará por debajo del tamaño configurado.",
|
||||
"animatedFormat": "Formato de salida",
|
||||
"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",
|
||||
"animatedMaxSize": "Tamaño de miniatura",
|
||||
"animatedInvalidSize": "Introduce un número entero entre 10 y 1024 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.",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
|
||||
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "生成されたサムネイルは既存のサムネイルを置き換えます。この操作は元に戻せず、",
|
||||
"animatedThumbnailDanger": "元のサムネイルは復元できません",
|
||||
"animatedThumbnailSizeNote": "。データが大きくなりすぎないよう、ファイルは設定サイズ以下に調整されます。",
|
||||
"animatedFormat": "出力形式",
|
||||
"animatedRange": "アニメーション範囲",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "終了",
|
||||
"framesUnit": "フレーム",
|
||||
"animatedQuality": "サムネイル品質",
|
||||
"animatedMaxSize": "ファイルサイズ上限",
|
||||
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
|
||||
"animatedMaxSize": "サムネイルサイズ",
|
||||
"animatedInvalidSize": "10 から 1024 KB までの整数を入力してください",
|
||||
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
|
||||
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
|
||||
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
|
||||
"animatedThumbnailAttention": "주의:",
|
||||
"animatedThumbnailNote": "생성된 썸네일은 기존 썸네일을 교체합니다. 이 작업은 취소할 수 없으며 ",
|
||||
"animatedThumbnailDanger": "기존 썸네일을 복원할 수 없습니다",
|
||||
"animatedThumbnailSizeNote": ". 데이터가 너무 커지지 않도록 파일은 설정한 크기 이하로 조정됩니다.",
|
||||
"animatedFormat": "출력 형식",
|
||||
"animatedRange": "애니메이션 범위",
|
||||
"animatedStart": "시작",
|
||||
"animatedEnd": "종료",
|
||||
"framesUnit": "프레임",
|
||||
"animatedQuality": "썸네일 품질",
|
||||
"animatedMaxSize": "파일 크기 제한",
|
||||
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
|
||||
"animatedMaxSize": "썸네일 크기",
|
||||
"animatedInvalidSize": "10~1024KB 사이의 정수를 입력하세요",
|
||||
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
|
||||
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"animatedThumbnailTitle": "Создать анимированную миниатюру",
|
||||
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
|
||||
"animatedThumbnailAttention": "Внимание:",
|
||||
"animatedThumbnailNote": "Созданная миниатюра заменит существующую. Это действие нельзя отменить, и ",
|
||||
"animatedThumbnailDanger": "исходную миниатюру нельзя восстановить",
|
||||
"animatedThumbnailSizeNote": ". Чтобы данные анимации не были слишком большими, файл будет уменьшен до размера ниже заданного.",
|
||||
"animatedFormat": "Формат вывода",
|
||||
"animatedRange": "Диапазон анимации",
|
||||
"animatedStart": "Начало",
|
||||
"animatedEnd": "Конец",
|
||||
"framesUnit": "кадров",
|
||||
"animatedQuality": "Качество миниатюры",
|
||||
"animatedMaxSize": "Лимит размера файла",
|
||||
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
|
||||
"animatedMaxSize": "Размер миниатюры",
|
||||
"animatedInvalidSize": "Введите целое число от 10 до 1024 КБ",
|
||||
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
|
||||
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
|
||||
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
|
||||
|
||||
@@ -20,16 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"animatedThumbnailTitle": "生成动画缩略图",
|
||||
"animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
|
||||
"animatedThumbnailTitle": "生成动态缩略图",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "生成的缩略图会替换原有缩略图,该操作不可撤销且",
|
||||
"animatedThumbnailDanger": "无法恢复原有缩略图",
|
||||
"animatedThumbnailSizeNote": ",为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下",
|
||||
"animatedFormat": "输出格式",
|
||||
"animatedRange": "动画范围",
|
||||
"animatedStart": "开始",
|
||||
"animatedEnd": "结束",
|
||||
"framesUnit": "帧",
|
||||
"animatedQuality": "缩略图质量",
|
||||
"animatedMaxSize": "文件大小上限",
|
||||
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
|
||||
"animatedMaxSize": "缩略图大小",
|
||||
"animatedInvalidSize": "请输入 10 到 1024 KB 之间的整数",
|
||||
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
|
||||
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"animatedThumbnailTitle": "產生動畫縮圖",
|
||||
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。",
|
||||
"animatedThumbnailAttention": "注意:",
|
||||
"animatedThumbnailNote": "產生的縮圖會取代原有縮圖,該操作無法復原且",
|
||||
"animatedThumbnailDanger": "無法恢復原有縮圖",
|
||||
"animatedThumbnailSizeNote": ",為避免動態縮圖資料過大,檔案尺寸將調整至設定大小以下",
|
||||
"animatedFormat": "輸出格式",
|
||||
"animatedRange": "動畫範圍",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "結束",
|
||||
"framesUnit": "影格",
|
||||
"animatedQuality": "縮圖品質",
|
||||
"animatedMaxSize": "檔案大小上限",
|
||||
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
|
||||
"animatedMaxSize": "縮圖大小",
|
||||
"animatedInvalidSize": "請輸入 10 到 1024 KB 之間的整數",
|
||||
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
|
||||
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
function findAlphaBounds(rgba, width, height, threshold = 4) {
|
||||
let left = width, top = height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1);
|
||||
bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
}
|
||||
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
||||
}
|
||||
|
||||
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
|
||||
if (!pixels || !isFinite(scale) || scale <= 0) return null;
|
||||
const left = centerX + (pixels.left - width / 2) / scale;
|
||||
const right = centerX + (pixels.right - width / 2) / scale;
|
||||
const top = centerY - (pixels.top - height / 2) / scale;
|
||||
const bottom = centerY - (pixels.bottom - height / 2) / scale;
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
module.exports = { findAlphaBounds, toWorldBounds };
|
||||
@@ -113,7 +113,10 @@
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
.animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
|
||||
.thumb-dialog-note.warning { color: #e6b35c; }
|
||||
.animated-thumb-card .thumb-dialog-title { text-align: center; }
|
||||
.animated-thumb-warning { color: #fff; }
|
||||
.animated-thumb-warning-title { display: block; }
|
||||
.animated-thumb-danger { color: var(--error); }
|
||||
.animated-thumb-card .thumb-dialog-note { font-size: 13px; }
|
||||
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
|
||||
.animated-thumb-label { color: var(--text-dim); }
|
||||
@@ -181,7 +184,8 @@
|
||||
#viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status {
|
||||
position: absolute; bottom: calc(22px + var(--timeline-height)); left: 12px;
|
||||
position: absolute; left: 50%; bottom: calc(22px + var(--timeline-height));
|
||||
transform: translateX(-50%);
|
||||
max-width: min(560px, calc(100% - 24px));
|
||||
padding: 6px 12px;
|
||||
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.45;
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
</div>
|
||||
<div id="animatedThumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedThumbDialogTitle">
|
||||
<div class="thumb-dialog-card animated-thumb-card">
|
||||
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动画缩略图</div>
|
||||
<div class="thumb-dialog-note warning" data-i18n="viewer.animatedThumbnailNote">生成后将替换当前素材缩略图,插件无法自动恢复原图。长动画会自动降低帧率以控制文件大小。</div>
|
||||
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动态缩略图</div>
|
||||
<div class="thumb-dialog-note animated-thumb-warning"><span class="animated-thumb-warning-title" data-i18n="viewer.animatedThumbnailAttention">注意:</span><span data-i18n="viewer.animatedThumbnailNote">生成的缩略图会替换原有缩略图,该操作不可撤销且</span><span class="animated-thumb-danger" data-i18n="viewer.animatedThumbnailDanger">无法恢复原有缩略图</span><span data-i18n="viewer.animatedThumbnailSizeNote">,为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下</span></div>
|
||||
<div class="animated-thumb-form">
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedFormat">输出格式</span>
|
||||
<select id="animatedThumbFormat"><option value="webp">AWebP (.webp)</option><option value="apng">APNG (.png)</option></select>
|
||||
@@ -102,8 +102,8 @@
|
||||
<label><span data-i18n="viewer.animatedEnd">结束</span><input id="animatedThumbEnd" type="number" min="0" step="1"></label>
|
||||
<span data-i18n="viewer.framesUnit">帧</span>
|
||||
</div>
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">文件大小上限</span>
|
||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="50" max="10240" step="50" value="100"><span>KB</span></span>
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
|
||||
@@ -128,6 +128,7 @@
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
const apngEncoder = require('../js/apng-encoder.js');
|
||||
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
|
||||
const visibleBounds = require('../js/spine-visible-bounds.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -751,7 +752,8 @@
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const geometryBounds = getBounds(skeleton);
|
||||
const bounds = await measureVisibleBounds(skeleton, geometryBounds, 1, () => {}) || geometryBounds;
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
@@ -801,7 +803,7 @@
|
||||
endInput.value = String(totalFrames);
|
||||
endInput.max = String(totalFrames);
|
||||
formatInput.value = 'webp';
|
||||
maxSizeInput.value = '100';
|
||||
maxSizeInput.value = '30';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
@@ -820,7 +822,7 @@
|
||||
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 50 || maxKilobytes > 10240) {
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 10 || maxKilobytes > 1024) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidSize');
|
||||
return;
|
||||
}
|
||||
@@ -836,8 +838,7 @@
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
startInput.focus();
|
||||
startInput.select();
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') document.activeElement.blur();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -862,6 +863,40 @@
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
async function measureVisibleBounds(targetSkeleton, fallbackBounds, poseCount, applyPose) {
|
||||
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
|
||||
const size = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size; canvas.height = size;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
|
||||
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
|
||||
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
|
||||
let measured = null;
|
||||
let probeRenderer = null;
|
||||
try {
|
||||
probeRenderer = clippingRenderer.createRenderer(spine, context, activeRuntimeDir);
|
||||
probeRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
for (let index = 0; index < poseCount; index++) {
|
||||
applyPose(index);
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, size, size);
|
||||
context.save();
|
||||
context.translate(size / 2, size / 2);
|
||||
context.scale(scale, -scale);
|
||||
context.translate(-centerX, -centerY);
|
||||
probeRenderer.draw(targetSkeleton);
|
||||
context.restore();
|
||||
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
|
||||
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (probeRenderer && probeRenderer.dispose) probeRenderer.dispose();
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
function quantizeFrame(data, bits) {
|
||||
if (bits >= 8) return new Uint8Array(data);
|
||||
const levels = (1 << bits) - 1;
|
||||
@@ -1032,6 +1067,9 @@
|
||||
}
|
||||
bounds = bounds || stableFitBounds;
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
|
||||
bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index) => {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
|
||||
}) || bounds;
|
||||
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
|
||||
@@ -58,6 +58,10 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
|
||||
assert.match(script, /return smallest;/);
|
||||
assert.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/);
|
||||
assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/);
|
||||
assert.match(script, /require\('\.\.\/js\/spine-visible-bounds\.js'\)/);
|
||||
assert.match(script, /async function measureVisibleBounds/);
|
||||
assert.match(script, /const bounds = await measureVisibleBounds\(skeleton, geometryBounds, 1/);
|
||||
assert.match(script, /measureVisibleBounds\(previewSkeleton, bounds, sampleTimes\.length/);
|
||||
assert.match(script, /async function encodeAnimatedWebpToLimit/);
|
||||
assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/);
|
||||
assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/);
|
||||
@@ -67,10 +71,19 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
|
||||
assert.match(html, /id="animatedThumbStart" type="number" min="0" step="1"/);
|
||||
assert.match(html, /id="animatedThumbEnd" type="number" min="0" step="1"/);
|
||||
assert.match(html, /data-i18n="viewer\.framesUnit"/);
|
||||
assert.match(html, /id="animatedThumbMaxSize" type="number" min="50" max="10240" step="50" value="100"/);
|
||||
assert.match(html, /id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"/);
|
||||
assert.match(html, /class="animated-thumb-danger" data-i18n="viewer\.animatedThumbnailDanger"/);
|
||||
assert.match(html, /class="animated-thumb-warning-title" data-i18n="viewer\.animatedThumbnailAttention"/);
|
||||
assert.match(css, /\.animated-thumb-warning-title \{ display: block; \}/);
|
||||
assert.match(script, /maxSizeInput\.value = '30'/);
|
||||
assert.match(script, /maxKilobytes < 10 \|\| maxKilobytes > 1024/);
|
||||
assert.doesNotMatch(script, /startInput\.(?:focus|select)\(\)/);
|
||||
assert.match(script, /document\.activeElement\.blur\(\)/);
|
||||
assert.doesNotMatch(html, /id="animatedThumbQuality"/);
|
||||
assert.doesNotMatch(html, /viewer\.secondsUnit/);
|
||||
assert.match(html, /id="animatedThumbDialog"/);
|
||||
assert.match(html, /id="animatedThumbDialogTitle"[^>]*>生成动态缩略图<\/div>/);
|
||||
assert.match(css, /\.animated-thumb-card \.thumb-dialog-title \{ text-align: center; \}/);
|
||||
assert.match(html, /id="animatedThumbFormat"><option value="webp">AWebP \(\.webp\)<\/option><option value="apng">APNG \(\.png\)<\/option>/);
|
||||
assert.match(css, /\.animated-thumb-form \{[^}]*grid-template-columns: 96px minmax\(0, 1fr\)/);
|
||||
assert.match(css, /\.animated-thumb-form input\[type="number"\]/);
|
||||
|
||||
@@ -17,6 +17,7 @@ function functionBody(source, name, nextName) {
|
||||
for (const root of roots) {
|
||||
const viewerPath = path.join(root, 'viewer/spine.html');
|
||||
const html = fs.readFileSync(viewerPath, 'utf8');
|
||||
const css = fs.readFileSync(path.join(root, 'viewer/spine.css'), 'utf8');
|
||||
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
new vm.Script(script);
|
||||
|
||||
@@ -33,6 +34,7 @@ for (const root of roots) {
|
||||
|
||||
const capture = functionBody(script, 'captureStableFitBounds', 'fitToCanvas');
|
||||
assert.match(capture, /getBounds\(skeleton\)/);
|
||||
assert.match(css, /#status \{\s*position: absolute; left: 50%;[^}]*transform: translateX\(-50%\)/, 'status notice must be horizontally centered');
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
|
||||
for (const language of manifest.languages) {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { findAlphaBounds, toWorldBounds } = require('../js/spine-visible-bounds');
|
||||
|
||||
const pixels = new Uint8Array(10 * 8 * 4);
|
||||
for (let y = 2; y < 6; y++) {
|
||||
for (let x = 3; x < 8; x++) pixels[(y * 10 + x) * 4 + 3] = 255;
|
||||
}
|
||||
assert.deepEqual(findAlphaBounds(pixels, 10, 8), { left: 3, top: 2, right: 8, bottom: 6 });
|
||||
assert.deepEqual(toWorldBounds({ left: 3, top: 2, right: 8, bottom: 6 }, 10, 8, 2, 20, 30), {
|
||||
x: 19,
|
||||
y: 29,
|
||||
width: 2.5,
|
||||
height: 2,
|
||||
});
|
||||
assert.equal(findAlphaBounds(new Uint8Array(10 * 8 * 4), 10, 8), null);
|
||||
|
||||
console.log('PASS: visible alpha pixels map back to the correct Spine world bounds');
|
||||
+6
-2
@@ -113,7 +113,10 @@
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
.animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
|
||||
.thumb-dialog-note.warning { color: #e6b35c; }
|
||||
.animated-thumb-card .thumb-dialog-title { text-align: center; }
|
||||
.animated-thumb-warning { color: #fff; }
|
||||
.animated-thumb-warning-title { display: block; }
|
||||
.animated-thumb-danger { color: var(--error); }
|
||||
.animated-thumb-card .thumb-dialog-note { font-size: 13px; }
|
||||
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
|
||||
.animated-thumb-label { color: var(--text-dim); }
|
||||
@@ -181,7 +184,8 @@
|
||||
#viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; }
|
||||
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#status {
|
||||
position: absolute; bottom: calc(22px + var(--timeline-height)); left: 12px;
|
||||
position: absolute; left: 50%; bottom: calc(22px + var(--timeline-height));
|
||||
transform: translateX(-50%);
|
||||
max-width: min(560px, calc(100% - 24px));
|
||||
padding: 6px 12px;
|
||||
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.45;
|
||||
|
||||
+47
-9
@@ -89,8 +89,8 @@
|
||||
</div>
|
||||
<div id="animatedThumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedThumbDialogTitle">
|
||||
<div class="thumb-dialog-card animated-thumb-card">
|
||||
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动画缩略图</div>
|
||||
<div class="thumb-dialog-note warning" data-i18n="viewer.animatedThumbnailNote">生成后将替换当前素材缩略图,插件无法自动恢复原图。长动画会自动降低帧率以控制文件大小。</div>
|
||||
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动态缩略图</div>
|
||||
<div class="thumb-dialog-note animated-thumb-warning"><span class="animated-thumb-warning-title" data-i18n="viewer.animatedThumbnailAttention">注意:</span><span data-i18n="viewer.animatedThumbnailNote">生成的缩略图会替换原有缩略图,该操作不可撤销且</span><span class="animated-thumb-danger" data-i18n="viewer.animatedThumbnailDanger">无法恢复原有缩略图</span><span data-i18n="viewer.animatedThumbnailSizeNote">,为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下</span></div>
|
||||
<div class="animated-thumb-form">
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedFormat">输出格式</span>
|
||||
<select id="animatedThumbFormat"><option value="webp">AWebP (.webp)</option><option value="apng">APNG (.png)</option></select>
|
||||
@@ -102,8 +102,8 @@
|
||||
<label><span data-i18n="viewer.animatedEnd">结束</span><input id="animatedThumbEnd" type="number" min="0" step="1"></label>
|
||||
<span data-i18n="viewer.framesUnit">帧</span>
|
||||
</div>
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">文件大小上限</span>
|
||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="50" max="10240" step="50" value="100"><span>KB</span></span>
|
||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
|
||||
@@ -128,6 +128,7 @@
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
const apngEncoder = require('../js/apng-encoder.js');
|
||||
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
|
||||
const visibleBounds = require('../js/spine-visible-bounds.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -751,7 +752,8 @@
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const geometryBounds = getBounds(skeleton);
|
||||
const bounds = await measureVisibleBounds(skeleton, geometryBounds, 1, () => {}) || geometryBounds;
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
@@ -801,7 +803,7 @@
|
||||
endInput.value = String(totalFrames);
|
||||
endInput.max = String(totalFrames);
|
||||
formatInput.value = 'webp';
|
||||
maxSizeInput.value = '100';
|
||||
maxSizeInput.value = '30';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
@@ -820,7 +822,7 @@
|
||||
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 50 || maxKilobytes > 10240) {
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 10 || maxKilobytes > 1024) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidSize');
|
||||
return;
|
||||
}
|
||||
@@ -836,8 +838,7 @@
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
startInput.focus();
|
||||
startInput.select();
|
||||
if (document.activeElement && typeof document.activeElement.blur === 'function') document.activeElement.blur();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -862,6 +863,40 @@
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
async function measureVisibleBounds(targetSkeleton, fallbackBounds, poseCount, applyPose) {
|
||||
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
|
||||
const size = 512;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size; canvas.height = size;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
|
||||
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
|
||||
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
|
||||
let measured = null;
|
||||
let probeRenderer = null;
|
||||
try {
|
||||
probeRenderer = clippingRenderer.createRenderer(spine, context, activeRuntimeDir);
|
||||
probeRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
for (let index = 0; index < poseCount; index++) {
|
||||
applyPose(index);
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, size, size);
|
||||
context.save();
|
||||
context.translate(size / 2, size / 2);
|
||||
context.scale(scale, -scale);
|
||||
context.translate(-centerX, -centerY);
|
||||
probeRenderer.draw(targetSkeleton);
|
||||
context.restore();
|
||||
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
|
||||
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (probeRenderer && probeRenderer.dispose) probeRenderer.dispose();
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
function quantizeFrame(data, bits) {
|
||||
if (bits >= 8) return new Uint8Array(data);
|
||||
const levels = (1 << bits) - 1;
|
||||
@@ -1032,6 +1067,9 @@
|
||||
}
|
||||
bounds = bounds || stableFitBounds;
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
|
||||
bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index) => {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
|
||||
}) || bounds;
|
||||
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
|
||||
Reference in new issue
Block a user