optimo 0.0.15 → 0.0.17

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 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
- `optimo` is an CLI for aggressively reducing image file size with sane defaults. It's implemented on top of [ImageMagick](https://imagemagick.org/#gsc.tab=0).
12
+ ## Highlights
13
+
14
+ - Format-specific tuning for stronger size reduction.
15
+ - Safety guard: if optimized output is not smaller, original file is kept.
16
+ - Backed by proven tools: ImageMagick, SVGO, Gifsicle, and MozJPEG.
17
+ - Resizing supports percentage values (`50%`), max file size targets (`100kB`), width (`w960`), & height (`h480`).
12
18
 
13
- ## Install
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 -f jpeg # convert + optimize
19
- npx -y optimo public/media/banner.png -r 50% # resize + optimize
20
- npx -y optimo public/media/banner.png -r 100kB # resize to max file size
21
- npx -y optimo public/media/banner.png -r w960 # resize to max width
22
- npx -y optimo public/media/banner.png -r h480 # resize to max height
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
- ## Highlights
33
+ ## Pipelines
26
34
 
27
- - Metadata stripping.
28
- - Compression-first per format.
29
- - Format-specific tuning for stronger size reduction.
30
- - Safety guard: if optimized output is not smaller, original file is kept.
31
- - Resizing supports percentage values (`50%`), max file size targets (`100kB`), width (`w960`), & height (`h480`).
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 optimo = require('optimo')
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.15",
5
+ "version": "0.0.17",
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,46 @@
1
+ 'use strict'
2
+
3
+ const path = require('node:path')
4
+
5
+ const mozjpegtran = require('./mozjpegtran')
6
+ const gifsicle = require('./gifsicle')
7
+ const magick = require('./magick')
8
+ const svgo = require('./svgo')
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(new Map(binaries.map(binary => [binary.name, binary])).values())
44
+ }
45
+
46
+ 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,108 +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 runMagick = async ({
27
- filePath,
28
- optimizedPath,
29
- flags,
30
- resizeGeometry = null
31
- }) => {
32
- const magickArgs = [
33
- filePath,
34
- ...(resizeGeometry ? ['-resize', resizeGeometry] : []),
35
- ...flags,
36
- optimizedPath
37
- ]
38
-
39
- await $('magick', magickArgs)
40
- return (await stat(optimizedPath)).size
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 optimizeForMaxSize = async ({
44
- filePath,
45
- optimizedPath,
46
- flags,
47
- maxSize
48
- }) => {
49
- const resultByScale = new Map()
50
-
51
- const measureScale = async scale => {
52
- if (resultByScale.has(scale)) return resultByScale.get(scale)
53
- const resizeGeometry = scale === 100 ? null : `${scale}%`
54
- const size = await runMagick({
55
- filePath,
56
- optimizedPath,
57
- flags,
58
- resizeGeometry
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
- await measureScale(bestScale)
91
- return resultByScale.get(bestScale)
57
+ return (await stat(optimizedPath)).size
92
58
  }
93
59
 
94
- const file = async (
95
- filePath,
96
- { onLogs = () => {}, dryRun, format: outputFormat, resize } = {}
97
- ) => {
98
- if (!magickPath) {
99
- throw new Error('ImageMagick is not installed')
100
- }
60
+ const file = async (filePath, { onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false } = {}) => {
101
61
  const outputPath = getOutputPath(filePath, outputFormat)
102
62
  const resizeConfig = parseResize(resize)
103
- const flags = magickFlags(outputPath)
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
+ )
104
79
 
105
- const optimizedPath = `${outputPath}.optimized`
80
+ const optimizedPath = `${outputPath}.optimized${path.extname(outputPath)}`
106
81
  const isConverting = outputPath !== filePath
107
82
 
108
83
  let originalSize
@@ -111,22 +86,36 @@ const file = async (
111
86
  try {
112
87
  originalSize = (await stat(filePath)).size
113
88
 
114
- if (resizeConfig?.mode === 'max-size') {
115
- optimizedSize = await optimizeForMaxSize({
116
- filePath,
117
- optimizedPath,
118
- flags,
119
- maxSize: resizeConfig.value
120
- })
89
+ if (executionPipeline.length === 0) {
90
+ await copyFile(filePath, optimizedPath)
91
+ optimizedSize = (await stat(optimizedPath)).size
121
92
  } else {
122
- optimizedSize = await runMagick({
93
+ optimizedSize = await executePipeline({
94
+ pipeline: executionPipeline,
123
95
  filePath,
124
96
  optimizedPath,
125
- flags,
126
- resizeGeometry: resizeConfig?.value
97
+ resizeConfig,
98
+ losy
127
99
  })
128
100
  }
129
- } 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
+ })
130
119
  onLogs(formatLog('[unsupported]', yellow, filePath))
131
120
  return { originalSize: 0, optimizedSize: 0 }
132
121
  }
@@ -169,9 +158,7 @@ const file = async (
169
158
  }
170
159
 
171
160
  const dir = async (folderPath, opts) => {
172
- const items = (await readdir(folderPath, { withFileTypes: true })).filter(
173
- item => !item.name.startsWith('.')
174
- )
161
+ const items = (await readdir(folderPath, { withFileTypes: true })).filter(item => !item.name.startsWith('.'))
175
162
  let totalOriginalSize = 0
176
163
  let totalOptimizedSize = 0
177
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,9 @@
1
+ 'use strict'
2
+
3
+ module.exports = format => {
4
+ if (!format) return null
5
+ const normalized = String(format).trim().toLowerCase().replace(/^\./, '')
6
+ if (normalized === 'jpg') return 'jpeg'
7
+ if (normalized === 'tif') return 'tiff'
8
+ return normalized
9
+ }
@@ -1,35 +1,6 @@
1
1
  'use strict'
2
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 => {
3
+ module.exports = resize => {
33
4
  if (resize === undefined || resize === null || resize === '') return null
34
5
 
35
6
  const raw = String(resize).trim()
@@ -84,11 +55,3 @@ const parseResize = resize => {
84
55
  value: `${value}%`
85
56
  }
86
57
  }
87
-
88
- module.exports = {
89
- formatBytes,
90
- formatLog,
91
- normalizeFormat,
92
- parseResize,
93
- percentage
94
- }
@@ -0,0 +1,4 @@
1
+ 'use strict'
2
+
3
+ module.exports = (partial, total) =>
4
+ (((partial - total) / total) * 100).toFixed(1)
@@ -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 }
File without changes