加入awebp编码支持

This commit is contained in:
tianmo committed 2026-09-19 10:36:32 +08:00
1 parent eb2a7f7d91
commit 081d06c70e
22 files changed
+392 -11

No files matched your search

+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen",
"animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.",
"animatedFormat": "Ausgabeformat",
"animatedRange": "Animationsbereich",
"animatedStart": "Start",
"animatedEnd": "Ende",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Dateigrößenlimit",
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein",
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
"qualityLow": "Niedrig",
"qualityMedium": "Mittel",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"animatedThumbnailTitle": "Generate animated thumbnail",
"animatedThumbnailNote": "This replaces the current thumbnail and cannot be undone by the plugin. Frame count, color precision and detail are reduced first; if needed, dimensions are reduced progressively from 400×400.",
"animatedFormat": "Output format",
"animatedRange": "Animation range",
"animatedStart": "Start",
"animatedEnd": "End",
@@ -30,6 +31,7 @@
"animatedMaxSize": "File size limit",
"animatedInvalidSize": "Enter a whole number between 50 and 10240 KB",
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
"qualityLow": "Low",
"qualityMedium": "Medium",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"animatedThumbnailTitle": "Generar miniatura animada",
"animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.",
"animatedFormat": "Formato de salida",
"animatedRange": "Rango de animación",
"animatedStart": "Inicio",
"animatedEnd": "Fin",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Límite de tamaño",
"animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB",
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
"qualityLow": "Baja",
"qualityMedium": "Media",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
"animatedFormat": "出力形式",
"animatedRange": "アニメーション範囲",
"animatedStart": "開始",
"animatedEnd": "終了",
@@ -30,6 +31,7 @@
"animatedMaxSize": "ファイルサイズ上限",
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
"qualityLow": "低",
"qualityMedium": "中",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
"animatedFormat": "출력 형식",
"animatedRange": "애니메이션 범위",
"animatedStart": "시작",
"animatedEnd": "종료",
@@ -30,6 +31,7 @@
"animatedMaxSize": "파일 크기 제한",
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
"qualityLow": "낮음",
"qualityMedium": "보통",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"animatedThumbnailTitle": "Создать анимированную миниатюру",
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
"animatedFormat": "Формат вывода",
"animatedRange": "Диапазон анимации",
"animatedStart": "Начало",
"animatedEnd": "Конец",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Лимит размера файла",
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
"qualityLow": "Низкое",
"qualityMedium": "Среднее",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"animatedThumbnailTitle": "生成动画缩略图",
"animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
"animatedFormat": "输出格式",
"animatedRange": "动画范围",
"animatedStart": "开始",
"animatedEnd": "结束",
@@ -30,6 +31,7 @@
"animatedMaxSize": "文件大小上限",
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
"qualityLow": "低",
"qualityMedium": "中",
+2
View File
@@ -22,6 +22,7 @@
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"animatedThumbnailTitle": "產生動畫縮圖",
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。",
"animatedFormat": "輸出格式",
"animatedRange": "動畫範圍",
"animatedStart": "開始",
"animatedEnd": "結束",
@@ -30,6 +31,7 @@
"animatedMaxSize": "檔案大小上限",
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
"qualityLow": "低",
"qualityMedium": "中",
+77
View File
@@ -0,0 +1,77 @@
'use strict';
function writeUint24LE(buffer, offset, value) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >>> 8) & 0xff;
buffer[offset + 2] = (value >>> 16) & 0xff;
}
function chunk(type, payload) {
const padding = payload.length & 1;
const output = Buffer.alloc(8 + payload.length + padding);
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function frameData(webp) {
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
module.exports = { encode };
@@ -22,6 +22,7 @@
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen",
"animatedThumbnailNote": "Das aktuelle Vorschaubild wird ersetzt und kann vom Plugin nicht wiederhergestellt werden. Bei langen Animationen wird die Bildrate automatisch reduziert, um die Dateigröße zu begrenzen.",
"animatedFormat": "Ausgabeformat",
"animatedRange": "Animationsbereich",
"animatedStart": "Start",
"animatedEnd": "Ende",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Dateigrößenlimit",
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 50 und 10240 KB ein",
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
"qualityLow": "Niedrig",
"qualityMedium": "Mittel",
@@ -22,6 +22,7 @@
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"animatedThumbnailTitle": "Generate animated thumbnail",
"animatedThumbnailNote": "This replaces the current thumbnail and cannot be undone by the plugin. Frame count, color precision and detail are reduced first; if needed, dimensions are reduced progressively from 400×400.",
"animatedFormat": "Output format",
"animatedRange": "Animation range",
"animatedStart": "Start",
"animatedEnd": "End",
@@ -30,6 +31,7 @@
"animatedMaxSize": "File size limit",
"animatedInvalidSize": "Enter a whole number between 50 and 10240 KB",
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
"qualityLow": "Low",
"qualityMedium": "Medium",
@@ -22,6 +22,7 @@
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"animatedThumbnailTitle": "Generar miniatura animada",
"animatedThumbnailNote": "Esto reemplaza la miniatura actual y el complemento no puede restaurar la imagen anterior. Las animaciones largas reducen automáticamente la frecuencia de fotogramas para limitar el tamaño.",
"animatedFormat": "Formato de salida",
"animatedRange": "Rango de animación",
"animatedStart": "Inicio",
"animatedEnd": "Fin",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Límite de tamaño",
"animatedInvalidSize": "Introduce un número entero entre 50 y 10240 KB",
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
"qualityLow": "Baja",
"qualityMedium": "Media",
@@ -22,6 +22,7 @@
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
"animatedThumbnailNote": "現在のサムネイルが置き換えられ、プラグインから元に戻すことはできません。長いアニメーションはファイルサイズを抑えるためフレームレートを自動的に下げます。",
"animatedFormat": "出力形式",
"animatedRange": "アニメーション範囲",
"animatedStart": "開始",
"animatedEnd": "終了",
@@ -30,6 +31,7 @@
"animatedMaxSize": "ファイルサイズ上限",
"animatedInvalidSize": "50 から 10240 KB までの整数を入力してください",
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
"qualityLow": "低",
"qualityMedium": "中",
@@ -22,6 +22,7 @@
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
"animatedThumbnailNote": "현재 썸네일이 교체되며 플러그인에서 이전 이미지를 복원할 수 없습니다. 긴 애니메이션은 파일 크기를 줄이기 위해 프레임 속도를 자동으로 낮춥니다.",
"animatedFormat": "출력 형식",
"animatedRange": "애니메이션 범위",
"animatedStart": "시작",
"animatedEnd": "종료",
@@ -30,6 +31,7 @@
"animatedMaxSize": "파일 크기 제한",
"animatedInvalidSize": "50~10240KB 사이의 정수를 입력하세요",
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
"qualityLow": "낮음",
"qualityMedium": "보통",
@@ -22,6 +22,7 @@
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"animatedThumbnailTitle": "Создать анимированную миниатюру",
"animatedThumbnailNote": "Текущая миниатюра будет заменена, и плагин не сможет восстановить прежнее изображение. Для длинных анимаций частота кадров снижается автоматически.",
"animatedFormat": "Формат вывода",
"animatedRange": "Диапазон анимации",
"animatedStart": "Начало",
"animatedEnd": "Конец",
@@ -30,6 +31,7 @@
"animatedMaxSize": "Лимит размера файла",
"animatedInvalidSize": "Введите целое число от 50 до 10240 КБ",
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
"qualityLow": "Низкое",
"qualityMedium": "Среднее",
@@ -22,6 +22,7 @@
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"animatedThumbnailTitle": "生成动画缩略图",
"animatedThumbnailNote": "生成后将替换所选素材的缩略图,插件无法自动恢复原图。将自动降低帧数、颜色精度和内部细节;仍超标时会把尺寸从 400×400 逐级调低。",
"animatedFormat": "输出格式",
"animatedRange": "动画范围",
"animatedStart": "开始",
"animatedEnd": "结束",
@@ -30,6 +31,7 @@
"animatedMaxSize": "文件大小上限",
"animatedInvalidSize": "请输入 50 到 10240 KB 之间的整数",
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
"qualityLow": "低",
"qualityMedium": "中",
@@ -22,6 +22,7 @@
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"animatedThumbnailTitle": "產生動畫縮圖",
"animatedThumbnailNote": "產生後會取代目前素材縮圖,外掛無法自動復原原圖。會自動降低影格數、色彩精度和內部細節;仍超標時會將尺寸從 400×400 逐級調低。",
"animatedFormat": "輸出格式",
"animatedRange": "動畫範圍",
"animatedStart": "開始",
"animatedEnd": "結束",
@@ -30,6 +31,7 @@
"animatedMaxSize": "檔案大小上限",
"animatedInvalidSize": "請輸入 50 到 10240 KB 之間的整數",
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
"qualityLow": "低",
"qualityMedium": "中",
@@ -0,0 +1,77 @@
'use strict';
function writeUint24LE(buffer, offset, value) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >>> 8) & 0xff;
buffer[offset + 2] = (value >>> 16) & 0xff;
}
function chunk(type, payload) {
const padding = payload.length & 1;
const output = Buffer.alloc(8 + payload.length + padding);
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function frameData(webp) {
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
module.exports = { encode };
@@ -92,6 +92,9 @@
<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">
<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>
</label>
<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>
@@ -124,6 +127,7 @@
const groupThumbnails = require('../js/spine-group-thumbnails.js');
const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -785,6 +789,7 @@
const dialog = document.getElementById('animatedThumbDialog');
const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat');
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm');
@@ -795,6 +800,7 @@
startInput.max = String(totalFrames);
endInput.value = String(totalFrames);
endInput.max = String(totalFrames);
formatInput.value = 'webp';
maxSizeInput.value = '100';
errorEl.textContent = '';
dialog.classList.remove('hidden');
@@ -821,6 +827,7 @@
finish({
start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp',
maxKilobytes,
});
};
@@ -947,6 +954,65 @@
return smallest;
}
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400;
const range = options.end - options.start;
@@ -993,16 +1059,19 @@
octx.restore();
await drawSpineBadge(octx, size, size);
spineBadge.drawVersion(octx, size, size, skeletonData.version || activeVersion);
const rgba = octx.getImageData(0, 0, size, size).data;
sourceFrames.push(new Uint8Array(rgba));
const rgba = new Uint8Array(octx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
sourceFrames.push(rgba);
setStatus(t('viewer.animatedGenerating', { current: index + 1, total: frameCount }), Infinity);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
}
const encoded = await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: '.png', frames: encoded.frames, size: encoded.size };
const encoded = options.format === 'apng'
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes)
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size };
}
async function generateAnimatedThumbnail() {
@@ -1025,7 +1094,7 @@
const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + '.png');
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', {
+52
View File
@@ -0,0 +1,52 @@
'use strict';
const assert = require('node:assert/strict');
const { encode } = require('../js/animated-webp-encoder');
function chunk(type, payload) {
const output = Buffer.alloc(8 + payload.length + (payload.length & 1));
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function stillWebp() {
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
function chunks(buffer) {
const result = [];
for (let offset = 12; offset + 8 <= buffer.length;) {
const size = buffer.readUInt32LE(offset + 4);
result.push({ type: buffer.toString('ascii', offset, offset + 4), payload: buffer.subarray(offset + 8, offset + 8 + size) });
offset += 8 + size + (size & 1);
}
return result;
}
const output = encode({
width: 400,
height: 300,
frames: [{ webp: stillWebp(), duration: 50 }, { webp: stillWebp(), duration: 75 }],
});
assert.equal(output.toString('ascii', 0, 4), 'RIFF');
assert.equal(output.readUInt32LE(4), output.length - 8);
assert.equal(output.toString('ascii', 8, 12), 'WEBP');
const parsed = chunks(output);
assert.deepEqual(parsed.map((entry) => entry.type), ['VP8X', 'ANIM', 'ANMF', 'ANMF']);
assert.equal(parsed[0].payload[0], 0x02, 'VP8X must declare animation without alpha');
assert.deepEqual([...parsed[0].payload.subarray(4, 7)], [143, 1, 0]);
assert.deepEqual([...parsed[0].payload.subarray(7, 10)], [43, 1, 0]);
assert.equal(parsed[1].payload[3], 255, 'animation background must be opaque');
assert.deepEqual([...parsed[2].payload.subarray(12, 15)], [50, 0, 0]);
assert.equal(parsed[2].payload[15], 0x02, 'full frames must replace without blending');
assert.equal(parsed[2].payload.toString('ascii', 16, 20), 'VP8 ');
assert.throws(() => encode({ width: 1, height: 1, frames: [{ webp: Buffer.alloc(0), duration: 50 }] }), /at least two frames/);
console.log('PASS: animated WebP RIFF structure, timing and opaque full-frame mode');
+6 -1
View File
@@ -57,7 +57,11 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.match(script, /const outputSize = profile\.size \|\| size/);
assert.match(script, /return smallest;/);
assert.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/);
assert.match(script, /extension: '\.png'/);
assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/);
assert.match(script, /async function encodeAnimatedWebpToLimit/);
assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/);
assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/);
assert.match(script, /for \(let alpha = 3; alpha < rgba\.length; alpha \+= 4\) rgba\[alpha\] = 255/);
assert.match(script, /const totalFrames = Math\.max\(1, frameOf\(animationLength\)\)/);
assert.match(script, /start: startFrame \/ FPS/);
assert.match(html, /id="animatedThumbStart" type="number" min="0" step="1"/);
@@ -67,6 +71,7 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
assert.doesNotMatch(html, /id="animatedThumbQuality"/);
assert.doesNotMatch(html, /viewer\.secondsUnit/);
assert.match(html, /id="animatedThumbDialog"/);
assert.match(html, /id="animatedThumbFormat"><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"\]/);
}
+74 -5
View File
@@ -92,6 +92,9 @@
<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">
<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>
</label>
<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>
@@ -124,6 +127,7 @@
const groupThumbnails = require('../js/spine-group-thumbnails.js');
const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
@@ -785,6 +789,7 @@
const dialog = document.getElementById('animatedThumbDialog');
const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat');
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm');
@@ -795,6 +800,7 @@
startInput.max = String(totalFrames);
endInput.value = String(totalFrames);
endInput.max = String(totalFrames);
formatInput.value = 'webp';
maxSizeInput.value = '100';
errorEl.textContent = '';
dialog.classList.remove('hidden');
@@ -821,6 +827,7 @@
finish({
start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp',
maxKilobytes,
});
};
@@ -947,6 +954,65 @@
return smallest;
}
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400;
const range = options.end - options.start;
@@ -993,16 +1059,19 @@
octx.restore();
await drawSpineBadge(octx, size, size);
spineBadge.drawVersion(octx, size, size, skeletonData.version || activeVersion);
const rgba = octx.getImageData(0, 0, size, size).data;
sourceFrames.push(new Uint8Array(rgba));
const rgba = new Uint8Array(octx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
sourceFrames.push(rgba);
setStatus(t('viewer.animatedGenerating', { current: index + 1, total: frameCount }), Infinity);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
}
const encoded = await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: '.png', frames: encoded.frames, size: encoded.size };
const encoded = options.format === 'apng'
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes)
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size };
}
async function generateAnimatedThumbnail() {
@@ -1025,7 +1094,7 @@
const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + '.png');
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', {