optimo 0.0.11 → 0.0.12

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
@@ -17,14 +17,16 @@ npx -y optimo public/media # for a directory
17
17
  npx -y optimo public/media/banner.png # for a file
18
18
  npx -y optimo public/media/banner.png -f jpeg # convert + optimize
19
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
20
21
  ```
21
22
 
22
- ## Optimization Strategy
23
+ ## Highlights
23
24
 
25
+ - Metadata stripping.
24
26
  - Compression-first per format.
25
- - Metadata stripping (`-strip`) where applicable.
26
27
  - Format-specific tuning for stronger size reduction.
27
28
  - Safety guard: if optimized output is not smaller, original file is kept.
29
+ - Resizing supports percentage values (`50%`) and max file size targets (`100kB`, `2MB`).
28
30
 
29
31
  ## Programmatic API
30
32
 
@@ -39,6 +41,11 @@ await optimo.file('/absolute/path/image.jpg', {
39
41
  onLogs: console.log
40
42
  })
41
43
 
44
+ await optimo.file('/absolute/path/image.jpg', {
45
+ resize: '100kB',
46
+ onLogs: console.log
47
+ })
48
+
42
49
  // optimize a dir recursively
43
50
  const result = await optimo.dir('/absolute/path/images')
44
51
 
package/bin/help.js CHANGED
@@ -10,7 +10,7 @@ Usage
10
10
  Options
11
11
  -d, --dry-run Show what would be optimized without making changes
12
12
  -f, --format Convert output format (e.g. jpeg, webp, avif)
13
- -r, --resize Resize image by percentage (e.g. 50 or 50%)
13
+ -r, --resize Resize by percentage (50%) or target max size (100kB)
14
14
 
15
15
  Examples
16
16
  $ optimo image.jpg
@@ -18,4 +18,5 @@ Examples
18
18
  $ optimo image.jpg -d
19
19
  $ optimo image.png -f jpeg
20
20
  $ optimo image.png -r 50%
21
+ $ optimo image.png -r 100kB
21
22
  `)
package/bin/index.js CHANGED
@@ -18,6 +18,18 @@ async function main () {
18
18
  })
19
19
 
20
20
  const input = argv._[0]
21
+ let resize = argv.resize
22
+
23
+ if (resize !== undefined && resize !== null) {
24
+ const unitToken = argv._[1]
25
+ if (
26
+ unitToken &&
27
+ /^[kmg]?b$/i.test(unitToken) &&
28
+ /^\d*\.?\d+$/.test(String(resize))
29
+ ) {
30
+ resize = `${resize}${unitToken}`
31
+ }
32
+ }
21
33
 
