Files
WRNexusJS/packages/image/src/optimize.ts
T
Clintchiz 586a6db8ff
Quality / quality (ubuntu-latest) (push) Failing after 21s
Quality / quality (windows-latest) (push) Canceled after 0s
release: WRNexusJS 0.8.0
2026-08-02 23:18:51 +05:30

113 lines
3.7 KiB
TypeScript

import { mkdirSync, statSync, writeFileSync } from "node:fs";
import { basename, extname, join, resolve } from "node:path";
import { normalizeImageWidths, type ImageFormat } from "./index.ts";
export interface ImageProcessorResult {
data: Uint8Array;
width: number;
height: number;
}
export interface ImageProcessor {
transform(
input: string,
options: { width: number; format: Exclude<ImageFormat, "original">; quality: number },
): Promise<ImageProcessorResult>;
}
export interface OptimizeImageOptions {
outputDir: string;
widths: number[];
formats?: Array<Exclude<ImageFormat, "original">>;
quality?: number;
maxVariants?: number;
processor?: ImageProcessor;
}
export interface OptimizedImageVariant {
path: string;
width: number;
height: number;
format: Exclude<ImageFormat, "original">;
bytes: number;
}
export interface OptimizedImageManifest {
source: string;
variants: OptimizedImageVariant[];
}
async function sharpProcessor(): Promise<ImageProcessor> {
const packageName = "sharp";
let sharp: any;
try {
const module = (await import(packageName)) as { default?: any };
sharp = module.default ?? module;
} catch (error) {
throw new Error(
"WRN-IMAGE-SHARP-MISSING: install the optional `sharp` peer or provide an ImageProcessor",
{ cause: error },
);
}
return {
async transform(input, options) {
const pipeline = sharp(input)
.rotate()
.resize({ width: options.width, withoutEnlargement: true });
const { data, info } = await pipeline
.toFormat(options.format, { quality: options.quality })
.toBuffer({ resolveWithObject: true });
return { data: new Uint8Array(data), width: info.width, height: info.height };
},
};
}
export async function optimizeImage(
input: string,
options: OptimizeImageOptions,
): Promise<OptimizedImageManifest> {
const widths = normalizeImageWidths(options.widths);
const formats = [...new Set(options.formats ?? ["avif", "webp"])] as Array<
Exclude<ImageFormat, "original">
>;
if (!widths.length) throw new RangeError("Image optimization requires at least one valid width");
if (
!formats.length ||
formats.some((format) => !["avif", "webp", "jpeg", "png"].includes(format))
)
throw new RangeError("Image optimization has an unsupported output format");
const maxVariants = options.maxVariants ?? 32;
if (!Number.isInteger(maxVariants) || maxVariants < 1)
throw new RangeError("Image maxVariants must be positive");
if (widths.length * formats.length > maxVariants)
throw new RangeError(`Image optimization exceeds ${maxVariants} variants`);
const quality = Math.max(1, Math.min(100, Math.round(options.quality ?? 80)));
const processor = options.processor ?? (await sharpProcessor());
const outputDir = resolve(options.outputDir);
mkdirSync(outputDir, { recursive: true });
const stem = basename(input, extname(input)).replace(/[^A-Za-z0-9._-]+/g, "-") || "image";
const variants: OptimizedImageVariant[] = [];
for (const format of formats) {
for (const width of widths) {
const result = await processor.transform(resolve(input), { width, format, quality });
if (
!Number.isInteger(result.width) ||
result.width < 1 ||
!Number.isInteger(result.height) ||
result.height < 1
)
throw new Error("Image processor returned invalid dimensions");
const path = join(outputDir, `${stem}-${result.width}.${format === "jpeg" ? "jpg" : format}`);
writeFileSync(path, result.data);
variants.push({
path,
width: result.width,
height: result.height,
format,
bytes: statSync(path).size,
});
}
}
return { source: resolve(input), variants };
}