image-video-optimizer 1.0.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/.image-video-optimizer.conf +4 -0
- package/README.md +144 -0
- package/bin/cli.js +44 -0
- package/licence.txt +6 -0
- package/package.json +33 -0
- package/src/config.js +57 -0
- package/src/fileSearcher.js +75 -0
- package/src/imageProcessor.js +105 -0
- package/src/index.js +141 -0
- package/src/videoProcessor.js +128 -0
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Image Video Optimizer
|
|
2
|
+
|
|
3
|
+
A powerful CLI tool to optimize images and videos with configurable resize and compression settings.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Image Optimization**: Resize and convert images to specified formats
|
|
8
|
+
- **Video Optimization**: Resize videos and encode to specified formats
|
|
9
|
+
- **Configurable Settings**: Use `.image-video-optimizer.conf` files for custom settings
|
|
10
|
+
- **Smart Compression**: Only keeps optimized files if compression is effective (>1%)
|
|
11
|
+
- **Recursive Search**: Finds all media files in subdirectories
|
|
12
|
+
- **Detailed Logging**: Shows processing progress and summary statistics
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install -g image-video-optimizer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
### Basic Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
image-video-optimizer /path/to/directory
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Options
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
image-video-optimizer /path/to/directory [options]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `<directory>`: Target directory to optimize (required)
|
|
35
|
+
- `-d, --dry-run`: Show what would be processed without making changes
|
|
36
|
+
- `-v, --verbose`: Enable verbose logging
|
|
37
|
+
- `-V, --version`: Show version number
|
|
38
|
+
- `-h, --help`: Show help
|
|
39
|
+
|
|
40
|
+
## Configuration
|
|
41
|
+
|
|
42
|
+
Create a `.image-video-optimizer.conf` file in your target directory to customize settings:
|
|
43
|
+
|
|
44
|
+
```ini
|
|
45
|
+
# Image settings
|
|
46
|
+
img_max_width=1080 # Maximum width for images (pixels)
|
|
47
|
+
img_format=jpg # Target format for image conversion
|
|
48
|
+
|
|
49
|
+
# Video settings
|
|
50
|
+
video_max_width=720 # Maximum width for videos (pixels)
|
|
51
|
+
video_encode=h264 # Video encoding format
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Default Configuration
|
|
55
|
+
|
|
56
|
+
If no configuration file is found, these defaults are used:
|
|
57
|
+
- `img_max_width`: 1080
|
|
58
|
+
- `img_format`: jpg
|
|
59
|
+
- `video_max_width`: 720
|
|
60
|
+
- `video_encode`: h264
|
|
61
|
+
|
|
62
|
+
## Supported Formats
|
|
63
|
+
|
|
64
|
+
### Images
|
|
65
|
+
- Input: jpg, jpeg, png, gif, bmp, tiff, webp
|
|
66
|
+
- Output: jpg, png, webp (configurable)
|
|
67
|
+
|
|
68
|
+
### Videos
|
|
69
|
+
- Input: avi, mov, wmv, flv, webm, mkv, m4v, mp4
|
|
70
|
+
- Output: mp4 (with configurable encoding)
|
|
71
|
+
|
|
72
|
+
## Processing Logic
|
|
73
|
+
|
|
74
|
+
### Image Processing
|
|
75
|
+
1. Searches for image files recursively
|
|
76
|
+
2. Checks if image width exceeds `img_max_width`
|
|
77
|
+
3. Resizes if necessary while maintaining aspect ratio
|
|
78
|
+
4. Converts to target format if different
|
|
79
|
+
5. Compares file sizes and keeps optimized version only if compression > 1%
|
|
80
|
+
|
|
81
|
+
### Video Processing
|
|
82
|
+
1. Searches for video files recursively
|
|
83
|
+
2. Checks if video width exceeds `video_max_width`
|
|
84
|
+
3. Resizes if necessary while maintaining aspect ratio
|
|
85
|
+
4. Encodes to target format (default: H.264)
|
|
86
|
+
5. Converts to MP4 format
|
|
87
|
+
6. Compares file sizes and keeps optimized version only if compression > 1%
|
|
88
|
+
|
|
89
|
+
## Examples
|
|
90
|
+
|
|
91
|
+
### Optimize a directory with default settings
|
|
92
|
+
```bash
|
|
93
|
+
image-video-optimizer ./photos
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Dry run to see what would be processed
|
|
97
|
+
```bash
|
|
98
|
+
image-video-optimizer ./photos --dry-run
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Custom configuration
|
|
102
|
+
Create `.image-video-optimizer.conf`:
|
|
103
|
+
```ini
|
|
104
|
+
img_max_width=1920
|
|
105
|
+
img_format=webp
|
|
106
|
+
video_max_width=1080
|
|
107
|
+
video_encode=h265
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Then run:
|
|
111
|
+
```bash
|
|
112
|
+
image-video-optimizer ./media
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Dependencies
|
|
116
|
+
|
|
117
|
+
- [sharp](https://sharp.pixelplumbing.com/) - Image processing
|
|
118
|
+
- [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) - Video processing
|
|
119
|
+
- [commander](https://github.com/tj/commander.js) - CLI framework
|
|
120
|
+
- [chalk](https://github.com/chalk/chalk) - Terminal styling
|
|
121
|
+
|
|
122
|
+
## System Requirements
|
|
123
|
+
|
|
124
|
+
- Node.js >= 14.0.0
|
|
125
|
+
- FFmpeg (for video processing)
|
|
126
|
+
|
|
127
|
+
### Installing FFmpeg
|
|
128
|
+
|
|
129
|
+
**Ubuntu/Debian:**
|
|
130
|
+
```bash
|
|
131
|
+
sudo apt update && sudo apt install ffmpeg
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**macOS:**
|
|
135
|
+
```bash
|
|
136
|
+
brew install ffmpeg
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**Windows:**
|
|
140
|
+
Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const chalk = require('chalk');
|
|
6
|
+
const ImageVideoOptimizer = require('../src/index');
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('image-video-optimizer')
|
|
12
|
+
.description('CLI tool to optimize images and videos with configurable resize and compression settings')
|
|
13
|
+
.version('1.0.0')
|
|
14
|
+
.argument('[directory]', 'Target directory to optimize (default: current directory)', process.cwd())
|
|
15
|
+
.option('-d, --dry-run', 'Show what would be processed without making changes')
|
|
16
|
+
.option('-v, --verbose', 'Enable verbose logging')
|
|
17
|
+
.action(async (directory, options) => {
|
|
18
|
+
try {
|
|
19
|
+
const targetDir = path.resolve(directory);
|
|
20
|
+
|
|
21
|
+
if (options.dryRun) {
|
|
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
|
+
}
|
|
35
|
+
|
|
36
|
+
await optimizer.optimize();
|
|
37
|
+
|
|
38
|
+
} catch (error) {
|
|
39
|
+
console.error(chalk.red('Error:'), error.message);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
program.parse();
|
package/licence.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
proprietary license restricts to share or gain knowledge, GPL or GNU license is the opposite, we have MIT and BSD that benefits conqueror company (* still using bsd under the hood and * using purchased qdos)
|
|
2
|
+
|
|
3
|
+
i hope rest of the people will become humanbeings. knowledge, experience, information will be free[please dont force to reverse eng., its better not to have any software or hardware with secret sauce]. A humanbeing without a driving license is much better than a person having a driving license, i wonder govt. dont provide a license to own a computer system but i need a license to run os on it?
|
|
4
|
+
the english word free is not enough, let me put this way : a book can have a price not the content and i must be able to share it.
|
|
5
|
+
|
|
6
|
+
praise to mukteshwar, hope one day licence raaj will be over till my work is under nirvána license and the terms and conditions are listed below
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "image-video-optimizer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to optimize images and videos with configurable resize and compression settings",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"image-video-optimizer": "./bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node src/index.js",
|
|
11
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"image",
|
|
15
|
+
"video",
|
|
16
|
+
"optimization",
|
|
17
|
+
"compression",
|
|
18
|
+
"resize",
|
|
19
|
+
"cli"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "nirvána",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"sharp": "^0.32.6",
|
|
25
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
26
|
+
"fluent-ffmpeg": "^2.1.2",
|
|
27
|
+
"commander": "^11.0.0",
|
|
28
|
+
"chalk": "^4.1.2"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=14.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
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;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
class FileSearcher {
|
|
5
|
+
constructor(targetDir) {
|
|
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
|
+
}
|
|
10
|
+
|
|
11
|
+
findMediaFiles() {
|
|
12
|
+
const imageFiles = this.findFilesByExtensions(this.imageExtensions);
|
|
13
|
+
const videoFiles = this.findFilesByExtensions(this.videoExtensions);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
images: imageFiles,
|
|
17
|
+
videos: videoFiles
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
findFilesByExtensions(extensions) {
|
|
22
|
+
const files = [];
|
|
23
|
+
|
|
24
|
+
const walkDirectory = (dir) => {
|
|
25
|
+
try {
|
|
26
|
+
const items = fs.readdirSync(dir);
|
|
27
|
+
|
|
28
|
+
for (const item of items) {
|
|
29
|
+
const fullPath = path.join(dir, item);
|
|
30
|
+
const stat = fs.statSync(fullPath);
|
|
31
|
+
|
|
32
|
+
if (stat.isDirectory()) {
|
|
33
|
+
walkDirectory(fullPath);
|
|
34
|
+
} else if (stat.isFile()) {
|
|
35
|
+
const ext = path.extname(item).toLowerCase().slice(1);
|
|
36
|
+
if (extensions.includes(ext)) {
|
|
37
|
+
files.push(fullPath);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn(`Warning: Cannot access directory ${dir}: ${error.message}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
walkDirectory(this.targetDir);
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isImageFile(filePath) {
|
|
51
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
52
|
+
return this.imageExtensions.includes(ext);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
isVideoFile(filePath) {
|
|
56
|
+
const ext = path.extname(filePath).toLowerCase().slice(1);
|
|
57
|
+
return this.videoExtensions.includes(ext);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
generateOutputPath(filePath, targetType, targetFormat) {
|
|
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
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = FileSearcher;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const sharp = require('sharp');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
class ImageProcessor {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async processImage(inputPath) {
|
|
11
|
+
try {
|
|
12
|
+
const metadata = await sharp(inputPath).metadata();
|
|
13
|
+
const maxWidth = this.config.get('img_max_width');
|
|
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
|
+
}
|
|
26
|
+
|
|
27
|
+
const outputPath = this.generateOutputPath(inputPath, targetFormat);
|
|
28
|
+
|
|
29
|
+
// Check if output path would be same as input path
|
|
30
|
+
if (outputPath === inputPath) {
|
|
31
|
+
console.log(`Output path same as input, skipping: ${path.basename(inputPath)}`);
|
|
32
|
+
return { processed: false, reason: 'same_path' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let sharpInstance = sharp(inputPath);
|
|
36
|
+
|
|
37
|
+
if (needsResize) {
|
|
38
|
+
const newHeight = Math.round((maxWidth / metadata.width) * metadata.height);
|
|
39
|
+
sharpInstance = sharpInstance.resize(maxWidth, newHeight, {
|
|
40
|
+
fit: 'inside',
|
|
41
|
+
withoutEnlargement: true
|
|
42
|
+
});
|
|
43
|
+
console.log(`Resizing to: ${maxWidth}x${newHeight}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
switch (targetFormat.toLowerCase()) {
|
|
47
|
+
case 'jpg':
|
|
48
|
+
case 'jpeg':
|
|
49
|
+
sharpInstance = sharpInstance.jpeg({ quality: 85 });
|
|
50
|
+
break;
|
|
51
|
+
case 'png':
|
|
52
|
+
sharpInstance = sharpInstance.png({ compressionLevel: 8 });
|
|
53
|
+
break;
|
|
54
|
+
case 'webp':
|
|
55
|
+
sharpInstance = sharpInstance.webp({ quality: 85 });
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
sharpInstance = sharpInstance.jpeg({ quality: 85 });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
await sharpInstance.toFile(outputPath);
|
|
62
|
+
|
|
63
|
+
const compressionResult = this.checkCompression(inputPath, outputPath);
|
|
64
|
+
|
|
65
|
+
if (compressionResult.effective) {
|
|
66
|
+
console.log(`✓ Processed: ${path.basename(inputPath)} (${compressionResult.compressionPercent}% reduction)`);
|
|
67
|
+
return {
|
|
68
|
+
processed: true,
|
|
69
|
+
outputPath,
|
|
70
|
+
originalSize: compressionResult.originalSize,
|
|
71
|
+
newSize: compressionResult.newSize,
|
|
72
|
+
compressionPercent: compressionResult.compressionPercent
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
console.log(`✗ Ineffective compression, keeping original: ${path.basename(inputPath)}`);
|
|
76
|
+
fs.unlinkSync(outputPath);
|
|
77
|
+
return { processed: false, reason: 'ineffective_compression' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`Error processing image ${inputPath}:`, error.message);
|
|
82
|
+
return { processed: false, error: error.message };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
generateOutputPath(inputPath, targetFormat) {
|
|
87
|
+
const parsedPath = path.parse(inputPath);
|
|
88
|
+
return path.join(parsedPath.dir, `${parsedPath.name}.${targetFormat}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
checkCompression(originalPath, processedPath) {
|
|
92
|
+
const originalSize = fs.statSync(originalPath).size;
|
|
93
|
+
const newSize = fs.statSync(processedPath).size;
|
|
94
|
+
const compressionPercent = Math.round(((originalSize - newSize) / originalSize) * 100);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
originalSize,
|
|
98
|
+
newSize,
|
|
99
|
+
compressionPercent,
|
|
100
|
+
effective: compressionPercent >= 1
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = ImageProcessor;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const chalk = require('chalk');
|
|
4
|
+
|
|
5
|
+
const Config = require('./config');
|
|
6
|
+
const FileSearcher = require('./fileSearcher');
|
|
7
|
+
const ImageProcessor = require('./imageProcessor');
|
|
8
|
+
const VideoProcessor = require('./videoProcessor');
|
|
9
|
+
|
|
10
|
+
class ImageVideoOptimizer {
|
|
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
|
+
}
|
|
27
|
+
|
|
28
|
+
async optimize() {
|
|
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('');
|
|
33
|
+
|
|
34
|
+
if (!fs.existsSync(this.targetDir)) {
|
|
35
|
+
console.error(chalk.red(`Error: Target directory does not exist: ${this.targetDir}`));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const mediaFiles = this.fileSearcher.findMediaFiles();
|
|
40
|
+
|
|
41
|
+
console.log(chalk.yellow(`Found ${mediaFiles.images.length} images and ${mediaFiles.videos.length} videos`));
|
|
42
|
+
console.log('');
|
|
43
|
+
|
|
44
|
+
if (mediaFiles.images.length === 0 && mediaFiles.videos.length === 0) {
|
|
45
|
+
console.log(chalk.green('No media files found to process.'));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
await this.processImages(mediaFiles.images);
|
|
50
|
+
await this.processVideos(mediaFiles.videos);
|
|
51
|
+
|
|
52
|
+
this.printSummary();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async processImages(imageFiles) {
|
|
56
|
+
if (imageFiles.length === 0) return;
|
|
57
|
+
|
|
58
|
+
console.log(chalk.cyan.bold('📸 Processing Images...'));
|
|
59
|
+
console.log('');
|
|
60
|
+
|
|
61
|
+
for (const imagePath of imageFiles) {
|
|
62
|
+
try {
|
|
63
|
+
const result = await this.imageProcessor.processImage(imagePath);
|
|
64
|
+
|
|
65
|
+
if (result.processed) {
|
|
66
|
+
this.stats.imagesProcessed++;
|
|
67
|
+
this.stats.totalSizeSaved += result.originalSize - result.newSize;
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(imagePath) && imagePath !== result.outputPath) {
|
|
70
|
+
fs.unlinkSync(imagePath);
|
|
71
|
+
}
|
|
72
|
+
} else if (result.reason === 'already_optimized' || result.reason === 'same_path') {
|
|
73
|
+
this.stats.imagesSkipped++;
|
|
74
|
+
} else if (result.error) {
|
|
75
|
+
this.stats.errors.push({ file: imagePath, error: result.error });
|
|
76
|
+
}
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(chalk.red(`Unexpected error processing ${imagePath}:`, error.message));
|
|
79
|
+
this.stats.errors.push({ file: imagePath, error: error.message });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
console.log('');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async processVideos(videoFiles) {
|
|
86
|
+
if (videoFiles.length === 0) return;
|
|
87
|
+
|
|
88
|
+
console.log(chalk.cyan.bold('🎥 Processing Videos...'));
|
|
89
|
+
console.log('');
|
|
90
|
+
|
|
91
|
+
for (const videoPath of videoFiles) {
|
|
92
|
+
try {
|
|
93
|
+
const result = await this.videoProcessor.processVideo(videoPath);
|
|
94
|
+
|
|
95
|
+
if (result.processed) {
|
|
96
|
+
this.stats.videosProcessed++;
|
|
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 });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
console.log('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
printSummary() {
|
|
116
|
+
console.log(chalk.green.bold('📊 Optimization Summary'));
|
|
117
|
+
console.log(chalk.gray('='.repeat(40)));
|
|
118
|
+
console.log(`Images processed: ${chalk.cyan(this.stats.imagesProcessed)}`);
|
|
119
|
+
console.log(`Videos processed: ${chalk.cyan(this.stats.videosProcessed)}`);
|
|
120
|
+
console.log(`Images skipped: ${chalk.yellow(this.stats.imagesSkipped)}`);
|
|
121
|
+
console.log(`Videos skipped: ${chalk.yellow(this.stats.videosSkipped)}`);
|
|
122
|
+
|
|
123
|
+
if (this.stats.totalSizeSaved > 0) {
|
|
124
|
+
const savedMB = (this.stats.totalSizeSaved / 1024 / 1024).toFixed(2);
|
|
125
|
+
console.log(`Total space saved: ${chalk.green.bold(savedMB + ' MB')}`);
|
|
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!'));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = ImageVideoOptimizer;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const ffmpeg = require('fluent-ffmpeg');
|
|
2
|
+
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
|
|
3
|
+
ffmpeg.setFfmpegPath(ffmpegInstaller.path);
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
class VideoProcessor {
|
|
8
|
+
constructor(config) {
|
|
9
|
+
this.config = config;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async processVideo(inputPath) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
try {
|
|
15
|
+
const maxWidth = this.config.get('video_max_width');
|
|
16
|
+
const encodeFormat = this.config.get('video_encode');
|
|
17
|
+
|
|
18
|
+
console.log(`Processing video: ${path.basename(inputPath)}`);
|
|
19
|
+
|
|
20
|
+
const outputPath = this.generateOutputPath(inputPath);
|
|
21
|
+
|
|
22
|
+
ffmpeg.ffprobe(inputPath, (err, metadata) => {
|
|
23
|
+
if (err) {
|
|
24
|
+
console.error(`Error reading video metadata for ${inputPath}:`, err.message);
|
|
25
|
+
resolve({ processed: false, error: err.message });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const videoStream = metadata.streams.find(stream => stream.codec_type === 'video');
|
|
30
|
+
if (!videoStream) {
|
|
31
|
+
console.error(`No video stream found in: ${inputPath}`);
|
|
32
|
+
resolve({ processed: false, error: 'No video stream found' });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log(`Original dimensions: ${videoStream.width}x${videoStream.height}`);
|
|
37
|
+
|
|
38
|
+
let needsResize = videoStream.width > maxWidth;
|
|
39
|
+
let alreadyMp4 = path.extname(inputPath).toLowerCase() === '.mp4';
|
|
40
|
+
|
|
41
|
+
if (!needsResize && alreadyMp4) {
|
|
42
|
+
console.log(`Video already optimized, skipping: ${path.basename(inputPath)}`);
|
|
43
|
+
resolve({ processed: false, reason: 'already_optimized' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let ffmpegCommand = ffmpeg(inputPath);
|
|
48
|
+
|
|
49
|
+
if (needsResize) {
|
|
50
|
+
const newHeight = Math.round((maxWidth / videoStream.width) * videoStream.height);
|
|
51
|
+
ffmpegCommand = ffmpegCommand.videoFilters(`scale=${maxWidth}:${newHeight}`);
|
|
52
|
+
console.log(`Resizing to: ${maxWidth}x${newHeight}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
switch (encodeFormat.toLowerCase()) {
|
|
56
|
+
case 'h264':
|
|
57
|
+
ffmpegCommand = ffmpegCommand.videoCodec('libx264').audioCodec('aac');
|
|
58
|
+
break;
|
|
59
|
+
case 'h265':
|
|
60
|
+
ffmpegCommand = ffmpegCommand.videoCodec('libx265').audioCodec('aac');
|
|
61
|
+
break;
|
|
62
|
+
case 'vp9':
|
|
63
|
+
ffmpegCommand = ffmpegCommand.videoCodec('libvpx-vp9').audioCodec('libvorbis');
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
ffmpegCommand = ffmpegCommand.videoCodec('libx264').audioCodec('aac');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ffmpegCommand
|
|
70
|
+
.outputOptions('-crf 23')
|
|
71
|
+
.outputOptions('-preset medium')
|
|
72
|
+
.format('mp4')
|
|
73
|
+
.output(outputPath)
|
|
74
|
+
.on('end', () => {
|
|
75
|
+
const compressionResult = this.checkCompression(inputPath, outputPath);
|
|
76
|
+
|
|
77
|
+
if (compressionResult.effective) {
|
|
78
|
+
console.log(`✓ Processed: ${path.basename(inputPath)} (${compressionResult.compressionPercent}% reduction)`);
|
|
79
|
+
resolve({
|
|
80
|
+
processed: true,
|
|
81
|
+
outputPath,
|
|
82
|
+
originalSize: compressionResult.originalSize,
|
|
83
|
+
newSize: compressionResult.newSize,
|
|
84
|
+
compressionPercent: compressionResult.compressionPercent
|
|
85
|
+
});
|
|
86
|
+
} else {
|
|
87
|
+
console.log(`✗ Ineffective compression, keeping original: ${path.basename(inputPath)}`);
|
|
88
|
+
fs.unlinkSync(outputPath);
|
|
89
|
+
resolve({ processed: false, reason: 'ineffective_compression' });
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
.on('error', (err) => {
|
|
93
|
+
console.error(`Error processing video ${inputPath}:`, err.message);
|
|
94
|
+
if (fs.existsSync(outputPath)) {
|
|
95
|
+
fs.unlinkSync(outputPath);
|
|
96
|
+
}
|
|
97
|
+
resolve({ processed: false, error: err.message });
|
|
98
|
+
})
|
|
99
|
+
.run();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error(`Error processing video ${inputPath}:`, error.message);
|
|
104
|
+
resolve({ processed: false, error: error.message });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
generateOutputPath(inputPath) {
|
|
110
|
+
const parsedPath = path.parse(inputPath);
|
|
111
|
+
return path.join(parsedPath.dir, `${parsedPath.name}.mp4`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
checkCompression(originalPath, processedPath) {
|
|
115
|
+
const originalSize = fs.statSync(originalPath).size;
|
|
116
|
+
const newSize = fs.statSync(processedPath).size;
|
|
117
|
+
const compressionPercent = Math.round(((originalSize - newSize) / originalSize) * 100);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
originalSize,
|
|
121
|
+
newSize,
|
|
122
|
+
compressionPercent,
|
|
123
|
+
effective: compressionPercent >= 1
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = VideoProcessor;
|