优化缩略图
This commit is contained in:
1 parent
081520dbd9
commit
fe4997f913
21 files changed
+128
-21
No files matched your search
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
|
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
|
||||||
"animatedPickConfirm": "Bestätigen",
|
"animatedPickConfirm": "Bestätigen",
|
||||||
"animatedPriority": "Priorität",
|
"animatedPriority": "Priorität",
|
||||||
|
"animatedCanvas": "Ausgabegröße",
|
||||||
"priorityResolution": "Auflösung zuerst",
|
"priorityResolution": "Auflösung zuerst",
|
||||||
"priorityFramerate": "Bildrate zuerst",
|
"priorityFramerate": "Bildrate zuerst",
|
||||||
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
|
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
|
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
|
||||||
"animatedPickConfirm": "Confirm",
|
"animatedPickConfirm": "Confirm",
|
||||||
"animatedPriority": "Priority",
|
"animatedPriority": "Priority",
|
||||||
|
"animatedCanvas": "Canvas size",
|
||||||
"priorityResolution": "Resolution first",
|
"priorityResolution": "Resolution first",
|
||||||
"priorityFramerate": "Frame rate first",
|
"priorityFramerate": "Frame rate first",
|
||||||
"priorityBalanced": "Alternate resolution and frame rate",
|
"priorityBalanced": "Alternate resolution and frame rate",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
|
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
|
||||||
"animatedPickConfirm": "Confirmar",
|
"animatedPickConfirm": "Confirmar",
|
||||||
"animatedPriority": "Prioridad",
|
"animatedPriority": "Prioridad",
|
||||||
|
"animatedCanvas": "Tamaño del lienzo",
|
||||||
"priorityResolution": "Resolución primero",
|
"priorityResolution": "Resolución primero",
|
||||||
"priorityFramerate": "Frecuencia primero",
|
"priorityFramerate": "Frecuencia primero",
|
||||||
"priorityBalanced": "Alternar resolución y frecuencia",
|
"priorityBalanced": "Alternar resolución y frecuencia",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
|
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
|
||||||
"animatedPickConfirm": "確認",
|
"animatedPickConfirm": "確認",
|
||||||
"animatedPriority": "優先度",
|
"animatedPriority": "優先度",
|
||||||
|
"animatedCanvas": "出力サイズ",
|
||||||
"priorityResolution": "解像度優先",
|
"priorityResolution": "解像度優先",
|
||||||
"priorityFramerate": "フレームレート優先",
|
"priorityFramerate": "フレームレート優先",
|
||||||
"priorityBalanced": "解像度とフレームレートを交互に",
|
"priorityBalanced": "解像度とフレームレートを交互に",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
|
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
|
||||||
"animatedPickConfirm": "확인",
|
"animatedPickConfirm": "확인",
|
||||||
"animatedPriority": "우선 순위",
|
"animatedPriority": "우선 순위",
|
||||||
|
"animatedCanvas": "출력 크기",
|
||||||
"priorityResolution": "해상도 우선",
|
"priorityResolution": "해상도 우선",
|
||||||
"priorityFramerate": "프레임률 우선",
|
"priorityFramerate": "프레임률 우선",
|
||||||
"priorityBalanced": "해상도와 프레임률 교차",
|
"priorityBalanced": "해상도와 프레임률 교차",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
|
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
|
||||||
"animatedPickConfirm": "Подтвердить",
|
"animatedPickConfirm": "Подтвердить",
|
||||||
"animatedPriority": "Приоритет",
|
"animatedPriority": "Приоритет",
|
||||||
|
"animatedCanvas": "Размер холста",
|
||||||
"priorityResolution": "Сначала разрешение",
|
"priorityResolution": "Сначала разрешение",
|
||||||
"priorityFramerate": "Сначала частота кадров",
|
"priorityFramerate": "Сначала частота кадров",
|
||||||
"priorityBalanced": "Чередовать разрешение и частоту",
|
"priorityBalanced": "Чередовать разрешение и частоту",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
|
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
|
||||||
"animatedPickConfirm": "确认",
|
"animatedPickConfirm": "确认",
|
||||||
"animatedPriority": "优先级别",
|
"animatedPriority": "优先级别",
|
||||||
|
"animatedCanvas": "输出尺寸",
|
||||||
"priorityResolution": "分辨率优先",
|
"priorityResolution": "分辨率优先",
|
||||||
"priorityFramerate": "帧率优先",
|
"priorityFramerate": "帧率优先",
|
||||||
"priorityBalanced": "分辨率和帧率交替进行",
|
"priorityBalanced": "分辨率和帧率交替进行",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
|
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
|
||||||
"animatedPickConfirm": "確認",
|
"animatedPickConfirm": "確認",
|
||||||
"animatedPriority": "優先級別",
|
"animatedPriority": "優先級別",
|
||||||
|
"animatedCanvas": "輸出尺寸",
|
||||||
"priorityResolution": "解析度優先",
|
"priorityResolution": "解析度優先",
|
||||||
"priorityFramerate": "影格率優先",
|
"priorityFramerate": "影格率優先",
|
||||||
"priorityBalanced": "解析度與影格率交替進行",
|
"priorityBalanced": "解析度與影格率交替進行",
|
||||||
|
|||||||
+30
-6
@@ -32,19 +32,35 @@ const ANIMATED_PRIORITIES = {
|
|||||||
|
|
||||||
const DEFAULT_PRIORITY = 'resolution';
|
const DEFAULT_PRIORITY = 'resolution';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选的输出画布边长(正方形),界面上的下拉就是这两项。
|
||||||
|
*
|
||||||
|
* Eagle 缩略图网格里每格实际只显示 100–240px 左右,200 与 400 在这个尺寸下几乎看不出差别,
|
||||||
|
* 但像素数只有四分之一,体积能降约 4 倍。配置里写的细节分辨率超过所选边长时会被压下来,
|
||||||
|
* 所以同一份档位表在两种尺寸下都能用。
|
||||||
|
*/
|
||||||
|
const ANIMATED_OUTPUT_SIZES = [200, 400];
|
||||||
|
const DEFAULT_OUTPUT_SIZE = 200;
|
||||||
|
|
||||||
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
|
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
|
||||||
const PATH_STEPS_PER_SEGMENT = 6;
|
const PATH_STEPS_PER_SEGMENT = 6;
|
||||||
|
|
||||||
function animatedProfiles(priority) {
|
function animatedProfiles(priority, outputSize) {
|
||||||
|
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||||
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||||
return plan.map(([size, fps, quality]) => ({ size, fps, quality }));
|
return plan.map(([size, fps, quality]) => ({
|
||||||
|
size: Math.min(size, limit),
|
||||||
|
fps,
|
||||||
|
quality,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
|
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
|
||||||
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
||||||
*/
|
*/
|
||||||
function expandAnimatedPath(priority) {
|
function expandAnimatedPath(priority, outputSize) {
|
||||||
|
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||||
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||||
const lerp = (from, to, t) => from + (to - from) * t;
|
const lerp = (from, to, t) => from + (to - from) * t;
|
||||||
const path = [];
|
const path = [];
|
||||||
@@ -54,15 +70,23 @@ function expandAnimatedPath(priority) {
|
|||||||
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
||||||
const t = step / PATH_STEPS_PER_SEGMENT;
|
const t = step / PATH_STEPS_PER_SEGMENT;
|
||||||
path.push({
|
path.push({
|
||||||
size: Math.round(lerp(fromSize, toSize, t)),
|
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
|
||||||
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
||||||
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [size, fps, quality] = anchors[anchors.length - 1];
|
const [size, fps, quality] = anchors[anchors.length - 1];
|
||||||
path.push({ size, fps, quality });
|
path.push({ size: Math.min(size, limit), fps, quality });
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, PATH_STEPS_PER_SEGMENT, animatedProfiles, expandAnimatedPath };
|
module.exports = {
|
||||||
|
ANIMATED_PRIORITIES,
|
||||||
|
DEFAULT_PRIORITY,
|
||||||
|
ANIMATED_OUTPUT_SIZES,
|
||||||
|
DEFAULT_OUTPUT_SIZE,
|
||||||
|
PATH_STEPS_PER_SEGMENT,
|
||||||
|
animatedProfiles,
|
||||||
|
expandAnimatedPath,
|
||||||
|
};
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
|
"animatedPickHint": "Alle Ergebnisse behalten eine 400×400-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
|
||||||
"animatedPickConfirm": "Bestätigen",
|
"animatedPickConfirm": "Bestätigen",
|
||||||
"animatedPriority": "Priorität",
|
"animatedPriority": "Priorität",
|
||||||
|
"animatedCanvas": "Ausgabegröße",
|
||||||
"priorityResolution": "Auflösung zuerst",
|
"priorityResolution": "Auflösung zuerst",
|
||||||
"priorityFramerate": "Bildrate zuerst",
|
"priorityFramerate": "Bildrate zuerst",
|
||||||
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
|
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
|
"animatedPickHint": "Every result keeps a 400×400 canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
|
||||||
"animatedPickConfirm": "Confirm",
|
"animatedPickConfirm": "Confirm",
|
||||||
"animatedPriority": "Priority",
|
"animatedPriority": "Priority",
|
||||||
|
"animatedCanvas": "Canvas size",
|
||||||
"priorityResolution": "Resolution first",
|
"priorityResolution": "Resolution first",
|
||||||
"priorityFramerate": "Frame rate first",
|
"priorityFramerate": "Frame rate first",
|
||||||
"priorityBalanced": "Alternate resolution and frame rate",
|
"priorityBalanced": "Alternate resolution and frame rate",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
|
"animatedPickHint": "Todos los resultados mantienen un lienzo de 400×400; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
|
||||||
"animatedPickConfirm": "Confirmar",
|
"animatedPickConfirm": "Confirmar",
|
||||||
"animatedPriority": "Prioridad",
|
"animatedPriority": "Prioridad",
|
||||||
|
"animatedCanvas": "Tamaño del lienzo",
|
||||||
"priorityResolution": "Resolución primero",
|
"priorityResolution": "Resolución primero",
|
||||||
"priorityFramerate": "Frecuencia primero",
|
"priorityFramerate": "Frecuencia primero",
|
||||||
"priorityBalanced": "Alternar resolución y frecuencia",
|
"priorityBalanced": "Alternar resolución y frecuencia",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
|
"animatedPickHint": "すべての結果は 400×400 のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
|
||||||
"animatedPickConfirm": "確認",
|
"animatedPickConfirm": "確認",
|
||||||
"animatedPriority": "優先度",
|
"animatedPriority": "優先度",
|
||||||
|
"animatedCanvas": "出力サイズ",
|
||||||
"priorityResolution": "解像度優先",
|
"priorityResolution": "解像度優先",
|
||||||
"priorityFramerate": "フレームレート優先",
|
"priorityFramerate": "フレームレート優先",
|
||||||
"priorityBalanced": "解像度とフレームレートを交互に",
|
"priorityBalanced": "解像度とフレームレートを交互に",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
|
"animatedPickHint": "모든 결과의 캔버스는 400×400이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
|
||||||
"animatedPickConfirm": "확인",
|
"animatedPickConfirm": "확인",
|
||||||
"animatedPriority": "우선 순위",
|
"animatedPriority": "우선 순위",
|
||||||
|
"animatedCanvas": "출력 크기",
|
||||||
"priorityResolution": "해상도 우선",
|
"priorityResolution": "해상도 우선",
|
||||||
"priorityFramerate": "프레임률 우선",
|
"priorityFramerate": "프레임률 우선",
|
||||||
"priorityBalanced": "해상도와 프레임률 교차",
|
"priorityBalanced": "해상도와 프레임률 교차",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
|
"animatedPickHint": "У всех результатов холст 400×400; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
|
||||||
"animatedPickConfirm": "Подтвердить",
|
"animatedPickConfirm": "Подтвердить",
|
||||||
"animatedPriority": "Приоритет",
|
"animatedPriority": "Приоритет",
|
||||||
|
"animatedCanvas": "Размер холста",
|
||||||
"priorityResolution": "Сначала разрешение",
|
"priorityResolution": "Сначала разрешение",
|
||||||
"priorityFramerate": "Сначала частота кадров",
|
"priorityFramerate": "Сначала частота кадров",
|
||||||
"priorityBalanced": "Чередовать разрешение и частоту",
|
"priorityBalanced": "Чередовать разрешение и частоту",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
|
"animatedPickHint": "所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
|
||||||
"animatedPickConfirm": "确认",
|
"animatedPickConfirm": "确认",
|
||||||
"animatedPriority": "优先级别",
|
"animatedPriority": "优先级别",
|
||||||
|
"animatedCanvas": "输出尺寸",
|
||||||
"priorityResolution": "分辨率优先",
|
"priorityResolution": "分辨率优先",
|
||||||
"priorityFramerate": "帧率优先",
|
"priorityFramerate": "帧率优先",
|
||||||
"priorityBalanced": "分辨率和帧率交替进行",
|
"priorityBalanced": "分辨率和帧率交替进行",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
|
"animatedPickHint": "所有結果畫布都是 400×400,標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
|
||||||
"animatedPickConfirm": "確認",
|
"animatedPickConfirm": "確認",
|
||||||
"animatedPriority": "優先級別",
|
"animatedPriority": "優先級別",
|
||||||
|
"animatedCanvas": "輸出尺寸",
|
||||||
"priorityResolution": "解析度優先",
|
"priorityResolution": "解析度優先",
|
||||||
"priorityFramerate": "影格率優先",
|
"priorityFramerate": "影格率優先",
|
||||||
"priorityBalanced": "解析度與影格率交替進行",
|
"priorityBalanced": "解析度與影格率交替進行",
|
||||||
|
|||||||
@@ -32,19 +32,35 @@ const ANIMATED_PRIORITIES = {
|
|||||||
|
|
||||||
const DEFAULT_PRIORITY = 'resolution';
|
const DEFAULT_PRIORITY = 'resolution';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可选的输出画布边长(正方形),界面上的下拉就是这两项。
|
||||||
|
*
|
||||||
|
* Eagle 缩略图网格里每格实际只显示 100–240px 左右,200 与 400 在这个尺寸下几乎看不出差别,
|
||||||
|
* 但像素数只有四分之一,体积能降约 4 倍。配置里写的细节分辨率超过所选边长时会被压下来,
|
||||||
|
* 所以同一份档位表在两种尺寸下都能用。
|
||||||
|
*/
|
||||||
|
const ANIMATED_OUTPUT_SIZES = [200, 400];
|
||||||
|
const DEFAULT_OUTPUT_SIZE = 200;
|
||||||
|
|
||||||
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
|
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
|
||||||
const PATH_STEPS_PER_SEGMENT = 6;
|
const PATH_STEPS_PER_SEGMENT = 6;
|
||||||
|
|
||||||
function animatedProfiles(priority) {
|
function animatedProfiles(priority, outputSize) {
|
||||||
|
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||||
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||||
return plan.map(([size, fps, quality]) => ({ size, fps, quality }));
|
return plan.map(([size, fps, quality]) => ({
|
||||||
|
size: Math.min(size, limit),
|
||||||
|
fps,
|
||||||
|
quality,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
|
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
|
||||||
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
||||||
*/
|
*/
|
||||||
function expandAnimatedPath(priority) {
|
function expandAnimatedPath(priority, outputSize) {
|
||||||
|
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||||
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||||
const lerp = (from, to, t) => from + (to - from) * t;
|
const lerp = (from, to, t) => from + (to - from) * t;
|
||||||
const path = [];
|
const path = [];
|
||||||
@@ -54,15 +70,23 @@ function expandAnimatedPath(priority) {
|
|||||||
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
||||||
const t = step / PATH_STEPS_PER_SEGMENT;
|
const t = step / PATH_STEPS_PER_SEGMENT;
|
||||||
path.push({
|
path.push({
|
||||||
size: Math.round(lerp(fromSize, toSize, t)),
|
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
|
||||||
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
||||||
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [size, fps, quality] = anchors[anchors.length - 1];
|
const [size, fps, quality] = anchors[anchors.length - 1];
|
||||||
path.push({ size, fps, quality });
|
path.push({ size: Math.min(size, limit), fps, quality });
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ANIMATED_PRIORITIES, DEFAULT_PRIORITY, PATH_STEPS_PER_SEGMENT, animatedProfiles, expandAnimatedPath };
|
module.exports = {
|
||||||
|
ANIMATED_PRIORITIES,
|
||||||
|
DEFAULT_PRIORITY,
|
||||||
|
ANIMATED_OUTPUT_SIZES,
|
||||||
|
DEFAULT_OUTPUT_SIZE,
|
||||||
|
PATH_STEPS_PER_SEGMENT,
|
||||||
|
animatedProfiles,
|
||||||
|
expandAnimatedPath,
|
||||||
|
};
|
||||||
@@ -105,6 +105,12 @@
|
|||||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
||||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedCanvas">输出尺寸</span>
|
||||||
|
<select id="animatedThumbCanvas">
|
||||||
|
<option value="200" selected>200 × 200</option>
|
||||||
|
<option value="400">400 × 400</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
|
||||||
<select id="animatedThumbPriority">
|
<select id="animatedThumbPriority">
|
||||||
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
|
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
|
||||||
@@ -818,6 +824,7 @@
|
|||||||
const endInput = document.getElementById('animatedThumbEnd');
|
const endInput = document.getElementById('animatedThumbEnd');
|
||||||
const formatInput = document.getElementById('animatedThumbFormat');
|
const formatInput = document.getElementById('animatedThumbFormat');
|
||||||
const priorityInput = document.getElementById('animatedThumbPriority');
|
const priorityInput = document.getElementById('animatedThumbPriority');
|
||||||
|
const canvasInput = document.getElementById('animatedThumbCanvas');
|
||||||
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
||||||
const errorEl = document.getElementById('animatedThumbDialogError');
|
const errorEl = document.getElementById('animatedThumbDialogError');
|
||||||
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
||||||
@@ -830,6 +837,7 @@
|
|||||||
endInput.max = String(totalFrames);
|
endInput.max = String(totalFrames);
|
||||||
formatInput.value = 'webp';
|
formatInput.value = 'webp';
|
||||||
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
|
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
|
||||||
|
canvasInput.value = String(animatedProfileConfig.DEFAULT_OUTPUT_SIZE);
|
||||||
maxSizeInput.value = '30';
|
maxSizeInput.value = '30';
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
dialog.classList.remove('hidden');
|
dialog.classList.remove('hidden');
|
||||||
@@ -858,6 +866,7 @@
|
|||||||
end: Math.min(endFrame / FPS, animationLength),
|
end: Math.min(endFrame / FPS, animationLength),
|
||||||
format: formatInput.value === 'apng' ? 'apng' : 'webp',
|
format: formatInput.value === 'apng' ? 'apng' : 'webp',
|
||||||
priority: priorityInput.value,
|
priority: priorityInput.value,
|
||||||
|
canvasSize: Number(canvasInput.value) || animatedProfileConfig.DEFAULT_OUTPUT_SIZE,
|
||||||
maxKilobytes,
|
maxKilobytes,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -950,7 +959,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderAnimatedSourceFrames(animation, options) {
|
async function renderAnimatedSourceFrames(animation, options) {
|
||||||
const size = 400;
|
const size = options.canvasSize || animatedProfileConfig.DEFAULT_OUTPUT_SIZE;
|
||||||
const range = options.end - options.start;
|
const range = options.end - options.start;
|
||||||
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
||||||
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
||||||
@@ -1101,8 +1110,8 @@
|
|||||||
const confirmButton = document.getElementById('animatedPickConfirm');
|
const confirmButton = document.getElementById('animatedPickConfirm');
|
||||||
const targetBytes = options.maxKilobytes * 1024;
|
const targetBytes = options.maxKilobytes * 1024;
|
||||||
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
|
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
|
||||||
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority);
|
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority, options.canvasSize);
|
||||||
const candidateCount = animatedProfiles(options.priority).length;
|
const candidateCount = animatedProfiles(options.priority, options.canvasSize).length;
|
||||||
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
|
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
|
||||||
const pool = Array.from({ length: 4 }, () => {
|
const pool = Array.from({ length: 4 }, () => {
|
||||||
const off = document.createElement('canvas');
|
const off = document.createElement('canvas');
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ for (const [name, plan] of Object.entries(priorities)) {
|
|||||||
const list = profilesModule.animatedProfiles(name);
|
const list = profilesModule.animatedProfiles(name);
|
||||||
assert.equal(list.length, plan.length, name + ' 档位数必须跟随配置长度');
|
assert.equal(list.length, plan.length, name + ' 档位数必须跟随配置长度');
|
||||||
list.forEach((profile, index) => {
|
list.forEach((profile, index) => {
|
||||||
assert.equal(profile.size, plan[index][0], name + ' 第 ' + (index + 1) + ' 档分辨率');
|
assert.equal(
|
||||||
|
profile.size,
|
||||||
|
Math.min(plan[index][0], profilesModule.DEFAULT_OUTPUT_SIZE),
|
||||||
|
name + ' 第 ' + (index + 1) + ' 档分辨率(超过输出尺寸的要被压下来)',
|
||||||
|
);
|
||||||
assert.equal(profile.fps, plan[index][1], name + ' 第 ' + (index + 1) + ' 档帧率');
|
assert.equal(profile.fps, plan[index][1], name + ' 第 ' + (index + 1) + ' 档帧率');
|
||||||
assert.equal(profile.quality, plan[index][2], name + ' 第 ' + (index + 1) + ' 档质量');
|
assert.equal(profile.quality, plan[index][2], name + ' 第 ' + (index + 1) + ' 档质量');
|
||||||
});
|
});
|
||||||
@@ -74,6 +78,23 @@ assert.deepEqual(
|
|||||||
profilesModule.animatedProfiles(profilesModule.DEFAULT_PRIORITY),
|
profilesModule.animatedProfiles(profilesModule.DEFAULT_PRIORITY),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 动态缩略图可选 200×200 或 400×400,默认 200(Eagle 缩略图格子里看不出差别,体积只有四分之一);
|
||||||
|
// 配置里超过所选尺寸的细节分辨率会被自动压下来,所以同一份档位表两种尺寸都能用。
|
||||||
|
assert.deepEqual(profilesModule.ANIMATED_OUTPUT_SIZES, [200, 400]);
|
||||||
|
assert.equal(profilesModule.DEFAULT_OUTPUT_SIZE, 200);
|
||||||
|
profilesModule.ANIMATED_PRIORITIES.__size = [[400, 15, 0.8], [100, 6, 0.2]];
|
||||||
|
assert.deepEqual(profilesModule.animatedProfiles('__size').map((item) => item.size), [200, 100], '默认按 200 裁剪');
|
||||||
|
assert.deepEqual(profilesModule.animatedProfiles('__size', 400).map((item) => item.size), [400, 100], '选 400 时保留 400');
|
||||||
|
assert.ok(
|
||||||
|
profilesModule.expandAnimatedPath('__size').every((item) => item.size <= profilesModule.DEFAULT_OUTPUT_SIZE),
|
||||||
|
'插值路径不能超过默认输出尺寸',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
profilesModule.expandAnimatedPath('__size', 400).every((item) => item.size <= 400),
|
||||||
|
'选 400 时插值路径上限跟着放宽',
|
||||||
|
);
|
||||||
|
delete profilesModule.ANIMATED_PRIORITIES.__size;
|
||||||
|
|
||||||
// 插值路径供按目标体积定位使用:比锚点更密,首尾与锚点一致。
|
// 插值路径供按目标体积定位使用:比锚点更密,首尾与锚点一致。
|
||||||
// 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以不要求整条路径单调。
|
// 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以不要求整条路径单调。
|
||||||
for (const name of Object.keys(priorities)) {
|
for (const name of Object.keys(priorities)) {
|
||||||
@@ -98,14 +119,18 @@ for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
|
|||||||
assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/);
|
assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/);
|
||||||
assert.match(script, /async function generateGroupThumbnails\(animated = false\)/);
|
assert.match(script, /async function generateGroupThumbnails\(animated = false\)/);
|
||||||
assert.match(script, /if \(animated[\s\S]*renderAnimatedSourceFrames\(animation, options\)[\s\S]*pickAnimatedThumbnail\(source\.sourceFrames, source\.size, source\.range, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/);
|
assert.match(script, /if \(animated[\s\S]*renderAnimatedSourceFrames\(animation, options\)[\s\S]*pickAnimatedThumbnail\(source\.sourceFrames, source\.size, source\.range, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/);
|
||||||
assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
|
assert.match(script, /const size = options\.canvasSize \|\| animatedProfileConfig\.DEFAULT_OUTPUT_SIZE;/, '动态缩略图用界面选的输出尺寸');
|
||||||
|
assert.match(script, /const size = 400;/, '静态缩略图仍然是 400×400');
|
||||||
|
assert.match(html, /id="animatedThumbCanvas"/);
|
||||||
|
assert.match(html, /<option value="200" selected>200 × 200<\/option>/);
|
||||||
|
assert.match(script, /canvasSize: Number\(canvasInput\.value\) \|\| animatedProfileConfig\.DEFAULT_OUTPUT_SIZE/);
|
||||||
assert.match(script, /async function renderAnimatedSourceFrames\(animation, options\)/);
|
assert.match(script, /async function renderAnimatedSourceFrames\(animation, options\)/);
|
||||||
assert.match(script, /async function pickAnimatedThumbnail\(sourceFrames, size, range, options\)/);
|
assert.match(script, /async function pickAnimatedThumbnail\(sourceFrames, size, range, options\)/);
|
||||||
assert.match(script, /const targetBytes = options\.maxKilobytes \* 1024/);
|
assert.match(script, /const targetBytes = options\.maxKilobytes \* 1024/);
|
||||||
// 档位表来自独立配置文件,三种优先级都走画廊手动挑,不会自动应用。
|
// 档位表来自独立配置文件,三种优先级都走画廊手动挑,不会自动应用。
|
||||||
assert.match(script, /require\('\.\.\/js\/animated-profiles\.js'\)/);
|
assert.match(script, /require\('\.\.\/js\/animated-profiles\.js'\)/);
|
||||||
// 候选先估算整条路径的体积,再按「目标 0.5× ~ 2× 对数间隔」挑,覆盖面比挤在目标附近更宽。
|
// 候选先估算整条路径的体积,再按「目标 0.5× ~ 2× 对数间隔」挑,覆盖面比挤在目标附近更宽。
|
||||||
assert.match(script, /animatedProfileConfig\.expandAnimatedPath\(options\.priority\)/);
|
assert.match(script, /animatedProfileConfig\.expandAnimatedPath\(options\.priority, options\.canvasSize\)/);
|
||||||
assert.match(script, /pickAnimatedCandidates\(\s*searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame,?\s*\)/);
|
assert.match(script, /pickAnimatedCandidates\(\s*searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame,?\s*\)/);
|
||||||
assert.match(script, /const low = Math\.log\(Math\.max\(samples\[0\]\.bytes, targetBytes \* 0\.5\)\)/);
|
assert.match(script, /const low = Math\.log\(Math\.max\(samples\[0\]\.bytes, targetBytes \* 0\.5\)\)/);
|
||||||
assert.match(script, /const wanted = Math\.exp\(low \+ \(high - low\) \* slot \/ span\)/);
|
assert.match(script, /const wanted = Math\.exp\(low \+ \(high - low\) \* slot \/ span\)/);
|
||||||
|
|||||||
+12
-3
@@ -105,6 +105,12 @@
|
|||||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
|
||||||
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedCanvas">输出尺寸</span>
|
||||||
|
<select id="animatedThumbCanvas">
|
||||||
|
<option value="200" selected>200 × 200</option>
|
||||||
|
<option value="400">400 × 400</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
|
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
|
||||||
<select id="animatedThumbPriority">
|
<select id="animatedThumbPriority">
|
||||||
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
|
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
|
||||||
@@ -818,6 +824,7 @@
|
|||||||
const endInput = document.getElementById('animatedThumbEnd');
|
const endInput = document.getElementById('animatedThumbEnd');
|
||||||
const formatInput = document.getElementById('animatedThumbFormat');
|
const formatInput = document.getElementById('animatedThumbFormat');
|
||||||
const priorityInput = document.getElementById('animatedThumbPriority');
|
const priorityInput = document.getElementById('animatedThumbPriority');
|
||||||
|
const canvasInput = document.getElementById('animatedThumbCanvas');
|
||||||
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
|
||||||
const errorEl = document.getElementById('animatedThumbDialogError');
|
const errorEl = document.getElementById('animatedThumbDialogError');
|
||||||
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
const confirm = document.getElementById('animatedThumbDialogConfirm');
|
||||||
@@ -830,6 +837,7 @@
|
|||||||
endInput.max = String(totalFrames);
|
endInput.max = String(totalFrames);
|
||||||
formatInput.value = 'webp';
|
formatInput.value = 'webp';
|
||||||
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
|
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
|
||||||
|
canvasInput.value = String(animatedProfileConfig.DEFAULT_OUTPUT_SIZE);
|
||||||
maxSizeInput.value = '30';
|
maxSizeInput.value = '30';
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
dialog.classList.remove('hidden');
|
dialog.classList.remove('hidden');
|
||||||
@@ -858,6 +866,7 @@
|
|||||||
end: Math.min(endFrame / FPS, animationLength),
|
end: Math.min(endFrame / FPS, animationLength),
|
||||||
format: formatInput.value === 'apng' ? 'apng' : 'webp',
|
format: formatInput.value === 'apng' ? 'apng' : 'webp',
|
||||||
priority: priorityInput.value,
|
priority: priorityInput.value,
|
||||||
|
canvasSize: Number(canvasInput.value) || animatedProfileConfig.DEFAULT_OUTPUT_SIZE,
|
||||||
maxKilobytes,
|
maxKilobytes,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -950,7 +959,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function renderAnimatedSourceFrames(animation, options) {
|
async function renderAnimatedSourceFrames(animation, options) {
|
||||||
const size = 400;
|
const size = options.canvasSize || animatedProfileConfig.DEFAULT_OUTPUT_SIZE;
|
||||||
const range = options.end - options.start;
|
const range = options.end - options.start;
|
||||||
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
|
||||||
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
|
||||||
@@ -1101,8 +1110,8 @@
|
|||||||
const confirmButton = document.getElementById('animatedPickConfirm');
|
const confirmButton = document.getElementById('animatedPickConfirm');
|
||||||
const targetBytes = options.maxKilobytes * 1024;
|
const targetBytes = options.maxKilobytes * 1024;
|
||||||
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
|
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
|
||||||
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority);
|
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority, options.canvasSize);
|
||||||
const candidateCount = animatedProfiles(options.priority).length;
|
const candidateCount = animatedProfiles(options.priority, options.canvasSize).length;
|
||||||
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
|
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
|
||||||
const pool = Array.from({ length: 4 }, () => {
|
const pool = Array.from({ length: 4 }, () => {
|
||||||
const off = document.createElement('canvas');
|
const off = document.createElement('canvas');
|
||||||
|
|||||||
Reference in new issue
Block a user