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/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(file, retries = 3, delayMs = 200) {
13
- for (let i = 0; i < retries; i++) {
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
- await fs.unlink(file);
16
- return;
17
- } catch (err) {
18
- if (i === retries - 1) throw err;
19
- await new Promise((res) => setTimeout(res, delayMs));
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
- dir,
26
- converted,
27
- format,
28
- quality,
29
- recursive,
30
- removeOriginal,
72
+ dir,
73
+ converted,
74
+ format,
75
+ quality,
76
+ recursive,
77
+ removeOriginal,
31
78
  }) {
32
- const rawPattern = recursive
33
- ? path.join(dir, "**", converted)
34
- : path.join(dir, converted);
79
+ const rawPattern = recursive
80
+ ? path.join(dir, "**", converted)
81
+ : path.join(dir, converted);
35
82
 
36
- const pattern = rawPattern.split(path.sep).join(path.posix.sep);
37
- const files = await fg(pattern, { caseSensitiveMatch: false });
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
- for (const file of files) {
40
- if (processingFiles.has(file)) continue;
41
- processingFiles.add(file);
90
+ for (const file of files) {
91
+ if (processingFiles.has(file)) continue;
92
+ processingFiles.add(file);
42
93
 
43
- try {
44
- const ext = path.extname(file);
45
- const outFile = file.replace(ext, `.${format}`);
46
-
47
- // Игнорируем файлы, которые уже в целевом формате
48
- if (ext.toLowerCase() === `.${format.toLowerCase()}`) {
49
- processingFiles.delete(file);
50
- continue;
51
- }
52
-
53
- const image = sharp(file);
54
- const buffer =
55
- format === "webp"
56
- ? await image.webp({ quality }).toBuffer()
57
- : await image.avif({ quality }).toBuffer();
58
-
59
- await fs.writeFile(outFile, buffer);
60
-
61
- if (removeOriginal) {
62
- await safeUnlink(file);
63
- }
64
-
65
- console.log(`✓ ${file} → ${outFile}`);
66
- } catch (err) {
67
- console.error("Error covertation:", err.message);
68
- } finally {
69
- processingFiles.delete(file);
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
+ }