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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build": "npm run build --workspace @343dev/optimizt",
"check": "npm run build && npm run lint && npm test && npm run license-check",
"lint": "npm run lint --workspaces --if-present",
"test": "npm test --workspaces --if-present",
"test": "npm test --workspace @343dev/optimizt && npm test --workspace @343dev/optimizt-gifsicle && npm test --workspace @343dev/optimizt-guetzli && npm test --workspace @343dev/optimizt-sharp",
"license-check": "npx license-compliance@3.0.1 --report detailed --allow \"0BSD;Apache-2.0;Artistic-2.0;BlueOak-1.0.0;BSD-2-Clause;BSD-3-Clause;BSD-4-Clause;CC0-1.0;CC-BY-4.0;ISC;LGPL-2.1-or-later;LGPL-3.0-or-later;MIT;MPL-1.1;MPL-2.0;Python-2.0;Unlicense;W3C;W3C-20150513;X11;Zlib\"",
"provenance:setup": "npm run setup-emsdk --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run setup-emsdk --workspace @343dev/optimizt-gifsicle",
"provenance:verify": "EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:upstream --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:dist --workspace @343dev/optimizt-guetzli && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:upstream --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:patch --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:dist --workspace @343dev/optimizt-gifsicle && EMSDK_INSTALL_DIR=$PWD/packages/optimizt-guetzli/.cache/emsdk npm run verify:parity --workspace @343dev/optimizt-gifsicle"
Expand Down
6 changes: 6 additions & 0 deletions packages/optimizt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Independent operations continue after a runtime failure and keep their successful outputs, while the invocation still exits with status `1`.
- Redirected output no longer contains animated progress or terminal decoration. Progress totals count generated outputs, and summaries distinguish optimization savings from bytes created by conversion.
- Equivalent operands and inputs are processed once. Symbolic links retain their documented placement and replacement behavior.
- Lossless JPEG scheduling now uses the detected image format instead of the file extension. Renamed JPEG files retain Guetzli's single-operation limit.
- Interrupt handling now stops new work, terminates cancellable encoders, and reports conventional `SIGINT` and `SIGTERM` exit statuses on POSIX.
- After an interruption, the summary reports completed operations separately. It does not classify abandoned operations as skipped or failed.

### Fixed

- Guetzli errors now preserve up to 64 KiB of encoder diagnostics. Messages distinguish startup failures, write failures, exit codes, and termination signals.

## [13.0.0](https://github.com/343dev/optimizt/releases/tag/v13.0.0) - 2026-06-27

Expand Down
50 changes: 20 additions & 30 deletions packages/optimizt/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { fileURLToPath } from 'node:url';
import { Command, CommanderError } from 'commander';

import optimizt from './index.js';
import { finishLifecycle, installSignalHandlers } from './lib/lifecycle.js';
import { setProgramOptions } from './lib/program-options.js';
import { createLifecycle } from './lib/lifecycle.js';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(await fs.readFile(path.join(dirname, 'package.json'), 'utf8'));
Expand All @@ -27,47 +26,38 @@ program
.option('-s, --suffix <text>', 'add a suffix to output file names')
.option('--no-color', 'disable color output')
.option('--debug', 'include stack traces and version details in errors')
.allowExcessArguments()
.usage('[options] [--] <dir|file ...>')
.allowExcessArguments().usage('[options] [--] <dir|file ...>')
.version(packageJson.version, '-V, --version')
.description(`${packageJson.description}.`)
.exitOverride();

