image-video-optimizer 1.2.1 β†’ 2.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.
@@ -1,4 +1,4 @@
1
1
  img_max_width=1080
2
- img_format=webp
2
+ img_format=jpg
3
3
  video_max_width=720
4
4
  video_encode=h264
package/README.md CHANGED
@@ -128,6 +128,8 @@ image-video-optimizer ./media
128
128
 
129
129
  **Ubuntu/Debian:**
130
130
  ```bash
131
+ sudo pacman -Syu ffmpeg x264
132
+
131
133
  sudo apt update && sudo apt install ffmpeg
132
134
  ```
133
135
 
package/bin/cli.js CHANGED
@@ -1,44 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { Command } = require('commander');
4
- const path = require('path');
3
+ const { program } = require('commander');
5
4
  const chalk = require('chalk');
6
- const ImageVideoOptimizer = require('../src/index');
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('CLI tool to optimize images and videos with configurable resize and compression settings')
10
+ .description('Optimize and compress images and videos in a directory')
13
11
  .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) => {
12
+ .argument('[target-dir]', 'Target directory to optimize (default: current directory)', process.cwd())
13
+ .action(async (targetDir) => {
18
14
  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
- }
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:'), error.message);
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": "1.2.1",
4
- "description": "CLI tool to optimize images and videos with configurable resize and compression settings",
3
+ "version": "2.0.0",
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 src/index.js",
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
- "optimization",
17
- "compression",
18
- "resize",
16
+ "optimizer",
17
+ "compress",
18
+ "ffmpeg",
19
19
  "cli"
20
20
  ],
21
21
  "author": "",
22
- "license": "nirvΓ‘na",
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": "^4.1.2"
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
+ }
@@ -1,75 +1,51 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
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
- }
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
- 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
- }
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
- 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
- }
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
- 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);
37
+ } catch (error) {
38
+ console.warn(`Warning: Could not read directory ${currentPath}: ${error.message}`);
39
+ }
53
40
  }
54
41
 
55
- isVideoFile(filePath) {
56
- const ext = path.extname(filePath).toLowerCase().slice(1);
57
- return this.videoExtensions.includes(ext);
58
- }
42
+ await walk(dirPath);
59
43
 
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
- }
44
+ return { images, videos };
73
45
  }
74
46
 
