image-video-optimizer 1.2.1 β 2.0.2
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.conf +1 -1
- package/README.md +2 -0
- package/bin/cli.js +13 -29
- package/package.json +16 -12
- package/src/fileSearcher.js +38 -62
- package/src/imageProcessor.js +95 -93
- package/src/index.js +146 -114
- package/src/videoProcessor.js +127 -127
package/README.md
CHANGED
package/bin/cli.js
CHANGED
|
@@ -1,44 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const path = require('path');
|
|
3
|
+
const { program } = require('commander');
|
|
5
4
|
const chalk = require('chalk');
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const program = new Command();
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const optimizer = require('../src/index');
|
|
9
7
|
|
|
10
8
|
program
|
|
11
9
|
.name('image-video-optimizer')
|
|
12
|
-
.description('
|
|
10
|
+
.description('Optimize and compress images and videos in a directory')
|
|
13
11
|
.version('1.0.0')
|
|
14
|
-
.argument('[
|
|
15
|
-
.
|
|
16
|
-
.option('-v, --verbose', 'Enable verbose logging')
|
|
17
|
-
.action(async (directory, options) => {
|
|
12
|
+
.argument('[target-dir]', 'Target directory to optimize (default: current directory)', process.cwd())
|
|
13
|
+
.action(async (targetDir) => {
|
|
18
14
|
try {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
console.log(chalk.yellow('π DRY RUN MODE - No files will be modified'));
|
|
23
|
-
console.log('');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const optimizer = new ImageVideoOptimizer(targetDir);
|
|
27
|
-
|
|
28
|
-
if (options.dryRun) {
|
|
29
|
-
optimizer.dryRun = true;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
if (options.verbose) {
|
|
33
|
-
optimizer.verbose = true;
|
|
34
|
-
}
|
|
15
|
+
const resolvedPath = path.resolve(targetDir);
|
|
16
|
+
console.log(chalk.blue.bold('\nπ Image Video Optimizer\n'));
|
|
17
|
+
console.log(chalk.gray(`Target directory: ${resolvedPath}`));
|
|
35
18
|
|
|
36
|
-
await optimizer.optimize();
|
|
19
|
+
await optimizer.optimize(resolvedPath);
|
|
37
20
|
|
|
21
|
+
console.log(chalk.green.bold('\nβ
Optimization complete!\n'));
|
|
38
22
|
} catch (error) {
|
|
39
|
-
console.error(chalk.red('Error
|
|
23
|
+
console.error(chalk.red.bold('\nβ Error:\n'), chalk.red(error.message));
|
|
40
24
|
process.exit(1);
|
|
41
25
|
}
|
|
42
26
|
});
|
|
43
27
|
|
|
44
|
-
program.parse();
|
|
28
|
+
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,33 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "image-video-optimizer",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "CLI tool to optimize images and videos with configurable
|
|
3
|
+
"version": "2.0.2",
|
|
4
|
+
"description": "CLI tool to optimize and compress images and videos with configurable settings",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
7
7
|
"image-video-optimizer": "./bin/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"start": "node
|
|
10
|
+
"start": "node bin/cli.js",
|
|
11
11
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"image",
|
|
15
15
|
"video",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
16
|
+
"optimizer",
|
|
17
|
+
"compress",
|
|
18
|
+
"ffmpeg",
|
|
19
19
|
"cli"
|
|
20
20
|
],
|
|
21
21
|
"author": "",
|
|
22
|
-
"license": "
|
|
22
|
+
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"sharp": "^0.32.6",
|
|
25
|
-
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
26
|
-
"fluent-ffmpeg": "^2.1.2",
|
|
27
24
|
"commander": "^11.0.0",
|
|
28
|
-
"chalk": "^
|
|
25
|
+
"chalk": "^5.3.0",
|
|
26
|
+
"sharp": "^0.32.0",
|
|
27
|
+
"fluent-ffmpeg": "^2.1.2"
|
|
29
28
|
},
|
|
29
|
+
"devDependencies": {},
|
|
30
30
|
"engines": {
|
|
31
31
|
"node": ">=14.0.0"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": ""
|
|
32
36
|
}
|
|
33
|
-
}
|
|
37
|
+
}
|
package/src/fileSearcher.js
CHANGED
|
@@ -1,75 +1,51 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
this.targetDir = targetDir;
|
|
7
|
-
this.imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'];
|
|
8
|
-
this.videoExtensions = ['avi', 'mov', 'wmv', 'flv', 'webm', 'mkv', 'm4v', 'mp4'];
|
|
9
|
-
}
|
|
4
|
+
const SUPPORTED_IMAGES = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp'];
|
|
5
|
+
const SUPPORTED_VIDEOS = ['.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv', '.m4v', '.mp4'];
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Recursively search for media files in a directory
|
|
9
|
+
* @param {string} dirPath - Directory path to search
|
|
10
|
+
* @returns {Promise<{images: Array, videos: Array}>} Found media files
|
|
11
|
+
*/
|
|
12
|
+
async function searchMediaFiles(dirPath) {
|
|
13
|
+
const images = [];
|
|
14
|
+
const videos = [];
|
|
15
|
+
|
|
16
|
+
async function walk(currentPath) {
|
|
17
|
+
try {
|
|
18
|
+
const files = await fs.promises.readdir(currentPath);
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const ext = path.extname(item).toLowerCase().slice(1);
|
|
36
|
-
if (extensions.includes(ext)) {
|
|
37
|
-
files.push(fullPath);
|
|
38
|
-
}
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const filePath = path.join(currentPath, file);
|
|
22
|
+
const stat = await fs.promises.stat(filePath);
|
|
23
|
+
|
|
24
|
+
if (stat.isDirectory()) {
|
|
25
|
+
// Recursively search subdirectories
|
|
26
|
+
await walk(filePath);
|
|
27
|
+
} else if (stat.isFile()) {
|
|
28
|
+
const ext = path.extname(file).toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (SUPPORTED_IMAGES.includes(ext)) {
|
|
31
|
+
images.push(filePath);
|
|
32
|
+
} else if (SUPPORTED_VIDEOS.includes(ext)) {
|
|
33
|
+
videos.push(filePath);
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
|
-
} catch (error) {
|
|
42
|
-
console.warn(`Warning: Cannot access directory ${dir}: ${error.message}`);
|
|
43
36
|
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return files;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
isImageFile(filePath) {
|
|
51
|
-
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
52
|
-
return this.imageExtensions.includes(ext);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.warn(`Warning: Could not read directory ${currentPath}: ${error.message}`);
|
|
39
|
+
}
|
|
53
40
|
}
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
57
|
-
return this.videoExtensions.includes(ext);
|
|
58
|
-
}
|
|
42
|
+
await walk(dirPath);
|
|
59
43
|
|
|
60
|
-
|
|
61
|
-
const parsedPath = path.parse(filePath);
|
|
62
|
-
const dir = parsedPath.dir;
|
|
63
|
-
const name = parsedPath.name;
|
|
64
|
-
|
|
65
|
-
if (targetType === 'image') {
|
|
66
|
-
return path.join(dir, `${name}.${targetFormat}`);
|
|
67
|
-
} else if (targetType === 'video') {
|
|
68
|
-
return path.join(dir, `${name}.mp4`);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return filePath;
|
|
72
|
-
}
|
|
44
|
+
return { images, videos };
|
|
73
45
|
}
|
|
74
46
|
|
|
75
|
-
module.exports =
|
|
47
|
+
module.exports = {
|
|
48
|
+
searchMediaFiles,
|
|
49
|
+
SUPPORTED_IMAGES,
|
|
50
|
+
SUPPORTED_VIDEOS
|
|
51
|
+
};
|
package/src/imageProcessor.js
CHANGED
|
@@ -1,107 +1,109 @@
|
|
|
1
1
|
const sharp = require('sharp');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const chalk = require('chalk');
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Process a single image file
|
|
8
|
+
* @param {string} imagePath - Path to the image
|
|
9
|
+
* @param {object} config - Configuration object
|
|
10
|
+
* @returns {Promise<{success: boolean, originalSize: number, optimizedSize: number, message: string}>}
|
|
11
|
+
*/
|
|
12
|
+
async function processImage(imagePath, config) {
|
|
13
|
+
try {
|
|
14
|
+
const stat = await fs.promises.stat(imagePath);
|
|
15
|
+
const originalSize = stat.size;
|
|
16
|
+
const tmpPath = imagePath + '.tmp';
|
|
17
|
+
const ext = path.extname(imagePath).toLowerCase();
|
|
18
|
+
const directory = path.dirname(imagePath);
|
|
19
|
+
const filename = path.basename(imagePath, ext);
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const targetFormat = this.config.get('img_format');
|
|
15
|
-
|
|
16
|
-
console.log(`Processing image: ${path.basename(inputPath)}`);
|
|
17
|
-
console.log(`Original dimensions: ${metadata.width}x${metadata.height}`);
|
|
18
|
-
|
|
19
|
-
let needsResize = metadata.width > maxWidth;
|
|
20
|
-
let needsFormatChange = path.extname(inputPath).toLowerCase().slice(1) !== targetFormat;
|
|
21
|
-
|
|
22
|
-
if (!needsResize && !needsFormatChange) {
|
|
23
|
-
console.log(`Image already optimized, skipping: ${path.basename(inputPath)}`);
|
|
24
|
-
return { processed: false, reason: 'already_optimized' };
|
|
25
|
-
}
|
|
21
|
+
// Determine output format
|
|
22
|
+
const outputFormat = config.img_format.toLowerCase();
|
|
23
|
+
const outputExt = outputFormat === 'jpg' ? '.jpg' : `.${outputFormat}`;
|
|
24
|
+
const finalPath = path.join(directory, filename + outputExt);
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
switch (targetFormat.toLowerCase()) {
|
|
46
|
-
case 'jpg':
|
|
47
|
-
case 'jpeg':
|
|
48
|
-
sharpInstance = sharpInstance.jpeg({ quality: 85 });
|
|
49
|
-
break;
|
|
50
|
-
case 'png':
|
|
51
|
-
sharpInstance = sharpInstance.png({ compressionLevel: 8 });
|
|
52
|
-
break;
|
|
53
|
-
case 'webp':
|
|
54
|
-
sharpInstance = sharpInstance.webp({ quality: 85 });
|
|
55
|
-
break;
|
|
56
|
-
default:
|
|
57
|
-
sharpInstance = sharpInstance.jpeg({ quality: 85 });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
await sharpInstance.toFile(tempPath);
|
|
61
|
-
|
|
62
|
-
const compressionResult = this.checkCompression(inputPath, tempPath);
|
|
63
|
-
|
|
64
|
-
if (compressionResult.effective) {
|
|
65
|
-
// Replace original with processed file
|
|
66
|
-
fs.unlinkSync(inputPath);
|
|
67
|
-
fs.renameSync(tempPath, outputPath);
|
|
68
|
-
console.log(`β Processed: ${path.basename(inputPath)} (${compressionResult.compressionPercent}% reduction)`);
|
|
69
|
-
return {
|
|
70
|
-
processed: true,
|
|
71
|
-
outputPath,
|
|
72
|
-
originalSize: compressionResult.originalSize,
|
|
73
|
-
newSize: compressionResult.newSize,
|
|
74
|
-
compressionPercent: compressionResult.compressionPercent
|
|
75
|
-
};
|
|
76
|
-
} else {
|
|
77
|
-
console.log(`β Ineffective compression, keeping original: ${path.basename(inputPath)}`);
|
|
78
|
-
fs.unlinkSync(tempPath);
|
|
79
|
-
return { processed: false, reason: 'ineffective_compression' };
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
} catch (error) {
|
|
83
|
-
console.error(`Error processing image ${inputPath}:`, error.message);
|
|
84
|
-
return { processed: false, error: error.message };
|
|
26
|
+
// Check if format conversion is needed
|
|
27
|
+
const needsFormatConversion = ext !== outputExt && ext !== (outputFormat === 'jpg' ? '.jpeg' : outputExt);
|
|
28
|
+
|
|
29
|
+
// Read and process image
|
|
30
|
+
let image = sharp(imagePath);
|
|
31
|
+
|
|
32
|
+
// Get image metadata
|
|
33
|
+
const metadata = await image.metadata();
|
|
34
|
+
|
|
35
|
+
// Resize if width exceeds max_width
|
|
36
|
+
let wasResized = false;
|
|
37
|
+
if (metadata.width > config.img_max_width) {
|
|
38
|
+
image = image.resize(config.img_max_width, null, {
|
|
39
|
+
withoutEnlargement: true
|
|
40
|
+
});
|
|
41
|
+
wasResized = true;
|
|
85
42
|
}
|
|
86
|
-
}
|
|
87
43
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
44
|
+
// Convert to target format with optimization
|
|
45
|
+
if (outputFormat === 'jpg' || outputFormat === 'jpeg') {
|
|
46
|
+
image = image.jpeg({ quality: 80, progressive: true });
|
|
47
|
+
} else if (outputFormat === 'png') {
|
|
48
|
+
image = image.png({ compressionLevel: 9 });
|
|
49
|
+
} else if (outputFormat === 'webp') {
|
|
50
|
+
image = image.webp({ quality: 80 });
|
|
51
|
+
} else {
|
|
52
|
+
image = image[outputFormat]({ quality: 80 });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Write to temporary file
|
|
56
|
+
await image.toFile(tmpPath);
|
|
57
|
+
|
|
58
|
+
// Check if optimized file is smaller
|
|
59
|
+
const tmpStat = await fs.promises.stat(tmpPath);
|
|
60
|
+
const optimizedSize = tmpStat.size;
|
|
92
61
|
|
|
93
|
-
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
62
|
+
// Decide whether to replace: if smaller OR if format conversion needed OR if resized
|
|
63
|
+
const shouldReplace = optimizedSize < originalSize || needsFormatConversion || wasResized;
|
|
64
|
+
|
|
65
|
+
if (shouldReplace) {
|
|
66
|
+
// Remove original and rename temp to final path
|
|
67
|
+
if (finalPath !== imagePath) {
|
|
68
|
+
await fs.promises.unlink(imagePath);
|
|
69
|
+
}
|
|
70
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
71
|
+
|
|
72
|
+
const action = needsFormatConversion ? `${path.basename(imagePath)} β ${path.basename(finalPath)}` : path.basename(finalPath);
|
|
73
|
+
const sizeInfo = optimizedSize < originalSize
|
|
74
|
+
? `(${(originalSize / 1024).toFixed(1)}KB β ${(optimizedSize / 1024).toFixed(1)}KB)`
|
|
75
|
+
: `(${(originalSize / 1024).toFixed(1)}KB)`;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
success: true,
|
|
79
|
+
originalSize,
|
|
80
|
+
optimizedSize,
|
|
81
|
+
message: `${chalk.green('β')} ${action} ${sizeInfo}`,
|
|
82
|
+
filePath: finalPath
|
|
83
|
+
};
|
|
84
|
+
} else {
|
|
85
|
+
// Remove temp file if not replacing
|
|
86
|
+
await fs.promises.unlink(tmpPath);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
originalSize,
|
|
91
|
+
optimizedSize,
|
|
92
|
+
message: `${chalk.yellow('β')} ${path.basename(imagePath)} - Already optimized (${(originalSize / 1024).toFixed(1)}KB)`,
|
|
93
|
+
filePath: imagePath
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
} catch (error) {
|
|
98
97
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
98
|
+
success: false,
|
|
99
|
+
originalSize: 0,
|
|
100
|
+
optimizedSize: 0,
|
|
101
|
+
message: `${chalk.red('β')} ${path.basename(imagePath)} - Error: ${error.message}`,
|
|
102
|
+
error: true
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
module.exports =
|
|
107
|
+
module.exports = {
|
|
108
|
+
processImage
|
|
109
|
+
};
|
package/src/index.js
CHANGED
|
@@ -1,141 +1,173 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const chalk = require('chalk');
|
|
4
|
+
const fileSearcher = require('./fileSearcher');
|
|
5
|
+
const imageProcessor = require('./imageProcessor');
|
|
6
|
+
const videoProcessor = require('./videoProcessor');
|
|
4
7
|
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
constructor(targetDir) {
|
|
12
|
-
this.targetDir = path.resolve(targetDir);
|
|
13
|
-
this.config = new Config(this.targetDir);
|
|
14
|
-
this.fileSearcher = new FileSearcher(this.targetDir);
|
|
15
|
-
this.imageProcessor = new ImageProcessor(this.config);
|
|
16
|
-
this.videoProcessor = new VideoProcessor(this.config);
|
|
17
|
-
|
|
18
|
-
this.stats = {
|
|
19
|
-
imagesProcessed: 0,
|
|
20
|
-
videosProcessed: 0,
|
|
21
|
-
imagesSkipped: 0,
|
|
22
|
-
videosSkipped: 0,
|
|
23
|
-
totalSizeSaved: 0,
|
|
24
|
-
errors: []
|
|
25
|
-
};
|
|
26
|
-
}
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
img_max_width: 1080,
|
|
10
|
+
img_format: 'jpg',
|
|
11
|
+
video_max_width: 720,
|
|
12
|
+
video_encode: 'h264'
|
|
13
|
+
};
|
|
27
14
|
|
|
28
|
-
|
|
29
|
-
console.log(chalk.blue.bold('π¬ Image Video Optimizer'));
|
|
30
|
-
console.log(chalk.gray(`Target directory: ${this.targetDir}`));
|
|
31
|
-
console.log(chalk.gray('Configuration:'), this.config.getAll());
|
|
32
|
-
console.log('');
|
|
15
|
+
const CONFIG_FILENAME = '.image-video-optimizer.conf';
|
|
33
16
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Parse configuration file
|
|
19
|
+
* @param {string} configPath - Path to configuration file
|
|
20
|
+
* @returns {object} Parsed configuration
|
|
21
|
+
*/
|
|
22
|
+
function parseConfig(configPath) {
|
|
23
|
+
try {
|
|
24
|
+
if (!fs.existsSync(configPath)) {
|
|
25
|
+
return DEFAULT_CONFIG;
|
|
37
26
|
}
|
|
38
27
|
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
console.log(chalk.yellow(`Found ${mediaFiles.images.length} images and ${mediaFiles.videos.length} videos`));
|
|
42
|
-
console.log('');
|
|
28
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
29
|
+
const config = { ...DEFAULT_CONFIG };
|
|
43
30
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
31
|
+
const lines = content.split('\n');
|
|
32
|
+
for (const line of lines) {
|
|
33
|
+
const trimmed = line.trim();
|
|
34
|
+
|
|
35
|
+
// Skip comments and empty lines
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
37
|
+
|
|
38
|
+
const [key, value] = trimmed.split('=').map(s => s.trim());
|
|
39
|
+
|
|
40
|
+
if (!key || !value) continue;
|
|
41
|
+
|
|
42
|
+
// Parse appropriate types
|
|
43
|
+
if (key === 'img_max_width' || key === 'video_max_width') {
|
|
44
|
+
config[key] = parseInt(value, 10);
|
|
45
|
+
} else {
|
|
46
|
+
config[key] = value;
|
|
47
|
+
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
return config;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.warn(chalk.yellow(`Warning: Could not parse config file: ${error.message}`));
|
|
53
|
+
return DEFAULT_CONFIG;
|
|
53
54
|
}
|
|
55
|
+
}
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Create default configuration file if it doesn't exist
|
|
59
|
+
* @param {string} targetDir - Target directory
|
|
60
|
+
*/
|
|
61
|
+
function ensureConfigFile(targetDir) {
|
|
62
|
+
const configPath = path.join(targetDir, CONFIG_FILENAME);
|
|
63
|
+
|
|
64
|
+
if (!fs.existsSync(configPath)) {
|
|
65
|
+
const configContent = `# Image Video Optimizer Configuration
|
|
66
|
+
# Generated automatically
|
|
67
|
+
|
|
68
|
+
img_max_width=${DEFAULT_CONFIG.img_max_width} # Maximum width for images (pixels)
|
|
69
|
+
img_format=${DEFAULT_CONFIG.img_format} # Target format for image conversion
|
|
70
|
+
video_max_width=${DEFAULT_CONFIG.video_max_width} # Maximum width for videos (pixels)
|
|
71
|
+
video_encode=${DEFAULT_CONFIG.video_encode} # Video encoding format
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
fs.writeFileSync(configPath, configContent);
|
|
76
|
+
console.log(chalk.cyan(`π Created default config: ${CONFIG_FILENAME}`));
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.warn(chalk.yellow(`Warning: Could not create config file: ${error.message}`));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Main optimization function
|
|
85
|
+
* @param {string} targetDir - Target directory to optimize
|
|
86
|
+
*/
|
|
87
|
+
async function optimize(targetDir) {
|
|
88
|
+
// Validate directory exists
|
|
89
|
+
if (!fs.existsSync(targetDir)) {
|
|
90
|
+
throw new Error(`Directory does not exist: ${targetDir}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Ensure config file exists
|
|
94
|
+
ensureConfigFile(targetDir);
|
|
95
|
+
|
|
96
|
+
// Load configuration
|
|
97
|
+
const configPath = path.join(targetDir, CONFIG_FILENAME);
|
|
98
|
+
const config = parseConfig(configPath);
|
|
99
|
+
|
|
100
|
+
console.log(chalk.gray('Configuration:'));
|
|
101
|
+
console.log(chalk.gray(` Image Max Width: ${config.img_max_width}px`));
|
|
102
|
+
console.log(chalk.gray(` Image Format: ${config.img_format}`));
|
|
103
|
+
console.log(chalk.gray(` Video Max Width: ${config.video_max_width}px`));
|
|
104
|
+
console.log(chalk.gray(` Video Encode: ${config.video_encode}\n`));
|
|
105
|
+
|
|
106
|
+
// Search for media files
|
|
107
|
+
console.log(chalk.blue('π Searching for media files...'));
|
|
108
|
+
const { images, videos } = await fileSearcher.searchMediaFiles(targetDir);
|
|
109
|
+
|
|
110
|
+
console.log(chalk.gray(`Found ${images.length} image(s) and ${videos.length} video(s)\n`));
|
|
111
|
+
|
|
112
|
+
if (images.length === 0 && videos.length === 0) {
|
|
113
|
+
console.log(chalk.yellow('No media files found to optimize.'));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let totalOriginalSize = 0;
|
|
118
|
+
let totalOptimizedSize = 0;
|
|
119
|
+
let successCount = 0;
|
|
120
|
+
|
|
121
|
+
// Process images
|
|
122
|
+
if (images.length > 0) {
|
|
123
|
+
console.log(chalk.blue('π· Processing images...'));
|
|
124
|
+
for (const imagePath of images) {
|
|
125
|
+
const result = await imageProcessor.processImage(imagePath, config);
|
|
126
|
+
console.log(result.message);
|
|
127
|
+
|
|
128
|
+
totalOriginalSize += result.originalSize;
|
|
129
|
+
totalOptimizedSize += result.optimizedSize;
|
|
130
|
+
|
|
131
|
+
if (result.success) {
|
|
132
|
+
successCount++;
|
|
80
133
|
}
|
|
81
134
|
}
|
|
82
135
|
console.log('');
|
|
83
136
|
}
|
|
84
137
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.stats.totalSizeSaved += result.originalSize - result.newSize;
|
|
98
|
-
|
|
99
|
-
if (fs.existsSync(videoPath) && videoPath !== result.outputPath) {
|
|
100
|
-
fs.unlinkSync(videoPath);
|
|
101
|
-
}
|
|
102
|
-
} else if (result.reason === 'already_optimized') {
|
|
103
|
-
this.stats.videosSkipped++;
|
|
104
|
-
} else if (result.error) {
|
|
105
|
-
this.stats.errors.push({ file: videoPath, error: result.error });
|
|
106
|
-
}
|
|
107
|
-
} catch (error) {
|
|
108
|
-
console.error(chalk.red(`Unexpected error processing ${videoPath}:`, error.message));
|
|
109
|
-
this.stats.errors.push({ file: videoPath, error: error.message });
|
|
138
|
+
// Process videos
|
|
139
|
+
if (videos.length > 0) {
|
|
140
|
+
console.log(chalk.blue('π¬ Processing videos...'));
|
|
141
|
+
for (const videoPath of videos) {
|
|
142
|
+
const result = await videoProcessor.processVideo(videoPath, config);
|
|
143
|
+
console.log(result.message);
|
|
144
|
+
|
|
145
|
+
totalOriginalSize += result.originalSize;
|
|
146
|
+
totalOptimizedSize += result.optimizedSize;
|
|
147
|
+
|
|
148
|
+
if (result.success) {
|
|
149
|
+
successCount++;
|
|
110
150
|
}
|
|
111
151
|
}
|
|
112
152
|
console.log('');
|
|
113
153
|
}
|
|
114
154
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
155
|
+
// Print summary
|
|
156
|
+
console.log(chalk.blue('π Summary:'));
|
|
157
|
+
console.log(chalk.gray(` Files optimized: ${successCount}/${images.length + videos.length}`));
|
|
158
|
+
|
|
159
|
+
if (totalOriginalSize > 0) {
|
|
160
|
+
const savedSize = totalOriginalSize - totalOptimizedSize;
|
|
161
|
+
const savedPercent = ((savedSize / totalOriginalSize) * 100).toFixed(1);
|
|
122
162
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (this.stats.errors.length > 0) {
|
|
129
|
-
console.log('');
|
|
130
|
-
console.log(chalk.red.bold(`Errors encountered: ${this.stats.errors.length}`));
|
|
131
|
-
this.stats.errors.forEach(({ file, error }) => {
|
|
132
|
-
console.log(chalk.red(` ${path.basename(file)}: ${error}`));
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
console.log('');
|
|
137
|
-
console.log(chalk.green.bold('β¨ Optimization complete!'));
|
|
163
|
+
console.log(chalk.gray(` Original size: ${(totalOriginalSize / 1024 / 1024).toFixed(2)}MB`));
|
|
164
|
+
console.log(chalk.gray(` Optimized size: ${(totalOptimizedSize / 1024 / 1024).toFixed(2)}MB`));
|
|
165
|
+
console.log(chalk.green(` Space saved: ${(savedSize / 1024 / 1024).toFixed(2)}MB (${savedPercent}%)`));
|
|
138
166
|
}
|
|
139
167
|
}
|
|
140
168
|
|
|
141
|
-
module.exports =
|
|
169
|
+
module.exports = {
|
|
170
|
+
optimize,
|
|
171
|
+
parseConfig,
|
|
172
|
+
ensureConfigFile
|
|
173
|
+
};
|
package/src/videoProcessor.js
CHANGED
|
@@ -1,141 +1,141 @@
|
|
|
1
1
|
const ffmpeg = require('fluent-ffmpeg');
|
|
2
|
-
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
|
|
3
|
-
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
|
4
2
|
const fs = require('fs');
|
|
5
3
|
const path = require('path');
|
|
4
|
+
const chalk = require('chalk');
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Process a single video file
|
|
8
|
+
* @param {string} videoPath - Path to the video
|
|
9
|
+
* @param {object} config - Configuration object
|
|
10
|
+
* @returns {Promise<{success: boolean, originalSize: number, optimizedSize: number, message: string}>}
|
|
11
|
+
*/
|
|
12
|
+
async function processVideo(videoPath, config) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
try {
|
|
15
|
+
fs.stat(videoPath, async (err, stat) => {
|
|
16
|
+
if (err) {
|
|
17
|
+
return resolve({
|
|
18
|
+
success: false,
|
|
19
|
+
originalSize: 0,
|
|
20
|
+
optimizedSize: 0,
|
|
21
|
+
message: `${chalk.red('β')} ${path.basename(videoPath)} - Cannot access file`,
|
|
22
|
+
error: true
|
|
23
|
+
});
|
|
24
|
+
}
|
|
11
25
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
26
|
+
const originalSize = stat.size;
|
|
27
|
+
const tmpPath = videoPath + '.tmp.mp4';
|
|
28
|
+
const ext = path.extname(videoPath).toLowerCase();
|
|
29
|
+
const directory = path.dirname(videoPath);
|
|
30
|
+
const filename = path.basename(videoPath, ext);
|
|
31
|
+
const finalPath = path.join(directory, filename + '.mp4');
|
|
32
|
+
|
|
33
|
+
// Create ffmpeg command
|
|
34
|
+
// Map codec names to ffmpeg codec names
|
|
35
|
+
const codecMap = {
|
|
36
|
+
'h264': 'libx264',
|
|
37
|
+
'h265': 'libx265',
|
|
38
|
+
'vp8': 'libvpx',
|
|
39
|
+
'vp9': 'libvpx-vp9'
|
|
40
|
+
};
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
const tempPath = path.join(parsedPath.dir, `${parsedPath.name}_temp.mp4`);
|
|
25
|
-
console.log(`Using temporary path for processing: ${path.basename(tempPath)}`);
|
|
42
|
+
const outputCodec = codecMap[config.video_encode.toLowerCase()] || config.video_encode || 'libx264';
|
|
26
43
|
|
|
27
|
-
ffmpeg
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (needsResize) {
|
|
55
|
-
const newHeight = Math.round((maxWidth / videoStream.width) * videoStream.height);
|
|
56
|
-
tempFfmpegCommand = tempFfmpegCommand.videoFilters(`scale=${maxWidth}:${newHeight}`);
|
|
57
|
-
console.log(`Resizing to: ${maxWidth}x${newHeight}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
switch (encodeFormat.toLowerCase()) {
|
|
61
|
-
case 'h264':
|
|
62
|
-
tempFfmpegCommand = tempFfmpegCommand.videoCodec('libx264').audioCodec('aac');
|
|
63
|
-
break;
|
|
64
|
-
case 'h265':
|
|
65
|
-
tempFfmpegCommand = tempFfmpegCommand.videoCodec('libx265').audioCodec('aac');
|
|
66
|
-
break;
|
|
67
|
-
case 'vp9':
|
|
68
|
-
tempFfmpegCommand = tempFfmpegCommand.videoCodec('libvpx-vp9').audioCodec('libvorbis');
|
|
69
|
-
break;
|
|
70
|
-
default:
|
|
71
|
-
tempFfmpegCommand = tempFfmpegCommand.videoCodec('libx264').audioCodec('aac');
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Add input/output options with proper quoting
|
|
75
|
-
tempFfmpegCommand = tempFfmpegCommand.inputOptions(['-fflags', '+genpts']);
|
|
76
|
-
tempFfmpegCommand = tempFfmpegCommand.outputOptions(['-crf', '23', '-preset', 'medium', '-y']);
|
|
77
|
-
tempFfmpegCommand = tempFfmpegCommand.format('mp4');
|
|
78
|
-
tempFfmpegCommand = tempFfmpegCommand.output(tempPath);
|
|
79
|
-
|
|
80
|
-
tempFfmpegCommand
|
|
81
|
-
.on('start', (commandLine) => {
|
|
82
|
-
console.log(`FFmpeg command: ${commandLine}`);
|
|
83
|
-
})
|
|
84
|
-
.on('end', () => {
|
|
85
|
-
const compressionResult = this.checkCompression(inputPath, tempPath);
|
|
86
|
-
|
|
87
|
-
if (compressionResult.effective) {
|
|
88
|
-
// Replace original with processed file
|
|
89
|
-
fs.unlinkSync(inputPath);
|
|
90
|
-
fs.renameSync(tempPath, outputPath);
|
|
91
|
-
console.log(`β Processed: ${path.basename(inputPath)} (${compressionResult.compressionPercent}% reduction)`);
|
|
44
|
+
const command = ffmpeg(videoPath)
|
|
45
|
+
.videoCodec(outputCodec)
|
|
46
|
+
.size(`${config.video_max_width}x?`)
|
|
47
|
+
.outputOptions([
|
|
48
|
+
'-preset fast',
|
|
49
|
+
'-crf 28',
|
|
50
|
+
'-c:a aac' // Use AAC for audio (widely compatible)
|
|
51
|
+
])
|
|
52
|
+
.output(tmpPath);
|
|
53
|
+
|
|
54
|
+
command
|
|
55
|
+
.on('end', async () => {
|
|
56
|
+
try {
|
|
57
|
+
const tmpStat = await new Promise((res, rej) => {
|
|
58
|
+
fs.stat(tmpPath, (err, stat) => {
|
|
59
|
+
if (err) rej(err);
|
|
60
|
+
else res(stat);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const optimizedSize = tmpStat.size;
|
|
65
|
+
|
|
66
|
+
if (optimizedSize < originalSize) {
|
|
67
|
+
// Remove original and rename temp to final
|
|
68
|
+
await fs.promises.unlink(videoPath);
|
|
69
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
70
|
+
|
|
92
71
|
resolve({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
72
|
+
success: true,
|
|
73
|
+
originalSize,
|
|
74
|
+
optimizedSize,
|
|
75
|
+
message: `${chalk.green('β')} ${path.basename(videoPath)} β ${path.basename(finalPath)} (${(originalSize / 1024 / 1024).toFixed(1)}MB β ${(optimizedSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
76
|
+
filePath: finalPath
|
|
98
77
|
});
|
|
99
78
|
} else {
|
|
100
|
-
|
|
101
|
-
fs.
|
|
102
|
-
|
|
79
|
+
// Remove temp file if not smaller
|
|
80
|
+
await fs.promises.unlink(tmpPath);
|
|
81
|
+
|
|
82
|
+
resolve({
|
|
83
|
+
success: false,
|
|
84
|
+
originalSize,
|
|
85
|
+
optimizedSize,
|
|
86
|
+
message: `${chalk.yellow('β')} ${path.basename(videoPath)} - Already optimized (${(originalSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
87
|
+
filePath: videoPath
|
|
88
|
+
});
|
|
103
89
|
}
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
90
|
+
} catch (error) {
|
|
91
|
+
resolve({
|
|
92
|
+
success: false,
|
|
93
|
+
originalSize,
|
|
94
|
+
optimizedSize: 0,
|
|
95
|
+
message: `${chalk.red('β')} ${path.basename(videoPath)} - Post-processing error: ${error.message}`,
|
|
96
|
+
error: true
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
.on('error', (err) => {
|
|
101
|
+
// Clean up temp file on error
|
|
102
|
+
fs.unlink(tmpPath, () => {
|
|
103
|
+
let errorMsg = err.message;
|
|
104
|
+
|
|
105
|
+
// Provide helpful suggestions for common errors
|
|
106
|
+
if (err.message.includes('Unknown encoder') || err.message.includes('Encoder')) {
|
|
107
|
+
errorMsg = `Video codec error. Check available codecs with: ffmpeg -encoders | grep lib`;
|
|
108
|
+
} else if (err.message.includes('is not available')) {
|
|
109
|
+
errorMsg = `Codec not available. Verify FFmpeg installation: ffmpeg -version`;
|
|
110
|
+
} else if (err.message.includes('ENOENT') || err.message.includes('ffmpeg')) {
|
|
111
|
+
errorMsg = `FFmpeg not found. Install with: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)`;
|
|
112
|
+
} else if (err.message.includes('Conversion failed') || err.message.includes('exited with code')) {
|
|
113
|
+
errorMsg = `Video encoding failed. Try a different codec in config: video_encode=h265 or video_encode=vp8`;
|
|
110
114
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
originalSize,
|
|
134
|
-
newSize,
|
|
135
|
-
compressionPercent,
|
|
136
|
-
effective: compressionPercent >= 1
|
|
137
|
-
};
|
|
138
|
-
}
|
|
115
|
+
|
|
116
|
+
resolve({
|
|
117
|
+
success: false,
|
|
118
|
+
originalSize,
|
|
119
|
+
optimizedSize: 0,
|
|
120
|
+
message: `${chalk.red('β')} ${path.basename(videoPath)} - ${errorMsg}`,
|
|
121
|
+
error: true
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
})
|
|
125
|
+
.run();
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
resolve({
|
|
129
|
+
success: false,
|
|
130
|
+
originalSize: 0,
|
|
131
|
+
optimizedSize: 0,
|
|
132
|
+
message: `${chalk.red('β')} ${path.basename(videoPath)} - Error: ${error.message}`,
|
|
133
|
+
error: true
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
module.exports =
|
|
139
|
+
module.exports = {
|
|
140
|
+
processVideo
|
|
141
|
+
};
|