optimo 0.0.14 → 0.0.16
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 +39 -13
- package/bin/help.js +5 -1
- package/bin/index.js +11 -10
- package/package.json +2 -1
- package/src/compressor/gifsicle.js +42 -0
- package/src/compressor/index.js +48 -0
- package/src/compressor/magick.js +274 -0
- package/src/compressor/mozjpegtran.js +33 -0
- package/src/compressor/svgo.js +98 -0
- package/src/index.js +92 -104
- package/src/util/debug.js +1 -0
- package/src/util/ensure-binaries.js +12 -0
- package/src/util/format-bytes.js +9 -0
- package/src/util/format-log.js +11 -0
- package/src/util/get-output-path.js +12 -0
- package/src/util/normalize-format.js +9 -0
- package/src/util/parse-resize.js +57 -0
- package/src/util/percentage.js +4 -0
- package/src/util/resolve-binary.js +15 -0
- package/src/magick.js +0 -128
- package/src/util.js +0 -92
- /package/src/{colors.js → util/colors.js} +0 -0
package/README.md
CHANGED
|
@@ -6,29 +6,54 @@
|
|
|
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 image file size aggressively, and safely.
|
|
9
10
|
</div>
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
## Highlights
|
|
13
|
+
|
|
14
|
+
- Format-specific tuning for stronger size reduction.
|
|
15
|
+
- Backed by proven tools: ImageMagick, SVGO, Gifsicle, and MozJPEG.
|
|
16
|
+
- Safety guard: if optimized output is not smaller, original file is kept.
|
|
17
|
+
- Resizing supports percentage values (`50%`), max file size targets (`100kB`), width (`w960`), & height (`h480`).
|
|
12
18
|
|
|
13
|
-
##
|
|
19
|
+
## Usage
|
|
14
20
|
|
|
15
21
|
```bash
|
|
16
22
|
npx -y optimo public/media # for a directory
|
|
17
23
|
npx -y optimo public/media/banner.png # for a file
|
|
18
|
-
npx -y optimo public/media/banner.png
|
|
19
|
-
npx -y optimo public/media/banner.png
|
|
20
|
-
npx -y optimo public/media/banner.png
|
|
21
|
-
npx -y optimo public/media/banner.png
|
|
22
|
-
npx -y optimo public/media/banner.png
|
|
24
|
+
npx -y optimo public/media/banner.png --losy # enable lossy + lossless mode
|
|
25
|
+
npx -y optimo public/media/banner.png --format jpeg # convert + optimize
|
|
26
|
+
npx -y optimo public/media/banner.png --resize 50% # resize + optimize
|
|
27
|
+
npx -y optimo public/media/banner.png --resize 100kB # resize to max file size
|
|
28
|
+
npx -y optimo public/media/banner.png --resize w960 # resize to max width
|
|
29
|
+
npx -y optimo public/media/banner.png --resize h480 # resize to max height
|
|
30
|
+
npx -y optimo public/media/banner.heic --dry-run --verbose # inspect unsupported failures
|
|
23
31
|
```
|
|
24
32
|
|
|
25
|
-
##
|
|
33
|
+
## Pipelines
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
35
|
+
When `optimo` is executed, a pipeline of compressors is chosen based on the output file format:
|
|
36
|
+
|
|
37
|
+
- `.png` -> `magick.png`
|
|
38
|
+
- `.svg` -> `svgo.svg`
|
|
39
|
+
- `.jpg/.jpeg` -> `magick.jpg/jpeg` + `mozjpegtran.jpg/jpeg`
|
|
40
|
+
- `.gif` -> `magick.gif` + `gifsicle.gif`
|
|
41
|
+
- other formats (`webp`, `avif`, `heic`, `heif`, `jxl`, etc.) -> `magick.<format>`
|
|
42
|
+
|
|
43
|
+
Mode behavior:
|
|
44
|
+
|
|
45
|
+
- default: lossless-first pipeline.
|
|
46
|
+
- `-l, --losy`: lossy + lossless pass per matching compressor.
|
|
47
|
+
- `-v, --verbose`: print debug logs (selected pipeline, binaries, executed commands, and errors).
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
Example output:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
✓ banner.jpg 1.2MB → 348kB (-71%)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
If the optimized file isn’t smaller, the original is kept.
|
|
32
57
|
|
|
33
58
|
## Programmatic API
|
|
34
59
|
|
|
@@ -38,6 +63,7 @@ const optimo = require('optimo')
|
|
|
38
63
|
// optimize a single file
|
|
39
64
|
await optimo.file('/absolute/path/image.jpg', {
|
|
40
65
|
dryRun: false,
|
|
66
|
+
losy: false,
|
|
41
67
|
format: 'webp',
|
|
42
68
|
resize: '50%',
|
|
43
69
|
onLogs: console.log
|
package/bin/help.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { gray, blue } = require('../src/colors')
|
|
3
|
+
const { gray, blue } = require('../src/util/colors')
|
|
4
4
|
|
|
5
5
|
module.exports = gray(`Efortless image optimizer
|
|
6
6
|
|
|
@@ -8,12 +8,15 @@ Usage
|
|
|
8
8
|
$ ${blue('optimo')} <file|dir> [options]
|
|
9
9
|
|
|
10
10
|
Options
|
|
11
|
+
-l, --losy Enable lossy + lossless pass (ImgBot-style aggressive mode)
|
|
11
12
|
-d, --dry-run Show what would be optimized without making changes
|
|
12
13
|
-f, --format Convert output format (e.g. jpeg, webp, avif)
|
|
13
14
|
-r, --resize Resize by percentage (50%), size (100kB), width (w960), or height (h480)
|
|
15
|
+
-v, --verbose Print debug logs (commands, pipeline, and errors)
|
|
14
16
|
|
|
15
17
|
Examples
|
|
16
18
|
$ optimo image.jpg
|
|
19
|
+
$ optimo image.jpg --losy
|
|
17
20
|
$ optimo image.png --dry-run
|
|
18
21
|
$ optimo image.jpg -d
|
|
19
22
|
$ optimo image.png -f jpeg
|
|
@@ -21,4 +24,5 @@ Examples
|
|
|
21
24
|
$ optimo image.png -r 100kB
|
|
22
25
|
$ optimo image.png -r w960
|
|
23
26
|
$ optimo image.png -r h480
|
|
27
|
+
$ optimo image.heic -d -v
|
|
24
28
|
`)
|
package/bin/index.js
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
'use strict'
|
|
3
3
|
|
|
4
4
|
const { stat } = require('node:fs/promises')
|
|
5
|
-
const
|
|
5
|
+
const colors = require('../src/util/colors')
|
|
6
6
|
const mri = require('mri')
|
|
7
7
|
|
|
8
|
-
const colors = require('../src/colors')
|
|
9
|
-
|
|
10
8
|
async function main () {
|
|
11
9
|
const argv = mri(process.argv.slice(2), {
|
|
12
10
|
alias: {
|
|
13
11
|
'dry-run': 'd',
|
|
14
12
|
format: 'f',
|
|
13
|
+
losy: 'l',
|
|
15
14
|
resize: 'r',
|
|
16
|
-
silent: 's'
|
|
15
|
+
silent: 's',
|
|
16
|
+
verbose: 'v'
|
|
17
17
|
}
|
|
18
18
|
})
|
|
19
19
|
|
|
@@ -22,11 +22,7 @@ async function main () {
|
|
|
22
22
|
|
|
23
23
|
if (resize !== undefined && resize !== null) {
|
|
24
24
|
const unitToken = argv._[1]
|
|
25
|
-
if (
|
|
26
|
-
unitToken &&
|
|
27
|
-
/^[kmg]?b$/i.test(unitToken) &&
|
|
28
|
-
/^\d*\.?\d+$/.test(String(resize))
|
|
29
|
-
) {
|
|
25
|
+
if (unitToken && /^[kmg]?b$/i.test(unitToken) && /^\d*\.?\d+$/.test(String(resize))) {
|
|
30
26
|
resize = `${resize}${unitToken}`
|
|
31
27
|
}
|
|
32
28
|
}
|
|
@@ -36,14 +32,19 @@ async function main () {
|
|
|
36
32
|
process.exit(0)
|
|
37
33
|
}
|
|
38
34
|
|
|
35
|
+
if (argv.verbose) {
|
|
36
|
+
process.env.DEBUG = `${process.env.DEBUG ? `${process.env.DEBUG},` : ''}optimo*`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
39
|
const stats = await stat(input)
|
|
40
40
|
const isDirectory = stats.isDirectory()
|
|
41
|
-
const fn = isDirectory ? optimo.dir : optimo.file
|
|
41
|
+
const fn = isDirectory ? require('optimo').dir : require('optimo').file
|
|
42
42
|
|
|
43
43
|
const logger = argv.silent ? () => {} : logEntry => console.log(logEntry)
|
|
44
44
|
!argv.silent && console.log()
|
|
45
45
|
|
|
46
46
|
await fn(input, {
|
|
47
|
+
losy: argv.losy,
|
|
47
48
|
dryRun: argv['dry-run'],
|
|
48
49
|
format: argv.format,
|
|
49
50
|
resize,
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "optimo",
|
|
3
3
|
"description": "The no-brainer ImageMagick CLI for optimizing images",
|
|
4
4
|
"homepage": "https://github.com/kikobeats/optimo",
|
|
5
|
-
"version": "0.0.
|
|
5
|
+
"version": "0.0.16",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js"
|
|
8
8
|
},
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"optimize"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"debug-logfmt": "~1.4.7",
|
|
35
36
|
"mri": "~1.2.0",
|
|
36
37
|
"tinyspawn": "~1.5.5"
|
|
37
38
|
},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { unlink } = require('node:fs/promises')
|
|
4
|
+
const $ = require('tinyspawn')
|
|
5
|
+
|
|
6
|
+
const resolveBinary = require('../util/resolve-binary')
|
|
7
|
+
|
|
8
|
+
const binaryPath = resolveBinary('gifsicle')
|
|
9
|
+
|
|
10
|
+
const withMeta = (format, fn) => {
|
|
11
|
+
const wrapped = async ctx => fn(ctx)
|
|
12
|
+
wrapped.binaryName = 'gifsicle'
|
|
13
|
+
wrapped.binaryPath = binaryPath
|
|
14
|
+
wrapped.format = format
|
|
15
|
+
return wrapped
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runLossless = async ({ inputPath, outputPath }) =>
|
|
19
|
+
$(binaryPath, ['-O3', inputPath, '-o', outputPath])
|
|
20
|
+
|
|
21
|
+
const runLossy = async ({ inputPath, outputPath }) =>
|
|
22
|
+
$(binaryPath, ['-O3', '--lossy=80', inputPath, '-o', outputPath])
|
|
23
|
+
|
|
24
|
+
const gif = withMeta('gif', async ({ inputPath, outputPath, losy = false }) => {
|
|
25
|
+
if (!losy) return runLossless({ inputPath, outputPath })
|
|
26
|
+
|
|
27
|
+
const lossyPath = `${outputPath}.lossy.gif`
|
|
28
|
+
try {
|
|
29
|
+
await runLossy({ inputPath, outputPath: lossyPath })
|
|
30
|
+
return runLossless({ inputPath: lossyPath, outputPath })
|
|
31
|
+
} finally {
|
|
32
|
+
try {
|
|
33
|
+
await unlink(lossyPath)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code !== 'ENOENT') {
|
|
36
|
+
// Ignore cleanup failures.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
module.exports = { binaryPath, gif }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
|
|
5
|
+
const magick = require('./magick')
|
|
6
|
+
const svgo = require('./svgo')
|
|
7
|
+
const gifsicle = require('./gifsicle')
|
|
8
|
+
const mozjpegtran = require('./mozjpegtran')
|
|
9
|
+
|
|
10
|
+
const PIPELINES = {
|
|
11
|
+
'.png': [magick.png],
|
|
12
|
+
'.svg': [svgo.svg],
|
|
13
|
+
'.jpg': [magick.jpg, mozjpegtran.jpg],
|
|
14
|
+
'.jpeg': [magick.jpeg, mozjpegtran.jpeg],
|
|
15
|
+
'.gif': [magick.gif, gifsicle.gif],
|
|
16
|
+
'.webp': [magick.webp],
|
|
17
|
+
'.avif': [magick.avif],
|
|
18
|
+
'.heic': [magick.heic],
|
|
19
|
+
'.heif': [magick.heif],
|
|
20
|
+
'.jxl': [magick.jxl]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const getPipeline = filePath => {
|
|
24
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
25
|
+
return PIPELINES[ext] || [magick.file]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getRequiredBinaries = (pipeline, opts = {}) => {
|
|
29
|
+
const binaries = []
|
|
30
|
+
|
|
31
|
+
for (const step of pipeline.filter(Boolean)) {
|
|
32
|
+
if (typeof step.getRequiredBinaries === 'function') {
|
|
33
|
+
binaries.push(...step.getRequiredBinaries(opts))
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
binaries.push({
|
|
38
|
+
name: step.binaryName,
|
|
39
|
+
binaryPath: step.binaryPath || false
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Array.from(
|
|
44
|
+
new Map(binaries.map(binary => [binary.name, binary])).values()
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { getPipeline, getRequiredBinaries }
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { stat, rename, unlink } = require('node:fs/promises')
|
|
4
|
+
const { execFileSync } = require('node:child_process')
|
|
5
|
+
const path = require('node:path')
|
|
6
|
+
const $ = require('tinyspawn')
|
|
7
|
+
|
|
8
|
+
const resolveBinary = require('../util/resolve-binary')
|
|
9
|
+
|
|
10
|
+
const binaryPath = resolveBinary('magick')
|
|
11
|
+
|
|
12
|
+
const PNG_QUALITY_CANDIDATES = [91, 94, 95, 97]
|
|
13
|
+
|
|
14
|
+
const withMeta = (format, fn) => {
|
|
15
|
+
const wrapped = async ctx => fn(ctx)
|
|
16
|
+
wrapped.binaryName = 'magick'
|
|
17
|
+
wrapped.binaryPath = binaryPath
|
|
18
|
+
wrapped.format = format
|
|
19
|
+
return wrapped
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAGICK_JPEG_LOSSY_FLAGS = [
|
|
23
|
+
'-strip',
|
|
24
|
+
'-sampling-factor',
|
|
25
|
+
'4:2:0',
|
|
26
|
+
'-define',
|
|
27
|
+
'jpeg:optimize-coding=true',
|
|
28
|
+
'-define',
|
|
29
|
+
'jpeg:dct-method=float',
|
|
30
|
+
'-quality',
|
|
31
|
+
'80',
|
|
32
|
+
'-interlace',
|
|
33
|
+
'Plane'
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const MAGICK_JPEG_LOSSLESS_FLAGS = ['-define', 'jpeg:optimize-coding=true', '-interlace', 'Plane']
|
|
37
|
+
|
|
38
|
+
const MAGICK_PNG_LOSSLESS_FLAGS = []
|
|
39
|
+
const MAGICK_PNG_LOSSY_FLAGS = [
|
|
40
|
+
'-strip',
|
|
41
|
+
'-define',
|
|
42
|
+
'png:exclude-chunks=all',
|
|
43
|
+
'-define',
|
|
44
|
+
'png:include-chunks=tRNS,gAMA'
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
|
|
48
|
+
|
|
49
|
+
const MAGICK_WEBP_FLAGS = ['-strip', '-define', 'webp:method=6', '-define', 'webp:thread-level=1', '-quality', '80']
|
|
50
|
+
|
|
51
|
+
const MAGICK_AVIF_FLAGS = ['-strip', '-define', 'heic:speed=1', '-quality', '50']
|
|
52
|
+
|
|
53
|
+
const MAGICK_HEIC_FLAGS = ['-strip', '-quality', '75']
|
|
54
|
+
|
|
55
|
+
const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
|
|
56
|
+
|
|
57
|
+
const MAGICK_SVG_FLAGS = ['-strip']
|
|
58
|
+
|
|
59
|
+
const MAGICK_GENERIC_FLAGS = ['-strip']
|
|
60
|
+
|
|
61
|
+
const flagsByExt = ({ ext, losy = false }) => {
|
|
62
|
+
if (ext === '.jpg' || ext === '.jpeg') return losy ? MAGICK_JPEG_LOSSY_FLAGS : MAGICK_JPEG_LOSSLESS_FLAGS
|
|
63
|
+
if (ext === '.png') return losy ? MAGICK_PNG_LOSSY_FLAGS : MAGICK_PNG_LOSSLESS_FLAGS
|
|
64
|
+
if (ext === '.gif') return MAGICK_GIF_FLAGS
|
|
65
|
+
if (ext === '.webp') return MAGICK_WEBP_FLAGS
|
|
66
|
+
if (ext === '.avif') return MAGICK_AVIF_FLAGS
|
|
67
|
+
if (ext === '.heic' || ext === '.heif') return MAGICK_HEIC_FLAGS
|
|
68
|
+
if (ext === '.jxl') return MAGICK_JXL_FLAGS
|
|
69
|
+
if (ext === '.svg') return MAGICK_SVG_FLAGS
|
|
70
|
+
return MAGICK_GENERIC_FLAGS
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const isAnimatedPng = ({ filePath }) => {
|
|
74
|
+
try {
|
|
75
|
+
const frames = execFileSync(binaryPath, ['identify', '-format', '%n', filePath], {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
78
|
+
})
|
|
79
|
+
.trim()
|
|
80
|
+
.split(/\s+/)
|
|
81
|
+
.map(value => Number.parseInt(value, 10))
|
|
82
|
+
.find(value => Number.isFinite(value) && value > 0)
|
|
83
|
+
|
|
84
|
+
const animated = (frames || 1) > 1
|
|
85
|
+
return animated
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const writePng = async ({ inputPath, outputPath, flags, resizeGeometry }) => {
|
|
92
|
+
const writeCandidates = isAnimatedPng({ filePath: inputPath }) ? [90] : PNG_QUALITY_CANDIDATES
|
|
93
|
+
const candidatePaths = []
|
|
94
|
+
let bestPath = null
|
|
95
|
+
let bestSize = Number.POSITIVE_INFINITY
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
for (const quality of writeCandidates) {
|
|
99
|
+
const candidatePath = `${outputPath}.q${quality}.png`
|
|
100
|
+
candidatePaths.push(candidatePath)
|
|
101
|
+
|
|
102
|
+
const args = [
|
|
103
|
+
inputPath,
|
|
104
|
+
...(resizeGeometry ? ['-resize', resizeGeometry] : []),
|
|
105
|
+
...flags,
|
|
106
|
+
'-quality',
|
|
107
|
+
String(quality),
|
|
108
|
+
candidatePath
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
await $(binaryPath, args)
|
|
112
|
+
const size = (await stat(candidatePath)).size
|
|
113
|
+
|
|
114
|
+
if (size < bestSize) {
|
|
115
|
+
bestSize = size
|
|
116
|
+
bestPath = candidatePath
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!bestPath) throw new Error('No PNG candidate was generated')
|
|
121
|
+
|
|
122
|
+
await rename(bestPath, outputPath)
|
|
123
|
+
} finally {
|
|
124
|
+
for (const candidatePath of candidatePaths) {
|
|
125
|
+
if (candidatePath === bestPath) continue
|
|
126
|
+
try {
|
|
127
|
+
await unlink(candidatePath)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error.code !== 'ENOENT') {
|
|
130
|
+
// Ignore cleanup failures to avoid masking optimization results.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false }) => {
|
|
138
|
+
const ext = path.extname(outputPath).toLowerCase()
|
|
139
|
+
const flags = flagsByExt({ ext, losy })
|
|
140
|
+
|
|
141
|
+
if (ext === '.png') {
|
|
142
|
+
return writePng({ inputPath, outputPath, flags, resizeGeometry })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return $(binaryPath, [inputPath, ...(resizeGeometry ? ['-resize', resizeGeometry] : []), ...flags, outputPath])
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false }) => {
|
|
149
|
+
const resultByScale = new Map()
|
|
150
|
+
|
|
151
|
+
const measureScale = async scale => {
|
|
152
|
+
if (resultByScale.has(scale)) return resultByScale.get(scale)
|
|
153
|
+
|
|
154
|
+
const candidatePath = `${outputPath}.scale${scale}${path.extname(outputPath)}`
|
|
155
|
+
const resizeGeometry = scale === 100 ? null : `${scale}%`
|
|
156
|
+
|
|
157
|
+
await runOnce({
|
|
158
|
+
inputPath,
|
|
159
|
+
outputPath: candidatePath,
|
|
160
|
+
resizeGeometry,
|
|
161
|
+
losy
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const size = (await stat(candidatePath)).size
|
|
165
|
+
resultByScale.set(scale, { size, candidatePath })
|
|
166
|
+
return resultByScale.get(scale)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const full = await measureScale(100)
|
|
170
|
+
if (full.size <= maxSize) return rename(full.candidatePath, outputPath)
|
|
171
|
+
|
|
172
|
+
const min = await measureScale(1)
|
|
173
|
+
if (min.size > maxSize) return rename(min.candidatePath, outputPath)
|
|
174
|
+
|
|
175
|
+
let low = 1
|
|
176
|
+
let high = 100
|
|
177
|
+
let bestScale = 1
|
|
178
|
+
|
|
179
|
+
while (high - low > 1) {
|
|
180
|
+
const mid = Math.floor((low + high) / 2)
|
|
181
|
+
const { size } = await measureScale(mid)
|
|
182
|
+
|
|
183
|
+
if (size <= maxSize) {
|
|
184
|
+
low = mid
|
|
185
|
+
bestScale = mid
|
|
186
|
+
} else {
|
|
187
|
+
high = mid
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const best = await measureScale(bestScale)
|
|
192
|
+
await rename(best.candidatePath, outputPath)
|
|
193
|
+
|
|
194
|
+
for (const [scale, value] of resultByScale.entries()) {
|
|
195
|
+
if (scale === bestScale) continue
|
|
196
|
+
try {
|
|
197
|
+
await unlink(value.candidatePath)
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error.code !== 'ENOENT') {
|
|
200
|
+
// Ignore cleanup failures.
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const run = async ({ inputPath, outputPath, resizeConfig, losy = false }) => {
|
|
207
|
+
if (resizeConfig?.mode === 'max-size') {
|
|
208
|
+
return runMaxSize({
|
|
209
|
+
inputPath,
|
|
210
|
+
outputPath,
|
|
211
|
+
maxSize: resizeConfig.value,
|
|
212
|
+
losy
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!losy) {
|
|
217
|
+
return runOnce({
|
|
218
|
+
inputPath,
|
|
219
|
+
outputPath,
|
|
220
|
+
resizeGeometry: resizeConfig?.value,
|
|
221
|
+
losy: false
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const lossyPath = `${outputPath}.lossy${path.extname(outputPath)}`
|
|
226
|
+
try {
|
|
227
|
+
await runOnce({
|
|
228
|
+
inputPath,
|
|
229
|
+
outputPath: lossyPath,
|
|
230
|
+
resizeGeometry: resizeConfig?.value,
|
|
231
|
+
losy: true
|
|
232
|
+
})
|
|
233
|
+
await runOnce({
|
|
234
|
+
inputPath: lossyPath,
|
|
235
|
+
outputPath,
|
|
236
|
+
losy: false
|
|
237
|
+
})
|
|
238
|
+
} finally {
|
|
239
|
+
try {
|
|
240
|
+
await unlink(lossyPath)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error.code !== 'ENOENT') {
|
|
243
|
+
// Ignore cleanup failures.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const jpg = withMeta('jpg', run)
|
|
250
|
+
const jpeg = withMeta('jpeg', run)
|
|
251
|
+
const png = withMeta('png', run)
|
|
252
|
+
const gif = withMeta('gif', run)
|
|
253
|
+
const webp = withMeta('webp', run)
|
|
254
|
+
const avif = withMeta('avif', run)
|
|
255
|
+
const heic = withMeta('heic', run)
|
|
256
|
+
const heif = withMeta('heif', run)
|
|
257
|
+
const jxl = withMeta('jxl', run)
|
|
258
|
+
const svg = withMeta('svg', run)
|
|
259
|
+
const file = withMeta('file', run)
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
binaryPath,
|
|
263
|
+
jpg,
|
|
264
|
+
jpeg,
|
|
265
|
+
png,
|
|
266
|
+
gif,
|
|
267
|
+
webp,
|
|
268
|
+
avif,
|
|
269
|
+
heic,
|
|
270
|
+
heif,
|
|
271
|
+
jxl,
|
|
272
|
+
svg,
|
|
273
|
+
file
|
|
274
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const $ = require('tinyspawn')
|
|
4
|
+
const resolveBinary = require('../util/resolve-binary')
|
|
5
|
+
|
|
6
|
+
const mozjpegtranPath =
|
|
7
|
+
resolveBinary('mozjpegtran') || resolveBinary('jpegtran')
|
|
8
|
+
|
|
9
|
+
const withMeta = (format, fn) => {
|
|
10
|
+
const wrapped = async ctx => fn(ctx)
|
|
11
|
+
wrapped.binaryName = 'mozjpegtran'
|
|
12
|
+
wrapped.binaryPath = mozjpegtranPath
|
|
13
|
+
wrapped.getRequiredBinaries = () => [
|
|
14
|
+
{ name: 'mozjpegtran/jpegtran', binaryPath: mozjpegtranPath || false }
|
|
15
|
+
]
|
|
16
|
+
wrapped.format = format
|
|
17
|
+
return wrapped
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const run = async ({ inputPath, outputPath }) =>
|
|
21
|
+
$(mozjpegtranPath, [
|
|
22
|
+
'-copy',
|
|
23
|
+
'none',
|
|
24
|
+
'-optimize',
|
|
25
|
+
'-outfile',
|
|
26
|
+
outputPath,
|
|
27
|
+
inputPath
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const jpg = withMeta('jpg', run)
|
|
31
|
+
const jpeg = withMeta('jpeg', run)
|
|
32
|
+
|
|
33
|
+
module.exports = { binaryPath: mozjpegtranPath, jpg, jpeg }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { unlink } = require('node:fs/promises')
|
|
4
|
+
const $ = require('tinyspawn')
|
|
5
|
+
|
|
6
|
+
const resolveBinary = require('../util/resolve-binary')
|
|
7
|
+
|
|
8
|
+
const binaryPath = resolveBinary('svgo')
|
|
9
|
+
|
|
10
|
+
const withMeta = (format, fn) => {
|
|
11
|
+
const wrapped = async ctx => fn(ctx)
|
|
12
|
+
wrapped.binaryName = 'svgo'
|
|
13
|
+
wrapped.binaryPath = binaryPath
|
|
14
|
+
wrapped.format = format
|
|
15
|
+
return wrapped
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const COMMON_PLUGINS = [
|
|
19
|
+
'cleanupAttrs',
|
|
20
|
+
'cleanupListOfValues',
|
|
21
|
+
'cleanupNumericValues',
|
|
22
|
+
'convertColors',
|
|
23
|
+
'minifyStyles',
|
|
24
|
+
'moveGroupAttrsToElems',
|
|
25
|
+
'removeComments',
|
|
26
|
+
'removeDoctype',
|
|
27
|
+
'removeEditorsNSData',
|
|
28
|
+
'removeEmptyAttrs',
|
|
29
|
+
'removeEmptyContainers',
|
|
30
|
+
'removeEmptyText',
|
|
31
|
+
'removeNonInheritableGroupAttrs',
|
|
32
|
+
'removeXMLProcInst',
|
|
33
|
+
'sortAttrs'
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const AGGRESSIVE_PLUGINS = COMMON_PLUGINS.concat([
|
|
37
|
+
'cleanupEnableBackground',
|
|
38
|
+
'cleanupIDs',
|
|
39
|
+
'collapseGroups',
|
|
40
|
+
'convertPathData',
|
|
41
|
+
'convertShapeToPath',
|
|
42
|
+
'convertTransform',
|
|
43
|
+
'mergePaths',
|
|
44
|
+
'moveElemsAttrsToGroup',
|
|
45
|
+
'removeAttrs',
|
|
46
|
+
'removeDesc',
|
|
47
|
+
'removeDimensions',
|
|
48
|
+
'removeElementsByAttr',
|
|
49
|
+
'removeHiddenElems',
|
|
50
|
+
'removeMetadata',
|
|
51
|
+
'removeRasterImages',
|
|
52
|
+
'removeStyleElement',
|
|
53
|
+
'removeTitle',
|
|
54
|
+
'removeUnknownsAndDefaults',
|
|
55
|
+
'removeUnusedNS',
|
|
56
|
+
'removeUselessDefs',
|
|
57
|
+
'removeUselessStrokeAndFill',
|
|
58
|
+
'removeViewBox',
|
|
59
|
+
'removeXMLNS'
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
const run = async ({ inputPath, outputPath, plugins }) =>
|
|
63
|
+
$(binaryPath, [
|
|
64
|
+
inputPath,
|
|
65
|
+
'--config={"full":true}',
|
|
66
|
+
'--multipass',
|
|
67
|
+
`--enable=${plugins.join(',')}`,
|
|
68
|
+
'--output',
|
|
69
|
+
outputPath
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
const svg = withMeta('svg', async ({ inputPath, outputPath, losy = false }) => {
|
|
73
|
+
if (!losy) return run({ inputPath, outputPath, plugins: COMMON_PLUGINS })
|
|
74
|
+
|
|
75
|
+
const lossyPath = `${outputPath}.lossy.svg`
|
|
76
|
+
try {
|
|
77
|
+
await run({
|
|
78
|
+
inputPath,
|
|
79
|
+
outputPath: lossyPath,
|
|
80
|
+
plugins: AGGRESSIVE_PLUGINS
|
|
81
|
+
})
|
|
82
|
+
await run({
|
|
83
|
+
inputPath: lossyPath,
|
|
84
|
+
outputPath,
|
|
85
|
+
plugins: COMMON_PLUGINS
|
|
86
|
+
})
|
|
87
|
+
} finally {
|
|
88
|
+
try {
|
|
89
|
+
await unlink(lossyPath)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.code !== 'ENOENT') {
|
|
92
|
+
// Ignore cleanup failures.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
module.exports = { binaryPath, svg }
|
package/src/index.js
CHANGED
|
@@ -1,107 +1,83 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
|
|
3
|
-
const { stat, unlink, rename, readdir } = require('node:fs/promises')
|
|
4
|
-
|
|
3
|
+
const { stat, unlink, rename, readdir, copyFile } = require('node:fs/promises')
|
|
5
4
|
const path = require('node:path')
|
|
6
|
-
const $ = require('tinyspawn')
|
|
7
|
-
|
|
8
|
-
const { magickPath, magickFlags } = require('./magick')
|
|
9
|
-
const { yellow, gray, green } = require('./colors')
|
|
10
|
-
|
|
11
|
-
const {
|
|
12
|
-
formatBytes,
|
|
13
|
-
formatLog,
|
|
14
|
-
normalizeFormat,
|
|
15
|
-
parseResize,
|
|
16
|
-
percentage
|
|
17
|
-
} = require('./util')
|
|
18
|
-
|
|
19
|
-
const getOutputPath = (filePath, format) => {
|
|
20
|
-
const normalizedFormat = normalizeFormat(format)
|
|
21
|
-
if (!normalizedFormat) return filePath
|
|
22
|
-
const parsed = path.parse(filePath)
|
|
23
|
-
return path.join(parsed.dir, `${parsed.name}.${normalizedFormat}`)
|
|
24
|
-
}
|
|
25
5
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
await
|
|
40
|
-
|
|
6
|
+
const { getPipeline, getRequiredBinaries } = require('./compressor')
|
|
7
|
+
const ensureBinaries = require('./util/ensure-binaries')
|
|
8
|
+
const { yellow, gray, green } = require('./util/colors')
|
|
9
|
+
const getOutputPath = require('./util/get-output-path')
|
|
10
|
+
const formatBytes = require('./util/format-bytes')
|
|
11
|
+
const parseResize = require('./util/parse-resize')
|
|
12
|
+
const percentage = require('./util/percentage')
|
|
13
|
+
const formatLog = require('./util/format-log')
|
|
14
|
+
const debug = require('./util/debug')
|
|
15
|
+
|
|
16
|
+
const runStepInPlaceIfSmaller = async ({ currentPath, extension, step }) => {
|
|
17
|
+
const candidatePath = `${currentPath}.candidate${extension}`
|
|
18
|
+
|
|
19
|
+
await step({
|
|
20
|
+
inputPath: currentPath,
|
|
21
|
+
outputPath: candidatePath,
|
|
22
|
+
resizeConfig: null,
|
|
23
|
+
losy: false
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const [currentSize, candidateSize] = await Promise.all([
|
|
27
|
+
stat(currentPath).then(value => value.size),
|
|
28
|
+
stat(candidatePath).then(value => value.size)
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
if (candidateSize < currentSize) {
|
|
32
|
+
await unlink(currentPath)
|
|
33
|
+
await rename(candidatePath, currentPath)
|
|
34
|
+
} else {
|
|
35
|
+
await unlink(candidatePath)
|
|
36
|
+
}
|
|
41
37
|
}
|
|
42
38
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
resizePercentage
|
|
39
|
+
const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy }) => {
|
|
40
|
+
const extension = path.extname(optimizedPath) || '.tmp'
|
|
41
|
+
|
|
42
|
+
await pipeline[0]({
|
|
43
|
+
inputPath: filePath,
|
|
44
|
+
outputPath: optimizedPath,
|
|
45
|
+
resizeConfig,
|
|
46
|
+
losy
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
for (const step of pipeline.slice(1)) {
|
|
50
|
+
await runStepInPlaceIfSmaller({
|
|
51
|
+
currentPath: optimizedPath,
|
|
52
|
+
extension,
|
|
53
|
+
step: async args => step({ ...args, losy })
|
|
59
54
|
})
|
|
60
|
-
resultByScale.set(scale, size)
|
|
61
|
-
return size
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const fullSize = await measureScale(100)
|
|
65
|
-
if (fullSize <= maxSize) {
|
|
66
|
-
return fullSize
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const minScaleSize = await measureScale(1)
|
|
70
|
-
if (minScaleSize > maxSize) {
|
|
71
|
-
return minScaleSize
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let low = 1
|
|
75
|
-
let high = 100
|
|
76
|
-
let bestScale = 1
|
|
77
|
-
|
|
78
|
-
while (high - low > 1) {
|
|
79
|
-
const mid = Math.floor((low + high) / 2)
|
|
80
|
-
const size = await measureScale(mid)
|
|
81
|
-
|
|
82
|
-
if (size <= maxSize) {
|
|
83
|
-
low = mid
|
|
84
|
-
bestScale = mid
|
|
85
|
-
} else {
|
|
86
|
-
high = mid
|
|
87
|
-
}
|
|
88
55
|
}
|
|
89
56
|
|
|
90
|
-
return
|
|
57
|
+
return (await stat(optimizedPath)).size
|
|
91
58
|
}
|
|
92
59
|
|
|
93
|
-
const file = async (
|
|
94
|
-
filePath,
|
|
95
|
-
{ onLogs = () => {}, dryRun, format: outputFormat, resize } = {}
|
|
96
|
-
) => {
|
|
97
|
-
if (!magickPath) {
|
|
98
|
-
throw new Error('ImageMagick is not installed')
|
|
99
|
-
}
|
|
60
|
+
const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false } = {}) => {
|
|
100
61
|
const outputPath = getOutputPath(filePath, outputFormat)
|
|
101
62
|
const resizeConfig = parseResize(resize)
|
|
102
|
-
const
|
|
63
|
+
const filePipeline = getPipeline(outputPath)
|
|
64
|
+
const executionPipeline = [...filePipeline]
|
|
65
|
+
|
|
66
|
+
const needsMagickForTransform = Boolean(resizeConfig) || outputPath !== filePath
|
|
67
|
+
if (needsMagickForTransform && executionPipeline[0]?.binaryName !== 'magick') {
|
|
68
|
+
const magick = require('./compressor/magick')
|
|
69
|
+
const ext = path.extname(outputPath).toLowerCase().replace(/^\./, '')
|
|
70
|
+
const magickStep = magick[ext] || magick.file
|
|
71
|
+
executionPipeline.unshift(magickStep)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ensureBinaries(
|
|
75
|
+
getRequiredBinaries(executionPipeline, {
|
|
76
|
+
losy
|
|
77
|
+
})
|
|
78
|
+
)
|
|
103
79
|
|
|
104
|
-
const optimizedPath = `${outputPath}.optimized`
|
|
80
|
+
const optimizedPath = `${outputPath}.optimized${path.extname(outputPath)}`
|
|
105
81
|
const isConverting = outputPath !== filePath
|
|
106
82
|
|
|
107
83
|
let originalSize
|
|
@@ -110,22 +86,36 @@ const file = async (
|
|
|
110
86
|
try {
|
|
111
87
|
originalSize = (await stat(filePath)).size
|
|
112
88
|
|
|
113
|
-
if (
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
optimizedPath,
|
|
117
|
-
flags,
|
|
118
|
-
maxSize: resizeConfig.value
|
|
119
|
-
})
|
|
89
|
+
if (executionPipeline.length === 0) {
|
|
90
|
+
await copyFile(filePath, optimizedPath)
|
|
91
|
+
optimizedSize = (await stat(optimizedPath)).size
|
|
120
92
|
} else {
|
|
121
|
-
optimizedSize = await
|
|
93
|
+
optimizedSize = await executePipeline({
|
|
94
|
+
pipeline: executionPipeline,
|
|
122
95
|
filePath,
|
|
123
96
|
optimizedPath,
|
|
124
|
-
|
|
125
|
-
|
|
97
|
+
resizeConfig,
|
|
98
|
+
losy
|
|
126
99
|
})
|
|
127
100
|
}
|
|
128
|
-
} catch {
|
|
101
|
+
} catch (error) {
|
|
102
|
+
try {
|
|
103
|
+
await unlink(optimizedPath)
|
|
104
|
+
} catch (cleanupError) {
|
|
105
|
+
if (cleanupError.code !== 'ENOENT') {
|
|
106
|
+
debug.warn('file=optimize stage=cleanup-error', {
|
|
107
|
+
filePath: optimizedPath,
|
|
108
|
+
message: cleanupError?.message || 'cleanup failed'
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
debug.error('file=optimize stage=error', {
|
|
114
|
+
filePath,
|
|
115
|
+
message: error?.message || 'unknown',
|
|
116
|
+
code: error?.code || 'unknown',
|
|
117
|
+
name: error?.name || 'Error'
|
|
118
|
+
})
|
|
129
119
|
onLogs(formatLog('[unsupported]', yellow, filePath))
|
|
130
120
|
return { originalSize: 0, optimizedSize: 0 }
|
|
131
121
|
}
|
|
@@ -168,9 +158,7 @@ const file = async (
|
|
|
168
158
|
}
|
|
169
159
|
|
|
170
160
|
const dir = async (folderPath, opts) => {
|
|
171
|
-
const items = (await readdir(folderPath, { withFileTypes: true })).filter(
|
|
172
|
-
item => !item.name.startsWith('.')
|
|
173
|
-
)
|
|
161
|
+
const items = (await readdir(folderPath, { withFileTypes: true })).filter(item => !item.name.startsWith('.'))
|
|
174
162
|
let totalOriginalSize = 0
|
|
175
163
|
let totalOptimizedSize = 0
|
|
176
164
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require('debug-logfmt')('optimo')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
module.exports = binaries => {
|
|
4
|
+
const missing = binaries.filter(binary => !binary.binaryPath)
|
|
5
|
+
if (missing.length !== 0) {
|
|
6
|
+
throw new Error(
|
|
7
|
+
`Missing required binaries: ${missing
|
|
8
|
+
.map(binary => binary.name)
|
|
9
|
+
.join(', ')}`
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
module.exports = bytes => {
|
|
4
|
+
if (bytes === 0) return '0 B'
|
|
5
|
+
const k = 1024
|
|
6
|
+
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
7
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
8
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { gray } = require('./colors')
|
|
4
|
+
|
|
5
|
+
const MAX_STATUS_LENGTH = 13 // Length of '[unsupported]'
|
|
6
|
+
|
|
7
|
+
module.exports = (plainStatus, colorize, filePath) => {
|
|
8
|
+
const padding = MAX_STATUS_LENGTH - plainStatus.length
|
|
9
|
+
const paddedPlainStatus = plainStatus + ' '.repeat(Math.max(0, padding))
|
|
10
|
+
return `${colorize(paddedPlainStatus)} ${gray(filePath)}`
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('path')
|
|
4
|
+
|
|
5
|
+
const normalizeFormat = require('./normalize-format')
|
|
6
|
+
|
|
7
|
+
module.exports = (filePath, format) => {
|
|
8
|
+
const normalizedFormat = normalizeFormat(format)
|
|
9
|
+
if (!normalizedFormat) return filePath
|
|
10
|
+
const parsed = path.parse(filePath)
|
|
11
|
+
return path.join(parsed.dir, `${parsed.name}.${normalizedFormat}`)
|
|
12
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
module.exports = resize => {
|
|
4
|
+
if (resize === undefined || resize === null || resize === '') return null
|
|
5
|
+
|
|
6
|
+
const raw = String(resize).trim()
|
|
7
|
+
const normalized = raw.toLowerCase().replace(/\s+/g, '')
|
|
8
|
+
|
|
9
|
+
const dimensionMatch =
|
|
10
|
+
normalized.match(/^([wh])(\d+)$/) || normalized.match(/^(\d+)([wh])$/)
|
|
11
|
+
if (dimensionMatch) {
|
|
12
|
+
const [, first, second] = dimensionMatch
|
|
13
|
+
const axis = first === 'w' || first === 'h' ? first : second
|
|
14
|
+
const value = Number(first === axis ? second : first)
|
|
15
|
+
|
|
16
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
17
|
+
throw new TypeError(
|
|
18
|
+
'Resize width/height must be greater than 0 (e.g. w960, 960w, h480, 480h)'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
mode: 'dimension',
|
|
24
|
+
value: axis === 'w' ? `${value}x` : `x${value}`
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const maxSizeMatch = normalized.match(/^(\d*\.?\d+)(b|kb|mb|gb)$/)
|
|
29
|
+
if (maxSizeMatch) {
|
|
30
|
+
const units = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }
|
|
31
|
+
const value = Number(maxSizeMatch[1])
|
|
32
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
'Resize max size must be greater than 0 (e.g. 100kB, 2MB)'
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
mode: 'max-size',
|
|
40
|
+
value: Math.floor(value * units[maxSizeMatch[2]])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const percentage = raw.replace(/%$/, '')
|
|
45
|
+
const value = Number(percentage)
|
|
46
|
+
|
|
47
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
48
|
+
throw new TypeError(
|
|
49
|
+
'Resize must be a percentage (50%), max size (100kB), width (w960/960w), or height (h480/480h)'
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
mode: 'percentage',
|
|
55
|
+
value: `${value}%`
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('node:child_process')
|
|
4
|
+
|
|
5
|
+
module.exports = binary => {
|
|
6
|
+
try {
|
|
7
|
+
return execSync(`which ${binary}`, {
|
|
8
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
9
|
+
})
|
|
10
|
+
.toString()
|
|
11
|
+
.trim()
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/magick.js
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const path = require('node:path')
|
|
4
|
-
const { execSync } = require('child_process')
|
|
5
|
-
|
|
6
|
-
const magickPath = (() => {
|
|
7
|
-
try {
|
|
8
|
-
return execSync('which magick', {
|
|
9
|
-
stdio: ['pipe', 'pipe', 'ignore']
|
|
10
|
-
})
|
|
11
|
-
.toString()
|
|
12
|
-
.trim()
|
|
13
|
-
} catch {
|
|
14
|
-
return false
|
|
15
|
-
}
|
|
16
|
-
})()
|
|
17
|
-
|
|
18
|
-
/*
|
|
19
|
-
* JPEG preset (compression-first):
|
|
20
|
-
* - Favor smaller output via chroma subsampling and optimized Huffman coding.
|
|
21
|
-
* - Progressive scan improves perceived loading on the web.
|
|
22
|
-
*/
|
|
23
|
-
const MAGICK_JPEG_FLAGS = [
|
|
24
|
-
'-strip',
|
|
25
|
-
'-sampling-factor',
|
|
26
|
-
'4:2:0',
|
|
27
|
-
'-define',
|
|
28
|
-
'jpeg:optimize-coding=true',
|
|
29
|
-
'-define',
|
|
30
|
-
'jpeg:dct-method=float',
|
|
31
|
-
'-quality',
|
|
32
|
-
'80',
|
|
33
|
-
'-interlace',
|
|
34
|
-
'Plane'
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
/*
|
|
38
|
-
* PNG preset (maximum deflate effort):
|
|
39
|
-
* - Strip metadata payloads.
|
|
40
|
-
* - Use highest compression level with explicit strategy/filter tuning.
|
|
41
|
-
*/
|
|
42
|
-
const MAGICK_PNG_FLAGS = [
|
|
43
|
-
'-strip',
|
|
44
|
-
'-define',
|
|
45
|
-
'png:compression-level=9',
|
|
46
|
-
'-define',
|
|
47
|
-
'png:compression-strategy=1',
|
|
48
|
-
'-define',
|
|
49
|
-
'png:compression-filter=5'
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
/*
|
|
53
|
-
* GIF preset (animation optimization):
|
|
54
|
-
* - Coalesce frames before layer optimization to maximize delta compression.
|
|
55
|
-
* - OptimizePlus is more aggressive for animated GIF size reduction.
|
|
56
|
-
*/
|
|
57
|
-
const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
|
|
58
|
-
|
|
59
|
-
/*
|
|
60
|
-
* WebP preset (compression-first):
|
|
61
|
-
* - Use a strong encoder method and preserve compatibility with lossy output.
|
|
62
|
-
*/
|
|
63
|
-
const MAGICK_WEBP_FLAGS = [
|
|
64
|
-
'-strip',
|
|
65
|
-
'-define',
|
|
66
|
-
'webp:method=6',
|
|
67
|
-
'-define',
|
|
68
|
-
'webp:thread-level=1',
|
|
69
|
-
'-quality',
|
|
70
|
-
'80'
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
/*
|
|
74
|
-
* AVIF preset (compression-first):
|
|
75
|
-
* - Slow encoder speed for stronger compression.
|
|
76
|
-
*/
|
|
77
|
-
const MAGICK_AVIF_FLAGS = [
|
|
78
|
-
'-strip',
|
|
79
|
-
'-define',
|
|
80
|
-
'heic:speed=1',
|
|
81
|
-
'-quality',
|
|
82
|
-
'50'
|
|
83
|
-
]
|
|
84
|
-
|
|
85
|
-
/*
|
|
86
|
-
* HEIC/HEIF preset (compression-first):
|
|
87
|
-
* - Slow encoder speed for stronger compression.
|
|
88
|
-
*/
|
|
89
|
-
const MAGICK_HEIC_FLAGS = [
|
|
90
|
-
'-strip',
|
|
91
|
-
'-define',
|
|
92
|
-
'heic:speed=1',
|
|
93
|
-
'-quality',
|
|
94
|
-
'75'
|
|
95
|
-
]
|
|
96
|
-
|
|
97
|
-
/*
|
|
98
|
-
* JPEG XL preset (compression-first):
|
|
99
|
-
* - Use max effort where supported.
|
|
100
|
-
*/
|
|
101
|
-
const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
|
|
102
|
-
|
|
103
|
-
/*
|
|
104
|
-
* SVG preset:
|
|
105
|
-
* - Keep optimization minimal to avoid destructive transformations.
|
|
106
|
-
*/
|
|
107
|
-
const MAGICK_SVG_FLAGS = ['-strip']
|
|
108
|
-
|
|
109
|
-
/*
|
|
110
|
-
* Generic preset for any other format:
|
|
111
|
-
* - Keep it broadly safe across decoders while still reducing size.
|
|
112
|
-
*/
|
|
113
|
-
const MAGICK_GENERIC_FLAGS = ['-strip']
|
|
114
|
-
|
|
115
|
-
const magickFlags = filePath => {
|
|
116
|
-
const ext = path.extname(filePath).toLowerCase()
|
|
117
|
-
if (ext === '.jpg' || ext === '.jpeg') return MAGICK_JPEG_FLAGS
|
|
118
|
-
if (ext === '.png') return MAGICK_PNG_FLAGS
|
|
119
|
-
if (ext === '.gif') return MAGICK_GIF_FLAGS
|
|
120
|
-
if (ext === '.webp') return MAGICK_WEBP_FLAGS
|
|
121
|
-
if (ext === '.avif') return MAGICK_AVIF_FLAGS
|
|
122
|
-
if (ext === '.heic' || ext === '.heif') return MAGICK_HEIC_FLAGS
|
|
123
|
-
if (ext === '.jxl') return MAGICK_JXL_FLAGS
|
|
124
|
-
if (ext === '.svg') return MAGICK_SVG_FLAGS
|
|
125
|
-
return MAGICK_GENERIC_FLAGS
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
module.exports = { magickPath, magickFlags }
|
package/src/util.js
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const { gray } = require('./colors')
|
|
4
|
-
|
|
5
|
-
const MAX_STATUS_LENGTH = 13 // Length of '[unsupported]'
|
|
6
|
-
|
|
7
|
-
const formatBytes = bytes => {
|
|
8
|
-
if (bytes === 0) return '0 B'
|
|
9
|
-
const k = 1024
|
|
10
|
-
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
11
|
-
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
12
|
-
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const formatLog = (plainStatus, colorize, filePath) => {
|
|
16
|
-
const padding = MAX_STATUS_LENGTH - plainStatus.length
|
|
17
|
-
const paddedPlainStatus = plainStatus + ' '.repeat(Math.max(0, padding))
|
|
18
|
-
return `${colorize(paddedPlainStatus)} ${gray(filePath)}`
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const percentage = (partial, total) =>
|
|
22
|
-
(((partial - total) / total) * 100).toFixed(1)
|
|
23
|
-
|
|
24
|
-
const normalizeFormat = format => {
|
|
25
|
-
if (!format) return null
|
|
26
|
-
const normalized = String(format).trim().toLowerCase().replace(/^\./, '')
|
|
27
|
-
if (normalized === 'jpg') return 'jpeg'
|
|
28
|
-
if (normalized === 'tif') return 'tiff'
|
|
29
|
-
return normalized
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const parseResize = resize => {
|
|
33
|
-
if (resize === undefined || resize === null || resize === '') return null
|
|
34
|
-
|
|
35
|
-
const raw = String(resize).trim()
|
|
36
|
-
const normalized = raw.toLowerCase().replace(/\s+/g, '')
|
|
37
|
-
|
|
38
|
-
const dimensionMatch = normalized.match(/^([wh])(\d+)$/)
|
|
39
|
-
if (dimensionMatch) {
|
|
40
|
-
const axis = dimensionMatch[1]
|
|
41
|
-
const value = Number(dimensionMatch[2])
|
|
42
|
-
|
|
43
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
44
|
-
throw new TypeError(
|
|
45
|
-
'Resize width/height must be greater than 0 (e.g. w960, h480)'
|
|
46
|
-
)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
mode: 'dimension',
|
|
51
|
-
value: axis === 'w' ? `${value}x` : `x${value}`
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const maxSizeMatch = normalized.match(/^(\d*\.?\d+)(b|kb|mb|gb)$/)
|
|
56
|
-
if (maxSizeMatch) {
|
|
57
|
-
const units = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }
|
|
58
|
-
const value = Number(maxSizeMatch[1])
|
|
59
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
60
|
-
throw new TypeError(
|
|
61
|
-
'Resize max size must be greater than 0 (e.g. 100kB, 2MB)'
|
|
62
|
-
)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
mode: 'max-size',
|
|
67
|
-
value: Math.floor(value * units[maxSizeMatch[2]])
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const percentage = raw.replace(/%$/, '')
|
|
72
|
-
const value = Number(percentage)
|
|
73
|
-
|
|
74
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
75
|
-
throw new TypeError(
|
|
76
|
-
'Resize must be a percentage (50%), max size (100kB), width (w960), or height (h480)'
|
|
77
|
-
)
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
mode: 'percentage',
|
|
82
|
-
value: `${value}%`
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
module.exports = {
|
|
87
|
-
formatBytes,
|
|
88
|
-
formatLog,
|
|
89
|
-
normalizeFormat,
|
|
90
|
-
parseResize,
|
|
91
|
-
percentage
|
|
92
|
-
}
|
|
File without changes
|