image-video-optimizer 3.4.0 → 3.5.1
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/.image-video-optimizer-status.json +6 -0
- package/.image-video-optimizer.conf +2 -2
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/imageProcessor.js +123 -56
- package/src/index.js +3 -6
- package/src/config.js +0 -57
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Image Video Optimizer Configuration
|
|
2
2
|
# Generated automatically
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
img_format=
|
|
4
|
+
max_image_width=720 # Compress images wider than this (pixels)
|
|
5
|
+
img_format=null # Target format for image conversion (null=no conversion)
|
|
6
6
|
video_max_width=720 # Maximum width for videos (pixels)
|
|
7
7
|
video_encode=h264 # Video encoding format
|
|
8
8
|
audio_ext=mp3 # Audio extension for audio files
|
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Create a `.image-video-optimizer.conf` file in your target directory to customiz
|
|
|
51
51
|
|
|
52
52
|
```ini
|
|
53
53
|
# Image settings
|
|
54
|
-
img_max_width=
|
|
54
|
+
img_max_width=720 # Maximum width for images (pixels)
|
|
55
55
|
img_format=jpg # Target format for image conversion
|
|
56
56
|
|
|
57
57
|
# Video settings
|
|
@@ -68,7 +68,7 @@ pdf_compress=true # Enable/disable PDF compression
|
|
|
68
68
|
### Default Configuration
|
|
69
69
|
|
|
70
70
|
If no configuration file is found, these defaults are used:
|
|
71
|
-
- `img_max_width`:
|
|
71
|
+
- `img_max_width`: 720
|
|
72
72
|
- `img_format`: jpg
|
|
73
73
|
- `video_max_width`: 720
|
|
74
74
|
- `video_encode`: h264
|
|
@@ -79,7 +79,7 @@ If no configuration file is found, these defaults are used:
|
|
|
79
79
|
|
|
80
80
|
### Images
|
|
81
81
|
- Input: jpg, jpeg, png, gif, bmp, tiff, webp
|
|
82
|
-
- Output: jpg, png, webp (
|
|
82
|
+
- Output: jpg, png, webp (as it is but compressed with better algorithms like avif webp)
|
|
83
83
|
|
|
84
84
|
### Videos
|
|
85
85
|
- Input: avi, mov, wmv, flv, webm, mkv, m4v, mp4
|
|
@@ -148,10 +148,10 @@ image-video-optimizer ./photos --verbose
|
|
|
148
148
|
### Custom configuration
|
|
149
149
|
Create `.image-video-optimizer.conf`:
|
|
150
150
|
```ini
|
|
151
|
-
img_max_width=
|
|
151
|
+
img_max_width=720
|
|
152
152
|
img_format=webp
|
|
153
|
-
video_max_width=
|
|
154
|
-
video_encode=
|
|
153
|
+
video_max_width=720
|
|
154
|
+
video_encode=h264
|
|
155
155
|
audio_ext=mp3
|
|
156
156
|
pdf_compress=true
|
|
157
157
|
```
|
package/package.json
CHANGED
package/src/imageProcessor.js
CHANGED
|
@@ -3,98 +3,165 @@ const sharp = require('sharp');
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
6
|
+
* Get compression options for a specific format
|
|
7
|
+
* @param {string} format - Output format (jpg, png, webp, avif)
|
|
8
|
+
* @returns {object} Sharp compression options
|
|
9
|
+
*/
|
|
10
|
+
function getCompressionOptions(format) {
|
|
11
|
+
const options = {
|
|
12
|
+
jpg: { quality: 80, progressive: true },
|
|
13
|
+
jpeg: { quality: 80, progressive: true },
|
|
14
|
+
png: { compressionLevel: 9 },
|
|
15
|
+
webp: { quality: 80 },
|
|
16
|
+
avif: { quality: 75 }
|
|
17
|
+
};
|
|
18
|
+
return options[format.toLowerCase()] || options.jpg;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compress image to a specific format
|
|
23
|
+
* @param {object} image - Sharp image object
|
|
24
|
+
* @param {string} format - Target format
|
|
25
|
+
* @returns {object} Sharp pipeline with format applied
|
|
26
|
+
*/
|
|
27
|
+
function applyFormat(image, format) {
|
|
28
|
+
const formatLower = format.toLowerCase();
|
|
29
|
+
const options = getCompressionOptions(formatLower);
|
|
30
|
+
|
|
31
|
+
switch (formatLower) {
|
|
32
|
+
case 'jpg':
|
|
33
|
+
case 'jpeg':
|
|
34
|
+
return image.jpeg(options);
|
|
35
|
+
case 'png':
|
|
36
|
+
return image.png(options);
|
|
37
|
+
case 'webp':
|
|
38
|
+
return image.webp(options);
|
|
39
|
+
case 'avif':
|
|
40
|
+
return image.avif(options);
|
|
41
|
+
case 'gif':
|
|
42
|
+
return image.gif();
|
|
43
|
+
case 'tiff':
|
|
44
|
+
return image.tiff();
|
|
45
|
+
default:
|
|
46
|
+
return image.jpeg(options);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Process image with multiple formats and keep the smallest
|
|
7
52
|
* @param {string} imagePath - Path to the image
|
|
8
53
|
* @param {object} config - Configuration object
|
|
9
|
-
* @returns {Promise<{success: boolean, originalSize: number, optimizedSize: number, message: string}>}
|
|
54
|
+
* @returns {Promise<{success: boolean, originalSize: number, optimizedSize: number, message: string, filePath: string}>}
|
|
10
55
|
*/
|
|
11
56
|
async function processImage(imagePath, config) {
|
|
12
57
|
try {
|
|
13
58
|
const stat = await fs.promises.stat(imagePath);
|
|
14
59
|
const originalSize = stat.size;
|
|
15
|
-
const tmpPath = imagePath + '.tmp';
|
|
16
60
|
const ext = path.extname(imagePath).toLowerCase();
|
|
17
61
|
const directory = path.dirname(imagePath);
|
|
18
62
|
const filename = path.basename(imagePath, ext);
|
|
19
63
|
|
|
20
|
-
//
|
|
21
|
-
const
|
|
22
|
-
const outputExt = outputFormat === 'jpg' ? '.jpg' : `.${outputFormat}`;
|
|
23
|
-
const finalPath = path.join(directory, filename + outputExt);
|
|
24
|
-
|
|
25
|
-
// Check if format conversion is needed
|
|
26
|
-
const needsFormatConversion = ext !== outputExt && ext !== (outputFormat === 'jpg' ? '.jpeg' : outputExt);
|
|
64
|
+
// Get max_image_width from config (default to 720)
|
|
65
|
+
const maxImageWidth = config.max_image_width || 720;
|
|
27
66
|
|
|
28
|
-
// Read and
|
|
67
|
+
// Read and get metadata
|
|
29
68
|
let image = sharp(imagePath);
|
|
30
|
-
|
|
31
|
-
// Get image metadata
|
|
32
69
|
const metadata = await image.metadata();
|
|
33
70
|
|
|
34
|
-
//
|
|
35
|
-
let
|
|
36
|
-
if (metadata.width >
|
|
37
|
-
image = image.resize(
|
|
38
|
-
withoutEnlargement: true
|
|
71
|
+
// Step 1: Apply conditional crop/resize based on max_image_width
|
|
72
|
+
let wasCropped = false;
|
|
73
|
+
if (metadata.width > maxImageWidth) {
|
|
74
|
+
image = image.resize(maxImageWidth, null, {
|
|
75
|
+
withoutEnlargement: true,
|
|
76
|
+
fit: 'inside'
|
|
39
77
|
});
|
|
40
|
-
|
|
78
|
+
wasCropped = true;
|
|
41
79
|
}
|
|
42
80
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
// Step 2: Compress original image format to temp file
|
|
82
|
+
const originalTmpPath = path.join(directory, `${filename}.original.tmp`);
|
|
83
|
+
const originalFormatImage = applyFormat(image.clone(), ext.replace('.', ''));
|
|
84
|
+
await originalFormatImage.toFile(originalTmpPath);
|
|
85
|
+
const originalTmpStat = await fs.promises.stat(originalTmpPath);
|
|
86
|
+
const originalCompressedSize = originalTmpStat.size;
|
|
87
|
+
|
|
88
|
+
// Step 3: Compress to AVIF and WebP formats to temp files
|
|
89
|
+
const avifTmpPath = path.join(directory, `${filename}.avif.tmp`);
|
|
90
|
+
const webpTmpPath = path.join(directory, `${filename}.webp.tmp`);
|
|
91
|
+
|
|
92
|
+
const avifImage = applyFormat(image.clone(), 'avif');
|
|
93
|
+
await avifImage.toFile(avifTmpPath);
|
|
94
|
+
const avifTmpStat = await fs.promises.stat(avifTmpPath);
|
|
95
|
+
const avifCompressedSize = avifTmpStat.size;
|
|
58
96
|
|
|
59
|
-
|
|
60
|
-
await
|
|
97
|
+
const webpImage = applyFormat(image.clone(), 'webp');
|
|
98
|
+
await webpImage.toFile(webpTmpPath);
|
|
99
|
+
const webpTmpStat = await fs.promises.stat(webpTmpPath);
|
|
100
|
+
const webpCompressedSize = webpTmpStat.size;
|
|
61
101
|
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
|
|
102
|
+
// Step 4: Compare sizes and determine best format
|
|
103
|
+
const formatComparison = [
|
|
104
|
+
{ format: 'original', size: originalCompressedSize, tmpPath: originalTmpPath },
|
|
105
|
+
{ format: 'avif', size: avifCompressedSize, tmpPath: avifTmpPath },
|
|
106
|
+
{ format: 'webp', size: webpCompressedSize, tmpPath: webpTmpPath }
|
|
107
|
+
];
|
|
65
108
|
|
|
66
|
-
//
|
|
67
|
-
|
|
109
|
+
// Sort by size to find the smallest
|
|
110
|
+
formatComparison.sort((a, b) => a.size - b.size);
|
|
111
|
+
const bestFormat = formatComparison[0];
|
|
112
|
+
|
|
113
|
+
// Step 5: Calculate compression savings
|
|
114
|
+
const optimizedSize = bestFormat.size;
|
|
115
|
+
const savedSize = originalSize - optimizedSize;
|
|
116
|
+
const savedPercent = ((savedSize / originalSize) * 100).toFixed(1);
|
|
117
|
+
|
|
118
|
+
// Step 6: Decide whether to replace
|
|
119
|
+
const shouldReplace = optimizedSize < originalSize || wasCropped;
|
|
68
120
|
|
|
69
121
|
if (shouldReplace) {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await fs.promises.
|
|
122
|
+
// Read the best file and save it with original filename + extension
|
|
123
|
+
const bestFileContent = await fs.promises.readFile(bestFormat.tmpPath);
|
|
124
|
+
const finalPath = imagePath; // Keep original filename and extension
|
|
125
|
+
|
|
126
|
+
await fs.promises.writeFile(finalPath, bestFileContent);
|
|
75
127
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
128
|
+
// Clean up temp files
|
|
129
|
+
await Promise.all([
|
|
130
|
+
fs.promises.unlink(originalTmpPath).catch(() => {}),
|
|
131
|
+
fs.promises.unlink(avifTmpPath).catch(() => {}),
|
|
132
|
+
fs.promises.unlink(webpTmpPath).catch(() => {})
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const compressionInfo = wasCropped ? `[CROPPED to ${maxImageWidth}px] ` : '';
|
|
136
|
+
const sizeInfo = `${(originalSize / 1024).toFixed(1)}KB → ${(optimizedSize / 1024).toFixed(1)}KB (${savedPercent}% saved)`;
|
|
137
|
+
const formatInfo = bestFormat.format !== 'original' ? ` [${bestFormat.format.toUpperCase()} codec]` : '';
|
|
80
138
|
|
|
81
139
|
return {
|
|
82
140
|
success: true,
|
|
83
141
|
originalSize,
|
|
84
142
|
optimizedSize,
|
|
85
|
-
|
|
86
|
-
|
|
143
|
+
savedSize,
|
|
144
|
+
savedPercent: parseFloat(savedPercent),
|
|
145
|
+
message: `✓ ${path.basename(imagePath)} ${compressionInfo}${sizeInfo}${formatInfo}`,
|
|
146
|
+
filePath: finalPath,
|
|
147
|
+
usedFormat: bestFormat.format,
|
|
148
|
+
wasCropped
|
|
87
149
|
};
|
|
88
150
|
} else {
|
|
89
|
-
//
|
|
90
|
-
await
|
|
151
|
+
// Clean up temp files
|
|
152
|
+
await Promise.all([
|
|
153
|
+
fs.promises.unlink(originalTmpPath).catch(() => {}),
|
|
154
|
+
fs.promises.unlink(avifTmpPath).catch(() => {}),
|
|
155
|
+
fs.promises.unlink(webpTmpPath).catch(() => {})
|
|
156
|
+
]);
|
|
91
157
|
|
|
92
158
|
return {
|
|
93
159
|
success: false,
|
|
94
160
|
originalSize,
|
|
95
|
-
optimizedSize,
|
|
161
|
+
optimizedSize: originalSize,
|
|
96
162
|
message: `○ ${path.basename(imagePath)} - Already optimized (${(originalSize / 1024).toFixed(1)}KB)`,
|
|
97
|
-
filePath: imagePath
|
|
163
|
+
filePath: imagePath,
|
|
164
|
+
usedFormat: 'original'
|
|
98
165
|
};
|
|
99
166
|
}
|
|
100
167
|
} catch (error) {
|
package/src/index.js
CHANGED
|
@@ -7,8 +7,7 @@ const audioProcessor = require('./audioProcessor');
|
|
|
7
7
|
const pdfProcessor = require('./pdfProcessor');
|
|
8
8
|
|
|
9
9
|
const DEFAULT_CONFIG = {
|
|
10
|
-
|
|
11
|
-
img_format: 'jpg',
|
|
10
|
+
max_image_width: 720,
|
|
12
11
|
video_max_width: 720,
|
|
13
12
|
video_encode: 'h264',
|
|
14
13
|
audio_ext: 'mp3',
|
|
@@ -70,8 +69,7 @@ function ensureConfigFile(targetDir) {
|
|
|
70
69
|
const configContent = `# Image Video Optimizer Configuration
|
|
71
70
|
# Generated automatically
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
img_format=${DEFAULT_CONFIG.img_format} # Target format for image conversion
|
|
72
|
+
max_image_width=${DEFAULT_CONFIG.max_image_width} # Compress images wider than this (pixels)
|
|
75
73
|
video_max_width=${DEFAULT_CONFIG.video_max_width} # Maximum width for videos (pixels)
|
|
76
74
|
video_encode=${DEFAULT_CONFIG.video_encode} # Video encoding format
|
|
77
75
|
audio_ext=${DEFAULT_CONFIG.audio_ext} # Audio extension for audio files
|
|
@@ -200,8 +198,7 @@ async function optimize(targetPath, verbose = false) {
|
|
|
200
198
|
const status = loadStatus(targetDir);
|
|
201
199
|
|
|
202
200
|
console.log('Configuration:');
|
|
203
|
-
console.log(` Image Max Width: ${config.
|
|
204
|
-
console.log(` Image Format: ${config.img_format}`);
|
|
201
|
+
console.log(` Image Max Width: ${config.max_image_width}px`);
|
|
205
202
|
console.log(` Video Max Width: ${config.video_max_width}px`);
|
|
206
203
|
console.log(` Video Encode: ${config.video_encode}`);
|
|
207
204
|
console.log(` Audio Extension: ${config.audio_ext}`);
|
package/src/config.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const path = require('path');
|
|
3
|
-
|
|
4
|
-
const DEFAULT_CONFIG = {
|
|
5
|
-
img_max_width: 1080,
|
|
6
|
-
img_format: 'jpg',
|
|
7
|
-
video_max_width: 720,
|
|
8
|
-
video_encode: 'h264'
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
class Config {
|
|
12
|
-
constructor(targetDir) {
|
|
13
|
-
this.targetDir = targetDir;
|
|
14
|
-
this.configPath = path.join(targetDir, '.image-video-optimizer.conf');
|
|
15
|
-
this.config = { ...DEFAULT_CONFIG };
|
|
16
|
-
this.loadConfig();
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
loadConfig() {
|
|
20
|
-
try {
|
|
21
|
-
if (fs.existsSync(this.configPath)) {
|
|
22
|
-
const configContent = fs.readFileSync(this.configPath, 'utf8');
|
|
23
|
-
const lines = configContent.split('\n');
|
|
24
|
-
|
|
25
|
-
lines.forEach(line => {
|
|
26
|
-
line = line.trim();
|
|
27
|
-
if (line && !line.startsWith('#')) {
|
|
28
|
-
const [key, value] = line.split('=').map(s => s.trim());
|
|
29
|
-
if (key && value) {
|
|
30
|
-
if (key.includes('width') || key.includes('max_width')) {
|
|
31
|
-
this.config[key] = parseInt(value, 10);
|
|
32
|
-
} else {
|
|
33
|
-
this.config[key] = value;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
console.log(`Loaded configuration from ${this.configPath}`);
|
|
40
|
-
} else {
|
|
41
|
-
console.log('Using default configuration');
|
|
42
|
-
}
|
|
43
|
-
} catch (error) {
|
|
44
|
-
console.warn('Error loading configuration file, using defaults:', error.message);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
get(key) {
|
|
49
|
-
return this.config[key];
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
getAll() {
|
|
53
|
-
return { ...this.config };
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
module.exports = Config;
|