Skip to content

Commit

Permalink
Merge pull request #10 from 343dev/improvement/refactoring
Browse files Browse the repository at this point in the history
Refactoring
  • Loading branch information
343dev authored Oct 3, 2024
2 parents 7d18d1a + dfb1bd9 commit 4eb7b90
Show file tree
Hide file tree
Showing 44 changed files with 1,037 additions and 890 deletions.
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
!lib/
!.optimiztrc.cjs
!cli.js
!convert.js
!index.js
!LICENSE
!optimize.js
!package*.json
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 9.0.0 (2024-10-03)

Changes:

- Improved image processing workflow. Check the [migration guide](./MIGRATION.md) for details.
- Added an interactive log for image processing.
- Changed the “File already exists” message from “error” to “info,” now only shown in verbose mode.
- Added a notice that animated AVIF is not supported (unfortunately).
- Fixed the progress indicator in conversion mode. It now correctly shows the total number of items.
- Fixed output to the user directory. The original folder structure is now preserved (i hope so!).

## 8.0.0 (2024-08-05)

❤️ Thank you for using Optimizt. If you have any suggestions or feedback, please don't hesitate to [open an issue](https://github.com/343dev/optimizt/issues).
Expand Down
11 changes: 11 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Migration

## 8.0.1 → 9.0.0

The main change in the new version is how Optimizt handles file processing. Before, the result was stored in memory until all files were processed. This could cause the app to crash if it ran out of memory, leading to a loss of optimization results. Now, each file is processed one by one, and the result is saved to disk immediately. Logging events also happen in real-time, instead of waiting until all files are done.

Previously, if you tried to create an AVIF or WebP file that already existed, an error message would appear in the log. Now, it shows an informational message, but only if you use the --verbose option.

To handle AVIF files, Optimizt uses the sharp module, which uses the libheif library. However, libheif doesn't support converting to animated AVIF (AVIS). In earlier versions, Optimizt would quietly convert animated GIFs into static AVIF. But from this version, an error will show up if you try to do this.

The logic for creating file structures when using the --output option has also changed. Now, the original file structure will be recreated inside the output folder, starting from the folder passed to the app. Who knows how this worked before, but hopefully, it works better now!


## 7.0.2 → 8.0.0

Optimizt now checks for the EXIF Orientation tag before processing images and rotates the resulting image accordingly.
Expand Down
16 changes: 14 additions & 2 deletions cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
import { program } from 'commander';

import optimizt from './index.js';
import { setProgramOptions } from './lib/program-options.js';

const dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(await fs.readFile(path.join(dirname, 'package.json')));
Expand All @@ -29,9 +30,20 @@ program
if (program.args.length === 0) {
program.help();
} else {
const { avif, webp, force, lossless, verbose, config, output } = program.opts();

setProgramOptions({
shouldConvertToAvif: Boolean(avif),
shouldConvertToWebp: Boolean(webp),
isForced: Boolean(force),
isLossless: Boolean(lossless),
isVerbose: Boolean(verbose),
});

optimizt({
paths: program.args,
...program.opts(),
inputPaths: program.args,
outputDirectoryPath: output,
configFilePath: config,
});
}

Expand Down
222 changes: 222 additions & 0 deletions convert.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import execBuffer from 'exec-buffer';
import gif2webp from 'gif2webp-bin';
import pLimit from 'p-limit';
import sharp from 'sharp';

import { calculateRatio } from './lib/calculate-ratio.js';
import { checkPathAccessibility } from './lib/check-path-accessibility.js';
import { createProgressBarContainer } from './lib/create-progress-bar-container.js';
import { SUPPORTED_FILE_TYPES } from './lib/constants.js';
import { formatBytes } from './lib/format-bytes.js';
import { getPlural } from './lib/get-plural.js';
import { getRelativePath } from './lib/get-relative-path.js';
import {
LOG_TYPES,
log,
logProgress,
logProgressVerbose,
} from './lib/log.js';
import { optionsToArguments } from './lib/options-to-arguments.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({ filePaths, config }) {
const {
isForced,
isLossless,
shouldConvertToAvif,
shouldConvertToWebp,
} = programOptions;

const filePathsCount = filePaths.length;

if (!filePathsCount) {
return;
}

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

const progressBarTotal = shouldConvertToAvif && shouldConvertToWebp
? filePathsCount * 2
: filePathsCount;
const progressBarContainer = createProgressBarContainer(progressBarTotal);
const progressBar = progressBarContainer.create(progressBarTotal, 0);

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

const avifConfig = isLossless
? config?.avif?.lossless
: config?.avif?.lossy;
const webpConfig = isLossless
? config?.webp?.lossless
: config?.webp?.lossy;
const webpGifConfig = isLossless
? config?.webpGif?.lossless
: config?.webpGif?.lossy;

const tasksSimultaneousLimit = pLimit(os.cpus().length);
const tasksPromises = filePaths.reduce((accumulator, filePath) => {
if (shouldConvertToAvif) {
accumulator.push(
tasksSimultaneousLimit(
() => processFile({
filePath,
config: avifConfig || {},
progressBarContainer,
progressBar,
totalSize,
isForced,
format: 'AVIF',
processFunction: processAvif,
}),
),
);
}

if (shouldConvertToWebp) {
accumulator.push(
tasksSimultaneousLimit(
() => processFile({
filePath,
config: (path.extname(filePath.input).toLowerCase() === '.gif'
? webpGifConfig
: webpConfig)
|| {},
progressBarContainer,
progressBar,
totalSize,
isForced,
format: 'WebP',
processFunction: processWebp,
}),
),
);
}

return accumulator;
}, []);

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

