image-video-optimizer 3.0.9 → 3.3.31
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/README.md +59 -15
- package/bin/cli.js +21 -9
- package/package.json +1 -1
- package/src/audioProcessor.js +99 -0
- package/src/fileSearcher.js +20 -2
- package/src/index.js +234 -29
- package/src/pdfProcessor.js +139 -0
package/README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# Image Video Optimizer
|
|
2
2
|
|
|
3
|
-
A powerful CLI tool to optimize images and
|
|
3
|
+
A powerful CLI tool to optimize images, videos, audio, and PDFs with configurable resize and compression settings.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
7
|
- **Image Optimization**: Resize and convert images to specified formats
|
|
8
8
|
- **Video Optimization**: Resize videos and encode to specified formats
|
|
9
|
+
- **Audio Optimization**: Convert and compress audio files to MP3
|
|
10
|
+
- **PDF Compression**: Compress PDF files using Ghostscript
|
|
11
|
+
- **Resume Support**: Track processed files and resume interrupted sessions
|
|
9
12
|
- **Configurable Settings**: Use `.image-video-optimizer.conf` files for custom settings
|
|
10
13
|
- **Smart Compression**: Only keeps optimized files if compression is effective (>1%)
|
|
11
14
|
- **Recursive Search**: Finds all media files in subdirectories
|
|
@@ -32,7 +35,7 @@ image-video-optimizer /path/to/directory [options]
|
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
- `<directory>`: Target directory to optimize (required)
|
|
35
|
-
- `-
|
|
38
|
+
- `-r, --reset`: Reset status file and start fresh
|
|
36
39
|
- `-v, --verbose`: Enable verbose logging
|
|
37
40
|
- `-V, --version`: Show version number
|
|
38
41
|
- `-h, --help`: Show help
|
|
@@ -49,6 +52,12 @@ img_format=jpg # Target format for image conversion
|
|
|
49
52
|
# Video settings
|
|
50
53
|
video_max_width=720 # Maximum width for videos (pixels)
|
|
51
54
|
video_encode=h264 # Video encoding format
|
|
55
|
+
|
|
56
|
+
# Audio settings
|
|
57
|
+
audio_ext=mp3 # Audio extension for audio files
|
|
58
|
+
|
|
59
|
+
# PDF settings
|
|
60
|
+
pdf_compress=true # Enable/disable PDF compression
|
|
52
61
|
```
|
|
53
62
|
|
|
54
63
|
### Default Configuration
|
|
@@ -58,6 +67,8 @@ If no configuration file is found, these defaults are used:
|
|
|
58
67
|
- `img_format`: jpg
|
|
59
68
|
- `video_max_width`: 720
|
|
60
69
|
- `video_encode`: h264
|
|
70
|
+
- `audio_ext`: mp3
|
|
71
|
+
- `pdf_compress`: true
|
|
61
72
|
|
|
62
73
|
## Supported Formats
|
|
63
74
|
|
|
@@ -69,6 +80,14 @@ If no configuration file is found, these defaults are used:
|
|
|
69
80
|
- Input: avi, mov, wmv, flv, webm, mkv, m4v, mp4
|
|
70
81
|
- Output: mp4 (with configurable encoding)
|
|
71
82
|
|
|
83
|
+
### Audio
|
|
84
|
+
- Input: mp3, wav, flac, aac, ogg, m4a
|
|
85
|
+
- Output: mp3 (configurable)
|
|
86
|
+
|
|
87
|
+
### Documents
|
|
88
|
+
- Input: pdf
|
|
89
|
+
- Output: pdf (compressed)
|
|
90
|
+
|
|
72
91
|
## Processing Logic
|
|
73
92
|
|
|
74
93
|
### Image Processing
|
|
@@ -86,6 +105,24 @@ If no configuration file is found, these defaults are used:
|
|
|
86
105
|
5. Converts to MP4 format
|
|
87
106
|
6. Compares file sizes and keeps optimized version only if compression > 1%
|
|
88
107
|
|
|
108
|
+
### Audio Processing
|
|
109
|
+
1. Searches for audio files recursively
|
|
110
|
+
2. Converts to target format (default: MP3)
|
|
111
|
+
3. Applies aggressive compression (single channel, 16kHz, 32k bitrate)
|
|
112
|
+
4. Replaces original with compressed version
|
|
113
|
+
|
|
114
|
+
### PDF Processing
|
|
115
|
+
1. Searches for PDF files recursively
|
|
116
|
+
2. Uses Ghostscript to compress PDFs
|
|
117
|
+
3. Falls back to copy if Ghostscript is not available
|
|
118
|
+
4. Replaces original with compressed version
|
|
119
|
+
|
|
120
|
+
### Resume Logic
|
|
121
|
+
1. Creates `.image-video-optimizer-status.json` to track processed files
|
|
122
|
+
2. Skips already processed files on subsequent runs
|
|
123
|
+
3. Updates status file after each file is processed
|
|
124
|
+
4. Use `--reset` to clear status and start fresh
|
|
125
|
+
|
|
89
126
|
## Examples
|
|
90
127
|
|
|
91
128
|
### Optimize a directory with default settings
|
|
@@ -93,9 +130,14 @@ If no configuration file is found, these defaults are used:
|
|
|
93
130
|
image-video-optimizer ./photos
|
|
94
131
|
```
|
|
95
132
|
|
|
96
|
-
###
|
|
133
|
+
### Reset status and start fresh
|
|
134
|
+
```bash
|
|
135
|
+
image-video-optimizer ./photos --reset
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Verbose output
|
|
97
139
|
```bash
|
|
98
|
-
image-video-optimizer ./photos --
|
|
140
|
+
image-video-optimizer ./photos --verbose
|
|
99
141
|
```
|
|
100
142
|
|
|
101
143
|
### Custom configuration
|
|
@@ -105,11 +147,14 @@ img_max_width=1920
|
|
|
105
147
|
img_format=webp
|
|
106
148
|
video_max_width=1080
|
|
107
149
|
video_encode=h265
|
|
150
|
+
audio_ext=mp3
|
|
151
|
+
pdf_compress=true
|
|
108
152
|
```
|
|
109
153
|
|
|
110
154
|
Then run:
|
|
111
155
|
```bash
|
|
112
|
-
image-video-optimizer
|
|
156
|
+
image-video-optimizer . # and image-video-optimizer the same as it will proceed the current directory
|
|
157
|
+
image-video-optimizer ./media/path/to/directory # will proceed the specified directory
|
|
113
158
|
```
|
|
114
159
|
|
|
115
160
|
## Dependencies
|
|
@@ -117,30 +162,29 @@ image-video-optimizer ./media
|
|
|
117
162
|
- [sharp](https://sharp.pixelplumbing.com/) - Image processing
|
|
118
163
|
- [fluent-ffmpeg](https://github.com/fluent-ffmpeg/node-fluent-ffmpeg) - Video processing
|
|
119
164
|
- [commander](https://github.com/tj/commander.js) - CLI framework
|
|
120
|
-
- [chalk](https://github.com/chalk/chalk) - Terminal styling
|
|
121
165
|
|
|
122
166
|
## System Requirements
|
|
123
167
|
|
|
124
168
|
- Node.js >= 14.0.0
|
|
125
|
-
- FFmpeg (for video processing)
|
|
169
|
+
- FFmpeg (for video and audio processing)
|
|
170
|
+
- Ghostscript (for PDF compression, optional)
|
|
126
171
|
|
|
127
|
-
### Installing
|
|
128
|
-
|
|
129
|
-
**Ubuntu/Debian:**
|
|
172
|
+
### Installing Requirements
|
|
173
|
+
**Arch/GopiOS:**
|
|
130
174
|
```bash
|
|
131
175
|
sudo pacman -Syu ffmpeg x264
|
|
176
|
+
```
|
|
132
177
|
|
|
133
|
-
|
|
178
|
+
**Ubuntu/Debian:**
|
|
179
|
+
```bash
|
|
180
|
+
sudo apt install ffmpeg ghostscript
|
|
134
181
|
```
|
|
135
182
|
|
|
136
183
|
**macOS:**
|
|
137
184
|
```bash
|
|
138
|
-
brew install ffmpeg
|
|
185
|
+
brew install ffmpeg ghostscript
|
|
139
186
|
```
|
|
140
187
|
|
|
141
|
-
**Windows:**
|
|
142
|
-
Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to PATH
|
|
143
|
-
|
|
144
188
|
## License
|
|
145
189
|
|
|
146
190
|
nirvána
|
package/bin/cli.js
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { Command } = require('commander');
|
|
4
4
|
const path = require('path');
|
|
5
|
-
const
|
|
5
|
+
const { optimize, resetStatus } = require('../src/index');
|
|
6
|
+
|
|
7
|
+
const program = new Command();
|
|
6
8
|
|
|
7
9
|
program
|
|
8
10
|
.name('image-video-optimizer')
|
|
9
|
-
.description('
|
|
10
|
-
.version('3.0
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
.description('CLI tool to optimize and compress images, videos, audio, and PDFs')
|
|
12
|
+
.version('3.2.0');
|
|
13
|
+
|
|
14
|
+
program
|
|
15
|
+
.argument('[directory]', 'Target directory to optimize', process.cwd())
|
|
16
|
+
.option('-r, --reset', 'Reset status file and start fresh')
|
|
17
|
+
.option('-v, --verbose', 'Show detailed processing information')
|
|
18
|
+
.action(async (directory, options) => {
|
|
13
19
|
try {
|
|
14
|
-
const
|
|
20
|
+
const targetDir = path.resolve(directory);
|
|
21
|
+
|
|
22
|
+
if (options.reset) {
|
|
23
|
+
resetStatus(targetDir);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
console.log('\n🚀 Image Video Optimizer\n');
|
|
16
|
-
console.log('Target directory: ' +
|
|
28
|
+
console.log('Target directory: ' + targetDir);
|
|
17
29
|
|
|
18
|
-
await
|
|
30
|
+
await optimize(targetDir, options.verbose);
|
|
19
31
|
|
|
20
32
|
console.log('\n✅ Optimization complete!\n');
|
|
21
33
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const ffmpeg = require('fluent-ffmpeg');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Process audio file with compression
|
|
7
|
+
* @param {string} audioPath - Path to audio file
|
|
8
|
+
* @param {object} config - Configuration object
|
|
9
|
+
* @returns {Promise<object>} Processing result
|
|
10
|
+
*/
|
|
11
|
+
async function processAudio(audioPath, config = {}) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const originalSize = fs.statSync(audioPath).size;
|
|
14
|
+
const parsedPath = path.parse(audioPath);
|
|
15
|
+
const outputExt = config.audio_ext || 'mp3';
|
|
16
|
+
const finalPath = path.join(parsedPath.dir, `${parsedPath.name}.${outputExt}`);
|
|
17
|
+
|
|
18
|
+
// Check if already in target format and no processing needed
|
|
19
|
+
if (path.extname(audioPath).toLowerCase().slice(1) === outputExt) {
|
|
20
|
+
// For now, assume audio files are already optimized if they're in target format
|
|
21
|
+
resolve({
|
|
22
|
+
success: false,
|
|
23
|
+
originalSize,
|
|
24
|
+
optimizedSize: originalSize,
|
|
25
|
+
message: `○ ${path.basename(audioPath)} - Already optimized (${(originalSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
26
|
+
filePath: audioPath
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Use temporary file to avoid conflicts
|
|
32
|
+
const tmpPath = path.join(parsedPath.dir, `${parsedPath.name}_tmp.${outputExt}`);
|
|
33
|
+
|
|
34
|
+
const command = ffmpeg(audioPath)
|
|
35
|
+
.audioCodec('libmp3lame')
|
|
36
|
+
.audioChannels(1)
|
|
37
|
+
.audioFrequency(16000)
|
|
38
|
+
.audioBitrate('32k')
|
|
39
|
+
.outputOptions([
|
|
40
|
+
'-ar 16000',
|
|
41
|
+
'-q:a 9'
|
|
42
|
+
])
|
|
43
|
+
.output(tmpPath);
|
|
44
|
+
|
|
45
|
+
command
|
|
46
|
+
.on('end', async () => {
|
|
47
|
+
try {
|
|
48
|
+
// Wait a bit for the file to be fully written
|
|
49
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
50
|
+
|
|
51
|
+
const tmpStat = await new Promise((res, rej) => {
|
|
52
|
+
fs.stat(tmpPath, (err, stat) => {
|
|
53
|
+
if (err) rej(err);
|
|
54
|
+
else res(stat);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const optimizedSize = tmpStat.size;
|
|
59
|
+
|
|
60
|
+
// Always replace with compressed version for audio
|
|
61
|
+
await fs.promises.unlink(audioPath);
|
|
62
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
63
|
+
|
|
64
|
+
resolve({
|
|
65
|
+
success: true,
|
|
66
|
+
originalSize,
|
|
67
|
+
optimizedSize,
|
|
68
|
+
message: `✓ ${path.basename(audioPath)} → ${path.basename(finalPath)} (${(originalSize / 1024 / 1024).toFixed(1)}MB → ${(optimizedSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
69
|
+
filePath: finalPath
|
|
70
|
+
});
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Clean up temp file on error
|
|
73
|
+
try {
|
|
74
|
+
await fs.promises.unlink(tmpPath);
|
|
75
|
+
} catch (cleanupError) {
|
|
76
|
+
// Ignore cleanup errors
|
|
77
|
+
}
|
|
78
|
+
reject(error);
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
.on('error', async (error) => {
|
|
82
|
+
// Clean up temp file on error
|
|
83
|
+
try {
|
|
84
|
+
await fs.promises.unlink(tmpPath);
|
|
85
|
+
} catch (cleanupError) {
|
|
86
|
+
// Ignore cleanup errors
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const errorMessage = error.message || 'Unknown error';
|
|
90
|
+
reject(new Error(`Audio processing failed for ${path.basename(audioPath)}: ${errorMessage}`));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
command.run();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
processAudio
|
|
99
|
+
};
|
package/src/fileSearcher.js
CHANGED
|
@@ -3,6 +3,8 @@ const path = require('path');
|
|
|
3
3
|
|
|
4
4
|
const SUPPORTED_IMAGES = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff', 'webp'];
|
|
5
5
|
const SUPPORTED_VIDEOS = ['avi', 'mov', 'wmv', 'flv', 'webm', 'mkv', 'm4v', 'mp4'];
|
|
6
|
+
const SUPPORTED_AUDIO = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a'];
|
|
7
|
+
const SUPPORTED_DOCUMENTS = ['pdf'];
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Recursively search for media files in a directory
|
|
@@ -13,6 +15,8 @@ const SUPPORTED_VIDEOS = ['avi', 'mov', 'wmv', 'flv', 'webm', 'mkv', 'm4v', 'mp4
|
|
|
13
15
|
async function searchMediaFiles(dirPath, options = {}) {
|
|
14
16
|
const images = [];
|
|
15
17
|
const videos = [];
|
|
18
|
+
const audio = [];
|
|
19
|
+
const documents = [];
|
|
16
20
|
const verbose = options.verbose || false;
|
|
17
21
|
|
|
18
22
|
async function walk(currentPath) {
|
|
@@ -39,6 +43,16 @@ async function searchMediaFiles(dirPath, options = {}) {
|
|
|
39
43
|
if (verbose) {
|
|
40
44
|
console.log(` Found video: ${fullPath}`);
|
|
41
45
|
}
|
|
46
|
+
} else if (SUPPORTED_AUDIO.includes(ext)) {
|
|
47
|
+
audio.push(fullPath);
|
|
48
|
+
if (verbose) {
|
|
49
|
+
console.log(` Found audio: ${fullPath}`);
|
|
50
|
+
}
|
|
51
|
+
} else if (SUPPORTED_DOCUMENTS.includes(ext)) {
|
|
52
|
+
documents.push(fullPath);
|
|
53
|
+
if (verbose) {
|
|
54
|
+
console.log(` Found document: ${fullPath}`);
|
|
55
|
+
}
|
|
42
56
|
}
|
|
43
57
|
}
|
|
44
58
|
} catch (error) {
|
|
@@ -59,7 +73,9 @@ async function searchMediaFiles(dirPath, options = {}) {
|
|
|
59
73
|
return {
|
|
60
74
|
images,
|
|
61
75
|
videos,
|
|
62
|
-
|
|
76
|
+
audio,
|
|
77
|
+
documents,
|
|
78
|
+
total: images.length + videos.length + audio.length + documents.length
|
|
63
79
|
};
|
|
64
80
|
}
|
|
65
81
|
|
|
@@ -95,5 +111,7 @@ module.exports = {
|
|
|
95
111
|
getFileSize,
|
|
96
112
|
formatFileSize,
|
|
97
113
|
SUPPORTED_IMAGES,
|
|
98
|
-
SUPPORTED_VIDEOS
|
|
114
|
+
SUPPORTED_VIDEOS,
|
|
115
|
+
SUPPORTED_AUDIO,
|
|
116
|
+
SUPPORTED_DOCUMENTS
|
|
99
117
|
};
|
package/src/index.js
CHANGED
|
@@ -3,15 +3,20 @@ const path = require('path');
|
|
|
3
3
|
const fileSearcher = require('./fileSearcher');
|
|
4
4
|
const imageProcessor = require('./imageProcessor');
|
|
5
5
|
const videoProcessor = require('./videoProcessor');
|
|
6
|
+
const audioProcessor = require('./audioProcessor');
|
|
7
|
+
const pdfProcessor = require('./pdfProcessor');
|
|
6
8
|
|
|
7
9
|
const DEFAULT_CONFIG = {
|
|
8
10
|
img_max_width: 1080,
|
|
9
11
|
img_format: 'jpg',
|
|
10
12
|
video_max_width: 720,
|
|
11
|
-
video_encode: 'h264'
|
|
13
|
+
video_encode: 'h264',
|
|
14
|
+
audio_ext: 'mp3',
|
|
15
|
+
pdf_compress: true
|
|
12
16
|
};
|
|
13
17
|
|
|
14
18
|
const CONFIG_FILENAME = '.image-video-optimizer.conf';
|
|
19
|
+
const STATUS_FILENAME = '.image-video-optimizer-status.json';
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* Parse configuration file
|
|
@@ -69,6 +74,8 @@ img_max_width=${DEFAULT_CONFIG.img_max_width} # Maximum width for image
|
|
|
69
74
|
img_format=${DEFAULT_CONFIG.img_format} # Target format for image conversion
|
|
70
75
|
video_max_width=${DEFAULT_CONFIG.video_max_width} # Maximum width for videos (pixels)
|
|
71
76
|
video_encode=${DEFAULT_CONFIG.video_encode} # Video encoding format
|
|
77
|
+
audio_ext=${DEFAULT_CONFIG.audio_ext} # Audio extension for audio files
|
|
78
|
+
pdf_compress=${DEFAULT_CONFIG.pdf_compress} # Enable/disable PDF compression
|
|
72
79
|
`;
|
|
73
80
|
|
|
74
81
|
try {
|
|
@@ -80,11 +87,101 @@ video_encode=${DEFAULT_CONFIG.video_encode} # Video encoding format
|
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Load status file
|
|
92
|
+
* @param {string} targetDir - Target directory
|
|
93
|
+
* @returns {object} Status object
|
|
94
|
+
*/
|
|
95
|
+
function loadStatus(targetDir) {
|
|
96
|
+
const statusPath = path.join(targetDir, STATUS_FILENAME);
|
|
97
|
+
try {
|
|
98
|
+
if (fs.existsSync(statusPath)) {
|
|
99
|
+
const content = fs.readFileSync(statusPath, 'utf8');
|
|
100
|
+
return JSON.parse(content);
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.warn(`Warning: Could not read status file: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Create initial status file if it doesn't exist
|
|
107
|
+
const initialStatus = {
|
|
108
|
+
processed: [],
|
|
109
|
+
failed: [],
|
|
110
|
+
startTime: null,
|
|
111
|
+
lastRun: null
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
fs.writeFileSync(statusPath, JSON.stringify(initialStatus, null, 2));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
console.warn(`Warning: Could not create status file: ${error.message}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return initialStatus;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Save status file
|
|
125
|
+
* @param {string} targetDir - Target directory
|
|
126
|
+
* @param {object} status - Status object
|
|
127
|
+
*/
|
|
128
|
+
function saveStatus(targetDir, status) {
|
|
129
|
+
const statusPath = path.join(targetDir, STATUS_FILENAME);
|
|
130
|
+
try {
|
|
131
|
+
status.lastRun = new Date().toISOString();
|
|
132
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2));
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.warn(`Warning: Could not save status file: ${error.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if file was already processed
|
|
140
|
+
* @param {string} filePath - File path to check
|
|
141
|
+
* @param {object} status - Status object
|
|
142
|
+
* @returns {boolean} True if already processed
|
|
143
|
+
*/
|
|
144
|
+
function isProcessed(filePath, status) {
|
|
145
|
+
return status.processed.includes(filePath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Mark file as processed
|
|
150
|
+
* @param {string} filePath - File path to mark
|
|
151
|
+
* @param {object} status - Status object
|
|
152
|
+
* @param {string} targetDir - Target directory for saving status
|
|
153
|
+
*/
|
|
154
|
+
function markProcessed(filePath, status, targetDir) {
|
|
155
|
+
if (!status.processed.includes(filePath)) {
|
|
156
|
+
status.processed.push(filePath);
|
|
157
|
+
// Save status immediately after each file is processed
|
|
158
|
+
saveStatus(targetDir, status);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Mark file as failed
|
|
164
|
+
* @param {string} filePath - File path to mark
|
|
165
|
+
* @param {string} error - Error message
|
|
166
|
+
* @param {object} status - Status object
|
|
167
|
+
* @param {string} targetDir - Target directory for saving status
|
|
168
|
+
*/
|
|
169
|
+
function markFailed(filePath, error, status, targetDir) {
|
|
170
|
+
status.failed.push({
|
|
171
|
+
file: filePath,
|
|
172
|
+
error: error,
|
|
173
|
+
timestamp: new Date().toISOString()
|
|
174
|
+
});
|
|
175
|
+
// Save status immediately after each file fails
|
|
176
|
+
saveStatus(targetDir, status);
|
|
177
|
+
}
|
|
178
|
+
|
|
83
179
|
/**
|
|
84
180
|
* Main optimization function
|
|
85
181
|
* @param {string} targetDir - Target directory to optimize
|
|
182
|
+
* @param {boolean} verbose - Show detailed processing information
|
|
86
183
|
*/
|
|
87
|
-
async function optimize(targetDir) {
|
|
184
|
+
async function optimize(targetDir, verbose = false) {
|
|
88
185
|
// Validate directory exists
|
|
89
186
|
if (!fs.existsSync(targetDir)) {
|
|
90
187
|
throw new Error(`Directory does not exist: ${targetDir}`);
|
|
@@ -93,24 +190,42 @@ async function optimize(targetDir) {
|
|
|
93
190
|
// Ensure config file exists
|
|
94
191
|
ensureConfigFile(targetDir);
|
|
95
192
|
|
|
96
|
-
// Load configuration
|
|
193
|
+
// Load configuration and status (create status file early)
|
|
97
194
|
const configPath = path.join(targetDir, CONFIG_FILENAME);
|
|
98
195
|
const config = parseConfig(configPath);
|
|
196
|
+
const status = loadStatus(targetDir);
|
|
99
197
|
|
|
100
198
|
console.log('Configuration:');
|
|
101
199
|
console.log(` Image Max Width: ${config.img_max_width}px`);
|
|
102
200
|
console.log(` Image Format: ${config.img_format}`);
|
|
103
201
|
console.log(` Video Max Width: ${config.video_max_width}px`);
|
|
104
|
-
console.log(` Video Encode: ${config.video_encode}
|
|
202
|
+
console.log(` Video Encode: ${config.video_encode}`);
|
|
203
|
+
console.log(` Audio Extension: ${config.audio_ext}`);
|
|
204
|
+
console.log(` PDF Compression: ${config.pdf_compress ? 'Enabled' : 'Disabled'}`);
|
|
205
|
+
|
|
206
|
+
if (status.processed.length > 0) {
|
|
207
|
+
console.log(` Resume: ${status.processed.length} files already processed`);
|
|
208
|
+
}
|
|
209
|
+
console.log('');
|
|
105
210
|
|
|
106
211
|
// Search for media files
|
|
107
212
|
console.log('🔍 Searching for media files...');
|
|
108
|
-
const { images, videos } = await fileSearcher.searchMediaFiles(targetDir);
|
|
213
|
+
const { images, videos, audio, documents } = await fileSearcher.searchMediaFiles(targetDir, { verbose });
|
|
109
214
|
|
|
110
|
-
|
|
215
|
+
// Filter out already processed files
|
|
216
|
+
const pendingImages = images.filter(img => !isProcessed(img, status));
|
|
217
|
+
const pendingVideos = videos.filter(vid => !isProcessed(vid, status));
|
|
218
|
+
const pendingAudio = audio.filter(aud => !isProcessed(aud, status));
|
|
219
|
+
const pendingDocuments = documents.filter(doc => !isProcessed(doc, status));
|
|
111
220
|
|
|
112
|
-
|
|
113
|
-
|
|
221
|
+
const totalFound = images.length + videos.length + audio.length + documents.length;
|
|
222
|
+
const totalPending = pendingImages.length + pendingVideos.length + pendingAudio.length + pendingDocuments.length;
|
|
223
|
+
const alreadyProcessed = totalFound - totalPending;
|
|
224
|
+
|
|
225
|
+
console.log(`Found ${totalFound} file(s) (${alreadyProcessed} already processed, ${totalPending} pending)\n`);
|
|
226
|
+
|
|
227
|
+
if (totalPending === 0) {
|
|
228
|
+
console.log('All files have been processed. Use --reset to clear status and start over.');
|
|
114
229
|
return;
|
|
115
230
|
}
|
|
116
231
|
|
|
@@ -119,34 +234,96 @@ async function optimize(targetDir) {
|
|
|
119
234
|
let successCount = 0;
|
|
120
235
|
|
|
121
236
|
// Process images
|
|
122
|
-
if (
|
|
237
|
+
if (pendingImages.length > 0) {
|
|
123
238
|
console.log('📷 Processing images...');
|
|
124
|
-
for (const imagePath of
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
239
|
+
for (const imagePath of pendingImages) {
|
|
240
|
+
try {
|
|
241
|
+
const result = await imageProcessor.processImage(imagePath, config);
|
|
242
|
+
console.log(result.message);
|
|
243
|
+
|
|
244
|
+
totalOriginalSize += result.originalSize;
|
|
245
|
+
totalOptimizedSize += result.optimizedSize;
|
|
246
|
+
|
|
247
|
+
// Mark as processed if success OR if already optimized (no processing needed)
|
|
248
|
+
if (result.success || result.message.includes('Already optimized')) {
|
|
249
|
+
successCount++;
|
|
250
|
+
markProcessed(imagePath, status, targetDir);
|
|
251
|
+
}
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.log(`❌ ${path.basename(imagePath)}: ${error.message}`);
|
|
254
|
+
markFailed(imagePath, error.message, status, targetDir);
|
|
133
255
|
}
|
|
134
256
|
}
|
|
135
257
|
console.log('');
|
|
136
258
|
}
|
|
137
259
|
|
|
138
260
|
// Process videos
|
|
139
|
-
if (
|
|
261
|
+
if (pendingVideos.length > 0) {
|
|
140
262
|
console.log('🎬 Processing videos...');
|
|
141
|
-
for (const videoPath of
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
263
|
+
for (const videoPath of pendingVideos) {
|
|
264
|
+
try {
|
|
265
|
+
const result = await videoProcessor.processVideo(videoPath, config);
|
|
266
|
+
console.log(result.message);
|
|
267
|
+
|
|
268
|
+
totalOriginalSize += result.originalSize;
|
|
269
|
+
totalOptimizedSize += result.optimizedSize;
|
|
270
|
+
|
|
271
|
+
// Mark as processed if success OR if already optimized (no processing needed)
|
|
272
|
+
if (result.success || result.message.includes('Already optimized')) {
|
|
273
|
+
successCount++;
|
|
274
|
+
markProcessed(videoPath, status, targetDir);
|
|
275
|
+
}
|
|
276
|
+
} catch (error) {
|
|
277
|
+
console.log(`❌ ${path.basename(videoPath)}: ${error.message}`);
|
|
278
|
+
markFailed(videoPath, error.message, status, targetDir);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
console.log('');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Process audio
|
|
285
|
+
if (pendingAudio.length > 0) {
|
|
286
|
+
console.log('🎵 Processing audio...');
|
|
287
|
+
for (const audioPath of pendingAudio) {
|
|
288
|
+
try {
|
|
289
|
+
const result = await audioProcessor.processAudio(audioPath, config);
|
|
290
|
+
console.log(result.message);
|
|
291
|
+
|
|
292
|
+
totalOriginalSize += result.originalSize;
|
|
293
|
+
totalOptimizedSize += result.optimizedSize;
|
|
294
|
+
|
|
295
|
+
// Mark as processed if success OR if already optimized (no processing needed)
|
|
296
|
+
if (result.success || result.message.includes('Already optimized')) {
|
|
297
|
+
successCount++;
|
|
298
|
+
markProcessed(audioPath, status, targetDir);
|
|
299
|
+
}
|
|
300
|
+
} catch (error) {
|
|
301
|
+
console.log(`❌ ${path.basename(audioPath)}: ${error.message}`);
|
|
302
|
+
markFailed(audioPath, error.message, status, targetDir);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
console.log('');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Process documents
|
|
309
|
+
if (pendingDocuments.length > 0) {
|
|
310
|
+
console.log('📄 Processing documents...');
|
|
311
|
+
for (const docPath of pendingDocuments) {
|
|
312
|
+
try {
|
|
313
|
+
const result = await pdfProcessor.processPDF(docPath, config);
|
|
314
|
+
console.log(result.message);
|
|
315
|
+
|
|
316
|
+
totalOriginalSize += result.originalSize;
|
|
317
|
+
totalOptimizedSize += result.optimizedSize;
|
|
318
|
+
|
|
319
|
+
// Mark as processed if success OR if already optimized (no processing needed)
|
|
320
|
+
if (result.success || result.message.includes('Already optimized')) {
|
|
321
|
+
successCount++;
|
|
322
|
+
markProcessed(docPath, status, targetDir);
|
|
323
|
+
}
|
|
324
|
+
} catch (error) {
|
|
325
|
+
console.log(`❌ ${path.basename(docPath)}: ${error.message}`);
|
|
326
|
+
markFailed(docPath, error.message, status, targetDir);
|
|
150
327
|
}
|
|
151
328
|
}
|
|
152
329
|
console.log('');
|
|
@@ -154,7 +331,13 @@ async function optimize(targetDir) {
|
|
|
154
331
|
|
|
155
332
|
// Print summary
|
|
156
333
|
console.log('📊 Summary:');
|
|
157
|
-
console.log(` Files
|
|
334
|
+
console.log(` Files processed: ${successCount}/${totalPending}`);
|
|
335
|
+
console.log(` Total files in directory: ${totalFound}`);
|
|
336
|
+
console.log(` Already processed: ${alreadyProcessed}`);
|
|
337
|
+
|
|
338
|
+
if (status.failed.length > 0) {
|
|
339
|
+
console.log(` Failed: ${status.failed.length}`);
|
|
340
|
+
}
|
|
158
341
|
|
|
159
342
|
if (totalOriginalSize > 0) {
|
|
160
343
|
const savedSize = totalOriginalSize - totalOptimizedSize;
|
|
@@ -164,10 +347,32 @@ async function optimize(targetDir) {
|
|
|
164
347
|
console.log(` Optimized size: ${(totalOptimizedSize / 1024 / 1024).toFixed(2)}MB`);
|
|
165
348
|
console.log(` Space saved: ${(savedSize / 1024 / 1024).toFixed(2)}MB (${savedPercent}%)`);
|
|
166
349
|
}
|
|
350
|
+
|
|
351
|
+
// Save status
|
|
352
|
+
saveStatus(targetDir, status);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reset status file
|
|
357
|
+
* @param {string} targetDir - Target directory
|
|
358
|
+
*/
|
|
359
|
+
function resetStatus(targetDir) {
|
|
360
|
+
const statusPath = path.join(targetDir, STATUS_FILENAME);
|
|
361
|
+
try {
|
|
362
|
+
if (fs.existsSync(statusPath)) {
|
|
363
|
+
fs.unlinkSync(statusPath);
|
|
364
|
+
console.log(`✓ Status file reset: ${STATUS_FILENAME}`);
|
|
365
|
+
} else {
|
|
366
|
+
console.log('No status file found to reset.');
|
|
367
|
+
}
|
|
368
|
+
} catch (error) {
|
|
369
|
+
console.warn(`Warning: Could not reset status file: ${error.message}`);
|
|
370
|
+
}
|
|
167
371
|
}
|
|
168
372
|
|
|
169
373
|
module.exports = {
|
|
170
374
|
optimize,
|
|
375
|
+
resetStatus,
|
|
171
376
|
parseConfig,
|
|
172
377
|
ensureConfigFile
|
|
173
378
|
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Process PDF file with compression
|
|
6
|
+
* @param {string} pdfPath - Path to PDF file
|
|
7
|
+
* @param {object} config - Configuration object
|
|
8
|
+
* @returns {Promise<object>} Processing result
|
|
9
|
+
*/
|
|
10
|
+
async function processPDF(pdfPath, config = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
// Check if PDF compression is disabled
|
|
13
|
+
if (config.pdf_compress === false) {
|
|
14
|
+
const originalSize = fs.statSync(pdfPath).size;
|
|
15
|
+
resolve({
|
|
16
|
+
success: false,
|
|
17
|
+
originalSize,
|
|
18
|
+
optimizedSize: originalSize,
|
|
19
|
+
message: `○ ${path.basename(pdfPath)} - Already optimized (PDF compression disabled)`,
|
|
20
|
+
filePath: pdfPath
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const originalSize = fs.statSync(pdfPath).size;
|
|
26
|
+
const parsedPath = path.parse(pdfPath);
|
|
27
|
+
|
|
28
|
+
// For now, assume PDF files are already optimized (no size check)
|
|
29
|
+
// In a real implementation, you might check file size, quality, etc.
|
|
30
|
+
resolve({
|
|
31
|
+
success: false,
|
|
32
|
+
originalSize,
|
|
33
|
+
optimizedSize: originalSize,
|
|
34
|
+
message: `○ ${path.basename(pdfPath)} - Already optimized (${(originalSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
35
|
+
filePath: pdfPath
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
|
|
39
|
+
// Use temporary file to avoid conflicts
|
|
40
|
+
const tmpPath = path.join(parsedPath.dir, `${parsedPath.name}_tmp.pdf`);
|
|
41
|
+
|
|
42
|
+
// Try to use ghostscript for PDF compression
|
|
43
|
+
const command = require('child_process').spawn('gs', [
|
|
44
|
+
'-sDEVICE=pdfwrite',
|
|
45
|
+
'-dCompatibilityLevel=1.4',
|
|
46
|
+
'-dPDFSETTINGS=/screen',
|
|
47
|
+
'-dNOPAUSE',
|
|
48
|
+
'-dQUIET',
|
|
49
|
+
'-dBATCH',
|
|
50
|
+
'-sOutputFile=' + tmpPath,
|
|
51
|
+
pdfPath
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
command.on('close', async (code) => {
|
|
55
|
+
try {
|
|
56
|
+
if (code !== 0 && code !== null) {
|
|
57
|
+
// If ghostscript fails, fall back to copy
|
|
58
|
+
console.warn(`Warning: Ghostscript failed (code ${code}), falling back to copy for ${path.basename(pdfPath)}`);
|
|
59
|
+
const copyCommand = require('child_process').spawn('cp', [pdfPath, tmpPath]);
|
|
60
|
+
|
|
61
|
+
copyCommand.on('close', async (copyCode) => {
|
|
62
|
+
if (copyCode !== 0) {
|
|
63
|
+
throw new Error(`PDF copy failed with code ${copyCode}`);
|
|
64
|
+
}
|
|
65
|
+
await processResult();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
copyCommand.on('error', (error) => {
|
|
69
|
+
reject(new Error(`PDF copy failed for ${path.basename(pdfPath)}: ${error.message}`));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await processResult();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
reject(error);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function processResult() {
|
|
82
|
+
try {
|
|
83
|
+
const tmpStat = await new Promise((res, rej) => {
|
|
84
|
+
fs.stat(tmpPath, (err, stat) => {
|
|
85
|
+
if (err) rej(err);
|
|
86
|
+
else res(stat);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const optimizedSize = tmpStat.size;
|
|
91
|
+
|
|
92
|
+
// Always replace with compressed version for PDFs
|
|
93
|
+
await fs.promises.unlink(pdfPath);
|
|
94
|
+
await fs.promises.rename(tmpPath, pdfPath);
|
|
95
|
+
|
|
96
|
+
resolve({
|
|
97
|
+
success: true,
|
|
98
|
+
originalSize,
|
|
99
|
+
optimizedSize,
|
|
100
|
+
message: `✓ ${path.basename(pdfPath)} (${(originalSize / 1024 / 1024).toFixed(1)}MB → ${(optimizedSize / 1024 / 1024).toFixed(1)}MB)`,
|
|
101
|
+
filePath: pdfPath
|
|
102
|
+
});
|
|
103
|
+
} catch (error) {
|
|
104
|
+
// Clean up temp file on error
|
|
105
|
+
try {
|
|
106
|
+
await fs.promises.unlink(tmpPath);
|
|
107
|
+
} catch (cleanupError) {
|
|
108
|
+
// Ignore cleanup errors
|
|
109
|
+
}
|
|
110
|
+
reject(error);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
command.on('error', async (error) => {
|
|
115
|
+
// If ghostscript is not available, fall back to copy
|
|
116
|
+
if (error.message.includes('ENOENT')) {
|
|
117
|
+
console.warn(`Warning: Ghostscript not found, falling back to copy for ${path.basename(pdfPath)}`);
|
|
118
|
+
const copyCommand = require('child_process').spawn('cp', [pdfPath, tmpPath]);
|
|
119
|
+
|
|
120
|
+
copyCommand.on('close', async (copyCode) => {
|
|
121
|
+
if (copyCode !== 0) {
|
|
122
|
+
throw new Error(`PDF copy failed with code ${copyCode}`);
|
|
123
|
+
}
|
|
124
|
+
await processResult();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
copyCommand.on('error', (copyError) => {
|
|
128
|
+
reject(new Error(`PDF copy failed for ${path.basename(pdfPath)}: ${copyError.message}`));
|
|
129
|
+
});
|
|
130
|
+
} else {
|
|
131
|
+
reject(new Error(`PDF processing failed for ${path.basename(pdfPath)}: ${error.message}`));
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
processPDF
|
|
139
|
+
};
|