diff --git a/README.md b/README.md index 1e3d6a6..9eaf91b 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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). @@ -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') diff --git a/bin/help.js b/bin/help.js index 7edda57..1bcad63 100644 --- a/bin/help.js +++ b/bin/help.js @@ -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 `) diff --git a/bin/index.js b/bin/index.js index aa3500d..1f98f59 100755 --- a/bin/index.js +++ b/bin/index.js @@ -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', @@ -19,6 +20,7 @@ async function main () { }) const input = argv._[0] + const dataUrl = argv['data-url'] === true const mute = argv.mute === undefined ? true @@ -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) } diff --git a/src/index.js b/src/index.js index 6b856de..fe8fb30 100644 --- a/src/index.js +++ b/src/index.js @@ -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 }) => { @@ -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) @@ -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') @@ -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) { @@ -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 diff --git a/src/util/to-data-url.js b/src/util/to-data-url.js new file mode 100644 index 0000000..2cbdadb --- /dev/null +++ b/src/util/to-data-url.js @@ -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')}` +} diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..52c0270 --- /dev/null +++ b/test/index.js @@ -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.') +}) diff --git a/test/util/to-data-url.js b/test/util/to-data-url.js new file mode 100644 index 0000000..d14c7b5 --- /dev/null +++ b/test/util/to-data-url.js @@ -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,')) +})