showTotal(totalSize.before, totalSize.after);
}

async function processFile({
filePath,
config,
progressBarContainer,
progressBar,
totalSize,
isForced,
format,
processFunction,
}) {
try {
const { dir, name } = path.parse(filePath.output);
const outputFilePath = path.join(dir, `${name}.${format.toLowerCase()}`);

const isAccessible = await checkPathAccessibility(outputFilePath);

if (!isForced && isAccessible) {
logProgressVerbose(getRelativePath(outputFilePath), {
description: `File already exists, '${outputFilePath}'`,
progressBarContainer,
});

return;
}

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

await fs.promises.mkdir(path.dirname(outputFilePath), { recursive: true });
await fs.promises.writeFile(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(filePath.input), {
type: LOG_TYPES.SUCCESS,
description: `${before}${format} ${after}. Ratio: ${ratio}%`,
progressBarContainer,
});
} catch (error) {
if (error.message) {
logProgress(getRelativePath(filePath.input), {
type: LOG_TYPES.ERROR,
description: (error.message || '').trim(),
progressBarContainer,
});
} else {
progressBarContainer.log(error);
}
} finally {
progressBar.increment();
}
}

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

const isAnimated = imageMetadata.pages > 1;

if (isAnimated) {
throw new Error('Animated AVIF is not supported'); // See: https://github.com/strukturag/libheif/issues/377
}

return sharp(fileBuffer)
.rotate() // Rotate image using information from EXIF Orientation tag
.avif(config)
.toBuffer();
}

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

if (imageMetadata.format === 'gif') {
return execBuffer({
bin: gif2webp,
args: [
...optionsToArguments({
options: config,
prefix: '-',
}),
execBuffer.input,
'-o',
execBuffer.output,
],
input: fileBuffer,
});
}

return sharp(fileBuffer)
.rotate() // Rotate image using information from EXIF Orientation tag
.webp(config)
.toBuffer();
}

function checkImageFormat(imageFormat) {
if (!imageFormat) {
throw new Error('Unknown file format');
}

if (!SUPPORTED_FILE_TYPES.CONVERT.includes(imageFormat)) {
throw new Error(`Unsupported image format: "${imageFormat}"`);
}
}
80 changes: 52 additions & 28 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,57 @@
import { pathToFileURL } from 'node:url';

import checkConfigPath from './lib/check-config-path.js';
import convert from './lib/convert.js';
import findConfig from './lib/find-config.js';
import { enableVerbose } from './lib/log.js';
import optimize from './lib/optimize.js';
import prepareFilePaths from './lib/prepare-file-paths.js';
import prepareOutputPath from './lib/prepare-output-path.js';

export default async function optimizt({ paths, avif, webp, force, lossless, verbose, output, config }) {
const configFilepath = pathToFileURL(config ? checkConfigPath(config) : findConfig());
const configData = await import(configFilepath);

if (verbose) {
enableVerbose();
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 { prepareFilePaths } from './lib/prepare-file-paths.js';
import { prepareOutputDirectoryPath } from './lib/prepare-output-directory-path.js';
import { programOptions } from './lib/program-options.js';

const MODE_NAME = {
CONVERT: 'convert',
OPTIMIZE: 'optimize',
};

export default async function optimizt({
inputPaths,
outputDirectoryPath,
configFilePath,
}) {
const {
isLossless,
shouldConvertToAvif,
shouldConvertToWebp,
} = programOptions;

const shouldConvert = shouldConvertToAvif || shouldConvertToWebp;

const currentMode = shouldConvert
? MODE_NAME.CONVERT
: MODE_NAME.OPTIMIZE;

const foundConfigFilePath = pathToFileURL(await findConfigFilePath(configFilePath));
const configData = await import(foundConfigFilePath);
const config = configData.default[currentMode.toLowerCase()];

const filePaths = await prepareFilePaths({
inputPaths,
outputDirectoryPath: await prepareOutputDirectoryPath(outputDirectoryPath),
extensions: SUPPORTED_FILE_TYPES[currentMode.toUpperCase()],
});

if (isLossless) {
log('Lossless optimization may take a long time');
}

await (avif || webp ? convert({
paths: prepareFilePaths(paths, ['gif', 'jpeg', 'jpg', 'png']),
lossless,
avif,
webp,
force,
output: prepareOutputPath(output),
config: configData.default.convert,
}) : optimize({
paths: prepareFilePaths(paths, ['gif', 'jpeg', 'jpg', 'png', 'svg']),
lossless,
output: prepareOutputPath(output),
config: configData.default.optimize,
}));
const processFunction = shouldConvert
? convert
: optimize;

await processFunction({
filePaths,
config,
});
}
2 changes: 1 addition & 1 deletion lib/calc-ratio.js → lib/calculate-ratio.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default function calcRatio(from, to) {
export function calculateRatio(from, to) {
return Math.round((from - to) / from * 100);
}
Loading

0 comments on commit 4eb7b90

Please sign in to comment.