optimo 0.0.16 → 0.0.18
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 +29 -4
- package/bin/help.js +20 -13
- package/bin/index.js +16 -3
- package/package.json +7 -3
- package/src/compressor/ffmpeg.js +186 -0
- package/src/compressor/index.js +12 -6
- package/src/compressor/magick.js +25 -19
- package/src/compressor/mozjpegtran.js +3 -2
- package/src/index.js +67 -11
- package/src/util/get-media-kind.js +18 -0
- package/src/util/to-data-url.js +26 -0
package/README.md
CHANGED
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
<img alt="Last version" src="https://img.shields.io/github/tag/kikobeats/optimo.svg?style=flat-square">
|
|
7
7
|
<a href="https://www.npmjs.org/package/optimo"><img alt="NPM Status" src="https://img.shields.io/npm/dm/optimo.svg?style=flat-square"></a>
|
|
8
8
|
<br><br>
|
|
9
|
-
optimo reduces
|
|
9
|
+
optimo reduces media file size aggressively, and safely.
|
|
10
10
|
</div>
|
|
11
11
|
|
|
12
12
|
## Highlights
|
|
13
13
|
|
|
14
14
|
- Format-specific tuning for stronger size reduction.
|
|
15
|
-
- Backed by proven tools: ImageMagick, SVGO, Gifsicle, and MozJPEG.
|
|
16
15
|
- Safety guard: if optimized output is not smaller, original file is kept.
|
|
17
|
-
-
|
|
16
|
+
- Backed by proven tools: ImageMagick, SVGO, Gifsicle, MozJPEG, and FFmpeg.
|
|
17
|
+
- Supports image and video optimization.
|
|
18
|
+
- Resizing supports percentage values (`50%`), max file size targets (`100kB`, images only), width (`w960`), & height (`h480`).
|
|
18
19
|
|
|
19
20
|
## Usage
|
|
20
21
|
|
|
@@ -27,7 +28,12 @@ npx -y optimo public/media/banner.png --resize 50% # resize + optimize
|
|
|
27
28
|
npx -y optimo public/media/banner.png --resize 100kB # resize to max file size
|
|
28
29
|
npx -y optimo public/media/banner.png --resize w960 # resize to max width
|
|
29
30
|
npx -y optimo public/media/banner.png --resize h480 # resize to max height
|
|
31
|
+
npx -y optimo public/media/banner.png --data-url # print optimized image as data URL
|
|
30
32
|
npx -y optimo public/media/banner.heic --dry-run --verbose # inspect unsupported failures
|
|
33
|
+
npx -y optimo public/media/clip.mp4 # optimize a video
|
|
34
|
+
npx -y optimo public/media/clip.mp4 --mute # optimize and remove audio
|
|
35
|
+
npx -y optimo public/media/clip.mp4 --mute false # optimize video and keep audio
|
|
36
|
+
npx -y optimo public/media/clip.mov --format webm # convert + optimize video
|
|
31
37
|
```
|
|
32
38
|
|
|
33
39
|
## Pipelines
|
|
@@ -38,12 +44,15 @@ When `optimo` is executed, a pipeline of compressors is chosen based on the outp
|
|
|
38
44
|
- `.svg` -> `svgo.svg`
|
|
39
45
|
- `.jpg/.jpeg` -> `magick.jpg/jpeg` + `mozjpegtran.jpg/jpeg`
|
|
40
46
|
- `.gif` -> `magick.gif` + `gifsicle.gif`
|
|
41
|
-
- other formats (`webp`, `avif`, `heic`, `heif`, `jxl`, etc.) -> `magick.<format>`
|
|
47
|
+
- other image formats (`webp`, `avif`, `heic`, `heif`, `jxl`, etc.) -> `magick.<format>`
|
|
48
|
+
- video formats (`mp4`, `m4v`, `mov`, `webm`, `mkv`, `avi`, `ogv`) -> `ffmpeg.<format>`
|
|
42
49
|
|
|
43
50
|
Mode behavior:
|
|
44
51
|
|
|
45
52
|
- default: lossless-first pipeline.
|
|
46
53
|
- `-l, --losy`: lossy + lossless pass per matching compressor.
|
|
54
|
+
- `-m, --mute`: remove audio tracks from video outputs (default: `true`; use `--mute false` to keep audio).
|
|
55
|
+
- `-u, --data-url`: return optimized image as data URL (single file only; image only).
|
|
47
56
|
- `-v, --verbose`: print debug logs (selected pipeline, binaries, executed commands, and errors).
|
|
48
57
|
|
|
49
58
|
|
|
@@ -79,6 +88,22 @@ await optimo.file('/absolute/path/image.jpg', {
|
|
|
79
88
|
onLogs: console.log
|
|
80
89
|
})
|
|
81
90
|
|
|
91
|
+
await optimo.file('/absolute/path/video.mp4', {
|
|
92
|
+
losy: true,
|
|
93
|
+
// mute defaults to true for videos; set false to keep audio
|
|
94
|
+
mute: false,
|
|
95
|
+
format: 'webm',
|
|
96
|
+
resize: 'w1280',
|
|
97
|
+
onLogs: console.log
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const { dataUrl } = await optimo.file('/absolute/path/image.jpg', {
|
|
101
|
+
dataUrl: true,
|
|
102
|
+
onLogs: console.log
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
console.log(dataUrl) // data:image/jpeg;base64,...
|
|
106
|
+
|
|
82
107
|
// optimize a dir recursively
|
|
83
108
|
const result = await optimo.dir('/absolute/path/images')
|
|
84
109
|
|
package/bin/help.js
CHANGED
|
@@ -2,27 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
const { gray, blue } = require('../src/util/colors')
|
|
4
4
|
|
|
5
|
-
module.exports = gray(`Efortless
|
|
5
|
+
module.exports = gray(`Efortless media optimizer
|
|
6
6
|
|
|
7
7
|
Usage
|
|
8
8
|
$ ${blue('optimo')} <file|dir> [options]
|
|
9
9
|
|
|
10
10
|
Options
|
|
11
|
-
-l, --losy Enable lossy + lossless
|
|
11
|
+
-l, --losy Enable lossy + lossless passes (default: false)
|
|
12
|
+
-m, --mute Remove audio tracks from videos (default: true)
|
|
13
|
+
-u, --data-url Return optimized image as data URL (file input only)
|
|
12
14
|
-d, --dry-run Show what would be optimized without making changes
|
|
13
15
|
-f, --format Convert output format (e.g. jpeg, webp, avif)
|
|
14
|
-
-r, --resize Resize by percentage (50%), size (100kB), width (w960), or height (h480)
|
|
16
|
+
-r, --resize Resize by percentage (50%), size (100kB, images only), width (w960), or height (h480)
|
|
15
17
|
-v, --verbose Print debug logs (commands, pipeline, and errors)
|
|
16
18
|
|
|
17
19
|
Examples
|
|
18
|
-
$ optimo image.jpg
|
|
19
|
-
$ optimo image.jpg --losy
|
|
20
|
-
$ optimo
|
|
21
|
-
$ optimo
|
|
22
|
-
$ optimo image.png -
|
|
23
|
-
$ optimo image.
|
|
24
|
-
$ optimo image.png -
|
|
25
|
-
$ optimo image.png -r
|
|
26
|
-
$ optimo image.png -r
|
|
27
|
-
$ optimo image.
|
|
20
|
+
$ optimo image.jpg # optimize a single image in place
|
|
21
|
+
$ optimo image.jpg --losy # run lossy + lossless optimization passes
|
|
22
|
+
$ optimo clip.mp4 --mute # optimize video and remove audio track
|
|
23
|
+
$ optimo clip.mp4 --mute false # optimize video and keep audio track
|
|
24
|
+
$ optimo image.png --dry-run # preview optimization without writing files
|
|
25
|
+
$ optimo image.jpg -d # short alias for dry-run preview mode
|
|
26
|
+
$ optimo image.png -f jpeg # convert PNG to JPEG and optimize
|
|
27
|
+
$ optimo image.png -r 50% # resize image to 50 percent then optimize
|
|
28
|
+
$ optimo image.png -r 100kB # resize image to target max file size
|
|
29
|
+
$ optimo image.png -r w960 # resize image to max width of 960px
|
|
30
|
+
$ optimo image.png -r h480 # resize image to max height of 480px
|
|
31
|
+
$ optimo image.png --data-url # output optimized image as data URL
|
|
32
|
+
$ optimo image.heic -d -v # dry-run HEIC optimization with verbose logs
|
|
33
|
+
$ optimo clip.mp4 # optimize a single video in place
|
|
34
|
+
$ optimo clip.mov -f webm # convert MOV to WebM and optimize
|
|
28
35
|
`)
|
package/bin/index.js
CHANGED
|
@@ -8,9 +8,11 @@ const mri = require('mri')
|
|
|
8
8
|
async function main () {
|
|
9
9
|
const argv = mri(process.argv.slice(2), {
|
|
10
10
|
alias: {
|
|
11
|
+
'data-url': 'u',
|
|
11
12
|
'dry-run': 'd',
|
|
12
13
|
format: 'f',
|
|
13
14
|
losy: 'l',
|
|
15
|
+
mute: 'm',
|
|
14
16
|
resize: 'r',
|
|
15
17
|
silent: 's',
|
|
16
18
|
verbose: 'v'
|
|
@@ -18,6 +20,11 @@ async function main () {
|
|
|
18
20
|
})
|
|
19
21
|
|
|
20
22
|
const input = argv._[0]
|
|
23
|
+
const dataUrl = argv['data-url'] === true
|
|
24
|
+
const mute =
|
|
25
|
+
argv.mute === undefined
|
|
26
|
+
? true
|
|
27
|
+
: !['false', '0', 'no', 'off'].includes(String(argv.mute).toLowerCase())
|
|
21
28
|
let resize = argv.resize
|
|
22
29
|
|
|
23
30
|
if (resize !== undefined && resize !== null) {
|
|
@@ -40,17 +47,23 @@ async function main () {
|
|
|
40
47
|
const isDirectory = stats.isDirectory()
|
|
41
48
|
const fn = isDirectory ? require('optimo').dir : require('optimo').file
|
|
42
49
|
|
|
43
|
-
const logger = argv.silent ? () => {} : logEntry => console.
|
|
44
|
-
!argv.silent && console.
|
|
50
|
+
const logger = argv.silent ? () => {} : logEntry => console.error(logEntry)
|
|
51
|
+
!argv.silent && console.error()
|
|
45
52
|
|
|
46
|
-
await fn(input, {
|
|
53
|
+
const result = await fn(input, {
|
|
47
54
|
losy: argv.losy,
|
|
55
|
+
mute,
|
|
48
56
|
dryRun: argv['dry-run'],
|
|
57
|
+
dataUrl,
|
|
49
58
|
format: argv.format,
|
|
50
59
|
resize,
|
|
51
60
|
onLogs: logger
|
|
52
61
|
})
|
|
53
62
|
|
|
63
|
+
if (dataUrl && result?.dataUrl) {
|
|
64
|
+
console.log(result.dataUrl)
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
process.exit(0)
|
|
55
68
|
}
|
|
56
69
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "optimo",
|
|
3
|
-
"description": "The no-brainer
|
|
3
|
+
"description": "The no-brainer CLI for optimizing images and videos",
|
|
4
4
|
"homepage": "https://github.com/kikobeats/optimo",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.18",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js"
|
|
8
8
|
},
|
|
@@ -23,13 +23,17 @@
|
|
|
23
23
|
"url": "https://github.com/kikobeats/optimo/issues"
|
|
24
24
|
},
|
|
25
25
|
"keywords": [
|
|
26
|
+
"ffmpeg",
|
|
26
27
|
"image",
|
|
27
28
|
"image-optimization",
|
|
28
29
|
"image-optimizer",
|
|
29
30
|
"imagemagick",
|
|
30
31
|
"images",
|
|
31
32
|
"optimization",
|
|
32
|
-
"optimize"
|
|
33
|
+
"optimize",
|
|
34
|
+
"video",
|
|
35
|
+
"video-optimization",
|
|
36
|
+
"video-optimizer"
|
|
33
37
|
],
|
|
34
38
|
"dependencies": {
|
|
35
39
|
"debug-logfmt": "~1.4.7",
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const $ = require('tinyspawn')
|
|
5
|
+
|
|
6
|
+
const resolveBinary = require('../util/resolve-binary')
|
|
7
|
+
|
|
8
|
+
const binaryPath = resolveBinary('ffmpeg')
|
|
9
|
+
|
|
10
|
+
const withMeta = (format, fn) => {
|
|
11
|
+
const wrapped = async ctx => fn(ctx)
|
|
12
|
+
wrapped.binaryName = 'ffmpeg'
|
|
13
|
+
wrapped.binaryPath = binaryPath
|
|
14
|
+
wrapped.format = format
|
|
15
|
+
return wrapped
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Translate optimo resize config to a FFmpeg `scale` filter.
|
|
20
|
+
*
|
|
21
|
+
* Output dimensions are normalized to even numbers because some codecs
|
|
22
|
+
* (notably H.264) require even width/height to encode correctly.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ mode: string, value: string | number } | null} resizeConfig
|
|
25
|
+
* @returns {string | null}
|
|
26
|
+
*/
|
|
27
|
+
const getScaleFilter = resizeConfig => {
|
|
28
|
+
if (!resizeConfig) return null
|
|
29
|
+
|
|
30
|
+
if (resizeConfig.mode === 'max-size') {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
'Resize max size (e.g. 100kB) is image-only. For videos use percentage (50%), width (w960), or height (h480).'
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (resizeConfig.mode === 'percentage') {
|
|
37
|
+
const percentage = Number.parseFloat(resizeConfig.value.replace('%', ''))
|
|
38
|
+
const ratio = percentage / 100
|
|
39
|
+
return `scale=trunc(iw*${ratio}/2)*2:trunc(ih*${ratio}/2)*2`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (resizeConfig.mode === 'dimension') {
|
|
43
|
+
if (resizeConfig.value.endsWith('x')) {
|
|
44
|
+
const width = Number.parseInt(resizeConfig.value.slice(0, -1), 10)
|
|
45
|
+
return `scale=${width}:-2:force_original_aspect_ratio=decrease`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const height = Number.parseInt(resizeConfig.value.slice(1), 10)
|
|
49
|
+
return `scale=-2:${height}:force_original_aspect_ratio=decrease`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build FFmpeg codec/muxing flags based on output extension.
|
|
57
|
+
*
|
|
58
|
+
* Flag glossary used below:
|
|
59
|
+
* - `-c:v <codec>`: video codec implementation.
|
|
60
|
+
* - `-crf <n>`: quality target for constant-quality encoders; higher = smaller/worse quality.
|
|
61
|
+
* - `-b:v 0`: disable target bitrate so CRF drives quality (VP9 mode).
|
|
62
|
+
* - `-preset <name>`: encoder speed/compression tradeoff.
|
|
63
|
+
* - `-tile-columns` / `-row-mt`: VP9 parallelism settings with better compression throughput.
|
|
64
|
+
* - `-frame-parallel 1`: improves VP9 decode compatibility for web playback.
|
|
65
|
+
* - `-pix_fmt yuv420p`: broadly compatible pixel format for playback.
|
|
66
|
+
* - `-c:a <codec>` and `-b:a <rate>` / `-q:a <n>`: audio codec and quality.
|
|
67
|
+
* - `-an`: drop audio entirely from output.
|
|
68
|
+
* - `-movflags +faststart`: move MP4 metadata (moov atom) to file start for faster streaming start.
|
|
69
|
+
*
|
|
70
|
+
* `losy=true` intentionally picks more aggressive values for smaller files.
|
|
71
|
+
*
|
|
72
|
+
* @param {{ ext: string, losy?: boolean, mute?: boolean }} params
|
|
73
|
+
* @returns {string[]}
|
|
74
|
+
*/
|
|
75
|
+
const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => {
|
|
76
|
+
if (ext === '.webm') {
|
|
77
|
+
return [
|
|
78
|
+
'-c:v',
|
|
79
|
+
'libvpx-vp9',
|
|
80
|
+
'-b:v',
|
|
81
|
+
'0',
|
|
82
|
+
'-crf',
|
|
83
|
+
losy ? '35' : '31',
|
|
84
|
+
'-row-mt',
|
|
85
|
+
'1',
|
|
86
|
+
'-tile-columns',
|
|
87
|
+
'2',
|
|
88
|
+
'-frame-parallel',
|
|
89
|
+
'1',
|
|
90
|
+
'-deadline',
|
|
91
|
+
'good',
|
|
92
|
+
'-cpu-used',
|
|
93
|
+
losy ? '2' : '1',
|
|
94
|
+
'-pix_fmt',
|
|
95
|
+
'yuv420p',
|
|
96
|
+
...(mute ? [] : ['-c:a', 'libopus', '-b:a', losy ? '64k' : '96k'])
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (ext === '.ogv') {
|
|
101
|
+
return [
|
|
102
|
+
'-c:v',
|
|
103
|
+
'libtheora',
|
|
104
|
+
'-q:v',
|
|
105
|
+
losy ? '4' : '6',
|
|
106
|
+
...(mute ? [] : ['-c:a', 'libvorbis', '-q:a', losy ? '3' : '4'])
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
'-c:v',
|
|
112
|
+
'libx264',
|
|
113
|
+
'-preset',
|
|
114
|
+
losy ? 'medium' : 'slow',
|
|
115
|
+
'-crf',
|
|
116
|
+
losy ? '28' : '23',
|
|
117
|
+
'-pix_fmt',
|
|
118
|
+
'yuv420p',
|
|
119
|
+
...(mute ? [] : ['-c:a', 'aac', '-b:a', losy ? '96k' : '128k']),
|
|
120
|
+
...(ext === '.mp4' || ext === '.m4v' || ext === '.mov'
|
|
121
|
+
? ['-movflags', '+faststart']
|
|
122
|
+
: [])
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Encode a video file with conservative defaults:
|
|
128
|
+
* - strips metadata (`-map_metadata -1`)
|
|
129
|
+
* - strips chapters/subtitles/data streams for smaller web outputs
|
|
130
|
+
* - keeps the primary video stream and optional first audio stream (`0:a:0?`)
|
|
131
|
+
* - applies a resize filter when requested
|
|
132
|
+
*
|
|
133
|
+
* @param {{
|
|
134
|
+
* inputPath: string,
|
|
135
|
+
* outputPath: string,
|
|
136
|
+
* resizeConfig?: { mode: string, value: string | number } | null,
|
|
137
|
+
* losy?: boolean,
|
|
138
|
+
* mute?: boolean
|
|
139
|
+
* }} params
|
|
140
|
+
*/
|
|
141
|
+
const run = async ({ inputPath, outputPath, resizeConfig, losy = false, mute = true }) => {
|
|
142
|
+
const ext = path.extname(outputPath).toLowerCase()
|
|
143
|
+
const scaleFilter = getScaleFilter(resizeConfig)
|
|
144
|
+
const codecArgs = getCodecArgsByExt({ ext, losy, mute })
|
|
145
|
+
|
|
146
|
+
await $(binaryPath, [
|
|
147
|
+
'-v',
|
|
148
|
+
'error',
|
|
149
|
+
'-y',
|
|
150
|
+
'-i',
|
|
151
|
+
inputPath,
|
|
152
|
+
...(scaleFilter ? ['-vf', scaleFilter] : []),
|
|
153
|
+
'-map_metadata',
|
|
154
|
+
'-1',
|
|
155
|
+
'-map_chapters',
|
|
156
|
+
'-1',
|
|
157
|
+
'-dn',
|
|
158
|
+
'-sn',
|
|
159
|
+
'-map',
|
|
160
|
+
'0:v:0',
|
|
161
|
+
...(mute ? ['-an'] : ['-map', '0:a:0?']),
|
|
162
|
+
...codecArgs,
|
|
163
|
+
outputPath
|
|
164
|
+
])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const mp4 = withMeta('mp4', run)
|
|
168
|
+
const m4v = withMeta('m4v', run)
|
|
169
|
+
const mov = withMeta('mov', run)
|
|
170
|
+
const webm = withMeta('webm', run)
|
|
171
|
+
const mkv = withMeta('mkv', run)
|
|
172
|
+
const avi = withMeta('avi', run)
|
|
173
|
+
const ogv = withMeta('ogv', run)
|
|
174
|
+
const file = withMeta('file', run)
|
|
175
|
+
|
|
176
|
+
module.exports = {
|
|
177
|
+
binaryPath,
|
|
178
|
+
mp4,
|
|
179
|
+
m4v,
|
|
180
|
+
mov,
|
|
181
|
+
webm,
|
|
182
|
+
mkv,
|
|
183
|
+
avi,
|
|
184
|
+
ogv,
|
|
185
|
+
file
|
|
186
|
+
}
|
package/src/compressor/index.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('node:path')
|
|
4
4
|
|
|
5
|
+
const mozjpegtran = require('./mozjpegtran')
|
|
6
|
+
const gifsicle = require('./gifsicle')
|
|
5
7
|
const magick = require('./magick')
|
|
8
|
+
const ffmpeg = require('./ffmpeg')
|
|
6
9
|
const svgo = require('./svgo')
|
|
7
|
-
const gifsicle = require('./gifsicle')
|
|
8
|
-
const mozjpegtran = require('./mozjpegtran')
|
|
9
10
|
|
|
10
11
|
const PIPELINES = {
|
|
11
12
|
'.png': [magick.png],
|
|
@@ -17,7 +18,14 @@ const PIPELINES = {
|
|
|
17
18
|
'.avif': [magick.avif],
|
|
18
19
|
'.heic': [magick.heic],
|
|
19
20
|
'.heif': [magick.heif],
|
|
20
|
-
'.jxl': [magick.jxl]
|
|
21
|
+
'.jxl': [magick.jxl],
|
|
22
|
+
'.mp4': [ffmpeg.mp4],
|
|
23
|
+
'.m4v': [ffmpeg.m4v],
|
|
24
|
+
'.mov': [ffmpeg.mov],
|
|
25
|
+
'.webm': [ffmpeg.webm],
|
|
26
|
+
'.mkv': [ffmpeg.mkv],
|
|
27
|
+
'.avi': [ffmpeg.avi],
|
|
28
|
+
'.ogv': [ffmpeg.ogv]
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
const getPipeline = filePath => {
|
|
@@ -40,9 +48,7 @@ const getRequiredBinaries = (pipeline, opts = {}) => {
|
|
|
40
48
|
})
|
|
41
49
|
}
|
|
42
50
|
|
|
43
|
-
return Array.from(
|
|
44
|
-
new Map(binaries.map(binary => [binary.name, binary])).values()
|
|
45
|
-
)
|
|
51
|
+
return Array.from(new Map(binaries.map(binary => [binary.name, binary])).values())
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
module.exports = { getPipeline, getRequiredBinaries }
|
package/src/compressor/magick.js
CHANGED
|
@@ -10,6 +10,7 @@ const resolveBinary = require('../util/resolve-binary')
|
|
|
10
10
|
const binaryPath = resolveBinary('magick')
|
|
11
11
|
|
|
12
12
|
const PNG_QUALITY_CANDIDATES = [91, 94, 95, 97]
|
|
13
|
+
const PNG_COMPRESSION_STRATEGY_CANDIDATES = [null, 0]
|
|
13
14
|
|
|
14
15
|
const withMeta = (format, fn) => {
|
|
15
16
|
const wrapped = async ctx => fn(ctx)
|
|
@@ -89,31 +90,36 @@ const isAnimatedPng = ({ filePath }) => {
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
const writePng = async ({ inputPath, outputPath, flags, resizeGeometry }) => {
|
|
92
|
-
const
|
|
93
|
+
const animated = isAnimatedPng({ filePath: inputPath })
|
|
94
|
+
const writeCandidates = animated ? [90] : PNG_QUALITY_CANDIDATES
|
|
95
|
+
const strategyCandidates = animated ? [null] : PNG_COMPRESSION_STRATEGY_CANDIDATES
|
|
93
96
|
const candidatePaths = []
|
|
94
97
|
let bestPath = null
|
|
95
98
|
let bestSize = Number.POSITIVE_INFINITY
|
|
96
99
|
|
|
97
100
|
try {
|
|
98
101
|
for (const quality of writeCandidates) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
102
|
+
for (const compressionStrategy of strategyCandidates) {
|
|
103
|
+
const candidatePath = `${outputPath}.q${quality}${compressionStrategy === null ? '' : `.s${compressionStrategy}`}.png`
|
|
104
|
+
candidatePaths.push(candidatePath)
|
|
105
|
+
|
|
106
|
+
const args = [
|
|
107
|
+
inputPath,
|
|
108
|
+
...(resizeGeometry ? ['-resize', resizeGeometry] : []),
|
|
109
|
+
...flags,
|
|
110
|
+
...(compressionStrategy === null ? [] : ['-define', `png:compression-strategy=${compressionStrategy}`]),
|
|
111
|
+
'-quality',
|
|
112
|
+
String(quality),
|
|
113
|
+
candidatePath
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
await $(binaryPath, args)
|
|
117
|
+
const size = (await stat(candidatePath)).size
|
|
118
|
+
|
|
119
|
+
if (size < bestSize) {
|
|
120
|
+
bestSize = size
|
|
121
|
+
bestPath = candidatePath
|
|
122
|
+
}
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
|
@@ -17,11 +17,12 @@ const withMeta = (format, fn) => {
|
|
|
17
17
|
return wrapped
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
const run = async ({ inputPath, outputPath }) =>
|
|
20
|
+
const run = async ({ inputPath, outputPath, losy = false }) =>
|
|
21
21
|
$(mozjpegtranPath, [
|
|
22
22
|
'-copy',
|
|
23
|
-
'none',
|
|
23
|
+
losy ? 'none' : 'all',
|
|
24
24
|
'-optimize',
|
|
25
|
+
'-progressive',
|
|
25
26
|
'-outfile',
|
|
26
27
|
outputPath,
|
|
27
28
|
inputPath
|
package/src/index.js
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { stat, unlink, rename, readdir, copyFile } = require('node:fs/promises')
|
|
3
|
+
const { stat, unlink, rename, readdir, copyFile, readFile } = require('node:fs/promises')
|
|
4
4
|
const path = require('node:path')
|
|
5
5
|
|
|
6
6
|
const { getPipeline, getRequiredBinaries } = require('./compressor')
|
|
7
7
|
const ensureBinaries = require('./util/ensure-binaries')
|
|
8
8
|
const { yellow, gray, green } = require('./util/colors')
|
|
9
9
|
const getOutputPath = require('./util/get-output-path')
|
|
10
|
+
const getMediaKind = require('./util/get-media-kind')
|
|
10
11
|
const formatBytes = require('./util/format-bytes')
|
|
11
12
|
const parseResize = require('./util/parse-resize')
|
|
13
|
+
const toDataUrl = require('./util/to-data-url')
|
|
12
14
|
const percentage = require('./util/percentage')
|
|
13
15
|
const formatLog = require('./util/format-log')
|
|
14
16
|
const debug = require('./util/debug')
|
|
15
17
|
|
|
16
|
-
const runStepInPlaceIfSmaller = async ({ currentPath, extension, step }) => {
|
|
18
|
+
const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute }) => {
|
|
17
19
|
const candidatePath = `${currentPath}.candidate${extension}`
|
|
18
20
|
|
|
19
21
|
await step({
|
|
20
22
|
inputPath: currentPath,
|
|
21
23
|
outputPath: candidatePath,
|
|
22
24
|
resizeConfig: null,
|
|
23
|
-
losy: false
|
|
25
|
+
losy: false,
|
|
26
|
+
mute
|
|
24
27
|
})
|
|
25
28
|
|
|
26
29
|
const [currentSize, candidateSize] = await Promise.all([
|
|
@@ -36,20 +39,22 @@ const runStepInPlaceIfSmaller = async ({ currentPath, extension, step }) => {
|
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
41
|
|
|
39
|
-
const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy }) => {
|
|
42
|
+
const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy, mute }) => {
|
|
40
43
|
const extension = path.extname(optimizedPath) || '.tmp'
|
|
41
44
|
|
|
42
45
|
await pipeline[0]({
|
|
43
46
|
inputPath: filePath,
|
|
44
47
|
outputPath: optimizedPath,
|
|
45
48
|
resizeConfig,
|
|
46
|
-
losy
|
|
49
|
+
losy,
|
|
50
|
+
mute
|
|
47
51
|
})
|
|
48
52
|
|
|
49
53
|
for (const step of pipeline.slice(1)) {
|
|
50
54
|
await runStepInPlaceIfSmaller({
|
|
51
55
|
currentPath: optimizedPath,
|
|
52
56
|
extension,
|
|
57
|
+
mute,
|
|
53
58
|
step: async args => step({ ...args, losy })
|
|
54
59
|
})
|
|
55
60
|
}
|
|
@@ -57,14 +62,42 @@ const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig
|
|
|
57
62
|
return (await stat(optimizedPath)).size
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
const file = async (
|
|
65
|
+
const file = async (
|
|
66
|
+
filePath,
|
|
67
|
+
{ onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true, dataUrl = false } = {}
|
|
68
|
+
) => {
|
|
61
69
|
const outputPath = getOutputPath(filePath, outputFormat)
|
|
62
70
|
const resizeConfig = parseResize(resize)
|
|
71
|
+
const mediaKind = getMediaKind(outputPath)
|
|
63
72
|
const filePipeline = getPipeline(outputPath)
|
|
64
73
|
const executionPipeline = [...filePipeline]
|
|
74
|
+
const isConverting = outputPath !== filePath
|
|
75
|
+
const outputExt = path.extname(outputPath).toLowerCase()
|
|
76
|
+
|
|
77
|
+
const canUseJpegLosslessFastPath =
|
|
78
|
+
!losy &&
|
|
79
|
+
!resizeConfig &&
|
|
80
|
+
!isConverting &&
|
|
81
|
+
(outputExt === '.jpg' || outputExt === '.jpeg') &&
|
|
82
|
+
executionPipeline[0]?.binaryName === 'magick' &&
|
|
83
|
+
executionPipeline[1]?.binaryName === 'mozjpegtran'
|
|
84
|
+
|
|
85
|
+
if (canUseJpegLosslessFastPath) {
|
|
86
|
+
executionPipeline.shift()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (mediaKind === 'video' && resizeConfig?.mode === 'max-size') {
|
|
90
|
+
throw new TypeError(
|
|
91
|
+
'Resize max size (e.g. 100kB) is image-only. For videos use percentage (50%), width (w960), or height (h480).'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (dataUrl && mediaKind !== 'image') {
|
|
96
|
+
throw new TypeError('Data URL output is only supported for images.')
|
|
97
|
+
}
|
|
65
98
|
|
|
66
99
|
const needsMagickForTransform = Boolean(resizeConfig) || outputPath !== filePath
|
|
67
|
-
if (needsMagickForTransform && executionPipeline[0]?.binaryName !== 'magick') {
|
|
100
|
+
if (mediaKind === 'image' && needsMagickForTransform && executionPipeline[0]?.binaryName !== 'magick') {
|
|
68
101
|
const magick = require('./compressor/magick')
|
|
69
102
|
const ext = path.extname(outputPath).toLowerCase().replace(/^\./, '')
|
|
70
103
|
const magickStep = magick[ext] || magick.file
|
|
@@ -78,7 +111,6 @@ const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat,
|
|
|
78
111
|
)
|
|
79
112
|
|
|
80
113
|
const optimizedPath = `${outputPath}.optimized${path.extname(outputPath)}`
|
|
81
|
-
const isConverting = outputPath !== filePath
|
|
82
114
|
|
|
83
115
|
let originalSize
|
|
84
116
|
let optimizedSize
|
|
@@ -95,7 +127,8 @@ const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat,
|
|
|
95
127
|
filePath,
|
|
96
128
|
optimizedPath,
|
|
97
129
|
resizeConfig,
|
|
98
|
-
losy
|
|
130
|
+
losy,
|
|
131
|
+
mute
|
|
99
132
|
})
|
|
100
133
|
}
|
|
101
134
|
} catch (error) {
|
|
@@ -123,7 +156,24 @@ const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat,
|
|
|
123
156
|
if (!isConverting && optimizedSize >= originalSize) {
|
|
124
157
|
await unlink(optimizedPath)
|
|
125
158
|
onLogs(formatLog('[optimized]', gray, filePath))
|
|
126
|
-
|
|
159
|
+
|
|
160
|
+
const result = { originalSize, optimizedSize: originalSize }
|
|
161
|
+
if (dataUrl) {
|
|
162
|
+
result.dataUrl = toDataUrl({
|
|
163
|
+
filePath,
|
|
164
|
+
content: await readFile(filePath)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let outputDataUrl = null
|
|
172
|
+
if (dataUrl) {
|
|
173
|
+
outputDataUrl = toDataUrl({
|
|
174
|
+
filePath: outputPath,
|
|
175
|
+
content: await readFile(optimizedPath)
|
|
176
|
+
})
|
|
127
177
|
}
|
|
128
178
|
|
|
129
179
|
if (dryRun) {
|
|
@@ -154,10 +204,16 @@ const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat,
|
|
|
154
204
|
)
|
|
155
205
|
)
|
|
156
206
|
|
|
157
|
-
|
|
207
|
+
const result = { originalSize, optimizedSize }
|
|
208
|
+
if (outputDataUrl) result.dataUrl = outputDataUrl
|
|
209
|
+
return result
|
|
158
210
|
}
|
|
159
211
|
|
|
160
212
|
const dir = async (folderPath, opts) => {
|
|
213
|
+
if (opts?.dataUrl) {
|
|
214
|
+
throw new TypeError('Data URL output is only supported when optimizing a single image file.')
|
|
215
|
+
}
|
|
216
|
+
|
|
161
217
|
const items = (await readdir(folderPath, { withFileTypes: true })).filter(item => !item.name.startsWith('.'))
|
|
162
218
|
let totalOriginalSize = 0
|
|
163
219
|
let totalOptimizedSize = 0
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
|
|
5
|
+
const VIDEO_EXTENSIONS = new Set([
|
|
6
|
+
'.mp4',
|
|
7
|
+
'.m4v',
|
|
8
|
+
'.mov',
|
|
9
|
+
'.webm',
|
|
10
|
+
'.mkv',
|
|
11
|
+
'.avi',
|
|
12
|
+
'.ogv'
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
module.exports = filePath => {
|
|
16
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
17
|
+
return VIDEO_EXTENSIONS.has(ext) ? 'video' : 'image'
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
|
|
5
|
+
const MIME_TYPES_BY_EXTENSION = {
|
|
6
|
+
'.jpg': 'image/jpeg',
|
|
7
|
+
'.jpeg': 'image/jpeg',
|
|
8
|
+
'.png': 'image/png',
|
|
9
|
+
'.gif': 'image/gif',
|
|
10
|
+
'.webp': 'image/webp',
|
|
11
|
+
'.avif': 'image/avif',
|
|
12
|
+
'.heic': 'image/heic',
|
|
13
|
+
'.heif': 'image/heif',
|
|
14
|
+
'.jxl': 'image/jxl',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.bmp': 'image/bmp',
|
|
17
|
+
'.tif': 'image/tiff',
|
|
18
|
+
'.tiff': 'image/tiff'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const getMimeType = filePath => MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream'
|
|
22
|
+
|
|
23
|
+
module.exports = ({ filePath, content }) => {
|
|
24
|
+
const mimeType = getMimeType(filePath)
|
|
25
|
+
return `data:${mimeType};base64,${content.toString('base64')}`
|
|
26
|
+
}
|