22
34
  if (!input) {
23
35
  console.log(require('./help'))
@@ -34,7 +46,7 @@ async function main () {
34
46
  await fn(input, {
35
47
  dryRun: argv['dry-run'],
36
48
  format: argv.format,
37
- resize: argv.resize,
49
+ resize,
38
50
  onLogs: logger
39
51
  })
40
52
 
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.11",
5
+ "version": "0.0.12",
6
6
  "exports": {
7
7
  ".": "./src/index.js"
8
8
  },
@@ -62,7 +62,7 @@
62
62
  "clean": "rm -rf node_modules",
63
63
  "contributors": "(git-authors-cli && finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
64
64
  "coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
65
- "lint": "standard-markdown README.md && standard",
65
+ "lint": "standard",
66
66
  "postrelease": "pnpm release:tags && pnpm release:github && (ci-publish || npm publish --access=public)",
67
67
  "pretest": "pnpm lint",
68
68
  "release": "pnpm release:version && pnpm release:changelog && pnpm release:commit && pnpm release:tag",
package/src/index.js CHANGED
@@ -1,169 +1,93 @@
1
1
  'use strict'
2
2
 
3
3
  const { stat, unlink, rename, readdir } = require('node:fs/promises')
4
- const { execSync } = require('child_process')
4
+
5
5
  const path = require('node:path')
6
6
  const $ = require('tinyspawn')
7
7
 
8
- const { formatLog, formatBytes } = require('./util')
8
+ const { magickPath, magickFlags } = require('./magick')
9
9
  const { yellow, gray, green } = require('./colors')
10
10
 
11
- /*
12
- * JPEG preset (compression-first):
13
- * - Favor smaller output via chroma subsampling and optimized Huffman coding.
14
- * - Progressive scan improves perceived loading on the web.
15
- */
16
- const MAGICK_JPEG_FLAGS = [
17
- '-strip',
18
- '-sampling-factor',
19
- '4:2:0',
20
- '-define',
21
- 'jpeg:optimize-coding=true',
22
- '-define',
23
- 'jpeg:dct-method=float',
24
- '-quality',
25
- '80',
26
- '-interlace',
27
- 'Plane'
28
- ]
29
-
30
- /*
31
- * PNG preset (maximum deflate effort):
32
- * - Strip metadata payloads.
33
- * - Use highest compression level with explicit strategy/filter tuning.
34
- */
35
- const MAGICK_PNG_FLAGS = [
36
- '-strip',
37
- '-define',
38
- 'png:compression-level=9',
39
- '-define',
40
- 'png:compression-strategy=1',
41
- '-define',
42
- 'png:compression-filter=5'
43
- ]
44
-
45
- /*
46
- * GIF preset (animation optimization):
47
- * - Coalesce frames before layer optimization to maximize delta compression.
48
- * - OptimizePlus is more aggressive for animated GIF size reduction.
49
- */
50
- const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
51
-
52
- /*
53
- * WebP preset (compression-first):
54
- * - Use a strong encoder method and preserve compatibility with lossy output.
55
- */
56
- const MAGICK_WEBP_FLAGS = [
57
- '-strip',
58
- '-define',
59
- 'webp:method=6',
60
- '-define',
61
- 'webp:thread-level=1',
62
- '-quality',
63
- '80'
64
- ]
65
-
66
- /*
67
- * AVIF preset (compression-first):
68
- * - Slow encoder speed for stronger compression.
69
- */
70
- const MAGICK_AVIF_FLAGS = [
71
- '-strip',
72
- '-define',
73
- 'heic:speed=1',
74
- '-quality',
75
- '50'
76
- ]
77
-
78
- /*
79
- * HEIC/HEIF preset (compression-first):
80
- * - Slow encoder speed for stronger compression.
81
- */
82
- const MAGICK_HEIC_FLAGS = [
83
- '-strip',
84
- '-define',
85
- 'heic:speed=1',
86
- '-quality',
87
- '75'
88
- ]
89
-
90
- /*
91
- * JPEG XL preset (compression-first):
92
- * - Use max effort where supported.
93
- */
94
- const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
95
-
96
- /*
97
- * SVG preset:
98
- * - Keep optimization minimal to avoid destructive transformations.
99
- */
100
- const MAGICK_SVG_FLAGS = ['-strip']
101
-
102
- /*
103
- * Generic preset for any other format:
104
- * - Keep it broadly safe across decoders while still reducing size.
105
- */
106
- const MAGICK_GENERIC_FLAGS = ['-strip']
107
-
108
- const magickPath = (() => {
109
- try {
110
- return execSync('which magick', {
111
- stdio: ['pipe', 'pipe', 'ignore']
112
- })
113
- .toString()
114
- .trim()
115
- } catch {
116
- return false
117
- }
118
- })()
119
-
120
- const percentage = (partial, total) =>
121
- (((partial - total) / total) * 100).toFixed(1)
122
-
123
- const normalizeFormat = format => {
124
- if (!format) return null
125
-
126
- const normalized = String(format).trim().toLowerCase().replace(/^\./, '')
127
- if (normalized === 'jpg') return 'jpeg'
128
- if (normalized === 'tif') return 'tiff'
129
-
130
- return normalized
131
- }
11
+ const {
12
+ formatBytes,
13
+ formatLog,
14
+ normalizeFormat,
15
+ parseResize,
16
+ percentage
17
+ } = require('./util')
132
18
 
133
19
  const getOutputPath = (filePath, format) => {
134
20
  const normalizedFormat = normalizeFormat(format)
135
21
  if (!normalizedFormat) return filePath
136
-
137
22
  const parsed = path.parse(filePath)
138
23
  return path.join(parsed.dir, `${parsed.name}.${normalizedFormat}`)
139
24
  }
140
25
 
141
- const getMagickFlags = filePath => {
142
- const ext = path.extname(filePath).toLowerCase()
143
- if (ext === '.jpg' || ext === '.jpeg') return MAGICK_JPEG_FLAGS
144
- if (ext === '.png') return MAGICK_PNG_FLAGS
145
- if (ext === '.gif') return MAGICK_GIF_FLAGS
146
- if (ext === '.webp') return MAGICK_WEBP_FLAGS
147
- if (ext === '.avif') return MAGICK_AVIF_FLAGS
148
- if (ext === '.heic' || ext === '.heif') return MAGICK_HEIC_FLAGS
149
- if (ext === '.jxl') return MAGICK_JXL_FLAGS
150
- if (ext === '.svg') return MAGICK_SVG_FLAGS
151
- return MAGICK_GENERIC_FLAGS
26
+ const runMagick = async ({
27
+ filePath,
28
+ optimizedPath,
29
+ flags,
30
+ resizePercentage = null
31
+ }) => {
32
+ const magickArgs = [
33
+ filePath,
34
+ ...(resizePercentage ? ['-resize', resizePercentage] : []),
35
+ ...flags,
36
+ optimizedPath
37
+ ]
38
+
39
+ await $('magick', magickArgs)
40
+ return (await stat(optimizedPath)).size
152
41
  }
153
42
 
154
- const parseResize = resize => {
155
- if (resize === undefined || resize === null || resize === '') return null
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 resizePercentage = scale === 100 ? null : `${scale}%`
54
+ const size = await runMagick({
55
+ filePath,
56
+ optimizedPath,
57
+ flags,
58
+ resizePercentage
59
+ })
60
+ resultByScale.set(scale, size)
61
+ return size
62
+ }
156
63
 
157
- const normalized = String(resize).trim().replace(/%$/, '')
158
- const value = Number(normalized)
64
+ const fullSize = await measureScale(100)
65
+ if (fullSize <= maxSize) {
66
+ return fullSize
67
+ }
159
68
 
160
- if (!Number.isFinite(value) || value <= 0) {
161
- throw new TypeError(
162
- 'Resize percentage must be a number greater than 0 (e.g. 50 or 50%)'
163
- )
69
+ const minScaleSize = await measureScale(1)
70
+ if (minScaleSize > maxSize) {
71
+ return minScaleSize
164
72
  }
165
73
 
166
- return `${value}%`
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
+ }
89
+
90
+ return resultByScale.get(bestScale)
167
91
  }
168
92
 
169
93
  const file = async (
@@ -174,32 +98,38 @@ const file = async (
174
98
  throw new Error('ImageMagick is not installed')
175
99
  }
176
100
  const outputPath = getOutputPath(filePath, outputFormat)
177
- const resizePercentage = parseResize(resize)
178
- const flags = getMagickFlags(outputPath)
101
+ const resizeConfig = parseResize(resize)
102
+ const flags = magickFlags(outputPath)
179
103
 
180
104
  const optimizedPath = `${outputPath}.optimized`
181
105
  const isConverting = outputPath !== filePath
182
106
 
183
107
  let originalSize
184
- try {
185
- const magickArgs = [
186
- filePath,
187
- ...(resizePercentage ? ['-resize', resizePercentage] : []),
188
- ...flags,
189
- optimizedPath
190
- ]
108
+ let optimizedSize
191
109
 
192
- ;[originalSize] = await Promise.all([
193
- (await stat(filePath)).size,
194
- await $('magick', magickArgs)
195
- ])
110
+ try {
111
+ originalSize = (await stat(filePath)).size
112
+
113
+ if (resizeConfig?.mode === 'max-size') {
114
+ optimizedSize = await optimizeForMaxSize({
115
+ filePath,
116
+ optimizedPath,
117
+ flags,
118
+ maxSize: resizeConfig.value
119
+ })
120
+ } else {
121
+ optimizedSize = await runMagick({
122
+ filePath,
123
+ optimizedPath,
124
+ flags,
125
+ resizePercentage: resizeConfig?.value
126
+ })
127
+ }
196
128
  } catch {
197
129
  onLogs(formatLog('[unsupported]', yellow, filePath))
198
130
  return { originalSize: 0, optimizedSize: 0 }
199
131
  }
200
132
 
201
- const optimizedSize = (await stat(optimizedPath)).size
202
-
203
133
  if (!isConverting && optimizedSize >= originalSize) {
204
134
  await unlink(optimizedPath)
205
135
  onLogs(formatLog('[optimized]', gray, filePath))
package/src/magick.js ADDED
@@ -0,0 +1,128 @@
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 CHANGED
@@ -18,7 +18,58 @@ const formatLog = (plainStatus, colorize, filePath) => {
18
18
  return `${colorize(paddedPlainStatus)} ${gray(filePath)}`
19
19
  }
20
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 maxSizeMatch = normalized.match(/^(\d*\.?\d+)(b|kb|mb|gb)$/)
39
+ if (maxSizeMatch) {
40
+ const units = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 }
41
+ const value = Number(maxSizeMatch[1])
42
+ if (!Number.isFinite(value) || value <= 0) {
43
+ throw new TypeError(
44
+ 'Resize max size must be greater than 0 (e.g. 100kB, 2MB)'
45
+ )
46
+ }
47
+
48
+ return {
49
+ mode: 'max-size',
50
+ value: Math.floor(value * units[maxSizeMatch[2]])
51
+ }
52
+ }
53
+
54
+ const percentage = raw.replace(/%$/, '')
55
+ const value = Number(percentage)
56
+
57
+ if (!Number.isFinite(value) || value <= 0) {
58
+ throw new TypeError(
59
+ 'Resize must be a percentage (e.g. 50 or 50%) or max size (e.g. 100kB)'
60
+ )
61
+ }
62
+
63
+ return {
64
+ mode: 'percentage',
65
+ value: `${value}%`
66
+ }
67
+ }
68
+
21
69
  module.exports = {
22
70
  formatBytes,
23
- formatLog
71
+ formatLog,
72
+ normalizeFormat,
73
+ parseResize,
74
+ percentage
24
75
  }