auto-image-converter 2.1.1 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -28
- package/bin/index.js +50 -19
- package/bin/onetimeresizer.mjs +93 -0
- package/bin/watcher.mjs +52 -35
- package/image-converter.config.mjs +19 -7
- package/lib/ConvertImages.js +68 -0
- package/lib/CreateSrcSet.js +60 -0
- package/lib/FileManager.js +205 -0
- package/lib/MarkerFile.js +98 -0
- package/lib/Pipeline.js +378 -0
- package/lib/Queue.js +41 -0
- package/lib/ResizeImages.js +105 -0
- package/lib/converter.js +135 -50
- package/lib/pipelines/ConvertationPipeline.js +182 -0
- package/lib/steps/ConvertationStep.js +493 -0
- package/package.json +29 -27
package/lib/Queue.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class Queue {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.head = null;
|
|
4
|
+
this.tail = null;
|
|
5
|
+
this.size = 0;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
enqueue(task) {
|
|
9
|
+
const node = { value: task, next: null };
|
|
10
|
+
if (this.tail) {
|
|
11
|
+
this.tail.next = node;
|
|
12
|
+
this.tail = node;
|
|
13
|
+
} else {
|
|
14
|
+
this.head = node;
|
|
15
|
+
this.tail = node;
|
|
16
|
+
}
|
|
17
|
+
this.size++;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
dequeue() {
|
|
21
|
+
if (!this.head) return null;
|
|
22
|
+
const value = this.head.value;
|
|
23
|
+
this.head = this.head.next;
|
|
24
|
+
if (!this.head) {
|
|
25
|
+
this.tail = null;
|
|
26
|
+
}
|
|
27
|
+
this.size--;
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
isEmpty() {
|
|
31
|
+
return this.size === 0;
|
|
32
|
+
}
|
|
33
|
+
peek() {
|
|
34
|
+
return this.head?.value ?? null;
|
|
35
|
+
}
|
|
36
|
+
clear() {
|
|
37
|
+
this.head = null;
|
|
38
|
+
this.tail = null;
|
|
39
|
+
this.size = 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Простая обёртка над sharp().resize() под наши кейсы:
|
|
5
|
+
* - уменьшить по ширине;
|
|
6
|
+
* - уменьшить по высоте;
|
|
7
|
+
* - вписать в прямоугольник с нужным соотношением сторон;
|
|
8
|
+
* во всех случаях можно контролировать fit, position, withoutEnlargement.
|
|
9
|
+
*
|
|
10
|
+
* ВНИМАНИЕ: методы возвращают sharp‑инстанс.
|
|
11
|
+
* Снаружи нужно завершать цепочку, например .toBuffer() или .toFile().
|
|
12
|
+
*/
|
|
13
|
+
export class ResizeImages {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string|Buffer} source - путь к файлу или Buffer с изображением
|
|
16
|
+
* @param {Object} [defaultOptions]
|
|
17
|
+
* @param {"cover"|"contain"|"fill"|"inside"|"outside"} [defaultOptions.fit]
|
|
18
|
+
* @param {string} [defaultOptions.position]
|
|
19
|
+
* @param {boolean} [defaultOptions.withoutEnlargement]
|
|
20
|
+
*/
|
|
21
|
+
constructor(source, defaultOptions = {}) {
|
|
22
|
+
this.source = source;
|
|
23
|
+
|
|
24
|
+
// Базовые дефолты, чтобы "просто работало" под типичный кейс:
|
|
25
|
+
this.defaultOptions = {
|
|
26
|
+
fit: "inside",
|
|
27
|
+
position: "center",
|
|
28
|
+
withoutEnlargement: true,
|
|
29
|
+
...defaultOptions,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Внутренний помощник: склеиваем дефолты и конкретные опции.
|
|
35
|
+
* @param {Object} extra
|
|
36
|
+
* @returns {import("sharp").ResizeOptions}
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
_buildOptions(extra = {}) {
|
|
40
|
+
return {
|
|
41
|
+
...this.defaultOptions,
|
|
42
|
+
...extra,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Уменьшить изображение до нужной ширины (высота по пропорциям).
|
|
48
|
+
*
|
|
49
|
+
* @param {number} width
|
|
50
|
+
* @param {Object} [options]
|
|
51
|
+
* @param {"cover"|"contain"|"fill"|"inside"|"outside"} [options.fit]
|
|
52
|
+
* @param {string} [options.position]
|
|
53
|
+
* @param {boolean} [options.withoutEnlargement]
|
|
54
|
+
* @returns {import("sharp").Sharp}
|
|
55
|
+
*/
|
|
56
|
+
byWidth(width, options = {}) {
|
|
57
|
+
const resizeOptions = this._buildOptions({
|
|
58
|
+
width,
|
|
59
|
+
...options,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return sharp(this.source).resize(resizeOptions);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Уменьшить изображение до нужной высоты (ширина по пропорциям).
|
|
67
|
+
*
|
|
68
|
+
* @param {number} height
|
|
69
|
+
* @param {Object} [options]
|
|
70
|
+
* @param {"cover"|"contain"|"fill"|"inside"|"outside"} [options.fit]
|
|
71
|
+
* @param {string} [options.position]
|
|
72
|
+
* @param {boolean} [options.withoutEnlargement]
|
|
73
|
+
* @returns {import("sharp").Sharp}
|
|
74
|
+
*/
|
|
75
|
+
byHeight(height, options = {}) {
|
|
76
|
+
const resizeOptions = this._buildOptions({
|
|
77
|
+
height,
|
|
78
|
+
...options,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return sharp(this.source).resize(resizeOptions);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Вписать/кадрировать изображение в прямоугольник (конкретное соотношение сторон).
|
|
86
|
+
* Обычно используется под 16:9, 1:1 и т.п.
|
|
87
|
+
*
|
|
88
|
+
* @param {number} width
|
|
89
|
+
* @param {number} height
|
|
90
|
+
* @param {Object} [options]
|
|
91
|
+
* @param {"cover"|"contain"|"fill"|"inside"|"outside"} [options.fit] - cover (обрезать лишнее) или contain (вписать с полями)
|
|
92
|
+
* @param {string} [options.position] - центр кадрирования при fit: "cover"
|
|
93
|
+
* @param {boolean} [options.withoutEnlargement] - запрещать ли растягивание маленьких исходников
|
|
94
|
+
* @returns {import("sharp").Sharp}
|
|
95
|
+
*/
|
|
96
|
+
toBox(width, height, options = {}) {
|
|
97
|
+
const resizeOptions = this._buildOptions({
|
|
98
|
+
width,
|
|
99
|
+
height,
|
|
100
|
+
...options,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return sharp(this.source).resize(resizeOptions);
|
|
104
|
+
}
|
|
105
|
+
}
|
package/lib/converter.js
CHANGED
|
@@ -9,64 +9,149 @@ const supportedExt = [".png", ".jpg", ".jpeg"];
|
|
|
9
9
|
|
|
10
10
|
const processingFiles = new Set();
|
|
11
11
|
|
|
12
|
-
async function safeUnlink(
|
|
13
|
-
|
|
12
|
+
async function safeUnlink(
|
|
13
|
+
file,
|
|
14
|
+
retries = 3,
|
|
15
|
+
delayMs = 200
|
|
16
|
+
) {
|
|
17
|
+
for (let i = 0; i < retries; i++) {
|
|
18
|
+
try {
|
|
19
|
+
await fs.unlink(file);
|
|
20
|
+
return;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (i === retries - 1) throw err;
|
|
23
|
+
await new Promise((res) =>
|
|
24
|
+
setTimeout(res, delayMs)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getUniqueOutputPath(outFile, newBufferSize) {
|
|
14
31
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
const existingStat = await fs.stat(outFile);
|
|
33
|
+
|
|
34
|
+
// Если размеры совпадают - скорее всего это дубликат
|
|
35
|
+
if (existingStat.size === newBufferSize) {
|
|
36
|
+
return null; // Сигнал пропустить файл
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Размеры разные - нужно уникальное имя
|
|
40
|
+
const dir = path.dirname(outFile);
|
|
41
|
+
const ext = path.extname(outFile);
|
|
42
|
+
const nameWithoutExt = path.basename(outFile, ext);
|
|
43
|
+
|
|
44
|
+
let counter = 1;
|
|
45
|
+
let uniquePath;
|
|
46
|
+
|
|
47
|
+
while (true) {
|
|
48
|
+
uniquePath = path.join(
|
|
49
|
+
dir,
|
|
50
|
+
`${nameWithoutExt}(${counter})${ext}`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const stat = await fs.stat(uniquePath);
|
|
55
|
+
// Если файл с таким именем существует и размер совпадает - это дубликат
|
|
56
|
+
if (stat.size === newBufferSize) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
counter++;
|
|
60
|
+
} catch {
|
|
61
|
+
// Файл не существует - можем использовать это имя
|
|
62
|
+
return uniquePath;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Файл не существует - используем оригинальное имя
|
|
67
|
+
return outFile;
|
|
20
68
|
}
|
|
21
|
-
}
|
|
22
69
|
}
|
|
23
70
|
|
|
24
71
|
export async function convertImages({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
72
|
+
dir,
|
|
73
|
+
converted,
|
|
74
|
+
format,
|
|
75
|
+
quality,
|
|
76
|
+
recursive,
|
|
77
|
+
removeOriginal,
|
|
31
78
|
}) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
79
|
+
const rawPattern = recursive
|
|
80
|
+
? path.join(dir, "**", converted)
|
|
81
|
+
: path.join(dir, converted);
|
|
35
82
|
|
|
36
|
-
|
|
37
|
-
|
|
83
|
+
const pattern = rawPattern
|
|
84
|
+
.split(path.sep)
|
|
85
|
+
.join(path.posix.sep);
|
|
86
|
+
const files = await fg(pattern, {
|
|
87
|
+
caseSensitiveMatch: false,
|
|
88
|
+
});
|
|
38
89
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
if (processingFiles.has(file)) continue;
|
|
92
|
+
processingFiles.add(file);
|
|
42
93
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
try {
|
|
95
|
+
const ext = path.extname(file);
|
|
96
|
+
let outFile = file.replace(ext, `.${format}`);
|
|
97
|
+
|
|
98
|
+
// Игнорируем файлы, которые уже в целевом формате
|
|
99
|
+
if (
|
|
100
|
+
ext.toLowerCase() ===
|
|
101
|
+
`.${format.toLowerCase()}`
|
|
102
|
+
) {
|
|
103
|
+
processingFiles.delete(file);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const image = sharp(file);
|
|
108
|
+
const buffer =
|
|
109
|
+
format === "webp"
|
|
110
|
+
? await image
|
|
111
|
+
.webp({ quality })
|
|
112
|
+
.toBuffer()
|
|
113
|
+
: await image
|
|
114
|
+
.avif({ quality })
|
|
115
|
+
.toBuffer();
|
|
116
|
+
|
|
117
|
+
// Проверяем, не существует ли уже файл с таким именем
|
|
118
|
+
const finalPath = await getUniqueOutputPath(
|
|
119
|
+
outFile,
|
|
120
|
+
buffer.length
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (finalPath === null) {
|
|
124
|
+
// Файл с таким же размером уже существует - это дубликат
|
|
125
|
+
console.log(
|
|
126
|
+
`⊘ ${file} → skipped (duplicate)`
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Удаляем оригинал-дубликат, если включена опция
|
|
130
|
+
if (removeOriginal) {
|
|
131
|
+
await safeUnlink(file);
|
|
132
|
+
console.log(`🗑️ ${file} → deleted`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
processingFiles.delete(file);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
outFile = finalPath;
|
|
140
|
+
|
|
141
|
+
await fs.writeFile(outFile, buffer);
|
|
142
|
+
|
|
143
|
+
if (removeOriginal) {
|
|
144
|
+
await safeUnlink(file);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
console.log(`✓ ${file} → ${outFile}`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error(
|
|
150
|
+
"Error covertation:",
|
|
151
|
+
err.message
|
|
152
|
+
);
|
|
153
|
+
} finally {
|
|
154
|
+
processingFiles.delete(file);
|
|
155
|
+
}
|
|
70
156
|
}
|
|
71
|
-
}
|
|
72
157
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fg from "fast-glob";
|
|
3
|
+
import { Queue } from "../Queue.js";
|
|
4
|
+
import { ConvertationStep } from "../steps/ConvertationStep.js";
|
|
5
|
+
import { MarkerFile } from "../MarkerFile.js";
|
|
6
|
+
|
|
7
|
+
export class ConvertationPipeline {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
|
|
11
|
+
const stepConfig =
|
|
12
|
+
config.modules?.convertation ||
|
|
13
|
+
config.convertation ||
|
|
14
|
+
config;
|
|
15
|
+
|
|
16
|
+
// Добавляем глобальные параметры в step конфиг
|
|
17
|
+
const mergedConfig = {
|
|
18
|
+
...stepConfig,
|
|
19
|
+
removeOriginal:
|
|
20
|
+
stepConfig.removeOriginal !== undefined
|
|
21
|
+
? stepConfig.removeOriginal
|
|
22
|
+
: config.removeOriginal,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
this.step = new ConvertationStep(mergedConfig);
|
|
26
|
+
this.queue = new Queue();
|
|
27
|
+
this.processing = new Set();
|
|
28
|
+
this.stats = {
|
|
29
|
+
total: 0,
|
|
30
|
+
converted: 0,
|
|
31
|
+
skipped: 0,
|
|
32
|
+
failed: 0,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Основной метод - запуск конвертации всех файлов
|
|
37
|
+
async run() {
|
|
38
|
+
console.log("🚀 Starting convertation pipeline...");
|
|
39
|
+
|
|
40
|
+
// 1. Собираем файлы
|
|
41
|
+
const files = await this.collectFiles();
|
|
42
|
+
console.log(
|
|
43
|
+
`📁 Found ${files.length} files to process`
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (files.length === 0) {
|
|
47
|
+
console.log("✅ No files to convert");
|
|
48
|
+
return this.stats;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 2. Добавляем в очередь
|
|
52
|
+
this.enqueueFiles(files);
|
|
53
|
+
|
|
54
|
+
// 3. Обрабатываем с параллелизмом
|
|
55
|
+
await this.processQueue();
|
|
56
|
+
|
|
57
|
+
// 4. Выводим итоговую статистику
|
|
58
|
+
this.printStats();
|
|
59
|
+
|
|
60
|
+
return this.stats;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Сбор файлов по конфигу
|
|
64
|
+
async collectFiles() {
|
|
65
|
+
const dir = this.config.dir || process.cwd();
|
|
66
|
+
const converted =
|
|
67
|
+
this.config.modules?.convertation?.converted ||
|
|
68
|
+
this.config.convertation?.converted ||
|
|
69
|
+
this.config.converted ||
|
|
70
|
+
"*.{png,jpg,jpeg}";
|
|
71
|
+
const recursive = this.config.recursive !== false;
|
|
72
|
+
|
|
73
|
+
const absDir = path.resolve(process.cwd(), dir);
|
|
74
|
+
const pattern = recursive
|
|
75
|
+
? path.join(absDir, "**", converted)
|
|
76
|
+
: path.join(absDir, converted);
|
|
77
|
+
|
|
78
|
+
const unified = pattern
|
|
79
|
+
.split(path.sep)
|
|
80
|
+
.join(path.posix.sep);
|
|
81
|
+
|
|
82
|
+
return fg(unified, { caseSensitiveMatch: false });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Добавление файлов в очередь
|
|
86
|
+
enqueueFiles(files) {
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
this.enqueueFile(file);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Добавление одного файла в очередь
|
|
93
|
+
enqueueFile(filePath) {
|
|
94
|
+
if (!filePath || this.processing.has(filePath)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this.processing.add(filePath);
|
|
99
|
+
this.queue.enqueue((workerId) =>
|
|
100
|
+
this.processFile(filePath, workerId)
|
|
101
|
+
);
|
|
102
|
+
this.stats.total++;
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Обработка очереди с параллелизмом
|
|
107
|
+
async processQueue() {
|
|
108
|
+
const concurrency = this.config.concurrency || 4;
|
|
109
|
+
const workers = Array.from(
|
|
110
|
+
{ length: concurrency },
|
|
111
|
+
(_, index) => this.workerLoop(index + 1)
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
await Promise.all(workers);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Рабочий цикл для обработки задач
|
|
118
|
+
async workerLoop(workerId) {
|
|
119
|
+
while (!this.queue.isEmpty()) {
|
|
120
|
+
const task = this.queue.dequeue();
|
|
121
|
+
if (!task) break;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await task(workerId);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.error(
|
|
127
|
+
`[Worker ${workerId}] ❌ Error:`,
|
|
128
|
+
err.message
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Обработка одного файла
|
|
135
|
+
async processFile(filePath, workerId) {
|
|
136
|
+
const workerPrefix = workerId
|
|
137
|
+
? `[Worker ${workerId}]`
|
|
138
|
+
: "";
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const result = await this.step.execute(
|
|
142
|
+
filePath
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (result.success) {
|
|
146
|
+
this.stats.converted++;
|
|
147
|
+
console.log(
|
|
148
|
+
`${workerPrefix} ✓ ${result.originalPath} → ${result.outputPath}`
|
|
149
|
+
);
|
|
150
|
+
} else if (result.skipped) {
|
|
151
|
+
this.stats.skipped++;
|
|
152
|
+
console.log(
|
|
153
|
+
`${workerPrefix} ⊘ ${result.originalPath} → ${result.reason}`
|
|
154
|
+
);
|
|
155
|
+
} else {
|
|
156
|
+
this.stats.failed++;
|
|
157
|
+
console.error(
|
|
158
|
+
`${workerPrefix} ❌ ${result.originalPath}: ${result.error}`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
this.stats.failed++;
|
|
163
|
+
console.error(
|
|
164
|
+
`${workerPrefix} ❌ ${filePath}:`,
|
|
165
|
+
err.message
|
|
166
|
+
);
|
|
167
|
+
} finally {
|
|
168
|
+
this.processing.delete(filePath);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Вывод итоговой статистики
|
|
173
|
+
printStats() {
|
|
174
|
+
console.log("\n📊 Convertation completed:");
|
|
175
|
+
console.log(` Total: ${this.stats.total}`);
|
|
176
|
+
console.log(
|
|
177
|
+
` Converted: ${this.stats.converted}`
|
|
178
|
+
);
|
|
179
|
+
console.log(` Skipped: ${this.stats.skipped}`);
|
|
180
|
+
console.log(` Failed: ${this.stats.failed}`);
|
|
181
|
+
}
|
|
182
|
+
}
|