let exitCode = 0;
installSignalHandlers();
const lifecycle = createLifecycle();
const releaseSignals = lifecycle.install();
try {
program.parse(process.argv);
if (program.args.length === 0) {
program.outputHelp();
} else {
const options = program.opts();
if (options.force && !options.avif && !options.webp) throw new Error('--force requires --avif or --webp');
setProgramOptions({
filePrefix: options.prefix || '',
fileSuffix: options.suffix || '',
isForced: Boolean(options.force),
isLossless: Boolean(options.lossless),
isVerbose: Boolean(options.verbose),
shouldConvertToAvif: Boolean(options.avif),
shouldConvertToWebp: Boolean(options.webp),
});
if (options.color === false) process.env.NO_COLOR = '1';
const result = await optimizt({
configFilePath: options.config,
inputPaths: program.args,
outputDirectoryPath: options.output,
if (program.args.length === 0) program.outputHelp();
else {
const parsed = program.opts();
if (parsed.force && !parsed.avif && !parsed.webp) throw new Error('--force requires --avif or --webp');
const options = Object.freeze({
filePrefix: parsed.prefix || '', fileSuffix: parsed.suffix || '', isForced: Boolean(parsed.force),
isLossless: Boolean(parsed.lossless), isVerbose: Boolean(parsed.verbose),
shouldConvertToAvif: Boolean(parsed.avif), shouldConvertToWebp: Boolean(parsed.webp),
shouldUseColor: parsed.color !== false && !process.env.NO_COLOR,
});
const result = await optimizt({ configFilePath: parsed.config, inputPaths: program.args, lifecycle, options, outputDirectoryPath: parsed.output });
exitCode = result.failed > 0 ? 1 : 0;
}
} catch (error) {
if (error instanceof CommanderError && ['commander.helpDisplayed', 'commander.version'].includes(error.code)) {
exitCode = 0;
} else {
if (error instanceof CommanderError && ['commander.helpDisplayed', 'commander.version'].includes(error.code)) exitCode = 0;
else {
exitCode = 1;
const debug = program.opts().debug;
const message = debug && error.stack ? error.stack : error.message;
process.stderr.write(`Error: ${message}\n`);
process.stderr.write(`Error: ${debug && error.stack ? error.stack : error.message}\n`);
if (debug) process.stderr.write(`Optimizt ${packageJson.version}; Node.js ${process.version}\n`);
}
} finally {
releaseSignals();
}
process.exitCode = finishLifecycle() ?? exitCode;
process.exitCode = lifecycle.finish() ?? exitCode;
199 changes: 33 additions & 166 deletions packages/optimizt/convert.js
Original file line number Diff line number Diff line change
@@ -1,176 +1,43 @@
import fs from 'node:fs';
import os from 'node:os';

import pLimit from 'p-limit';
import fs from 'node:fs/promises';

import { atomicWrite } from './lib/atomic-write.js';
import { calculateRatio } from './lib/calculate-ratio.js';
import { createProgressBarContainer } from './lib/create-progress-bar-container.js';
import { SUPPORTED_FILE_TYPES } from './lib/constants.js';
import { describeCodecFailure } from './lib/describe-codec-failure.js';
import { formatBytes } from './lib/format-bytes.js';
import { getPlural } from './lib/get-plural.js';
import { getRelativePath } from './lib/get-relative-path.js';
import { isInterrupted } from './lib/lifecycle.js';
import sharp from './lib/sharp.js';
import {
LOG_TYPES,
log,
logProgress,
logProgressVerbose,
} from './lib/log.js';
import { OUTCOME_STATUS } from './lib/outcome-status.js';
import { createOperationRuntime } from './lib/operation-runtime.js';
import { parseImageMetadata } from './lib/parse-image-metadata.js';
import { programOptions } from './lib/program-options.js';
import { showTotal } from './lib/show-total.js';

export async function convert({ operations, config, configPath }) {
const { isLossless } = programOptions;
const filePathsCount = new Set(operations.map(operation => operation.input)).size;

if (!filePathsCount) {
return;
}

log(`Converting ${filePathsCount} ${getPlural(filePathsCount, 'image', 'images')} (${isLossless ? 'lossless' : 'lossy'})`);

const progressBarTotal = operations.length;
const progressBarContainer = createProgressBarContainer(progressBarTotal);
const progressBar = progressBarContainer.create(progressBarTotal, 0);

const totalSize = { before: 0, after: 0 };

const getConfig = format => config?.[format]?.[isLossless ? 'lossless' : 'lossy'];

const avifConfig = getConfig('avif');
const webpConfig = getConfig('webp');

const cpuCount = os.cpus().length;
const tasksSimultaneousLimit = pLimit(cpuCount);

const outcomes = await Promise.all(operations.map((operation, planIndex) => tasksSimultaneousLimit(() => {
if (isInterrupted()) return { planIndex, status: OUTCOME_STATUS.UNSTARTED };
const isAvif = operation.format === 'avif';
return processFile({
config: (isAvif ? avifConfig : webpConfig) || {},
configPath,
filePath: { input: operation.input, output: operation.output },
format: isAvif ? 'AVIF' : 'WebP',
isLossless,
processFunction: isAvif ? processAvif : processWebp,
progressBar,
progressBarContainer,
planIndex,
skipReason: operation.skipReason,
totalSize,
});
})));

progressBarContainer.update(); // Prevent logs lost. See: https://github.com/npkgz/cli-progress/issues/145#issuecomment-1859594159
progressBarContainer.stop();

showTotal(totalSize.before, totalSize.after, outcomes, { conversion: true });
return { failed: outcomes.filter(outcome => outcome.status === OUTCOME_STATUS.FAILED).length, outcomes };
}

async function processFile({
filePath,
config,
configPath,
isLossless,
progressBarContainer,
progressBar,
planIndex,
totalSize,
skipReason,
format,
processFunction,
}) {
const outputFilePath = filePath.output;

try {
if (skipReason) {
logProgressVerbose(getRelativePath(outputFilePath), {
description: `Output file already exists: ${outputFilePath}. Use --force to replace it.`,
progressBarContainer,
});
import sharp from './lib/sharp.js';

return { planIndex, status: OUTCOME_STATUS.SKIPPED };
export async function convert({ operations, config, configPath, lifecycle, options, reporter }) {
const runtime = createOperationRuntime({
detect: (_operation, input) => parseImageMetadata(input),
lifecycle,
read: operation => fs.readFile(operation.input),
writer: { write: atomicWrite },
});
return runtime.execute({
onCompleted: reporter.operationCompleted,
operations,
policy: {
complete({ encoded, input }) {
return { after: encoded.length, before: input.length, ratio: calculateRatio(input.length, encoded.length), write: true };
},
},
selectCodec(metadata, operation) {
if (!metadata.format) throw new Error('Unable to read the image format. Check that the file is a valid, supported image.');
if (!SUPPORTED_FILE_TYPES.CONVERT.includes(metadata.format)) throw new Error(`Unsupported image format: "${metadata.format}"`);
return { encode: input => encode(input, metadata, operation.format) };
},
});

async function encode(input, metadata, format) {
if (format === 'avif' && metadata.pages > 1) throw new Error('Unable to create an animated AVIF. Use WebP or provide a non-animated image.');
const codecConfig = config?.[format]?.[options.isLossless ? 'lossless' : 'lossy'] || {};
try {
const image = sharp(input, { animated: format === 'webp' && metadata.pages > 1 }).rotate();
return await image[format](codecConfig).toBuffer();
} catch (error) {
throw describeCodecFailure({ configPath, error, format, isLossless: options.isLossless, mode: 'convert' });
}

const fileBuffer = await fs.promises.readFile(filePath.input);
const processedFileBuffer = await processFunction({ fileBuffer, config, configPath, isLossless });

await atomicWrite(outputFilePath, processedFileBuffer);

const fileSize = fileBuffer.length;
const processedFileSize = processedFileBuffer.length;

totalSize.before += fileSize;
totalSize.after += Math.min(fileSize, processedFileSize);

const ratio = calculateRatio(fileSize, processedFileSize);
const before = formatBytes(fileSize);
const after = formatBytes(processedFileSize);

logProgress(getRelativePath(outputFilePath), {
type: LOG_TYPES.SUCCESS,
description: `${before} → ${format} ${after}. Ratio: ${ratio}%`,
progressBarContainer,
});
return { after: processedFileSize, before: fileSize, planIndex, status: OUTCOME_STATUS.PROCESSED };
} catch (error) {
// Work abandoned during shutdown is left undone by the interruption, not failed.
if (isInterrupted()) return { planIndex, status: OUTCOME_STATUS.UNSTARTED };
return { error, output: outputFilePath, planIndex, status: OUTCOME_STATUS.FAILED };
} finally {
progressBar.increment();
}
}

async function processAvif({ fileBuffer, config, configPath, isLossless }) {
const imageMetadata = await parseImageMetadata(fileBuffer);
checkImageFormat(imageMetadata.format);

const isAnimated = imageMetadata.pages > 1;

if (isAnimated) {
throw new Error('Unable to create an animated AVIF. Use WebP or provide a non-animated image.'); // See: https://github.com/strukturag/libheif/issues/377
}

// Only the codec call is enriched, so detection and support errors keep speaking for themselves.
try {
return await sharp(fileBuffer)
.rotate() // Rotate image using information from EXIF Orientation tag
.avif(config)
.toBuffer();
} catch (error) {
throw describeCodecFailure({ configPath, error, format: 'avif', isLossless, mode: 'convert' });
}
}

async function processWebp({ fileBuffer, config, configPath, isLossless }) {
const imageMetadata = await parseImageMetadata(fileBuffer);
checkImageFormat(imageMetadata.format);

const isAnimated = imageMetadata.pages > 1;

try {
return await sharp(fileBuffer, { animated: isAnimated })
.rotate() // Rotate image using information from EXIF Orientation tag
.webp(config)
.toBuffer();
} catch (error) {
throw describeCodecFailure({ configPath, error, format: 'webp', isLossless, mode: 'convert' });
}
}

function checkImageFormat(imageFormat) {
if (!imageFormat) {
throw new Error('Unable to read the image format. Check that the file is a valid, supported image.');
}

if (!SUPPORTED_FILE_TYPES.CONVERT.includes(imageFormat)) {
throw new Error(`Unsupported image format: "${imageFormat}"`);
}
}
47 changes: 23 additions & 24 deletions packages/optimizt/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,26 @@ import { convert } from './convert.js';
import { optimize } from './optimize.js';
import { SUPPORTED_FILE_TYPES } from './lib/constants.js';
import { findConfigFilePath } from './lib/find-config-file-path.js';
import { log } from './lib/log.js';
import { createLog } from './lib/log.js';
import { prepareOperationPlan } from './lib/prepare-operation-plan.js';
import { programOptions } from './lib/program-options.js';
import { createReporter } from './lib/reporter.js';

export default async function optimizt({ inputPaths, outputDirectoryPath, configFilePath }) {
const { isForced, isLossless, filePrefix, fileSuffix, shouldConvertToAvif, shouldConvertToWebp } = programOptions;
const formats = [
...shouldConvertToAvif ? ['avif'] : [],
...shouldConvertToWebp ? ['webp'] : [],
];
const currentMode = formats.length > 0 ? 'convert' : 'optimize';
const operations = await prepareOperationPlan({
extensions: SUPPORTED_FILE_TYPES[currentMode.toUpperCase()],
force: isForced,
export default async function optimizt({ inputPaths, outputDirectoryPath, configFilePath, lifecycle, options }) {
const { log } = createLog({ shouldUseColor: options.shouldUseColor });
const formats = [...options.shouldConvertToAvif ? ['avif'] : [], ...options.shouldConvertToWebp ? ['webp'] : []];
const mode = formats.length > 0 ? 'convert' : 'optimize';
const plan = await prepareOperationPlan({
extensions: SUPPORTED_FILE_TYPES[mode.toUpperCase()],
force: options.isForced,
formats: formats.length > 0 ? formats : ['optimize'],
inputPaths,
outputDirectoryPath,
prefix: filePrefix,
suffix: fileSuffix,
prefix: options.filePrefix,
suffix: options.fileSuffix,
});

if (operations.length === 0) {
if (plan.operations.length === 0) {
log('No eligible images found');
return { failed: 0 };
return { failed: 0, interrupted: false };
}

const configPath = await findConfigFilePath(configFilePath);
Expand All @@ -37,12 +33,15 @@ export default async function optimizt({ inputPaths, outputDirectoryPath, config
} catch (error) {
throw new Error(`Unable to load configuration file ${configPath}: ${error.message}`, { cause: error });
}
const config = configData.default?.[currentMode];
if (!config || typeof config !== 'object' || Array.isArray(config)) {
throw new Error(`Unable to use configuration file ${configPath}. Define "${currentMode}" as an object.`);
}
const config = configData.default?.[mode];
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error(`Unable to use configuration file ${configPath}. Define "${mode}" as an object.`);

if (isLossless) log('Lossless mode may take a long time; JPEG uses Guetzli and is not strictly lossless');
const processOperations = currentMode === 'convert' ? convert : optimize;
return processOperations({ config, configPath, operations });
const reporter = createReporter({ isLossless: options.isLossless, isVerbose: options.isVerbose, mode, operations: plan.operations, shouldUseColor: options.shouldUseColor });
for (const notice of plan.notices) reporter.notice(notice);
if (options.isLossless) log('Lossless mode may take a long time; JPEG uses Guetzli and is not strictly lossless');
reporter.start();
const execute = mode === 'convert' ? convert : optimize;
const result = await execute({ config, configPath, lifecycle, operations: plan.operations, options, reporter });
reporter.finish(result);
return result;
}
6 changes: 5 additions & 1 deletion packages/optimizt/lib/colorize.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { canUseColor } from './stream-capabilities.js';

export function colorize(...arguments_) {
return colorizeFor(undefined, ...arguments_);
}

export function colorizeFor(shouldUseColor, ...arguments_) {
const string_ = arguments_.join(' ');
const shouldColor = canUseColor();
const shouldColor = canUseColor(shouldUseColor);
const buildColor = (start, end) => `${shouldColor ? start : ''}${string_}${shouldColor ? end : ''}`;

return {
Expand Down
Loading
Loading