动态缩略图生成
This commit is contained in:
1 parent
52bdbc9f3c
commit
c113089e2d
23 files changed
+1196
No files matched your search
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
|
||||
"thumbnail": "Als aktuelles Vorschaubild setzen",
|
||||
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
|
||||
"animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen",
|
||||
"animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.",
|
||||
"animatedRange": "Animationsbereich",
|
||||
"animatedStart": "Start",
|
||||
"animatedEnd": "Ende",
|
||||
"framesUnit": "Frames",
|
||||
"animatedQuality": "Vorschaubildqualität",
|
||||
"animatedMaxSize": "Dateigrößenlimit",
|
||||
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein",
|
||||
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
|
||||
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
|
||||
"qualityLow": "Niedrig",
|
||||
"qualityMedium": "Mittel",
|
||||
"qualityHigh": "Hoch",
|
||||
"animatedConfirm": "Erzeugen und ersetzen",
|
||||
"animatedInvalidRange": "Geben Sie einen gültigen ganzzahligen Framebereich zwischen 0 und {duration} ein",
|
||||
"animatedGenerating": "Animiertes Vorschaubild wird erzeugt {current}/{total}…",
|
||||
"animatedTooLarge": "Das animierte Vorschaubild ist größer als 50 MB. Verkürzen Sie den Bereich oder wählen Sie eine niedrigere Qualität.",
|
||||
"animatedThumbUpdated": "Animiertes Vorschaubild aktualisiert · {frames} Frames · {size} KB",
|
||||
"groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
"groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen",
|
||||
"groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
|
||||
"thumbnail": "Set current item thumbnail",
|
||||
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
|
||||
"animatedThumbnailTitle": "Generate animated thumbnail",
|
||||
"animatedThumbnailNote": "This replaces the current item thumbnail and the plugin cannot restore the previous image. Frame count, color precision and internal detail are reduced automatically to meet the size limit.",
|
||||
"animatedRange": "Animation range",
|
||||
"animatedStart": "Start",
|
||||
"animatedEnd": "End",
|
||||
"framesUnit": "frames",
|
||||
"animatedQuality": "Thumbnail quality",
|
||||
"animatedMaxSize": "File size limit",
|
||||
"animatedInvalidSize": "Enter a whole number between 50 and 10240 KB",
|
||||
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
|
||||
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
|
||||
"qualityLow": "Low",
|
||||
"qualityMedium": "Medium",
|
||||
"qualityHigh": "High",
|
||||
"animatedConfirm": "Generate and replace",
|
||||
"animatedInvalidRange": "Enter a valid whole-frame range between 0 and {duration}",
|
||||
"animatedGenerating": "Generating animated thumbnail {current}/{total}…",
|
||||
"animatedTooLarge": "The animated thumbnail exceeds 50 MB. Shorten the range or select a lower quality.",
|
||||
"animatedThumbUpdated": "Animated thumbnail updated · {frames} frames · {size} KB",
|
||||
"groupThumbnail": "Generate thumbnails for related assets",
|
||||
"groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch",
|
||||
"groupDialogTitle": "Generate thumbnails for related assets",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
|
||||
"thumbnail": "Usar como miniatura actual",
|
||||
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
|
||||
"animatedThumbnailTitle": "Generar miniatura animada",
|
||||
"animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.",
|
||||
"animatedRange": "Rango de animación",
|
||||
"animatedStart": "Inicio",
|
||||
"animatedEnd": "Fin",
|
||||
"framesUnit": "fotogramas",
|
||||
"animatedQuality": "Calidad de miniatura",
|
||||
"animatedMaxSize": "Límite de tamaño",
|
||||
"animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB",
|
||||
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
|
||||
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
|
||||
"qualityLow": "Baja",
|
||||
"qualityMedium": "Media",
|
||||
"qualityHigh": "Alta",
|
||||
"animatedConfirm": "Generar y reemplazar",
|
||||
"animatedInvalidRange": "Introduce un rango de fotogramas enteros entre 0 y {duration}",
|
||||
"animatedGenerating": "Generando miniatura animada {current}/{total}…",
|
||||
"animatedTooLarge": "La miniatura animada supera los 50 MB. Acorta el rango o elige una calidad inferior.",
|
||||
"animatedThumbUpdated": "Miniatura animada actualizada · {frames} fotogramas · {size} KB",
|
||||
"groupThumbnail": "Generar miniaturas de recursos relacionados",
|
||||
"groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes",
|
||||
"groupDialogTitle": "Generar miniaturas de recursos relacionados",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
|
||||
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
|
||||
"animatedRange": "アニメーション範囲",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "終了",
|
||||
"framesUnit": "フレーム",
|
||||
"animatedQuality": "サムネイル品質",
|
||||
"animatedMaxSize": "ファイルサイズ上限",
|
||||
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
|
||||
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
|
||||
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "生成して置き換え",
|
||||
"animatedInvalidRange": "0 から {duration} フレームまでの有効な整数フレーム範囲を入力してください",
|
||||
"animatedGenerating": "アニメーションサムネイルを生成中 {current}/{total}…",
|
||||
"animatedTooLarge": "アニメーションサムネイルが 50 MB を超えます。範囲を短くするか、低い品質を選択してください。",
|
||||
"animatedThumbUpdated": "アニメーションサムネイルを更新 · {frames} フレーム · {size} KB",
|
||||
"groupThumbnail": "関連アセットのサムネイルを生成",
|
||||
"groupThumbnailTitle": "関連する Spine アセットを確認して一括生成",
|
||||
"groupDialogTitle": "関連アセットのサムネイルを生成",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
|
||||
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
|
||||
"animatedRange": "애니메이션 범위",
|
||||
"animatedStart": "시작",
|
||||
"animatedEnd": "종료",
|
||||
"framesUnit": "프레임",
|
||||
"animatedQuality": "썸네일 품질",
|
||||
"animatedMaxSize": "파일 크기 제한",
|
||||
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
|
||||
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
|
||||
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
|
||||
"qualityLow": "낮음",
|
||||
"qualityMedium": "보통",
|
||||
"qualityHigh": "높음",
|
||||
"animatedConfirm": "생성 및 교체",
|
||||
"animatedInvalidRange": "0에서 {duration}프레임 사이의 올바른 정수 프레임 범위를 입력하세요",
|
||||
"animatedGenerating": "애니메이션 썸네일 생성 중 {current}/{total}…",
|
||||
"animatedTooLarge": "애니메이션 썸네일이 50MB를 초과합니다. 범위를 줄이거나 더 낮은 품질을 선택하세요.",
|
||||
"animatedThumbUpdated": "애니메이션 썸네일 업데이트 · {frames}프레임 · {size}KB",
|
||||
"groupThumbnail": "관련 자산 썸네일 생성",
|
||||
"groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "관련 자산 썸네일 생성",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"animatedThumbnailTitle": "Создать анимированную миниатюру",
|
||||
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
|
||||
"animatedRange": "Диапазон анимации",
|
||||
"animatedStart": "Начало",
|
||||
"animatedEnd": "Конец",
|
||||
"framesUnit": "кадров",
|
||||
"animatedQuality": "Качество миниатюры",
|
||||
"animatedMaxSize": "Лимит размера файла",
|
||||
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
|
||||
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
|
||||
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
|
||||
"qualityLow": "Низкое",
|
||||
"qualityMedium": "Среднее",
|
||||
"qualityHigh": "Высокое",
|
||||
"animatedConfirm": "Создать и заменить",
|
||||
"animatedInvalidRange": "Введите целый диапазон кадров от 0 до {duration}",
|
||||
"animatedGenerating": "Создание анимированной миниатюры {current}/{total}…",
|
||||
"animatedTooLarge": "Миниатюра превышает 50 МБ. Сократите диапазон или выберите более низкое качество.",
|
||||
"animatedThumbUpdated": "Миниатюра обновлена · {frames} кадров · {size} КБ",
|
||||
"groupThumbnail": "Создать миниатюры для связанных ресурсов",
|
||||
"groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры",
|
||||
"groupDialogTitle": "Создать миниатюры для связанных ресурсов",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"animatedThumbnailTitle": "生成动画缩略图",
|
||||
"animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节以满足体积上限。",
|
||||
"animatedRange": "动画范围",
|
||||
"animatedStart": "开始",
|
||||
"animatedEnd": "结束",
|
||||
"framesUnit": "帧",
|
||||
"animatedQuality": "缩略图质量",
|
||||
"animatedMaxSize": "文件大小上限",
|
||||
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
|
||||
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
|
||||
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "生成并替换",
|
||||
"animatedInvalidRange": "请输入 0 到 {duration} 帧之间的有效整数帧范围",
|
||||
"animatedGenerating": "正在生成动画缩略图 {current}/{total}…",
|
||||
"animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量",
|
||||
"animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB",
|
||||
"groupThumbnail": "为关联资源生成缩略图",
|
||||
"groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为关联资源生成缩略图",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"animatedThumbnailTitle": "產生動畫縮圖",
|
||||
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節以符合檔案上限。",
|
||||
"animatedRange": "動畫範圍",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "結束",
|
||||
"framesUnit": "影格",
|
||||
"animatedQuality": "縮圖品質",
|
||||
"animatedMaxSize": "檔案大小上限",
|
||||
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
|
||||
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
|
||||
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "產生並取代",
|
||||
"animatedInvalidRange": "請輸入 0 到 {duration} 影格之間的有效整數影格範圍",
|
||||
"animatedGenerating": "正在產生動畫縮圖 {current}/{total}…",
|
||||
"animatedTooLarge": "動畫縮圖超過 50 MB,請縮短範圍或選擇較低品質",
|
||||
"animatedThumbUpdated": "動畫縮圖已更新 · {frames} 影格 · {size} KB",
|
||||
"groupThumbnail": "為關聯資源產生縮圖",
|
||||
"groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為關聯資源產生縮圖",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
const zlib = require('zlib');
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let value = n;
|
||||
for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
|
||||
crcTable[n] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const name = Buffer.from(type, 'ascii');
|
||||
const body = Buffer.from(data || []);
|
||||
const out = Buffer.allocUnsafe(12 + body.length);
|
||||
out.writeUInt32BE(body.length, 0);
|
||||
name.copy(out, 4);
|
||||
body.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function paeth(left, above, upperLeft) {
|
||||
const estimate = left + above - upperLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const aboveDistance = Math.abs(estimate - above);
|
||||
const cornerDistance = Math.abs(estimate - upperLeft);
|
||||
return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left
|
||||
: aboveDistance <= cornerDistance ? above : upperLeft;
|
||||
}
|
||||
|
||||
function filterFrame(rgba, width, height) {
|
||||
const stride = width * 4;
|
||||
const result = Buffer.allocUnsafe((stride + 1) * height);
|
||||
const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride));
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowStart = y * stride;
|
||||
let bestType = 0;
|
||||
let bestScore = Infinity;
|
||||
for (let type = 0; type < 5; type++) {
|
||||
const candidate = candidates[type];
|
||||
let score = 0;
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const raw = rgba[rowStart + x];
|
||||
const left = x >= 4 ? rgba[rowStart + x - 4] : 0;
|
||||
const above = y > 0 ? rgba[rowStart - stride + x] : 0;
|
||||
const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0;
|
||||
let predicted = 0;
|
||||
if (type === 1) predicted = left;
|
||||
else if (type === 2) predicted = above;
|
||||
else if (type === 3) predicted = Math.floor((left + above) / 2);
|
||||
else if (type === 4) predicted = paeth(left, above, upperLeft);
|
||||
const filtered = (raw - predicted + 256) & 0xff;
|
||||
candidate[x] = filtered;
|
||||
score += Math.abs(filtered < 128 ? filtered : filtered - 256);
|
||||
}
|
||||
if (score < bestScore) { bestScore = score; bestType = type; }
|
||||
}
|
||||
const outputStart = y * (stride + 1);
|
||||
result[outputStart] = bestType;
|
||||
candidates[bestType].copy(result, outputStart + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encode(options) {
|
||||
const width = Number(options.width);
|
||||
const height = Number(options.height);
|
||||
const frames = options.frames || [];
|
||||
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效');
|
||||
if (!frames.length) throw new TypeError('APNG 至少需要一帧');
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
|
||||
const animationControl = Buffer.alloc(8);
|
||||
animationControl.writeUInt32BE(frames.length, 0);
|
||||
animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4);
|
||||
const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)];
|
||||
let sequence = 0;
|
||||
for (let index = 0; index < frames.length; index++) {
|
||||
const frame = frames[index];
|
||||
const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength);
|
||||
if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效');
|
||||
const control = Buffer.alloc(26);
|
||||
control.writeUInt32BE(sequence++, 0);
|
||||
control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8);
|
||||
control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16);
|
||||
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20);
|
||||
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22);
|
||||
control[24] = 0; // keep the previous frame
|
||||
control[25] = 0; // replace the full canvas
|
||||
chunks.push(chunk('fcTL', control));
|
||||
const compressed = zlib.deflateSync(filterFrame(rgba, width, height), {
|
||||
level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)),
|
||||
});
|
||||
if (index === 0) chunks.push(chunk('IDAT', compressed));
|
||||
else {
|
||||
const frameData = Buffer.allocUnsafe(4 + compressed.length);
|
||||
frameData.writeUInt32BE(sequence++, 0);
|
||||
compressed.copy(frameData, 4);
|
||||
chunks.push(chunk('fdAT', frameData));
|
||||
}
|
||||
}
|
||||
chunks.push(chunk('IEND'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { encode };
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
|
||||
"thumbnail": "Als aktuelles Vorschaubild setzen",
|
||||
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
|
||||
"animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen",
|
||||
"animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.",
|
||||
"animatedRange": "Animationsbereich",
|
||||
"animatedStart": "Start",
|
||||
"animatedEnd": "Ende",
|
||||
"framesUnit": "Frames",
|
||||
"animatedQuality": "Vorschaubildqualität",
|
||||
"animatedMaxSize": "Dateigrößenlimit",
|
||||
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein",
|
||||
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
|
||||
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
|
||||
"qualityLow": "Niedrig",
|
||||
"qualityMedium": "Mittel",
|
||||
"qualityHigh": "Hoch",
|
||||
"animatedConfirm": "Erzeugen und ersetzen",
|
||||
"animatedInvalidRange": "Geben Sie einen gültigen ganzzahligen Framebereich zwischen 0 und {duration} ein",
|
||||
"animatedGenerating": "Animiertes Vorschaubild wird erzeugt {current}/{total}…",
|
||||
"animatedTooLarge": "Das animierte Vorschaubild ist größer als 50 MB. Verkürzen Sie den Bereich oder wählen Sie eine niedrigere Qualität.",
|
||||
"animatedThumbUpdated": "Animiertes Vorschaubild aktualisiert · {frames} Frames · {size} MB",
|
||||
"groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
"groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen",
|
||||
"groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
|
||||
"thumbnail": "Set current item thumbnail",
|
||||
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
|
||||
"animatedThumbnailTitle": "Generate animated thumbnail",
|
||||
"animatedThumbnailNote": "This replaces the current item thumbnail and the plugin cannot restore the previous image. Frame count, color precision and internal detail are reduced automatically to meet the size limit.",
|
||||
"animatedRange": "Animation range",
|
||||
"animatedStart": "Start",
|
||||
"animatedEnd": "End",
|
||||
"framesUnit": "frames",
|
||||
"animatedQuality": "Thumbnail quality",
|
||||
"animatedMaxSize": "File size limit",
|
||||
"animatedInvalidSize": "Enter a whole number between 50 and 10240 KB",
|
||||
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
|
||||
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
|
||||
"qualityLow": "Low",
|
||||
"qualityMedium": "Medium",
|
||||
"qualityHigh": "High",
|
||||
"animatedConfirm": "Generate and replace",
|
||||
"animatedInvalidRange": "Enter a valid whole-frame range between 0 and {duration}",
|
||||
"animatedGenerating": "Generating animated thumbnail {current}/{total}…",
|
||||
"animatedTooLarge": "The animated thumbnail exceeds 50 MB. Shorten the range or select a lower quality.",
|
||||
"animatedThumbUpdated": "Animated thumbnail updated · {frames} frames · {size} MB",
|
||||
"groupThumbnail": "Generate thumbnails for related assets",
|
||||
"groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch",
|
||||
"groupDialogTitle": "Generate thumbnails for related assets",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
|
||||
"thumbnail": "Usar como miniatura actual",
|
||||
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
|
||||
"animatedThumbnailTitle": "Generar miniatura animada",
|
||||
"animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.",
|
||||
"animatedRange": "Rango de animación",
|
||||
"animatedStart": "Inicio",
|
||||
"animatedEnd": "Fin",
|
||||
"framesUnit": "fotogramas",
|
||||
"animatedQuality": "Calidad de miniatura",
|
||||
"animatedMaxSize": "Límite de tamaño",
|
||||
"animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB",
|
||||
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
|
||||
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
|
||||
"qualityLow": "Baja",
|
||||
"qualityMedium": "Media",
|
||||
"qualityHigh": "Alta",
|
||||
"animatedConfirm": "Generar y reemplazar",
|
||||
"animatedInvalidRange": "Introduce un rango de fotogramas enteros entre 0 y {duration}",
|
||||
"animatedGenerating": "Generando miniatura animada {current}/{total}…",
|
||||
"animatedTooLarge": "La miniatura animada supera los 50 MB. Acorta el rango o elige una calidad inferior.",
|
||||
"animatedThumbUpdated": "Miniatura animada actualizada · {frames} fotogramas · {size} MB",
|
||||
"groupThumbnail": "Generar miniaturas de recursos relacionados",
|
||||
"groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes",
|
||||
"groupDialogTitle": "Generar miniaturas de recursos relacionados",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
|
||||
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
|
||||
"animatedRange": "アニメーション範囲",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "終了",
|
||||
"framesUnit": "フレーム",
|
||||
"animatedQuality": "サムネイル品質",
|
||||
"animatedMaxSize": "ファイルサイズ上限",
|
||||
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
|
||||
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
|
||||
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "生成して置き換え",
|
||||
"animatedInvalidRange": "0 から {duration} フレームまでの有効な整数フレーム範囲を入力してください",
|
||||
"animatedGenerating": "アニメーションサムネイルを生成中 {current}/{total}…",
|
||||
"animatedTooLarge": "アニメーションサムネイルが 50 MB を超えます。範囲を短くするか、低い品質を選択してください。",
|
||||
"animatedThumbUpdated": "アニメーションサムネイルを更新 · {frames} フレーム · {size} MB",
|
||||
"groupThumbnail": "関連アセットのサムネイルを生成",
|
||||
"groupThumbnailTitle": "関連する Spine アセットを確認して一括生成",
|
||||
"groupDialogTitle": "関連アセットのサムネイルを生成",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
|
||||
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
|
||||
"animatedRange": "애니메이션 범위",
|
||||
"animatedStart": "시작",
|
||||
"animatedEnd": "종료",
|
||||
"framesUnit": "프레임",
|
||||
"animatedQuality": "썸네일 품질",
|
||||
"animatedMaxSize": "파일 크기 제한",
|
||||
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
|
||||
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
|
||||
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
|
||||
"qualityLow": "낮음",
|
||||
"qualityMedium": "보통",
|
||||
"qualityHigh": "높음",
|
||||
"animatedConfirm": "생성 및 교체",
|
||||
"animatedInvalidRange": "0에서 {duration}프레임 사이의 올바른 정수 프레임 범위를 입력하세요",
|
||||
"animatedGenerating": "애니메이션 썸네일 생성 중 {current}/{total}…",
|
||||
"animatedTooLarge": "애니메이션 썸네일이 50MB를 초과합니다. 범위를 줄이거나 더 낮은 품질을 선택하세요.",
|
||||
"animatedThumbUpdated": "애니메이션 썸네일 업데이트 · {frames}프레임 · {size}MB",
|
||||
"groupThumbnail": "관련 자산 썸네일 생성",
|
||||
"groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "관련 자산 썸네일 생성",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"animatedThumbnailTitle": "Создать анимированную миниатюру",
|
||||
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
|
||||
"animatedRange": "Диапазон анимации",
|
||||
"animatedStart": "Начало",
|
||||
"animatedEnd": "Конец",
|
||||
"framesUnit": "кадров",
|
||||
"animatedQuality": "Качество миниатюры",
|
||||
"animatedMaxSize": "Лимит размера файла",
|
||||
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
|
||||
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
|
||||
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
|
||||
"qualityLow": "Низкое",
|
||||
"qualityMedium": "Среднее",
|
||||
"qualityHigh": "Высокое",
|
||||
"animatedConfirm": "Создать и заменить",
|
||||
"animatedInvalidRange": "Введите целый диапазон кадров от 0 до {duration}",
|
||||
"animatedGenerating": "Создание анимированной миниатюры {current}/{total}…",
|
||||
"animatedTooLarge": "Миниатюра превышает 50 МБ. Сократите диапазон или выберите более низкое качество.",
|
||||
"animatedThumbUpdated": "Миниатюра обновлена · {frames} кадров · {size} МБ",
|
||||
"groupThumbnail": "Создать миниатюры для связанных ресурсов",
|
||||
"groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры",
|
||||
"groupDialogTitle": "Создать миниатюры для связанных ресурсов",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"animatedThumbnailTitle": "生成动画缩略图",
|
||||
"animatedThumbnailNote": "生成后将替换当前素材缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节以满足体积上限。",
|
||||
"animatedRange": "动画范围",
|
||||
"animatedStart": "开始",
|
||||
"animatedEnd": "结束",
|
||||
"framesUnit": "帧",
|
||||
"animatedQuality": "缩略图质量",
|
||||
"animatedMaxSize": "文件大小上限",
|
||||
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
|
||||
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
|
||||
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "生成并替换",
|
||||
"animatedInvalidRange": "请输入 0 到 {duration} 帧之间的有效整数帧范围",
|
||||
"animatedGenerating": "正在生成动画缩略图 {current}/{total}…",
|
||||
"animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量",
|
||||
"animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} MB",
|
||||
"groupThumbnail": "为关联资源生成缩略图",
|
||||
"groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为关联资源生成缩略图",
|
||||
|
||||
@@ -20,6 +20,25 @@
|
||||
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"animatedThumbnailTitle": "產生動畫縮圖",
|
||||
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節以符合檔案上限。",
|
||||
"animatedRange": "動畫範圍",
|
||||
"animatedStart": "開始",
|
||||
"animatedEnd": "結束",
|
||||
"framesUnit": "影格",
|
||||
"animatedQuality": "縮圖品質",
|
||||
"animatedMaxSize": "檔案大小上限",
|
||||
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
|
||||
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
|
||||
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
"animatedConfirm": "產生並取代",
|
||||
"animatedInvalidRange": "請輸入 0 到 {duration} 影格之間的有效整數影格範圍",
|
||||
"animatedGenerating": "正在產生動畫縮圖 {current}/{total}…",
|
||||
"animatedTooLarge": "動畫縮圖超過 50 MB,請縮短範圍或選擇較低品質",
|
||||
"animatedThumbUpdated": "動畫縮圖已更新 · {frames} 影格 · {size} MB",
|
||||
"groupThumbnail": "為關聯資源產生縮圖",
|
||||
"groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為關聯資源產生縮圖",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
const zlib = require('zlib');
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let value = n;
|
||||
for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
|
||||
crcTable[n] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const name = Buffer.from(type, 'ascii');
|
||||
const body = Buffer.from(data || []);
|
||||
const out = Buffer.allocUnsafe(12 + body.length);
|
||||
out.writeUInt32BE(body.length, 0);
|
||||
name.copy(out, 4);
|
||||
body.copy(out, 8);
|
||||
out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function paeth(left, above, upperLeft) {
|
||||
const estimate = left + above - upperLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const aboveDistance = Math.abs(estimate - above);
|
||||
const cornerDistance = Math.abs(estimate - upperLeft);
|
||||
return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left
|
||||
: aboveDistance <= cornerDistance ? above : upperLeft;
|
||||
}
|
||||
|
||||
function filterFrame(rgba, width, height) {
|
||||
const stride = width * 4;
|
||||
const result = Buffer.allocUnsafe((stride + 1) * height);
|
||||
const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride));
|
||||
for (let y = 0; y < height; y++) {
|
||||
const rowStart = y * stride;
|
||||
let bestType = 0;
|
||||
let bestScore = Infinity;
|
||||
for (let type = 0; type < 5; type++) {
|
||||
const candidate = candidates[type];
|
||||
let score = 0;
|
||||
for (let x = 0; x < stride; x++) {
|
||||
const raw = rgba[rowStart + x];
|
||||
const left = x >= 4 ? rgba[rowStart + x - 4] : 0;
|
||||
const above = y > 0 ? rgba[rowStart - stride + x] : 0;
|
||||
const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0;
|
||||
let predicted = 0;
|
||||
if (type === 1) predicted = left;
|
||||
else if (type === 2) predicted = above;
|
||||
else if (type === 3) predicted = Math.floor((left + above) / 2);
|
||||
else if (type === 4) predicted = paeth(left, above, upperLeft);
|
||||
const filtered = (raw - predicted + 256) & 0xff;
|
||||
candidate[x] = filtered;
|
||||
score += Math.abs(filtered < 128 ? filtered : filtered - 256);
|
||||
}
|
||||
if (score < bestScore) { bestScore = score; bestType = type; }
|
||||
}
|
||||
const outputStart = y * (stride + 1);
|
||||
result[outputStart] = bestType;
|
||||
candidates[bestType].copy(result, outputStart + 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function encode(options) {
|
||||
const width = Number(options.width);
|
||||
const height = Number(options.height);
|
||||
const frames = options.frames || [];
|
||||
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效');
|
||||
if (!frames.length) throw new TypeError('APNG 至少需要一帧');
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
|
||||
const animationControl = Buffer.alloc(8);
|
||||
animationControl.writeUInt32BE(frames.length, 0);
|
||||
animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4);
|
||||
const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)];
|
||||
let sequence = 0;
|
||||
for (let index = 0; index < frames.length; index++) {
|
||||
const frame = frames[index];
|
||||
const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength);
|
||||
if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效');
|
||||
const control = Buffer.alloc(26);
|
||||
control.writeUInt32BE(sequence++, 0);
|
||||
control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8);
|
||||
control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16);
|
||||
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20);
|
||||
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22);
|
||||
control[24] = 0; // keep the previous frame
|
||||
control[25] = 0; // replace the full canvas
|
||||
chunks.push(chunk('fcTL', control));
|
||||
const compressed = zlib.deflateSync(filterFrame(rgba, width, height), {
|
||||
level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)),
|
||||
});
|
||||
if (index === 0) chunks.push(chunk('IDAT', compressed));
|
||||
else {
|
||||
const frameData = Buffer.allocUnsafe(4 + compressed.length);
|
||||
frameData.writeUInt32BE(sequence++, 0);
|
||||
compressed.copy(frameData, 4);
|
||||
chunks.push(chunk('fdAT', frameData));
|
||||
}
|
||||
}
|
||||
chunks.push(chunk('IEND'));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
module.exports = { encode };
|
||||
@@ -112,6 +112,16 @@
|
||||
}
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
.animated-thumb-card { width: min(520px, 92vw); }
|
||||
.thumb-dialog-note.warning { color: #e6b35c; }
|
||||
.animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; }
|
||||
.animated-thumb-label { color: var(--text-dim); }
|
||||
.animated-thumb-range { display: flex; align-items: center; gap: 8px; }
|
||||
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
|
||||
.animated-thumb-range input { width: 88px; }
|
||||
.animated-thumb-quality { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-dim); }
|
||||
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
|
||||
.animated-thumb-size input { width: 108px; }
|
||||
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
|
||||
#toolbar #suffixSelect { max-width: 140px; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
|
||||
@@ -87,6 +87,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</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 class="animated-thumb-form">
|
||||
<div class="animated-thumb-label" data-i18n="viewer.animatedRange">动画范围</div>
|
||||
<div class="animated-thumb-range">
|
||||
<label><span data-i18n="viewer.animatedStart">开始</span><input id="animatedThumbStart" type="number" min="0" step="1"></label>
|
||||
<span>–</span>
|
||||
<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>
|
||||
</div>
|
||||
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="animatedThumbDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="animatedThumbDialogConfirm" class="primary" data-i18n="viewer.animatedConfirm">生成并替换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -100,6 +123,7 @@
|
||||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||||
const groupThumbnails = require('../js/spine-group-thumbnails.js');
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
const apngEncoder = require('../js/apng-encoder.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -757,6 +781,267 @@
|
||||
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
|
||||
}
|
||||
|
||||
function confirmAnimatedThumbnail(animation) {
|
||||
const dialog = document.getElementById('animatedThumbDialog');
|
||||
const startInput = document.getElementById('animatedThumbStart');
|
||||
const endInput = document.getElementById('animatedThumbEnd');
|
||||
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
||||
const errorEl = document.getElementById('animatedThumbDialogError');
|
||||
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
||||
const cancel = document.getElementById('animatedThumbDialogCancel');
|
||||
const animationLength = Math.max(0, animationDuration(animation));
|
||||
const totalFrames = Math.max(1, frameOf(animationLength));
|
||||
startInput.value = '0';
|
||||
startInput.max = String(totalFrames);
|
||||
endInput.value = String(totalFrames);
|
||||
endInput.max = String(totalFrames);
|
||||
maxSizeInput.value = '100';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value) => {
|
||||
dialog.classList.add('hidden');
|
||||
confirm.removeEventListener('click', accept);
|
||||
cancel.removeEventListener('click', reject);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(value);
|
||||
};
|
||||
const accept = () => {
|
||||
const startFrame = Number(startInput.value);
|
||||
const endFrame = Number(endInput.value);
|
||||
const maxKilobytes = Number(maxSizeInput.value);
|
||||
if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame) || startFrame < 0 || endFrame <= startFrame || endFrame > totalFrames) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 50 || maxKilobytes > 10240) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidSize');
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
start: startFrame / FPS,
|
||||
end: Math.min(endFrame / FPS, animationLength),
|
||||
maxKilobytes,
|
||||
});
|
||||
};
|
||||
const reject = () => finish(null);
|
||||
const keydown = (event) => { if (event.key === 'Escape') reject(); };
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
startInput.focus();
|
||||
startInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
function applyAnimationPose(targetSkeleton, targetState, animation, time) {
|
||||
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
|
||||
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
|
||||
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
|
||||
const entry = targetState.setAnimation(0, animation.name, false);
|
||||
if (entry) entry.loop = false;
|
||||
targetState.update(Math.max(0, time));
|
||||
targetState.apply(targetSkeleton);
|
||||
updateWorld(targetSkeleton);
|
||||
}
|
||||
|
||||
function mergeBounds(total, next) {
|
||||
if (!next || next.width <= 0 || next.height <= 0) return total;
|
||||
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
|
||||
const left = Math.min(total.x, next.x);
|
||||
const bottom = Math.min(total.y, next.y);
|
||||
const right = Math.max(total.x + total.width, next.x + next.width);
|
||||
const top = Math.max(total.y + total.height, next.y + next.height);
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
function quantizeFrame(data, bits) {
|
||||
if (bits >= 8) return new Uint8Array(data);
|
||||
const levels = (1 << bits) - 1;
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
|
||||
const output = new Uint8Array(data.length);
|
||||
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
|
||||
return output;
|
||||
}
|
||||
|
||||
function compressFramePixels(data, size, bits, blockSize) {
|
||||
if (blockSize <= 1) return quantizeFrame(data, bits);
|
||||
const levels = (1 << bits) - 1;
|
||||
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
|
||||
const output = new Uint8Array(data.length);
|
||||
for (let top = 0; top < size; top += blockSize) {
|
||||
for (let left = 0; left < size; left += blockSize) {
|
||||
const source = (top * size + left) * 4;
|
||||
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
|
||||
for (let y = top; y < Math.min(size, top + blockSize); y++) {
|
||||
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function resizeFramePixels(data, sourceSize, targetSize) {
|
||||
if (sourceSize === targetSize) return data;
|
||||
const output = new Uint8Array(targetSize * targetSize * 4);
|
||||
for (let y = 0; y < targetSize; y++) {
|
||||
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
|
||||
for (let x = 0; x < targetSize; x++) {
|
||||
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
|
||||
const source = (sourceY * sourceSize + sourceX) * 4;
|
||||
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) {
|
||||
const targetBytes = maxKilobytes * 1024;
|
||||
const profiles = [
|
||||
{ ratio: 1, bits: 8, block: 1 },
|
||||
{ ratio: 0.75, bits: 7, block: 1 },
|
||||
{ ratio: 0.5, bits: 7, block: 1 },
|
||||
{ ratio: 0.5, bits: 6, block: 2 },
|
||||
{ ratio: 0.33, bits: 6, block: 2 },
|
||||
{ ratio: 0.25, bits: 5, block: 3 },
|
||||
{ ratio: 0.16, bits: 5, block: 4 },
|
||||
{ count: 3, bits: 4, block: 8 },
|
||||
{ count: 2, bits: 3, block: 12 },
|
||||
{ count: 3, bits: 5, block: 1, size: 320 },
|
||||
{ count: 3, bits: 5, block: 1, size: 256 },
|
||||
{ count: 2, bits: 4, block: 1, size: 192 },
|
||||
{ count: 2, bits: 4, block: 1, size: 128 },
|
||||
{ count: 2, bits: 3, block: 1, size: 96 },
|
||||
{ count: 2, bits: 3, block: 1, size: 64 },
|
||||
{ count: 2, bits: 2, block: 1, 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 delayMs = Math.max(1, Math.round(range * 1000 / count));
|
||||
const makeFrame = (index) => {
|
||||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
|
||||
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
|
||||
return {
|
||||
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
|
||||
delayNumerator: delayMs,
|
||||
delayDenominator: 1000,
|
||||
};
|
||||
};
|
||||
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
|
||||
if (count > 3) {
|
||||
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
|
||||
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
|
||||
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
|
||||
}
|
||||
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
|
||||
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
|
||||
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;
|
||||
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
||||
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
||||
const frameDuration = range / frameCount;
|
||||
const sampleTimes = Array.from({ length: frameCount }, (_, index) => options.start + index * frameDuration);
|
||||
const previewSkeleton = new spine.Skeleton(skeletonData);
|
||||
utils.applySkin(previewSkeleton, currentSkin || utils.preferredSkin(skeletonData));
|
||||
const previewStateData = new spine.AnimationStateData(skeletonData);
|
||||
previewStateData.defaultMix = 0;
|
||||
const previewState = new spine.AnimationState(previewStateData);
|
||||
let bounds = null;
|
||||
for (const time of sampleTimes) {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, time);
|
||||
bounds = mergeBounds(bounds, getBounds(previewSkeleton));
|
||||
}
|
||||
bounds = bounds || stableFitBounds;
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
|
||||
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
let offRenderer = null;
|
||||
const sourceFrames = [];
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
|
||||
octx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
octx.clearRect(0, 0, size, size);
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-centerX, -centerY);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
offRenderer.draw(previewSkeleton);
|
||||
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));
|
||||
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 };
|
||||
}
|
||||
|
||||
async function generateAnimatedThumbnail() {
|
||||
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
if (isStaticAnimation || currentAnim === SETUP_POSE_INDEX) {
|
||||
await generateThumbnail();
|
||||
return;
|
||||
}
|
||||
const animation = (skeletonData.animations || [])[currentAnim];
|
||||
if (!animation || animationDuration(animation) <= STATIC_EPSILON) {
|
||||
await generateThumbnail();
|
||||
return;
|
||||
}
|
||||
const options = await confirmAnimatedThumbnail(animation);
|
||||
if (!options) return;
|
||||
const thumbBtn = document.getElementById('thumbBtn');
|
||||
thumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
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');
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.animatedThumbUpdated', {
|
||||
frames: rendered.frames,
|
||||
size: Math.ceil(rendered.buffer.length / 1024),
|
||||
}), 5000);
|
||||
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (error && error.message) || String(error) }), 7000);
|
||||
log('生成当前素材动画缩略图失败: ' + ((error && error.stack) || error));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
|
||||
thumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateGroupThumbnails() {
|
||||
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const groupThumbBtn = document.getElementById('groupThumbBtn');
|
||||
@@ -1575,6 +1860,10 @@
|
||||
});
|
||||
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
|
||||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||||
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
generateAnimatedThumbnail();
|
||||
});
|
||||
function seekTimelineToNearestFrame() {
|
||||
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
|
||||
timeline.value = String(snappedTime);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const { encode } = require('../js/apng-encoder');
|
||||
|
||||
function chunks(buffer) {
|
||||
const result = [];
|
||||
let offset = 8;
|
||||
while (offset < buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString('ascii', offset + 4, offset + 8);
|
||||
result.push({ type, data: buffer.subarray(offset + 8, offset + 8 + length) });
|
||||
offset += length + 12;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const red = new Uint8Array(16);
|
||||
const blue = new Uint8Array(16);
|
||||
for (let index = 0; index < 16; index += 4) {
|
||||
red.set([255, 0, 0, 255], index);
|
||||
blue.set([0, 0, 255, 255], index);
|
||||
}
|
||||
const png = encode({
|
||||
width: 2,
|
||||
height: 2,
|
||||
frames: [
|
||||
{ rgba: red, delayNumerator: 125, delayDenominator: 1000 },
|
||||
{ rgba: blue, delayNumerator: 125, delayDenominator: 1000 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
|
||||
const parsed = chunks(png);
|
||||
assert.deepEqual(parsed.map((entry) => entry.type), ['IHDR', 'acTL', 'fcTL', 'IDAT', 'fcTL', 'fdAT', 'IEND']);
|
||||
assert.equal(parsed.find((entry) => entry.type === 'acTL').data.readUInt32BE(0), 2);
|
||||
assert.equal(parsed[2].data.readUInt32BE(0), 0);
|
||||
assert.equal(parsed[4].data.readUInt32BE(0), 1);
|
||||
assert.equal(parsed[5].data.readUInt32BE(0), 2);
|
||||
|
||||
for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
|
||||
const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8');
|
||||
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
|
||||
new vm.Script(script);
|
||||
assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/);
|
||||
assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
|
||||
assert.match(script, /async function encodeAnimatedThumbnailToLimit/);
|
||||
assert.match(script, /const targetBytes = maxKilobytes \* 1024/);
|
||||
assert.match(script, /if \(buffer\.length <= targetBytes\) return/);
|
||||
assert.match(script, /count: 2, bits: 3, block: 12/);
|
||||
assert.match(script, /extension: '\.png'/);
|
||||
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"/);
|
||||
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.doesNotMatch(html, /id="animatedThumbQuality"/);
|
||||
assert.doesNotMatch(html, /viewer\.secondsUnit/);
|
||||
assert.match(html, /id="animatedThumbDialog"/);
|
||||
}
|
||||
|
||||
console.log('PASS: APNG structure, fixed 400x400 output, quality profiles and right-click wiring');
|
||||
@@ -112,6 +112,16 @@
|
||||
}
|
||||
.thumb-dialog-input.hidden { display: none; }
|
||||
.thumb-dialog-error { color: var(--error); min-height: 18px; }
|
||||
.animated-thumb-card { width: min(520px, 92vw); }
|
||||
.thumb-dialog-note.warning { color: #e6b35c; }
|
||||
.animated-thumb-form { display: grid; gap: 12px; padding: 4px 0; }
|
||||
.animated-thumb-label { color: var(--text-dim); }
|
||||
.animated-thumb-range { display: flex; align-items: center; gap: 8px; }
|
||||
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
|
||||
.animated-thumb-range input { width: 88px; }
|
||||
.animated-thumb-quality { display: flex; align-items: center; justify-content: space-between; gap: 16px; color: var(--text-dim); }
|
||||
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
|
||||
.animated-thumb-size input { width: 108px; }
|
||||
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
|
||||
#toolbar #suffixSelect { max-width: 140px; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
|
||||
@@ -87,6 +87,29 @@
|
||||
</div>
|
||||
</div>
|
||||
</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 class="animated-thumb-form">
|
||||
<div class="animated-thumb-label" data-i18n="viewer.animatedRange">动画范围</div>
|
||||
<div class="animated-thumb-range">
|
||||
<label><span data-i18n="viewer.animatedStart">开始</span><input id="animatedThumbStart" type="number" min="0" step="1"></label>
|
||||
<span>–</span>
|
||||
<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>
|
||||
</div>
|
||||
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="animatedThumbDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="animatedThumbDialogConfirm" class="primary" data-i18n="viewer.animatedConfirm">生成并替换</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -100,6 +123,7 @@
|
||||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||||
const groupThumbnails = require('../js/spine-group-thumbnails.js');
|
||||
const previewSettingsModule = require('../js/spine-preview-settings.js');
|
||||
const apngEncoder = require('../js/apng-encoder.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -757,6 +781,267 @@
|
||||
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
|
||||
}
|
||||
|
||||
function confirmAnimatedThumbnail(animation) {
|
||||
const dialog = document.getElementById('animatedThumbDialog');
|
||||
const startInput = document.getElementById('animatedThumbStart');
|
||||
const endInput = document.getElementById('animatedThumbEnd');
|
||||
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
||||
const errorEl = document.getElementById('animatedThumbDialogError');
|
||||
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
||||
const cancel = document.getElementById('animatedThumbDialogCancel');
|
||||
const animationLength = Math.max(0, animationDuration(animation));
|
||||
const totalFrames = Math.max(1, frameOf(animationLength));
|
||||
startInput.value = '0';
|
||||
startInput.max = String(totalFrames);
|
||||
endInput.value = String(totalFrames);
|
||||
endInput.max = String(totalFrames);
|
||||
maxSizeInput.value = '100';
|
||||
errorEl.textContent = '';
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (value) => {
|
||||
dialog.classList.add('hidden');
|
||||
confirm.removeEventListener('click', accept);
|
||||
cancel.removeEventListener('click', reject);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(value);
|
||||
};
|
||||
const accept = () => {
|
||||
const startFrame = Number(startInput.value);
|
||||
const endFrame = Number(endInput.value);
|
||||
const maxKilobytes = Number(maxSizeInput.value);
|
||||
if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame) || startFrame < 0 || endFrame <= startFrame || endFrame > totalFrames) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 50 || maxKilobytes > 10240) {
|
||||
errorEl.textContent = t('viewer.animatedInvalidSize');
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
start: startFrame / FPS,
|
||||
end: Math.min(endFrame / FPS, animationLength),
|
||||
maxKilobytes,
|
||||
});
|
||||
};
|
||||
const reject = () => finish(null);
|
||||
const keydown = (event) => { if (event.key === 'Escape') reject(); };
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
startInput.focus();
|
||||
startInput.select();
|
||||
});
|
||||
}
|
||||
|
||||
function applyAnimationPose(targetSkeleton, targetState, animation, time) {
|
||||
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
|
||||
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
|
||||
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
|
||||
const entry = targetState.setAnimation(0, animation.name, false);
|
||||
if (entry) entry.loop = false;
|
||||
targetState.update(Math.max(0, time));
|
||||
targetState.apply(targetSkeleton);
|
||||
updateWorld(targetSkeleton);
|
||||
}
|
||||
|
||||
function mergeBounds(total, next) {
|
||||
if (!next || next.width <= 0 || next.height <= 0) return total;
|
||||
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
|
||||
const left = Math.min(total.x, next.x);
|
||||
const bottom = Math.min(total.y, next.y);
|
||||
const right = Math.max(total.x + total.width, next.x + next.width);
|
||||
const top = Math.max(total.y + total.height, next.y + next.height);
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
function quantizeFrame(data, bits) {
|
||||
if (bits >= 8) return new Uint8Array(data);
|
||||
const levels = (1 << bits) - 1;
|
||||
const lookup = new Uint8Array(256);
|
||||
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
|
||||
const output = new Uint8Array(data.length);
|
||||
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
|
||||
return output;
|
||||
}
|
||||
|
||||
function compressFramePixels(data, size, bits, blockSize) {
|
||||
if (blockSize <= 1) return quantizeFrame(data, bits);
|
||||
const levels = (1 << bits) - 1;
|
||||
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
|
||||
const output = new Uint8Array(data.length);
|
||||
for (let top = 0; top < size; top += blockSize) {
|
||||
for (let left = 0; left < size; left += blockSize) {
|
||||
const source = (top * size + left) * 4;
|
||||
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
|
||||
for (let y = top; y < Math.min(size, top + blockSize); y++) {
|
||||
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function resizeFramePixels(data, sourceSize, targetSize) {
|
||||
if (sourceSize === targetSize) return data;
|
||||
const output = new Uint8Array(targetSize * targetSize * 4);
|
||||
for (let y = 0; y < targetSize; y++) {
|
||||
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
|
||||
for (let x = 0; x < targetSize; x++) {
|
||||
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
|
||||
const source = (sourceY * sourceSize + sourceX) * 4;
|
||||
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) {
|
||||
const targetBytes = maxKilobytes * 1024;
|
||||
const profiles = [
|
||||
{ ratio: 1, bits: 8, block: 1 },
|
||||
{ ratio: 0.75, bits: 7, block: 1 },
|
||||
{ ratio: 0.5, bits: 7, block: 1 },
|
||||
{ ratio: 0.5, bits: 6, block: 2 },
|
||||
{ ratio: 0.33, bits: 6, block: 2 },
|
||||
{ ratio: 0.25, bits: 5, block: 3 },
|
||||
{ ratio: 0.16, bits: 5, block: 4 },
|
||||
{ count: 3, bits: 4, block: 8 },
|
||||
{ count: 2, bits: 3, block: 12 },
|
||||
{ count: 3, bits: 5, block: 1, size: 320 },
|
||||
{ count: 3, bits: 5, block: 1, size: 256 },
|
||||
{ count: 2, bits: 4, block: 1, size: 192 },
|
||||
{ count: 2, bits: 4, block: 1, size: 128 },
|
||||
{ count: 2, bits: 3, block: 1, size: 96 },
|
||||
{ count: 2, bits: 3, block: 1, size: 64 },
|
||||
{ count: 2, bits: 2, block: 1, 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 delayMs = Math.max(1, Math.round(range * 1000 / count));
|
||||
const makeFrame = (index) => {
|
||||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
|
||||
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
|
||||
return {
|
||||
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
|
||||
delayNumerator: delayMs,
|
||||
delayDenominator: 1000,
|
||||
};
|
||||
};
|
||||
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
|
||||
if (count > 3) {
|
||||
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
|
||||
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
|
||||
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
|
||||
}
|
||||
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
|
||||
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
|
||||
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;
|
||||
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
||||
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
||||
const frameDuration = range / frameCount;
|
||||
const sampleTimes = Array.from({ length: frameCount }, (_, index) => options.start + index * frameDuration);
|
||||
const previewSkeleton = new spine.Skeleton(skeletonData);
|
||||
utils.applySkin(previewSkeleton, currentSkin || utils.preferredSkin(skeletonData));
|
||||
const previewStateData = new spine.AnimationStateData(skeletonData);
|
||||
previewStateData.defaultMix = 0;
|
||||
const previewState = new spine.AnimationState(previewStateData);
|
||||
let bounds = null;
|
||||
for (const time of sampleTimes) {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, time);
|
||||
bounds = mergeBounds(bounds, getBounds(previewSkeleton));
|
||||
}
|
||||
bounds = bounds || stableFitBounds;
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
|
||||
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
let offRenderer = null;
|
||||
const sourceFrames = [];
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
|
||||
octx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
octx.clearRect(0, 0, size, size);
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-centerX, -centerY);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
offRenderer.draw(previewSkeleton);
|
||||
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));
|
||||
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 };
|
||||
}
|
||||
|
||||
async function generateAnimatedThumbnail() {
|
||||
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
if (isStaticAnimation || currentAnim === SETUP_POSE_INDEX) {
|
||||
await generateThumbnail();
|
||||
return;
|
||||
}
|
||||
const animation = (skeletonData.animations || [])[currentAnim];
|
||||
if (!animation || animationDuration(animation) <= STATIC_EPSILON) {
|
||||
await generateThumbnail();
|
||||
return;
|
||||
}
|
||||
const options = await confirmAnimatedThumbnail(animation);
|
||||
if (!options) return;
|
||||
const thumbBtn = document.getElementById('thumbBtn');
|
||||
thumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
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');
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.animatedThumbUpdated', {
|
||||
frames: rendered.frames,
|
||||
size: Math.ceil(rendered.buffer.length / 1024),
|
||||
}), 5000);
|
||||
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
|
||||
} catch (error) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (error && error.message) || String(error) }), 7000);
|
||||
log('生成当前素材动画缩略图失败: ' + ((error && error.stack) || error));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
|
||||
thumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateGroupThumbnails() {
|
||||
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const groupThumbBtn = document.getElementById('groupThumbBtn');
|
||||
@@ -1575,6 +1860,10 @@
|
||||
});
|
||||
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
|
||||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||||
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
|
||||
event.preventDefault();
|
||||
generateAnimatedThumbnail();
|
||||
});
|
||||
function seekTimelineToNearestFrame() {
|
||||
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
|
||||
timeline.value = String(snappedTime);
|
||||
|
||||
Reference in new issue
Block a user