75
- module.exports = FileSearcher;
47
+ module.exports = {
48
+ searchMediaFiles,
49
+ SUPPORTED_IMAGES,
50
+ SUPPORTED_VIDEOS
51
+ };
@@ -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
- class ImageProcessor {
6
- constructor(config) {
7
- this.config = config;
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
- 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
- }
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
- const outputPath = this.generateOutputPath(inputPath, targetFormat);
28
- const parsedPath = path.parse(inputPath);
29
-
30
- // Always use temporary file for processing
31
- const tempPath = path.join(parsedPath.dir, `${parsedPath.name}_temp.${targetFormat}`);
32
- console.log(`Using temporary path for processing: ${path.basename(tempPath)}`);
33
-
34
- let sharpInstance = sharp(inputPath);
35
-
36
- if (needsResize) {
37
- const newHeight = Math.round((maxWidth / metadata.width) * metadata.height);
38
- sharpInstance = sharpInstance.resize(maxWidth, newHeight, {
39
- fit: 'inside',
40
- withoutEnlargement: true
41
- });
42
- console.log(`Resizing to: ${maxWidth}x${newHeight}`);
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
- generateOutputPath(inputPath, targetFormat) {
89
- const parsedPath = path.parse(inputPath);
90
- return path.join(parsedPath.dir, `${parsedPath.name}.${targetFormat}`);
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
- checkCompression(originalPath, processedPath) {
94
- const originalSize = fs.statSync(originalPath).size;
95
- const newSize = fs.statSync(processedPath).size;
96
- const compressionPercent = Math.round(((originalSize - newSize) / originalSize) * 100);
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
- originalSize,
100
- newSize,
101
- compressionPercent,
102
- effective: compressionPercent >= 1
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 = ImageProcessor;
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 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
- }
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
- 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('');
15
+ const CONFIG_FILENAME = '.image-video-optimizer.conf';
33
16
 
34
- if (!fs.existsSync(this.targetDir)) {
35
- console.error(chalk.red(`Error: Target directory does not exist: ${this.targetDir}`));
36
- return;
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 mediaFiles = this.fileSearcher.findMediaFiles();
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
- if (mediaFiles.images.length === 0 && mediaFiles.videos.length === 0) {
45
- console.log(chalk.green('No media files found to process.'));
46
- return;
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
- await this.processImages(mediaFiles.images);
50
- await this.processVideos(mediaFiles.videos);
51
-
52
- this.printSummary();
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
- 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 });
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
- 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 });
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
- 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)}`);
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
- 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!'));
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 = ImageVideoOptimizer;
169
+ module.exports = {
170
+ optimize,
171
+ parseConfig,
172
+ ensureConfigFile
173
+ };
@@ -1,141 +1,140 @@
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
- class VideoProcessor {
8
- constructor(config) {
9
- this.config = config;
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
- 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
- const parsedPath = path.parse(inputPath);
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
- // Always use temporary file for processing
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.ffprobe(inputPath, (err, metadata) => {
28
- if (err) {
29
- console.error(`Error reading video metadata for ${inputPath}:`, err.message);
30
- resolve({ processed: false, error: err.message });
31
- return;
32
- }
33
-
34
- const videoStream = metadata.streams.find(stream => stream.codec_type === 'video');
35
- if (!videoStream) {
36
- console.error(`No video stream found in: ${inputPath}`);
37
- resolve({ processed: false, error: 'No video stream found' });
38
- return;
39
- }
40
-
41
- console.log(`Original dimensions: ${videoStream.width}x${videoStream.height}`);
42
-
43
- let needsResize = videoStream.width > maxWidth;
44
- let alreadyMp4 = path.extname(inputPath).toLowerCase() === '.mp4';
45
-
46
- if (!needsResize && alreadyMp4) {
47
- console.log(`Video already optimized, skipping: ${path.basename(inputPath)}`);
48
- resolve({ processed: false, reason: 'already_optimized' });
49
- return;
50
- }
51
-
52
- let tempFfmpegCommand = ffmpeg(inputPath);
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
+ .aspect('16:9')
48
+ .autopad()
49
+ .outputOptions([
50
+ '-preset fast',
51
+ '-crf 28'
52
+ ])
53
+ .output(tmpPath);
54
+
55
+ command
56
+ .on('end', async () => {
57
+ try {
58
+ const tmpStat = await new Promise((res, rej) => {
59
+ fs.stat(tmpPath, (err, stat) => {
60
+ if (err) rej(err);
61
+ else res(stat);
62
+ });
63
+ });
64
+
65
+ const optimizedSize = tmpStat.size;
66
+
67
+ if (optimizedSize < originalSize) {
68
+ // Remove original and rename temp to final
69
+ await fs.promises.unlink(videoPath);
70
+ await fs.promises.rename(tmpPath, finalPath);
71
+
92
72
  resolve({
93
- processed: true,
94
- outputPath,
95
- originalSize: compressionResult.originalSize,
96
- newSize: compressionResult.newSize,
97
- compressionPercent: compressionResult.compressionPercent
73
+ success: true,
74
+ originalSize,
75
+ optimizedSize,
76
+ message: `${chalk.green('βœ“')} ${path.basename(videoPath)} β†’ ${path.basename(finalPath)} (${(originalSize / 1024 / 1024).toFixed(1)}MB β†’ ${(optimizedSize / 1024 / 1024).toFixed(1)}MB)`,
77
+ filePath: finalPath
98
78
  });
99
79
  } else {
100
- console.log(`βœ— Ineffective compression, keeping original: ${path.basename(inputPath)}`);
101
- fs.unlinkSync(tempPath);
102
- resolve({ processed: false, reason: 'ineffective_compression' });
80
+ // Remove temp file if not smaller
81
+ await fs.promises.unlink(tmpPath);
82
+
83
+ resolve({
84
+ success: false,
85
+ originalSize,
86
+ optimizedSize,
87
+ message: `${chalk.yellow('β—‹')} ${path.basename(videoPath)} - Already optimized (${(originalSize / 1024 / 1024).toFixed(1)}MB)`,
88
+ filePath: videoPath
89
+ });
103
90
  }
104
- })
105
- .on('error', (err) => {
106
- console.error(`Error processing video ${inputPath}:`, err.message);
107
- console.error(`FFmpeg stderr: ${err.stderr || 'No stderr available'}`);
108
- if (fs.existsSync(tempPath)) {
109
- fs.unlinkSync(tempPath);
91
+ } catch (error) {
92
+ resolve({
93
+ success: false,
94
+ originalSize,
95
+ optimizedSize: 0,
96
+ message: `${chalk.red('βœ—')} ${path.basename(videoPath)} - Post-processing error: ${error.message}`,
97
+ error: true
98
+ });
99
+ }
100
+ })
101
+ .on('error', (err) => {
102
+ // Clean up temp file on error
103
+ fs.unlink(tmpPath, () => {
104
+ let errorMsg = err.message;
105
+
106
+ // Provide helpful suggestions for common errors
107
+ if (err.message.includes('Unknown encoder') || err.message.includes('Encoder')) {
108
+ errorMsg = `Video codec error. Check available codecs with: ffmpeg -encoders | grep lib`;
109
+ } else if (err.message.includes('is not available')) {
110
+ errorMsg = `Codec not available. Verify FFmpeg installation: ffmpeg -version`;
111
+ } else if (err.message.includes('ENOENT') || err.message.includes('ffmpeg')) {
112
+ errorMsg = `FFmpeg not found. Install with: brew install ffmpeg (macOS) or apt-get install ffmpeg (Linux)`;
110
113
  }
111
- resolve({ processed: false, error: err.message });
112
- })
113
- .run();
114
- });
115
- } catch (error) {
116
- console.error(`Error processing video ${inputPath}:`, error.message);
117
- resolve({ processed: false, error: error.message });
118
- }
119
- });
120
- }
121
-
122
- generateOutputPath(inputPath) {
123
- const parsedPath = path.parse(inputPath);
124
- return path.join(parsedPath.dir, `${parsedPath.name}.mp4`);
125
- }
126
-
127
- checkCompression(originalPath, processedPath) {
128
- const originalSize = fs.statSync(originalPath).size;
129
- const newSize = fs.statSync(processedPath).size;
130
- const compressionPercent = Math.round(((originalSize - newSize) / originalSize) * 100);
131
-
132
- return {
133
- originalSize,
134
- newSize,
135
- compressionPercent,
136
- effective: compressionPercent >= 1
137
- };
138
- }
114
+
115
+ resolve({
116
+ success: false,
117
+ originalSize,
118
+ optimizedSize: 0,
119
+ message: `${chalk.red('βœ—')} ${path.basename(videoPath)} - ${errorMsg}`,
120
+ error: true
121
+ });
122
+ });
123
+ })
124
+ .run();
125
+ });
126
+ } catch (error) {
127
+ resolve({
128
+ success: false,
129
+ originalSize: 0,
130
+ optimizedSize: 0,
131
+ message: `${chalk.red('βœ—')} ${path.basename(videoPath)} - Error: ${error.message}`,
132
+ error: true
133
+ });
134
+ }
135
+ });
139
136
  }
140
137
 
141
- module.exports = VideoProcessor;
138
+ module.exports = {
139
+ processVideo
140
+ };