From 51a5ed5f3e62b2ee9dd22434345d4973f76d3ecc Mon Sep 17 00:00:00 2001 From: Kiko Beats Date: Sun, 22 Mar 2026 17:29:46 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20losy=20=E2=86=92=20lossy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- bin/help.js | 4 ++-- bin/index.js | 13 +++++++------ src/compressor/ffmpeg.js | 28 ++++++++++++++-------------- src/compressor/gifsicle.js | 4 ++-- src/compressor/magick.js | 30 +++++++++++++++--------------- src/compressor/svgo.js | 4 ++-- src/index.js | 18 ++++++++++-------- 8 files changed, 56 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 4833c2a..96e0e98 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ ```bash npx -y optimo public/media # for a directory npx -y optimo public/media/banner.png # for a file -npx -y optimo public/media/banner.png --losy # enable lossy + lossless mode +npx -y optimo public/media/banner.png --lossy # enable lossy + lossless mode npx -y optimo public/media/banner.png --format jpeg # convert + optimize 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 @@ -52,7 +52,7 @@ When `optimo` is executed, a pipeline of compressors is chosen based on the outp Mode behavior: - default: lossless-first pipeline. -- `-l, --losy`: lossy + lossless pass per matching compressor. +- `-l, --lossy`: 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). @@ -75,7 +75,7 @@ const optimo = require('optimo') // optimize a single file await optimo.file('/absolute/path/image.jpg', { dryRun: false, - losy: false, + lossy: false, preserveExif: false, format: 'webp', resize: '50%', @@ -93,7 +93,7 @@ await optimo.file('/absolute/path/image.jpg', { }) await optimo.file('/absolute/path/video.mp4', { - losy: true, + lossy: true, // mute defaults to true for videos; set false to keep audio mute: false, format: 'webm', diff --git a/bin/help.js b/bin/help.js index 94f53f1..a408669 100644 --- a/bin/help.js +++ b/bin/help.js @@ -8,7 +8,7 @@ Usage $ ${blue('optimo')} [options] Options - -l, --losy Enable lossy + lossless passes (default: false) + -l, --lossy 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) @@ -20,7 +20,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 image.jpg --lossy # 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 diff --git a/bin/index.js b/bin/index.js index dbca6bb..966048c 100755 --- a/bin/index.js +++ b/bin/index.js @@ -13,7 +13,6 @@ 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), { @@ -21,20 +20,22 @@ async function main () { 'data-url': 'u', 'dry-run': 'd', format: 'f', + lossy: 'l', losy: 'l', mute: 'm', 'preserve-exif': 'p', resize: 'r', silent: 's', verbose: 'v' - } + }, + boolean: ['lossy', 'losy', 'mute', 'preserve-exif'] }) const input = argv._[0] const dataUrl = argv['data-url'] === true - 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']) + const lossy = argv.lossy === undefined ? argv.losy : argv.lossy + const mute = argv.mute === undefined ? true : argv.mute + const preserveExif = argv['preserve-exif'] === undefined ? false : argv['preserve-exif'] let resize = argv.resize if (resize !== undefined && resize !== null) { @@ -61,7 +62,7 @@ async function main () { !argv.silent && console.error() const result = await fn(input, { - losy: argv.losy, + lossy, mute, preserveExif, dryRun: argv['dry-run'], diff --git a/src/compressor/ffmpeg.js b/src/compressor/ffmpeg.js index 5f328e6..f059830 100644 --- a/src/compressor/ffmpeg.js +++ b/src/compressor/ffmpeg.js @@ -67,12 +67,12 @@ const getScaleFilter = resizeConfig => { * - `-an`: drop audio entirely from output. * - `-movflags +faststart`: move MP4 metadata (moov atom) to file start for faster streaming start. * - * `losy=true` intentionally picks more aggressive values for smaller files. + * `lossy=true` intentionally picks more aggressive values for smaller files. * - * @param {{ ext: string, losy?: boolean, mute?: boolean }} params + * @param {{ ext: string, lossy?: boolean, mute?: boolean }} params * @returns {string[]} */ -const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { +const getCodecArgsByExt = ({ ext, lossy = false, mute = true }) => { if (ext === '.webm') { return [ '-c:v', @@ -80,7 +80,7 @@ const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { '-b:v', '0', '-crf', - losy ? '35' : '31', + lossy ? '35' : '31', '-row-mt', '1', '-tile-columns', @@ -90,10 +90,10 @@ const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { '-deadline', 'good', '-cpu-used', - losy ? '2' : '1', + lossy ? '2' : '1', '-pix_fmt', 'yuv420p', - ...(mute ? [] : ['-c:a', 'libopus', '-b:a', losy ? '64k' : '96k']) + ...(mute ? [] : ['-c:a', 'libopus', '-b:a', lossy ? '64k' : '96k']) ] } @@ -102,8 +102,8 @@ const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { '-c:v', 'libtheora', '-q:v', - losy ? '4' : '6', - ...(mute ? [] : ['-c:a', 'libvorbis', '-q:a', losy ? '3' : '4']) + lossy ? '4' : '6', + ...(mute ? [] : ['-c:a', 'libvorbis', '-q:a', lossy ? '3' : '4']) ] } @@ -111,12 +111,12 @@ const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { '-c:v', 'libx264', '-preset', - losy ? 'medium' : 'slow', + lossy ? 'medium' : 'slow', '-crf', - losy ? '28' : '23', + lossy ? '28' : '23', '-pix_fmt', 'yuv420p', - ...(mute ? [] : ['-c:a', 'aac', '-b:a', losy ? '96k' : '128k']), + ...(mute ? [] : ['-c:a', 'aac', '-b:a', lossy ? '96k' : '128k']), ...(ext === '.mp4' || ext === '.m4v' || ext === '.mov' ? ['-movflags', '+faststart'] : []) @@ -134,14 +134,14 @@ const getCodecArgsByExt = ({ ext, losy = false, mute = true }) => { * inputPath: string, * outputPath: string, * resizeConfig?: { mode: string, value: string | number } | null, - * losy?: boolean, + * lossy?: boolean, * mute?: boolean * }} params */ -const run = async ({ inputPath, outputPath, resizeConfig, losy = false, mute = true }) => { +const run = async ({ inputPath, outputPath, resizeConfig, lossy = false, mute = true }) => { const ext = path.extname(outputPath).toLowerCase() const scaleFilter = getScaleFilter(resizeConfig) - const codecArgs = getCodecArgsByExt({ ext, losy, mute }) + const codecArgs = getCodecArgsByExt({ ext, lossy, mute }) await $(binaryPath, [ '-v', diff --git a/src/compressor/gifsicle.js b/src/compressor/gifsicle.js index 7aca15a..e19d92f 100644 --- a/src/compressor/gifsicle.js +++ b/src/compressor/gifsicle.js @@ -21,8 +21,8 @@ const runLossless = async ({ inputPath, outputPath }) => const runLossy = async ({ inputPath, outputPath }) => $(binaryPath, ['-O3', '--lossy=80', inputPath, '-o', outputPath]) -const gif = withMeta('gif', async ({ inputPath, outputPath, losy = false }) => { - if (!losy) return runLossless({ inputPath, outputPath }) +const gif = withMeta('gif', async ({ inputPath, outputPath, lossy = false }) => { + if (!lossy) return runLossless({ inputPath, outputPath }) const lossyPath = `${outputPath}.lossy.gif` try { diff --git a/src/compressor/magick.js b/src/compressor/magick.js index e3f6974..5db9d21 100644 --- a/src/compressor/magick.js +++ b/src/compressor/magick.js @@ -86,13 +86,13 @@ const MAGICK_SVG_FLAGS = [] const MAGICK_GENERIC_FLAGS = [] -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 && !preserveExif ? MAGICK_PNG_LOSSY_FLAGS : MAGICK_PNG_LOSSLESS_FLAGS +const flagsByExt = ({ ext, lossy = false, preserveExif = false }) => { + if (ext === '.jpg' || ext === '.jpeg') return lossy ? MAGICK_JPEG_LOSSY_FLAGS : MAGICK_JPEG_LOSSLESS_FLAGS + if (ext === '.png') return lossy && !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 === '.webp') return lossy ? MAGICK_WEBP_LOSSY_FLAGS : MAGICK_WEBP_LOSSLESS_FLAGS if (ext === '.avif') return MAGICK_AVIF_FLAGS - if (ext === '.heic' || ext === '.heif') return losy ? MAGICK_HEIC_LOSSY_FLAGS : MAGICK_HEIC_LOSSLESS_FLAGS + if (ext === '.heic' || ext === '.heif') return lossy ? MAGICK_HEIC_LOSSY_FLAGS : MAGICK_HEIC_LOSSLESS_FLAGS if (ext === '.jxl') return MAGICK_JXL_FLAGS if (ext === '.svg') return MAGICK_SVG_FLAGS return MAGICK_GENERIC_FLAGS @@ -167,9 +167,9 @@ const writePng = async ({ inputPath, outputPath, flags, resizeGeometry }) => { } } -const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false, preserveExif = false }) => { +const runOnce = async ({ inputPath, outputPath, resizeGeometry, lossy = false, preserveExif = false }) => { const ext = path.extname(outputPath).toLowerCase() - const flags = [...(preserveExif ? [] : ['-strip']), ...flagsByExt({ ext, losy, preserveExif })] + const flags = [...(preserveExif ? [] : ['-strip']), ...flagsByExt({ ext, lossy, preserveExif })] if (ext === '.png') { return writePng({ inputPath, outputPath, flags, resizeGeometry }) @@ -178,7 +178,7 @@ const runOnce = async ({ inputPath, outputPath, resizeGeometry, losy = false, pr return $(binaryPath, [inputPath, ...(resizeGeometry ? ['-resize', resizeGeometry] : []), ...flags, outputPath]) } -const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false, preserveExif = false }) => { +const runMaxSize = async ({ inputPath, outputPath, maxSize, lossy = false, preserveExif = false }) => { const resultByScale = new Map() const measureScale = async scale => { @@ -191,7 +191,7 @@ const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false, preser inputPath, outputPath: candidatePath, resizeGeometry, - losy, + lossy, preserveExif }) @@ -237,23 +237,23 @@ const runMaxSize = async ({ inputPath, outputPath, maxSize, losy = false, preser } } -const run = async ({ inputPath, outputPath, resizeConfig, losy = false, preserveExif = false }) => { +const run = async ({ inputPath, outputPath, resizeConfig, lossy = false, preserveExif = false }) => { if (resizeConfig?.mode === 'max-size') { return runMaxSize({ inputPath, outputPath, maxSize: resizeConfig.value, - losy, + lossy, preserveExif }) } - if (!losy) { + if (!lossy) { return runOnce({ inputPath, outputPath, resizeGeometry: resizeConfig?.value, - losy: false, + lossy: false, preserveExif }) } @@ -264,13 +264,13 @@ const run = async ({ inputPath, outputPath, resizeConfig, losy = false, preserve inputPath, outputPath: lossyPath, resizeGeometry: resizeConfig?.value, - losy: true, + lossy: true, preserveExif }) await runOnce({ inputPath: lossyPath, outputPath, - losy: false, + lossy: false, preserveExif }) } finally { diff --git a/src/compressor/svgo.js b/src/compressor/svgo.js index 5380c95..fdea824 100644 --- a/src/compressor/svgo.js +++ b/src/compressor/svgo.js @@ -49,8 +49,8 @@ const run = async ({ inputPath, outputPath, extraPlugins }) => { } } -const svg = withMeta('svg', async ({ inputPath, outputPath, losy = false }) => { - if (!losy) return run({ inputPath, outputPath, extraPlugins: EXTRA_COMMON_PLUGINS }) +const svg = withMeta('svg', async ({ inputPath, outputPath, lossy = false }) => { + if (!lossy) return run({ inputPath, outputPath, extraPlugins: EXTRA_COMMON_PLUGINS }) const lossyPath = `${outputPath}.lossy.svg` try { diff --git a/src/index.js b/src/index.js index 8778ae3..ae3dda1 100644 --- a/src/index.js +++ b/src/index.js @@ -21,7 +21,7 @@ const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute, pre inputPath: currentPath, outputPath: candidatePath, resizeConfig: null, - losy: false, + lossy: false, mute, preserveExif }) @@ -39,14 +39,14 @@ const runStepInPlaceIfSmaller = async ({ currentPath, extension, step, mute, pre } } -const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, losy, mute, preserveExif }) => { +const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig, lossy, mute, preserveExif }) => { const extension = path.extname(optimizedPath) || '.tmp' await pipeline[0]({ inputPath: filePath, outputPath: optimizedPath, resizeConfig, - losy, + lossy, mute, preserveExif }) @@ -57,7 +57,7 @@ const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig extension, mute, preserveExif, - step: async args => step({ ...args, losy, preserveExif }) + step: async args => step({ ...args, lossy, preserveExif }) }) } @@ -66,8 +66,10 @@ const executePipeline = async ({ pipeline, filePath, optimizedPath, resizeConfig const file = async ( filePath, - { onLogs = () => {}, dryRun, format: outputFormat, resize, losy = false, mute = true, preserveExif = false, dataUrl = false } = {} + { onLogs = () => {}, dryRun, format: outputFormat, resize, lossy, losy, mute = true, preserveExif = false, dataUrl = false } = {} ) => { + // Keep backward compatibility with the old `losy` option key. + const lossyMode = lossy === undefined ? (losy === undefined ? false : losy) : lossy const outputPath = getOutputPath(filePath, outputFormat) const resizeConfig = parseResize(resize) const mediaKind = getMediaKind(outputPath) @@ -77,7 +79,7 @@ const file = async ( const outputExt = path.extname(outputPath).toLowerCase() const canUseJpegLosslessFastPath = - !losy && + !lossyMode && !resizeConfig && !isConverting && (outputExt === '.jpg' || outputExt === '.jpeg') && @@ -108,7 +110,7 @@ const file = async ( const missingBinaries = ensureBinaries( getRequiredBinaries(executionPipeline, { - losy, + lossy: lossyMode, preserveExif }) ) @@ -137,7 +139,7 @@ const file = async ( filePath, optimizedPath, resizeConfig, - losy, + lossy: lossyMode, mute, preserveExif })