Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ npx -y optimo public/media/banner.png --resize 50% # resize + optimize
npx -y optimo public/media/banner.png --resize 100kB # resize to max file size
npx -y optimo public/media/banner.png --resize w960 # resize to max width
npx -y optimo public/media/banner.png --resize h480 # resize to max height
npx -y optimo public/media/banner.png --data-url # print optimized image as data URL
npx -y optimo public/media/banner.heic --dry-run --verbose # inspect unsupported failures
npx -y optimo public/media/clip.mp4 # optimize a video
npx -y optimo public/media/clip.mp4 --mute # optimize and remove audio
Expand All @@ -51,6 +52,7 @@ Mode behavior:
- default: lossless-first pipeline.
- `-l, --losy`: lossy + lossless pass per matching compressor.
- `-m, --mute`: remove audio tracks from video outputs (default: `true`; use `--mute false` to keep audio).
- `-u, --data-url`: return optimized image as data URL (single file only; image only).
- `-v, --verbose`: print debug logs (selected pipeline, binaries, executed commands, and errors).


Expand Down Expand Up @@ -95,6 +97,13 @@ await optimo.file('/absolute/path/video.mp4', {
onLogs: console.log
})

const { dataUrl } = await optimo.file('/absolute/path/image.jpg', {
dataUrl: true,
onLogs: console.log
})

console.log(dataUrl) // data:image/jpeg;base64,...

// optimize a dir recursively
const result = await optimo.dir('/absolute/path/images')

Expand Down
30 changes: 16 additions & 14 deletions bin/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,26 @@ Usage
Options
-l, --losy Enable lossy + lossless passes (default: false)
-m, --mute Remove audio tracks from videos (default: true)
-u, --data-url Return optimized image as data URL (file input only)
-d, --dry-run Show what would be optimized without making changes
-f, --format Convert output format (e.g. jpeg, webp, avif)
-r, --resize Resize by percentage (50%), size (100kB, images only), width (w960), or height (h480)
-v, --verbose Print debug logs (commands, pipeline, and errors)

