pulp-image 0.1.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/LICENSE +22 -0
- package/README.md +332 -0
- package/bin/pulp.js +251 -0
- package/package.json +37 -0
- package/src/banner.js +10 -0
- package/src/buildOutputPath.js +67 -0
- package/src/formats.js +54 -0
- package/src/planTasks.js +42 -0
- package/src/processImage.js +216 -0
- package/src/reporter.js +115 -0
- package/src/runJob.js +97 -0
- package/src/stats.js +30 -0
- package/src/uiServer.js +398 -0
- package/ui/app.js +1153 -0
- package/ui/assets/pulp-logo-white.svg +13 -0
- package/ui/index.html +475 -0
- package/ui/styles.css +929 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { dirname, basename, extname, join, resolve } from 'path';
|
|
2
|
+
import { mkdirSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Builds the output path for an image file
|
|
6
|
+
*/
|
|
7
|
+
export function buildOutputPath(inputPath, config, fileIndex = null) {
|
|
8
|
+
const { out, format, width, height, suffix, autoSuffix, renamePattern } = config;
|
|
9
|
+
|
|
10
|
+
// Resolve output directory (but don't create it yet - will be created when writing)
|
|
11
|
+
const outputDir = resolve(out);
|
|
12
|
+
|
|
13
|
+
// Get base filename and extension
|
|
14
|
+
const baseName = basename(inputPath);
|
|
15
|
+
const inputExt = extname(baseName);
|
|
16
|
+
const nameWithoutExt = baseName.slice(0, -inputExt.length);
|
|
17
|
+
|
|
18
|
+
// Determine output extension
|
|
19
|
+
const outputExt = format ? `.${format}` : inputExt;
|
|
20
|
+
const outputExtNoDot = outputExt.slice(1); // Remove leading dot
|
|
21
|
+
|
|
22
|
+
// If rename pattern is provided, use it
|
|
23
|
+
if (renamePattern) {
|
|
24
|
+
let outputFilename = renamePattern
|
|
25
|
+
.replace(/{name}/g, nameWithoutExt)
|
|
26
|
+
.replace(/{ext}/g, outputExtNoDot)
|
|
27
|
+
.replace(/{index}/g, fileIndex !== null ? String(fileIndex + 1) : '1');
|
|
28
|
+
|
|
29
|
+
// Ensure we have an extension
|
|
30
|
+
if (!outputFilename.includes('.')) {
|
|
31
|
+
outputFilename += outputExt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const outputPath = join(outputDir, outputFilename);
|
|
35
|
+
return outputPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Original logic (suffix-based naming)
|
|
39
|
+
// Build suffix parts
|
|
40
|
+
const suffixParts = [];
|
|
41
|
+
|
|
42
|
+
// Auto suffix first (if enabled)
|
|
43
|
+
if (autoSuffix) {
|
|
44
|
+
if (width && height) {
|
|
45
|
+
suffixParts.push(`${width}x${height}`);
|
|
46
|
+
} else if (width) {
|
|
47
|
+
suffixParts.push(`${width}w`);
|
|
48
|
+
} else if (height) {
|
|
49
|
+
suffixParts.push(`${height}h`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Custom suffix second (if provided)
|
|
54
|
+
if (suffix) {
|
|
55
|
+
suffixParts.push(suffix);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Combine suffix
|
|
59
|
+
const finalSuffix = suffixParts.length > 0 ? `-${suffixParts.join('-')}` : '';
|
|
60
|
+
|
|
61
|
+
// Build final filename
|
|
62
|
+
const outputFilename = `${nameWithoutExt}${finalSuffix}${outputExt}`;
|
|
63
|
+
const outputPath = join(outputDir, outputFilename);
|
|
64
|
+
|
|
65
|
+
return outputPath;
|
|
66
|
+
}
|
|
67
|
+
|
package/src/formats.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const SUPPORTED_INPUT_FORMATS = ['png', 'jpg', 'jpeg', 'webp', 'avif'];
|
|
2
|
+
export const SUPPORTED_OUTPUT_FORMATS = ['png', 'jpg', 'webp', 'avif'];
|
|
3
|
+
|
|
4
|
+
export const FORMATS_WITH_TRANSPARENCY = ['png', 'webp', 'avif'];
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_QUALITY = {
|
|
7
|
+
jpg: 80,
|
|
8
|
+
webp: 80,
|
|
9
|
+
avif: 50,
|
|
10
|
+
png: null // PNG is lossless by default
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const FORMATS_SUPPORTING_LOSSLESS = ['png', 'webp', 'avif'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validates if a format is supported for output
|
|
17
|
+
*/
|
|
18
|
+
export function isValidOutputFormat(format) {
|
|
19
|
+
return format && SUPPORTED_OUTPUT_FORMATS.includes(format.toLowerCase());
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gets the default quality for a format
|
|
24
|
+
*/
|
|
25
|
+
export function getDefaultQuality(format) {
|
|
26
|
+
if (!format) return null;
|
|
27
|
+
return DEFAULT_QUALITY[format.toLowerCase()] || null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Checks if a format supports transparency
|
|
32
|
+
*/
|
|
33
|
+
export function supportsTransparency(format) {
|
|
34
|
+
if (!format) return false;
|
|
35
|
+
return FORMATS_WITH_TRANSPARENCY.includes(format.toLowerCase());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Checks if a format supports lossless compression
|
|
40
|
+
*/
|
|
41
|
+
export function supportsLossless(format) {
|
|
42
|
+
if (!format) return false;
|
|
43
|
+
return FORMATS_SUPPORTING_LOSSLESS.includes(format.toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Normalizes format name (jpg -> jpeg for Sharp compatibility)
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeFormat(format) {
|
|
50
|
+
if (!format) return null;
|
|
51
|
+
const normalized = format.toLowerCase();
|
|
52
|
+
return normalized === 'jpg' ? 'jpeg' : normalized;
|
|
53
|
+
}
|
|
54
|
+
|
package/src/planTasks.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import { SUPPORTED_INPUT_FORMATS } from './formats.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Plans tasks for processing images in a directory
|
|
7
|
+
* Returns array of file paths to process
|
|
8
|
+
*/
|
|
9
|
+
export function planTasks(directoryPath) {
|
|
10
|
+
const resolvedPath = resolve(directoryPath);
|
|
11
|
+
const tasks = [];
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const entries = readdirSync(resolvedPath);
|
|
15
|
+
|
|
16
|
+
for (const entry of entries) {
|
|
17
|
+
const entryPath = join(resolvedPath, entry);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const stats = statSync(entryPath);
|
|
21
|
+
|
|
22
|
+
// Only process files (not directories)
|
|
23
|
+
if (stats.isFile()) {
|
|
24
|
+
// Check if file has supported extension
|
|
25
|
+
const ext = entry.split('.').pop()?.toLowerCase();
|
|
26
|
+
|
|
27
|
+
if (ext && SUPPORTED_INPUT_FORMATS.includes(ext)) {
|
|
28
|
+
tasks.push(entryPath);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// Skip entries we can't stat (permissions, etc.)
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
throw new Error(`Failed to read directory: ${error.message}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return tasks;
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
import { readFileSync, statSync, existsSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { buildOutputPath } from './buildOutputPath.js';
|
|
5
|
+
import {
|
|
6
|
+
normalizeFormat,
|
|
7
|
+
supportsTransparency,
|
|
8
|
+
supportsLossless,
|
|
9
|
+
getDefaultQuality,
|
|
10
|
+
isValidOutputFormat
|
|
11
|
+
} from './formats.js';
|
|
12
|
+
import { calculateStats, formatBytes } from './stats.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Processes a single image file
|
|
16
|
+
* @param {string} inputPath - Input file path
|
|
17
|
+
* @param {object} config - Processing configuration
|
|
18
|
+
* @param {number} fileIndex - Optional 0-based index for batch processing (used in rename patterns)
|
|
19
|
+
*/
|
|
20
|
+
export async function processImage(inputPath, config, fileIndex = null) {
|
|
21
|
+
const resolvedInputPath = resolve(inputPath);
|
|
22
|
+
|
|
23
|
+
// Check if input file exists
|
|
24
|
+
if (!existsSync(resolvedInputPath)) {
|
|
25
|
+
throw new Error(`Input file not found: ${resolvedInputPath}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Get original file size
|
|
29
|
+
const originalStats = statSync(resolvedInputPath);
|
|
30
|
+
const originalSize = originalStats.size;
|
|
31
|
+
|
|
32
|
+
// Read image metadata
|
|
33
|
+
const image = sharp(resolvedInputPath);
|
|
34
|
+
const metadata = await image.metadata();
|
|
35
|
+
|
|
36
|
+
// Determine output format
|
|
37
|
+
const outputFormat = config.format
|
|
38
|
+
? normalizeFormat(config.format)
|
|
39
|
+
: normalizeFormat(metadata.format);
|
|
40
|
+
|
|
41
|
+
// Validate output format
|
|
42
|
+
if (config.format && !isValidOutputFormat(config.format)) {
|
|
43
|
+
throw new Error(`Unsupported output format: ${config.format}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build output path
|
|
47
|
+
const outputPath = buildOutputPath(resolvedInputPath, config, fileIndex);
|
|
48
|
+
|
|
49
|
+
// Safety check: If input and output paths are the same, require --overwrite
|
|
50
|
+
// This prevents accidental in-place overwrites
|
|
51
|
+
if (resolvedInputPath === outputPath && !config.overwrite) {
|
|
52
|
+
throw new Error(`Input and output paths are the same: ${outputPath}. Use --overwrite to process in-place.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check if output exists and overwrite is not enabled
|
|
56
|
+
if (!config.overwrite && existsSync(outputPath)) {
|
|
57
|
+
throw new Error(`Output file already exists: ${outputPath} (use --overwrite to overwrite)`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Prepare Sharp pipeline
|
|
61
|
+
let pipeline = image.clone();
|
|
62
|
+
|
|
63
|
+
// Handle resize
|
|
64
|
+
if (config.width || config.height) {
|
|
65
|
+
const resizeOptions = {};
|
|
66
|
+
|
|
67
|
+
if (config.width && config.height) {
|
|
68
|
+
// Both dimensions provided - exact resize
|
|
69
|
+
resizeOptions.width = config.width;
|
|
70
|
+
resizeOptions.height = config.height;
|
|
71
|
+
} else if (config.width) {
|
|
72
|
+
// Only width - preserve aspect ratio
|
|
73
|
+
resizeOptions.width = config.width;
|
|
74
|
+
resizeOptions.height = null;
|
|
75
|
+
} else if (config.height) {
|
|
76
|
+
// Only height - preserve aspect ratio
|
|
77
|
+
resizeOptions.width = null;
|
|
78
|
+
resizeOptions.height = config.height;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
pipeline = pipeline.resize(resizeOptions);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Handle format conversion and quality
|
|
85
|
+
const formatOptions = {};
|
|
86
|
+
|
|
87
|
+
// Check if output format supports transparency
|
|
88
|
+
const outputSupportsTransparency = supportsTransparency(config.format || metadata.format);
|
|
89
|
+
const inputHasAlpha = metadata.hasAlpha;
|
|
90
|
+
|
|
91
|
+
// Handle transparency for formats that don't support it (JPG)
|
|
92
|
+
if (inputHasAlpha && !outputSupportsTransparency) {
|
|
93
|
+
if (config.alphaMode === 'error') {
|
|
94
|
+
throw new Error(`Input has transparency but output format ${outputFormat} does not support it. Use --alpha-mode flatten or choose a format that supports transparency.`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Flatten onto background color
|
|
98
|
+
const background = parseColor(config.background);
|
|
99
|
+
pipeline = pipeline.flatten({ background });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Set quality/lossless
|
|
103
|
+
if (config.lossless) {
|
|
104
|
+
if (supportsLossless(outputFormat)) {
|
|
105
|
+
formatOptions.lossless = true;
|
|
106
|
+
} else {
|
|
107
|
+
console.warn(`Warning: Lossless compression not supported for ${outputFormat}, using quality settings instead.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Set quality if not lossless or if lossless not supported
|
|
112
|
+
if (!formatOptions.lossless) {
|
|
113
|
+
const quality = config.quality || getDefaultQuality(outputFormat);
|
|
114
|
+
if (quality !== null) {
|
|
115
|
+
formatOptions.quality = quality;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convert to output format
|
|
120
|
+
switch (outputFormat) {
|
|
121
|
+
case 'png':
|
|
122
|
+
pipeline = pipeline.png(formatOptions);
|
|
123
|
+
break;
|
|
124
|
+
case 'jpeg':
|
|
125
|
+
pipeline = pipeline.jpeg(formatOptions);
|
|
126
|
+
break;
|
|
127
|
+
case 'webp':
|
|
128
|
+
pipeline = pipeline.webp(formatOptions);
|
|
129
|
+
break;
|
|
130
|
+
case 'avif':
|
|
131
|
+
pipeline = pipeline.avif(formatOptions);
|
|
132
|
+
break;
|
|
133
|
+
default:
|
|
134
|
+
// Keep original format
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Ensure output directory exists (only create when about to write)
|
|
139
|
+
const { mkdirSync } = await import('fs');
|
|
140
|
+
const { dirname } = await import('path');
|
|
141
|
+
const outputDir = dirname(outputPath);
|
|
142
|
+
mkdirSync(outputDir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
// Write output
|
|
145
|
+
await pipeline.toFile(outputPath);
|
|
146
|
+
|
|
147
|
+
// Get final file size
|
|
148
|
+
const finalStats = statSync(outputPath);
|
|
149
|
+
const finalSize = finalStats.size;
|
|
150
|
+
|
|
151
|
+
// Calculate statistics
|
|
152
|
+
const stats = calculateStats(originalSize, finalSize);
|
|
153
|
+
|
|
154
|
+
// Handle delete original if requested
|
|
155
|
+
// Safety: Only delete after successful write, and only if paths differ
|
|
156
|
+
// This prevents accidental deletion of the output file or same-path scenarios
|
|
157
|
+
let deleteError = null;
|
|
158
|
+
if (config.deleteOriginal && resolvedInputPath !== outputPath) {
|
|
159
|
+
try {
|
|
160
|
+
const { unlinkSync } = await import('fs');
|
|
161
|
+
// Verify file still exists before attempting deletion
|
|
162
|
+
if (existsSync(resolvedInputPath)) {
|
|
163
|
+
unlinkSync(resolvedInputPath);
|
|
164
|
+
} else {
|
|
165
|
+
console.warn(`Original file already deleted or not found: ${resolvedInputPath}`);
|
|
166
|
+
}
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// Don't fail the entire operation if deletion fails
|
|
169
|
+
// Log warning but continue processing
|
|
170
|
+
console.warn(`Failed to delete original file ${resolvedInputPath}:`, err.message);
|
|
171
|
+
deleteError = err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
inputPath: resolvedInputPath,
|
|
177
|
+
outputPath,
|
|
178
|
+
originalSize,
|
|
179
|
+
finalSize,
|
|
180
|
+
...stats,
|
|
181
|
+
metadata: {
|
|
182
|
+
width: metadata.width,
|
|
183
|
+
height: metadata.height,
|
|
184
|
+
format: metadata.format,
|
|
185
|
+
hasAlpha: metadata.hasAlpha
|
|
186
|
+
},
|
|
187
|
+
deleteError: deleteError ? deleteError.message : null
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parses a color string (hex, rgb, etc.) to RGBA object
|
|
193
|
+
* Simple implementation for hex colors
|
|
194
|
+
*/
|
|
195
|
+
function parseColor(color) {
|
|
196
|
+
// Handle hex colors (#ffffff, #fff)
|
|
197
|
+
if (color.startsWith('#')) {
|
|
198
|
+
const hex = color.slice(1);
|
|
199
|
+
if (hex.length === 3) {
|
|
200
|
+
// Short hex (#fff -> #ffffff)
|
|
201
|
+
const r = parseInt(hex[0] + hex[0], 16);
|
|
202
|
+
const g = parseInt(hex[1] + hex[1], 16);
|
|
203
|
+
const b = parseInt(hex[2] + hex[2], 16);
|
|
204
|
+
return { r, g, b, alpha: 1 };
|
|
205
|
+
} else if (hex.length === 6) {
|
|
206
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
207
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
208
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
209
|
+
return { r, g, b, alpha: 1 };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Default to white if parsing fails
|
|
214
|
+
return { r: 255, g: 255, b: 255, alpha: 1 };
|
|
215
|
+
}
|
|
216
|
+
|
package/src/reporter.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { formatBytes } from './stats.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reporter class to track processing results
|
|
6
|
+
*/
|
|
7
|
+
export class Reporter {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.processed = [];
|
|
10
|
+
this.skipped = [];
|
|
11
|
+
this.failed = [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Record a successfully processed file
|
|
16
|
+
*/
|
|
17
|
+
recordProcessed(result) {
|
|
18
|
+
this.processed.push(result);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Record a skipped file
|
|
23
|
+
*/
|
|
24
|
+
recordSkipped(filePath, reason) {
|
|
25
|
+
this.skipped.push({ filePath, reason });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a failed file
|
|
30
|
+
*/
|
|
31
|
+
recordFailed(filePath, error) {
|
|
32
|
+
this.failed.push({ filePath, error: error.message || String(error) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculate totals
|
|
37
|
+
*/
|
|
38
|
+
getTotals() {
|
|
39
|
+
const totalOriginal = this.processed.reduce((sum, r) => sum + r.originalSize, 0);
|
|
40
|
+
const totalFinal = this.processed.reduce((sum, r) => sum + r.finalSize, 0);
|
|
41
|
+
const totalSaved = totalOriginal - totalFinal;
|
|
42
|
+
const percentSaved = totalOriginal > 0
|
|
43
|
+
? ((totalSaved / totalOriginal) * 100).toFixed(2)
|
|
44
|
+
: '0.00';
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
totalOriginal,
|
|
48
|
+
totalFinal,
|
|
49
|
+
totalSaved,
|
|
50
|
+
percentSaved: parseFloat(percentSaved),
|
|
51
|
+
processedCount: this.processed.length,
|
|
52
|
+
skippedCount: this.skipped.length,
|
|
53
|
+
failedCount: this.failed.length
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Print summary report
|
|
59
|
+
*/
|
|
60
|
+
printSummary(verbose = false) {
|
|
61
|
+
const totals = this.getTotals();
|
|
62
|
+
|
|
63
|
+
console.log(chalk.cyan('\n' + '='.repeat(60)));
|
|
64
|
+
console.log(chalk.bold.cyan('Processing Summary'));
|
|
65
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
66
|
+
|
|
67
|
+
// Overall statistics
|
|
68
|
+
if (totals.processedCount > 0) {
|
|
69
|
+
console.log(chalk.green(`\n✓ Processed: ${totals.processedCount} file(s)`));
|
|
70
|
+
console.log(chalk.gray(` Total original size: ${formatBytes(totals.totalOriginal)}`));
|
|
71
|
+
console.log(chalk.gray(` Total final size: ${formatBytes(totals.totalFinal)}`));
|
|
72
|
+
console.log(chalk.gray(` Total saved: ${formatBytes(totals.totalSaved)} (${totals.percentSaved}%)`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Skipped files
|
|
76
|
+
if (totals.skippedCount > 0) {
|
|
77
|
+
console.log(chalk.yellow(`\n⚠ Skipped: ${totals.skippedCount} file(s)`));
|
|
78
|
+
if (verbose) {
|
|
79
|
+
this.skipped.forEach(({ filePath, reason }) => {
|
|
80
|
+
console.log(chalk.gray(` - ${filePath}: ${reason}`));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Failed files
|
|
86
|
+
if (totals.failedCount > 0) {
|
|
87
|
+
console.log(chalk.red(`\n✗ Failed: ${totals.failedCount} file(s)`));
|
|
88
|
+
if (verbose) {
|
|
89
|
+
this.failed.forEach(({ filePath, error }) => {
|
|
90
|
+
console.log(chalk.gray(` - ${filePath}: ${error}`));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(chalk.cyan('\n' + '='.repeat(60)));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Print per-file results (for verbose mode)
|
|
100
|
+
*/
|
|
101
|
+
printFileResult(result, verbose = false) {
|
|
102
|
+
if (verbose) {
|
|
103
|
+
console.log(chalk.green(`\n✓ ${result.outputPath}`));
|
|
104
|
+
console.log(chalk.gray(` Original: ${formatBytes(result.originalSize)} (${result.metadata.width}x${result.metadata.height})`));
|
|
105
|
+
console.log(chalk.gray(` Final: ${formatBytes(result.finalSize)}`));
|
|
106
|
+
console.log(chalk.gray(` Saved: ${formatBytes(result.bytesSaved)} (${result.percentSaved}%)`));
|
|
107
|
+
|
|
108
|
+
// Warn if deletion failed
|
|
109
|
+
if (result.deleteError) {
|
|
110
|
+
console.log(chalk.yellow(` Warning: Failed to delete original: ${result.deleteError}`));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
package/src/runJob.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { statSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { processImage } from './processImage.js';
|
|
4
|
+
import { planTasks } from './planTasks.js';
|
|
5
|
+
import { Reporter } from './reporter.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Orchestrates image processing job
|
|
9
|
+
* Returns pure JS object with results (no terminal output)
|
|
10
|
+
*
|
|
11
|
+
* @param {string} inputPath - Input file or directory path
|
|
12
|
+
* @param {object} config - Processing configuration
|
|
13
|
+
* @returns {Promise<object>} Results object with processed, skipped, failed arrays and totals
|
|
14
|
+
*/
|
|
15
|
+
export async function runJob(inputPath, config) {
|
|
16
|
+
const resolvedInputPath = resolve(inputPath);
|
|
17
|
+
|
|
18
|
+
// Validate input exists
|
|
19
|
+
if (!existsSync(resolvedInputPath)) {
|
|
20
|
+
throw new Error(`Input not found: ${resolvedInputPath}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const inputStats = statSync(resolvedInputPath);
|
|
24
|
+
const isDirectory = inputStats.isDirectory();
|
|
25
|
+
const isFile = inputStats.isFile();
|
|
26
|
+
|
|
27
|
+
if (!isFile && !isDirectory) {
|
|
28
|
+
throw new Error(`Input must be a file or directory: ${resolvedInputPath}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Initialize reporter for data aggregation (not printing)
|
|
32
|
+
const reporter = new Reporter();
|
|
33
|
+
|
|
34
|
+
if (isFile) {
|
|
35
|
+
// Single file processing
|
|
36
|
+
try {
|
|
37
|
+
const result = await processImage(resolvedInputPath, config, 0);
|
|
38
|
+
reporter.recordProcessed(result);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Check if it's a skip (file exists or same path) or a real error
|
|
41
|
+
if (error.message.includes('already exists') || error.message.includes('Input and output paths are the same')) {
|
|
42
|
+
reporter.recordSkipped(resolvedInputPath, error.message);
|
|
43
|
+
} else {
|
|
44
|
+
reporter.recordFailed(resolvedInputPath, error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} else {
|
|
48
|
+
// Directory processing
|
|
49
|
+
const tasks = planTasks(resolvedInputPath);
|
|
50
|
+
|
|
51
|
+
if (tasks.length === 0) {
|
|
52
|
+
// Return empty results for empty directory
|
|
53
|
+
return {
|
|
54
|
+
processed: [],
|
|
55
|
+
skipped: [],
|
|
56
|
+
failed: [],
|
|
57
|
+
totals: {
|
|
58
|
+
totalOriginal: 0,
|
|
59
|
+
totalFinal: 0,
|
|
60
|
+
totalSaved: 0,
|
|
61
|
+
percentSaved: 0,
|
|
62
|
+
processedCount: 0,
|
|
63
|
+
skippedCount: 0,
|
|
64
|
+
failedCount: 0
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Process each file
|
|
70
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
71
|
+
const filePath = tasks[i];
|
|
72
|
+
try {
|
|
73
|
+
const result = await processImage(filePath, config, i);
|
|
74
|
+
reporter.recordProcessed(result);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// Check if it's a skip (file exists or same path) or a real error
|
|
77
|
+
if (error.message.includes('already exists') || error.message.includes('Input and output paths are the same')) {
|
|
78
|
+
reporter.recordSkipped(filePath, error.message);
|
|
79
|
+
} else {
|
|
80
|
+
reporter.recordFailed(filePath, error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Get totals from reporter
|
|
87
|
+
const totals = reporter.getTotals();
|
|
88
|
+
|
|
89
|
+
// Return pure JS object (no terminal output)
|
|
90
|
+
return {
|
|
91
|
+
processed: reporter.processed,
|
|
92
|
+
skipped: reporter.skipped,
|
|
93
|
+
failed: reporter.failed,
|
|
94
|
+
totals
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
package/src/stats.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculates file size statistics
|
|
3
|
+
*/
|
|
4
|
+
export function calculateStats(originalSize, finalSize) {
|
|
5
|
+
const bytesSaved = originalSize - finalSize;
|
|
6
|
+
const percentSaved = originalSize > 0
|
|
7
|
+
? ((bytesSaved / originalSize) * 100).toFixed(2)
|
|
8
|
+
: '0.00';
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
originalSize,
|
|
12
|
+
finalSize,
|
|
13
|
+
bytesSaved,
|
|
14
|
+
percentSaved: parseFloat(percentSaved)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Formats bytes to human-readable string
|
|
20
|
+
*/
|
|
21
|
+
export function formatBytes(bytes) {
|
|
22
|
+
if (bytes === 0) return '0 B';
|
|
23
|
+
|
|
24
|
+
const k = 1024;
|
|
25
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
26
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
27
|
+
|
|
28
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
|
29
|
+
}
|
|
30
|
+
|