optimo 0.0.17 → 0.0.19

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