Examples
$ optimo image.jpg
$ optimo image.jpg --losy
$ optimo clip.mp4 --mute
$ optimo clip.mp4 --mute false
$ optimo image.png --dry-run
$ optimo image.jpg -d
$ optimo image.png -f jpeg
$ optimo image.png -r 50%
$ optimo image.png -r 100kB
$ optimo image.png -r w960
$ optimo image.png -r h480
$ optimo image.heic -d -v
$ optimo clip.mp4
$ optimo clip.mov -f webm
$ optimo image.jpg # optimize a single image in place
$ optimo image.jpg --losy # run lossy + lossless optimization passes
$ optimo clip.mp4 --mute # optimize video and remove audio track
$ optimo clip.mp4 --mute false # optimize video and keep audio track
$ optimo image.png --dry-run # preview optimization without writing files
$ optimo image.jpg -d # short alias for dry-run preview mode
$ optimo image.png -f jpeg # convert PNG to JPEG and optimize
$ optimo image.png -r 50% # resize image to 50 percent then optimize
$ optimo image.png -r 100kB # resize image to target max file size
$ optimo image.png -r w960 # resize image to max width of 960px
$ optimo image.png -r h480 # resize image to max height of 480px
$ optimo image.png --data-url # output optimized image as data URL
$ optimo image.heic -d -v # dry-run HEIC optimization with verbose logs
$ optimo clip.mp4 # optimize a single video in place
$ optimo clip.mov -f webm # convert MOV to WebM and optimize
`)
13 changes: 10 additions & 3 deletions bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const mri = require('mri')
async function main () {
const argv = mri(process.argv.slice(2), {
alias: {
'data-url': 'u',
'dry-run': 'd',
format: 'f',
losy: 'l',
Expand All @@ -19,6 +20,7 @@ async function main () {
})

const input = argv._[0]
const dataUrl = argv['data-url'] === true
const mute =
argv.mute === undefined
? true
Expand All @@ -45,18 +47,23 @@ async function main () {
const isDirectory = stats.isDirectory()
const fn = isDirectory ? require('optimo').dir : require('optimo').file

const logger = argv.silent ? () => {} : logEntry => console.log(logEntry)
!argv.silent && console.log()
const logger = argv.silent ? () => {} : logEntry => console.error(logEntry)
!argv.silent && console.error()

await fn(input, {
const result = await fn(input, {
losy: argv.losy,
mute,
dryRun: argv['dry-run'],
dataUrl,
format: argv.format,
resize,
onLogs: logger
})

if (dataUrl && result?.dataUrl) {
console.log(result.dataUrl)
}

process.exit(0)
}

Expand Down
38 changes: 33 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
'use strict'

const { stat, unlink, rename, readdir, copyFile } = require('node:fs/promises')
const { stat, unlink, rename, readdir, copyFile, readFile } = require('node:fs/promises')
const path = require('node:path')

const { getPipeline, getRequiredBinaries } = require('./compressor')
const ensureBinaries = require('./util/ensure-binaries')
const { yellow, gray, green } = require('./util/colors')
const getOutputPath = require('./util/get-output-path')
const getMediaKind = require('./util/get-media-kind')
const formatBytes = require('./util/format-bytes')
const parseResize = require('./util/parse-resize')
const toDataUrl = require('./util/to-data-url')
const percentage = require('./util/percentage')
const formatLog = require('./util/format-log')
const getMediaKind = require('./util/get-media-kind')
const debug = require('./util/debug')

const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute }) => {
Expand Down Expand Up @@ -63,7 +64,7 @@ const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig

const file = async (
filePath,
{ onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true } = {}
{ onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true, dataUrl = false } = {}
) => {
const outputPath = getOutputPath(filePath, outputFormat)
const resizeConfig = parseResize(resize)
Expand Down Expand Up @@ -91,6 +92,10 @@ const file = async (
)
}

if (dataUrl && mediaKind !== 'image') {
throw new TypeError('Data URL output is only supported for images.')
}

const needsMagickForTransform = Boolean(resizeConfig) || outputPath !== filePath
if (mediaKind === 'image' && needsMagickForTransform && executionPipeline[0]?.binaryName !== 'magick') {
const magick = require('./compressor/magick')
Expand Down Expand Up @@ -151,7 +156,24 @@ const file = async (
if (!isConverting && optimizedSize >= originalSize) {
await unlink(optimizedPath)
onLogs(formatLog('[optimized]', gray, filePath))
return { originalSize, optimizedSize: originalSize }

const result = { originalSize, optimizedSize: originalSize }
if (dataUrl) {
result.dataUrl = toDataUrl({
filePath,
content: await readFile(filePath)
})
}

return result
}

let outputDataUrl = null
if (dataUrl) {
outputDataUrl = toDataUrl({
filePath: outputPath,
content: await readFile(optimizedPath)
})
}

if (dryRun) {
Expand Down Expand Up @@ -182,10 +204,16 @@ const file = async (
)
)

return { originalSize, optimizedSize }
const result = { originalSize, optimizedSize }
if (outputDataUrl) result.dataUrl = outputDataUrl
return result
}

const dir = async (folderPath, opts) => {
if (opts?.dataUrl) {
throw new TypeError('Data URL output is only supported when optimizing a single image file.')
}

const items = (await readdir(folderPath, { withFileTypes: true })).filter(item => !item.name.startsWith('.'))
let totalOriginalSize = 0
let totalOptimizedSize = 0
Expand Down
26 changes: 26 additions & 0 deletions src/util/to-data-url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use strict'

const path = require('node:path')

const MIME_TYPES_BY_EXTENSION = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.avif': 'image/avif',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.jxl': 'image/jxl',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.tif': 'image/tiff',
'.tiff': 'image/tiff'
}

const getMimeType = filePath => MIME_TYPES_BY_EXTENSION[path.extname(filePath).toLowerCase()] || 'application/octet-stream'

module.exports = ({ filePath, content }) => {
const mimeType = getMimeType(filePath)
return `data:${mimeType};base64,${content.toString('base64')}`
}
24 changes: 24 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use strict'

const test = require('ava')
const optimo = require('../src')

test('file throws when dataUrl is requested for video output', async t => {
const error = await t.throwsAsync(
optimo.file('/tmp/video.mp4', {
dataUrl: true
})
)

t.is(error.message, 'Data URL output is only supported for images.')
})

test('dir throws when dataUrl is requested', async t => {
const error = await t.throwsAsync(
optimo.dir('/tmp', {
dataUrl: true
})
)

t.is(error.message, 'Data URL output is only supported when optimizing a single image file.')
})
18 changes: 18 additions & 0 deletions test/util/to-data-url.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict'

const test = require('ava')
const toDataUrl = require('../../src/util/to-data-url')

test('toDataUrl returns data URL with mime inferred from extension', t => {
const content = Buffer.from('hello world')
const value = toDataUrl({ filePath: '/tmp/image.jpg', content })

t.true(value.startsWith('data:image/jpeg;base64,'))
})

test('toDataUrl falls back to octet-stream for unknown extension', t => {
const content = Buffer.from('hello world')
const value = toDataUrl({ filePath: '/tmp/image.unknown', content })

t.true(value.startsWith('data:application/octet-stream;base64,'))
})
Loading