auto-image-converter 2.1.2 → 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 +41 -18
- package/bin/onetimeresizer.mjs +93 -0
- package/bin/watcher.mjs +53 -36
- 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 +2 -2
- package/lib/pipelines/ConvertationPipeline.js +182 -0
- package/lib/steps/ConvertationStep.js +493 -0
- package/package.json +29 -27
|
@@ -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
|
+
}
|
package/lib/Pipeline.js
ADDED
|
@@ -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
|
+
}
|