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.
@@ -0,0 +1,205 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+
5
+ export class FileManager {
6
+ // Предустановленные паттерны
7
+ static PATTERNS = {
8
+ SAME_DIR: "{dir}/{name}{marker}{ext}",
9
+ THUMBS: "{dir}/thumbs/{name}{marker}{ext}",
10
+ ORIGINAL_NAME:
11
+ "{dir}/{original}/{name}{marker}{ext}",
12
+ SRCSET: "{dir}/srcset/{original}/{size}-{name}{marker}{ext}",
13
+ CUSTOM: null,
14
+ };
15
+
16
+ constructor(filePath, options = {}) {
17
+ this.filePath = filePath;
18
+ this.outputPattern =
19
+ options.outputPattern ||
20
+ FileManager.PATTERNS.SAME_DIR;
21
+ this.defaultOutputDir = options.outputDir || null;
22
+
23
+ // Парсим путь
24
+ this.dir = path.dirname(filePath);
25
+ this.ext = path.extname(filePath);
26
+ this.nameWithoutExt = path.basename(
27
+ filePath,
28
+ this.ext
29
+ );
30
+ this.fullName = path.basename(filePath);
31
+ }
32
+
33
+ // Вычислить директорию для сохранения
34
+ resolveOutputDir(outputDir) {
35
+ const targetDir =
36
+ outputDir !== undefined
37
+ ? outputDir
38
+ : this.defaultOutputDir;
39
+
40
+ if (!targetDir) {
41
+ // null → та же папка что и оригинал
42
+ return this.dir;
43
+ }
44
+
45
+ if (path.isAbsolute(targetDir)) {
46
+ // Абсолютный путь → создаём подпапку с именем файла
47
+ return path.join(
48
+ targetDir,
49
+ this.nameWithoutExt
50
+ );
51
+ }
52
+
53
+ // Относительный путь → относительно оригинала
54
+ return path.join(this.dir, targetDir);
55
+ }
56
+
57
+ // Заменить плейсхолдеры в паттерне
58
+ resolvePath(customData = {}) {
59
+ const pattern =
60
+ customData.pattern || this.outputPattern;
61
+
62
+ // Если абсолютный путь - используем как есть
63
+ if (
64
+ path.isAbsolute(pattern) &&
65
+ !pattern.includes("{")
66
+ ) {
67
+ return pattern;
68
+ }
69
+
70
+ // Вычисляем целевую директорию
71
+ const outputDir = this.resolveOutputDir(
72
+ customData.outputDir
73
+ );
74
+
75
+ // Доступные плейсхолдеры
76
+ const placeholders = {
77
+ "{dir}": outputDir,
78
+ "{name}":
79
+ customData.name || this.nameWithoutExt,
80
+ "{ext}": customData.ext || this.ext,
81
+ "{original}": this.nameWithoutExt,
82
+ "{marker}": customData.marker
83
+ ? `.${customData.marker}`
84
+ : "",
85
+ "{width}": customData.width || "",
86
+ "{height}": customData.height || "",
87
+ "{size}":
88
+ customData.size ||
89
+ (customData.width && customData.height
90
+ ? `${customData.width}x${customData.height}`
91
+ : ""),
92
+ "{format}": customData.format || "",
93
+ "{quality}": customData.quality || "",
94
+ };
95
+
96
+ let resolved = pattern;
97
+ for (const [placeholder, value] of Object.entries(
98
+ placeholders
99
+ )) {
100
+ resolved = resolved.replace(
101
+ new RegExp(
102
+ placeholder.replace(/[{}]/g, "\\$&"),
103
+ "g"
104
+ ),
105
+ value
106
+ );
107
+ }
108
+
109
+ return resolved;
110
+ }
111
+
112
+ // Сохранить буфер с учетом всех параметров
113
+ async save(buffer, customData = {}) {
114
+ const outputPath = this.resolvePath(customData);
115
+
116
+ // Создаем директорию если не существует
117
+ const dir = path.dirname(outputPath);
118
+ await fs.mkdir(dir, { recursive: true });
119
+
120
+ await fs.writeFile(outputPath, buffer);
121
+ return outputPath;
122
+ }
123
+
124
+ // Сохранить результат ConvertImage
125
+ async saveConverted(
126
+ buffer,
127
+ format,
128
+ marker = null,
129
+ options = {}
130
+ ) {
131
+ return this.save(buffer, {
132
+ ext: `.${format}`,
133
+ marker: marker,
134
+ format: format,
135
+ outputDir: options.outputDir,
136
+ });
137
+ }
138
+
139
+ // Сохранить srcset элемент
140
+ async saveSrcSet(
141
+ buffer,
142
+ { width, height, format, marker = null, outputDir }
143
+ ) {
144
+ return this.save(buffer, {
145
+ width,
146
+ height,
147
+ size: `${width}x${height}`,
148
+ ext: `.${format}`,
149
+ marker: marker,
150
+ format: format,
151
+ outputDir: outputDir,
152
+ });
153
+ }
154
+
155
+ // Получить хэш файла
156
+ async getFileHash() {
157
+ const buffer = await fs.readFile(this.filePath);
158
+ return crypto
159
+ .createHash("sha256")
160
+ .update(buffer)
161
+ .digest("hex");
162
+ }
163
+
164
+ // Получить размер файла
165
+ async getFileSize() {
166
+ const stats = await fs.stat(this.filePath);
167
+ return stats.size;
168
+ }
169
+
170
+ // Удалить файл
171
+ async deleteFile(
172
+ filePath = this.filePath,
173
+ retries = 3,
174
+ delayMs = 200
175
+ ) {
176
+ for (let i = 0; i < retries; i++) {
177
+ try {
178
+ await fs.unlink(filePath);
179
+ return true;
180
+ } catch (err) {
181
+ if (i === retries - 1) throw err;
182
+ await new Promise((res) =>
183
+ setTimeout(res, delayMs)
184
+ );
185
+ }
186
+ }
187
+ }
188
+
189
+ // Проверить существование
190
+ async exists(filePath = this.filePath) {
191
+ try {
192
+ await fs.access(filePath);
193
+ return true;
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+
199
+ // Переименовать
200
+ async rename(newPath) {
201
+ await fs.rename(this.filePath, newPath);
202
+ this.filePath = newPath;
203
+ return newPath;
204
+ }
205
+ }
@@ -0,0 +1,98 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+
4
+ export class MarkerFile {
5
+ static MARKERS = {
6
+ PROCESSED: "processed",
7
+ ORIGINAL: "original",
8
+ DELETE: "delete",
9
+ SRCSET: "srcset",
10
+ };
11
+ constructor(filePath) {
12
+ this.filePath = filePath;
13
+ this.dir = path.dirname(filePath);
14
+ this.ext = path.extname(filePath);
15
+ this.nameWithoutExt = path.basename(
16
+ filePath,
17
+ this.ext
18
+ );
19
+ }
20
+ getMarkedPath(marker) {
21
+ return path.join(
22
+ this.dir,
23
+ `${this.nameWithoutExt}.${marker}${this.ext}`
24
+ );
25
+ }
26
+ hasMarker(marker) {
27
+ return this.nameWithoutExt.endsWith(`.${marker}`);
28
+ }
29
+ async addMarker(marker) {
30
+ // Если уже есть этот маркер - ничего не делаем
31
+ if (this.hasMarker(marker)) return this.filePath;
32
+
33
+ // Убираем все существующие маркеры перед добавлением нового
34
+ const cleanName = this.removeAllMarkers();
35
+ const dir = path.dirname(this.filePath);
36
+ const ext = path.extname(this.filePath);
37
+
38
+ const newPath = path.join(
39
+ dir,
40
+ `${cleanName}.${marker}${ext}`
41
+ );
42
+ await fs.rename(this.filePath, newPath);
43
+ return newPath;
44
+ }
45
+
46
+ // Удалить все известные маркеры из имени
47
+ removeAllMarkers() {
48
+ let cleanName = this.nameWithoutExt;
49
+
50
+ // Удаляем все известные маркеры
51
+ for (const markerValue of Object.values(
52
+ MarkerFile.MARKERS
53
+ )) {
54
+ cleanName = cleanName.replace(
55
+ `.${markerValue}`,
56
+ ""
57
+ );
58
+ }
59
+
60
+ return cleanName;
61
+ }
62
+
63
+ removeMarker(marker) {
64
+ if (!this.hasMarker(marker)) return this.filePath;
65
+ const cleanedName = this.nameWithoutExt.replace(
66
+ `.${marker}`,
67
+ ""
68
+ );
69
+ return path.join(
70
+ this.dir,
71
+ `${cleanedName}${this.ext}`
72
+ );
73
+ }
74
+ async markProcessed() {
75
+ return this.addMarker(MarkerFile.MARKERS.PROCESSED);
76
+ }
77
+ async markOriginal() {
78
+ return this.addMarker(MarkerFile.MARKERS.ORIGINAL);
79
+ }
80
+ async markSrcset() {
81
+ return this.addMarker(MarkerFile.MARKERS.SRCSET);
82
+ }
83
+ async markDelete() {
84
+ return this.addMarker(MarkerFile.MARKERS.DELETE);
85
+ }
86
+ isMarkProcessed() {
87
+ return this.hasMarker(MarkerFile.MARKERS.PROCESSED);
88
+ }
89
+ isMarkOriginal() {
90
+ return this.hasMarker(MarkerFile.MARKERS.ORIGINAL);
91
+ }
92
+ isMarkSrcset() {
93
+ return this.hasMarker(MarkerFile.MARKERS.SRCSET);
94
+ }
95
+ isMarkDelete() {
96
+ return this.hasMarker(MarkerFile.MARKERS.DELETE);
97
+ }
98
+ }
@@ -0,0 +1,378 @@
1
+ import fg from "fast-glob";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import sharp from "sharp";
5
+ import { Queue } from "./Queue.js";
6
+ import { FileManager } from "./FileManager.js";
7
+ import { ResizeImages } from "./ResizeImages.js";
8
+ import { ConvertImages } from "./ConvertImages.js";
9
+
10
+ const DEFAULT_CONCURRENCY = 4;
11
+ const SUPPORTED_FORMATS = new Set([
12
+ "webp",
13
+ "avif",
14
+ "png",
15
+ "jpg",
16
+ "jpeg",
17
+ "tiff",
18
+ ]);
19
+
20
+ export class Pipeline {
21
+ constructor(config) {
22
+ this.config = config ?? {};
23
+ this.queue = new Queue();
24
+ this.processing = new Set();
25
+ this.isRunning = false;
26
+ this.workers = [];
27
+ this.stats = {
28
+ total: 0,
29
+ converted: 0,
30
+ skipped: 0,
31
+ failed: 0,
32
+ };
33
+ }
34
+
35
+ async run() {
36
+ this.#validateConfig();
37
+ const images = await this.collectImages();
38
+ this.enqueueFiles(images);
39
+ await this.processQueue();
40
+ return this.stats;
41
+ }
42
+
43
+ async collectImages() {
44
+ const dir = this.config.dir ?? process.cwd();
45
+ const converted =
46
+ this.config.convertation?.converted ??
47
+ this.config.converted ??
48
+ "*.{png,jpg,jpeg}";
49
+ const recursive = this.config.recursive ?? true;
50
+
51
+ const absDir = path.resolve(process.cwd(), dir);
52
+ const pattern = recursive
53
+ ? path.join(absDir, "**", converted)
54
+ : path.join(absDir, converted);
55
+ const unified = pattern
56
+ .split(path.sep)
57
+ .join(path.posix.sep);
58
+
59
+ return fg(unified, { caseSensitiveMatch: false });
60
+ }
61
+
62
+ enqueueFiles(files) {
63
+ for (const file of files) {
64
+ this.enqueueFile(file);
65
+ }
66
+ }
67
+
68
+ enqueueFile(filePath) {
69
+ if (!filePath || this.processing.has(filePath)) {
70
+ return false;
71
+ }
72
+
73
+ this.processing.add(filePath);
74
+ this.queue.enqueue((workerId) => this.handleFile(filePath, workerId));
75
+ this.stats.total += 1;
76
+ return true;
77
+ }
78
+
79
+ /**
80
+ * Разовый режим: обрабатывает все файлы и завершается.
81
+ */
82
+ async processQueue() {
83
+ const concurrency =
84
+ this.config.concurrency ?? DEFAULT_CONCURRENCY;
85
+ const workerCount = Math.max(
86
+ 1,
87
+ Number(concurrency) || DEFAULT_CONCURRENCY
88
+ );
89
+
90
+ const workers = Array.from(
91
+ { length: workerCount },
92
+ (_, index) => this.#workerLoop(false, index + 1)
93
+ );
94
+ await Promise.all(workers);
95
+ }
96
+
97
+ /**
98
+ * Постоянный режим: запускает воркеров в фоне, они работают пока не остановить.
99
+ * Для watcher'а.
100
+ */
101
+ startWorkers() {
102
+ if (this.isRunning) {
103
+ return;
104
+ }
105
+
106
+ this.#validateConfig();
107
+ this.isRunning = true;
108
+
109
+ const concurrency =
110
+ this.config.concurrency ?? DEFAULT_CONCURRENCY;
111
+ const workerCount = Math.max(
112
+ 1,
113
+ Number(concurrency) || DEFAULT_CONCURRENCY
114
+ );
115
+
116
+ this.workers = Array.from(
117
+ { length: workerCount },
118
+ (_, index) => this.#workerLoop(true, index + 1)
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Остановить постоянных воркеров.
124
+ */
125
+ async stop() {
126
+ this.isRunning = false;
127
+ // Ждём завершения всех воркеров
128
+ await Promise.all(this.workers);
129
+ this.workers = [];
130
+ }
131
+
132
+ /**
133
+ * Внутренний цикл воркера.
134
+ * @param {boolean} continuous - если true, работает постоянно; если false, завершается когда очередь пуста
135
+ * @param {number} workerId - номер воркера (для логирования)
136
+ */
137
+ async #workerLoop(continuous, workerId) {
138
+ while (true) {
139
+ const task = this.queue.dequeue();
140
+
141
+ if (!task) {
142
+ if (continuous && this.isRunning) {
143
+ // В постоянном режиме ждём немного перед следующей проверкой
144
+ await new Promise((resolve) => setTimeout(resolve, 100));
145
+ continue;
146
+ } else {
147
+ // В разовом режиме завершаемся
148
+ break;
149
+ }
150
+ }
151
+
152
+ try {
153
+ await task(workerId);
154
+ } catch (err) {
155
+ this.stats.failed += 1;
156
+ this.#logError(err);
157
+ }
158
+ }
159
+ }
160
+
161
+ async handleFile(filePath, workerId = null) {
162
+ try {
163
+ const format = this.#resolveFormat();
164
+ const quality =
165
+ this.config.convertation?.quality ?? 80;
166
+
167
+ // Читаем файл в буфер для корректной обработки путей с кириллицей/спецсимволами
168
+ const sourceBuffer = await fs.readFile(filePath);
169
+
170
+ // Шаг 1: Опциональный resize
171
+ let sharpInstance = sharp(sourceBuffer);
172
+ if (this.config.needResize && this.config.resize) {
173
+ const resizeConfig = this.config.resize;
174
+ const resizeOptions = {
175
+ fit: resizeConfig.fit ?? "cover",
176
+ position: resizeConfig.position ?? "center",
177
+ withoutEnlargement:
178
+ resizeConfig.withoutEnlargement ?? true,
179
+ };
180
+
181
+ const resizer = new ResizeImages(sourceBuffer, resizeOptions);
182
+
183
+ if (resizeConfig.width && resizeConfig.height) {
184
+ // Оба размера заданы → toBox
185
+ sharpInstance = resizer.toBox(
186
+ resizeConfig.width,
187
+ resizeConfig.height,
188
+ resizeOptions
189
+ );
190
+ } else if (resizeConfig.width) {
191
+ // Только ширина → byWidth
192
+ sharpInstance = resizer.byWidth(
193
+ resizeConfig.width,
194
+ resizeOptions
195
+ );
196
+ } else if (resizeConfig.height) {
197
+ // Только высота → byHeight
198
+ sharpInstance = resizer.byHeight(
199
+ resizeConfig.height,
200
+ resizeOptions
201
+ );
202
+ }
203
+ }
204
+
205
+ // Шаг 2: Конвертация
206
+ const converter = new ConvertImages(sharpInstance, {
207
+ quality,
208
+ });
209
+ const convertedSharp = this.#convertByFormat(
210
+ converter,
211
+ format
212
+ );
213
+ const buffer = await convertedSharp.toBuffer();
214
+
215
+ // Шаг 3: Сохранение
216
+ const fileManager = new FileManager(filePath, {
217
+ outputPattern:
218
+ this.config.convertation?.pattern ||
219
+ FileManager.PATTERNS.SAME_DIR,
220
+ outputDir: this.config.convertation?.outputDir,
221
+ });
222
+
223
+ // В режиме ресайза: если removeOriginal: false, добавляем размер в имя
224
+ // Если removeOriginal: true, перезаписываем файл (тот же путь)
225
+ let outputPath;
226
+ if (this.config.isResizeMode && this.config.resize) {
227
+ if (this.config.removeOriginal) {
228
+ // Перезапись: используем тот же путь
229
+ outputPath = filePath;
230
+ } else {
231
+ // Сохраняем с размером в имени: image.webp → image-1920x1080.webp
232
+ const resizeConfig = this.config.resize;
233
+ const width = resizeConfig.width;
234
+ const height = resizeConfig.height;
235
+
236
+ const baseName = path.basename(filePath, path.extname(filePath));
237
+ const dir = path.dirname(filePath);
238
+ let sizeSuffix = "";
239
+
240
+ if (width && height) {
241
+ sizeSuffix = `-${width}x${height}`;
242
+ } else if (width) {
243
+ sizeSuffix = `-${width}w`;
244
+ } else if (height) {
245
+ sizeSuffix = `-${height}h`;
246
+ }
247
+
248
+ outputPath = path.join(dir, `${baseName}${sizeSuffix}.${format}`);
249
+ }
250
+ } else {
251
+ // Обычный режим (не ресайз)
252
+ outputPath = fileManager.resolvePath({
253
+ ext: `.${format}`,
254
+ outputDir: this.config.convertation?.outputDir,
255
+ });
256
+ }
257
+
258
+ // Создаём директорию если нужно
259
+ const dir = path.dirname(outputPath);
260
+ await fs.mkdir(dir, { recursive: true });
261
+ await fs.writeFile(outputPath, buffer);
262
+
263
+ // Шаг 4: Удаление оригинала (если нужно)
264
+ // Не удаляем, если файл был перезаписан (outputPath === filePath)
265
+ if (this.config.removeOriginal && outputPath !== filePath) {
266
+ await fileManager.deleteFile(filePath);
267
+ }
268
+
269
+ this.stats.converted += 1;
270
+
271
+ // Логирование успешной конвертации
272
+ const workerInfo = workerId ? `[Worker #${workerId}]` : "";
273
+ console.log(
274
+ `✅ ${workerInfo} ${filePath} → ${outputPath}`
275
+ );
276
+ } catch (err) {
277
+ this.stats.failed += 1;
278
+ this.#logError(err, filePath);
279
+ } finally {
280
+ this.processing.delete(filePath);
281
+ }
282
+ }
283
+
284
+ #convertByFormat(converter, format) {
285
+ if (!SUPPORTED_FORMATS.has(format)) {
286
+ throw new Error(
287
+ `Unsupported target format: ${format}`
288
+ );
289
+ }
290
+
291
+ switch (format.toLowerCase()) {
292
+ case "webp":
293
+ return converter.toWebp();
294
+ case "avif":
295
+ return converter.toAvif();
296
+ case "png":
297
+ return converter.toPng();
298
+ case "jpg":
299
+ case "jpeg":
300
+ return converter.toJpg();
301
+ case "tiff":
302
+ return converter.toTiff();
303
+ default:
304
+ throw new Error(
305
+ `Unsupported target format: ${format}`
306
+ );
307
+ }
308
+ }
309
+
310
+ #resolveFormat() {
311
+ const format = (
312
+ this.config.convertation?.format ??
313
+ this.config.format ??
314
+ "webp"
315
+ ).toLowerCase();
316
+ if (!SUPPORTED_FORMATS.has(format)) {
317
+ throw new Error(
318
+ `Unsupported target format: ${format}`
319
+ );
320
+ }
321
+ return format;
322
+ }
323
+
324
+ #validateConfig() {
325
+ // Проверка формата
326
+ const format = this.#resolveFormat();
327
+ if (!SUPPORTED_FORMATS.has(format)) {
328
+ throw new Error(
329
+ `Unsupported target format: ${format}. Supported: ${Array.from(SUPPORTED_FORMATS).join(", ")}`
330
+ );
331
+ }
332
+
333
+ // Проверка resize конфига
334
+ if (this.config.needResize) {
335
+ if (!this.config.resize) {
336
+ throw new Error(
337
+ "Config error: needResize is true, but resize config is missing"
338
+ );
339
+ }
340
+
341
+ const { width, height } = this.config.resize;
342
+
343
+ // Оба размера null → ошибка
344
+ if (width === null && height === null) {
345
+ throw new Error(
346
+ "Config error: needResize is true, but both width and height are null. At least one must be specified."
347
+ );
348
+ }
349
+
350
+ // Проверка на валидные числовые значения (если заданы)
351
+ if (width !== null && (typeof width !== "number" || width <= 0)) {
352
+ throw new Error(
353
+ `Config error: resize.width must be a positive number, got: ${width}`
354
+ );
355
+ }
356
+
357
+ if (height !== null && (typeof height !== "number" || height <= 0)) {
358
+ throw new Error(
359
+ `Config error: resize.height must be a positive number, got: ${height}`
360
+ );
361
+ }
362
+ }
363
+ }
364
+
365
+ #logError(err, filePath) {
366
+ if (filePath) {
367
+ console.error(
368
+ `❌ ${filePath}:`,
369
+ err.message ?? err
370
+ );
371
+ } else {
372
+ console.error(
373
+ "❌ Pipeline error:",
374
+ err.message ?? err
375
+ );
376
+ }
377
+ }
378
+ }