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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Safety guard: if optimized output is not smaller, original file is kept.
- Backed by proven tools: ImageMagick, SVGO, Gifsicle, MozJPEG, and FFmpeg.
- Supports image and video optimization.
- Strips metadata by default for smaller outputs.
- Resizing supports percentage values (`50%`), max file size targets (`100kB`, images only), width (`w960`), & height (`h480`).

## Usage
Expand All @@ -28,6 +29,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.jpg --preserve-exif # keep EXIF metadata
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
Expand All @@ -52,6 +54,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).
- `-p, --preserve-exif`: preserve EXIF metadata on image outputs (default: `false`).
- `-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 All @@ -73,6 +76,7 @@ const optimo = require('optimo')
await optimo.file('/absolute/path/image.jpg', {
dryRun: false,
losy: false,
preserveExif: false,
format: 'webp',
resize: '50%',
onLogs: console.log
Expand Down
2 changes: 2 additions & 0 deletions bin/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Usage
Options
-l, --losy Enable lossy + lossless passes (default: false)
-m, --mute Remove audio tracks from videos (default: true)
-p, --preserve-exif Preserve EXIF metadata (default: false)
-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)
Expand All @@ -18,6 +19,7 @@ Options

Examples
$ optimo image.jpg # optimize a single image in place
$ optimo image.jpg --preserve-exif # keep EXIF metadata on output image
$ 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
Expand Down
7 changes: 6 additions & 1 deletion bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const INSTALL_HINTS = {
'mozjpegtran/jpegtran': 'brew install mozjpeg',
'magick:jxl': 'brew install imagemagick-full && brew link --overwrite --force imagemagick-full'
}
const FALSE_VALUES = ['false', '0', 'no', 'off']

async function main () {
const argv = mri(process.argv.slice(2), {
Expand All @@ -22,6 +23,7 @@ async function main () {
format: 'f',
losy: 'l',
mute: 'm',
'preserve-exif': 'p',
resize: 'r',
silent: 's',
verbose: 'v'
Expand All @@ -30,7 +32,9 @@ async function main () {

const input = argv._[0]
const dataUrl = argv['data-url'] === true
const mute = argv.mute === undefined ? true : !['false', '0', 'no', 'off'].includes(String(argv.mute).toLowerCase())
const isEnabled = value => !FALSE_VALUES.includes(String(value).toLowerCase())
const mute = argv.mute === undefined ? true : isEnabled(argv.mute)
const preserveExif = argv['preserve-exif'] === undefined ? false : isEnabled(argv['preserve-exif'])
let resize = argv.resize

if (resize !== undefined && resize !== null) {
Expand Down Expand Up @@ -59,6 +63,7 @@ async function main () {
const result = await fn(input, {
losy: argv.losy,
mute,
preserveExif,
dryRun: argv['dry-run'],
dataUrl,
format: argv.format,
Expand Down
47 changes: 25 additions & 22 deletions src/compressor/magick.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ const withMeta = (format, fn, { delegate } = {}) => {
}

const MAGICK_JPEG_LOSSY_FLAGS = [
'-strip',
'-sampling-factor',
'4:2:0',
'-define',
Expand All @@ -65,32 +64,31 @@ const MAGICK_JPEG_LOSSLESS_FLAGS = ['-define', 'jpeg:optimize-coding=true', '-in

const MAGICK_PNG_LOSSLESS_FLAGS = []
const MAGICK_PNG_LOSSY_FLAGS = [
'-strip',
'-define',
'png:exclude-chunks=all',
'-define',
'png:include-chunks=tRNS,gAMA'
]

const MAGICK_GIF_FLAGS = ['-strip', '-coalesce', '-layers', 'OptimizePlus']
const MAGICK_GIF_FLAGS = ['-coalesce', '-layers', 'OptimizePlus']

const MAGICK_WEBP_LOSSY_FLAGS = ['-strip', '-define', 'webp:method=6', '-define', 'webp:thread-level=1', '-quality', '80']
const MAGICK_WEBP_LOSSLESS_FLAGS = ['-strip', '-define', 'webp:method=6', '-define', 'webp:thread-level=1', '-quality', '100']
const MAGICK_WEBP_LOSSY_FLAGS = ['-define', 'webp:method=6', '-define', 'webp:thread-level=1', '-quality', '80']
const MAGICK_WEBP_LOSSLESS_FLAGS = ['-define', 'webp:method=6', '-define', 'webp:thread-level=1', '-quality', '100']

const MAGICK_AVIF_FLAGS = ['-strip', '-define', 'heic:speed=1', '-quality', '50']
const MAGICK_AVIF_FLAGS = ['-define', 'heic:speed=1', '-quality', '50']

const MAGICK_HEIC_LOSSY_FLAGS = ['-strip', '-quality', '75']
const MAGICK_HEIC_LOSSLESS_FLAGS = ['-strip', '-quality', '100']
const MAGICK_HEIC_LOSSY_FLAGS = ['-quality', '75']
const MAGICK_HEIC_LOSSLESS_FLAGS = ['-quality', '100']

const MAGICK_JXL_FLAGS = ['-strip', '-define', 'jxl:effort=9', '-quality', '75']
const MAGICK_JXL_FLAGS = ['-define', 'jxl:effort=9', '-quality', '75']

const MAGICK_SVG_FLAGS = ['-strip']
const MAGICK_SVG_FLAGS = []

const MAGICK_GENERIC_FLAGS = ['-strip']
const MAGICK_GENERIC_FLAGS = []

const flagsByExt = ({ ext, losy = false }) => {
const flagsByExt = ({ ext, losy = false, preserveExif = false }) => {
if (ext === '.jpg' || ext === '.jpeg') return losy ? MAGICK_JPEG_LOSSY_FLAGS : MAGICK_JPEG_LOSSLESS_FLAGS
if (ext === '.png') return losy ? MAGICK_PNG_LOSSY_FLAGS : MAGICK_PNG_LOSSLESS_FLAGS
if (ext === '.png') return losy && !preserveExif ? MAGICK_PNG_LOSSY_FLAGS : MAGICK_PNG_LOSSLESS_FLAGS
if (ext === '.gif') return MAGICK_GIF_FLAGS
if (ext === '.webp') return losy ? MAGICK_WEBP_LOSSY_FLAGS : MAGICK_WEBP_LOSSLESS_FLAGS
if (ext === '.avif') return MAGICK_AVIF_FLAGS
Expand Down Expand Up @@ -169,9 +167,9 @@ const writePng = async ({ inputPath, outputPath, flags, resizeGeometry }) => {
}
}

const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false }) => {
const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false, preserveExif = false }) => {
const ext = path.extname(outputPath).toLowerCase()
const flags = flagsByExt({ ext, losy })
const flags = [...(preserveExif ? [] : ['-strip']), ...flagsByExt({ ext, losy, preserveExif })]

if (ext === '.png') {
return writePng({ inputPath, outputPath, flags, resizeGeometry })
Expand All @@ -180,7 +178,7 @@ const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false })
return $(binaryPath, [inputPath, ...(resizeGeometry ? ['-resize', resizeGeometry] : []), ...flags, outputPath])
}

const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false }) => {
const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false, preserveExif = false }) => {
const resultByScale = new Map()

const measureScale = async scale => {
Expand All @@ -193,7 +191,8 @@ const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false }) => {
inputPath,
outputPath: candidatePath,
resizeGeometry,
losy
losy,
preserveExif
})

const size = (await stat(candidatePath)).size
Expand Down Expand Up @@ -238,13 +237,14 @@ const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false }) => {
}
}

const run = async ({ inputPath, outputPath, resizeConfig, losy = false }) => {
const run = async ({ inputPath, outputPath, resizeConfig, losy = false, preserveExif = false }) => {
if (resizeConfig?.mode === 'max-size') {
return runMaxSize({
inputPath,
outputPath,
maxSize: resizeConfig.value,
losy
losy,
preserveExif
})
}

Expand All @@ -253,7 +253,8 @@ const run = async ({ inputPath, outputPath, resizeConfig, losy = false }) => {
inputPath,
outputPath,
resizeGeometry: resizeConfig?.value,
losy: false
losy: false,
preserveExif
})
}

Expand All @@ -263,12 +264,14 @@ const run = async ({ inputPath, outputPath, resizeConfig, losy = false }) => {
inputPath,
outputPath: lossyPath,
resizeGeometry: resizeConfig?.value,
losy: true
losy: true,
preserveExif
})
await runOnce({
inputPath: lossyPath,
outputPath,
losy: false
losy: false,
preserveExif
})
} finally {
try {
Expand Down
4 changes: 2 additions & 2 deletions src/compressor/mozjpegtran.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ const withMeta = (format, fn) => {
return wrapped
}

const run = async ({ inputPath, outputPath, losy = false }) =>
const run = async ({ inputPath, outputPath, preserveExif = false }) =>
$(mozjpegtranPath, [
'-copy',
losy ? 'none' : 'all',
preserveExif ? 'all' : 'none',
'-optimize',
'-progressive',
'-outfile',
Expand Down
21 changes: 13 additions & 8 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,16 @@ const toDataUrl = require('./util/to-data-url')
const formatLog = require('./util/format-log')
const debug = require('./util/debug')

const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute }) => {
const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute, preserveExif }) => {
const candidatePath = `${currentPath}.candidate${extension}`

await step({
inputPath: currentPath,
outputPath: candidatePath,
resizeConfig: null,
losy: false,
mute
mute,
preserveExif
})

const [currentSize, candidateSize] = await Promise.all([
Expand All @@ -38,23 +39,25 @@ const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute }) =
}
}

const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy, mute }) => {
const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy, mute, preserveExif }) => {
const extension = path.extname(optimizedPath) || '.tmp'

await pipeline[0]({
inputPath: filePath,
outputPath: optimizedPath,
resizeConfig,
losy,
mute
mute,
preserveExif
})

for (const step of pipeline.slice(1)) {
await runStepInPlaceIfSmaller({
currentPath: optimizedPath,
extension,
mute,
step: async args => step({ ...args, losy })
preserveExif,
step: async args => step({ ...args, losy, preserveExif })
})
}

Expand All @@ -63,7 +66,7 @@ const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig

const file = async (
filePath,
{ onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true, dataUrl = false } = {}
{ onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true, preserveExif = false, dataUrl = false } = {}
) => {
const outputPath = getOutputPath(filePath, outputFormat)
const resizeConfig = parseResize(resize)
Expand Down Expand Up @@ -105,7 +108,8 @@ const file = async (

const missingBinaries = ensureBinaries(
getRequiredBinaries(executionPipeline, {
losy
losy,
preserveExif
})
)

Expand Down Expand Up @@ -134,7 +138,8 @@ const file = async (
optimizedPath,
resizeConfig,
losy,
mute
mute,
preserveExif
})
}
} catch (error) {
Expand Down