113 lines
3.7 KiB
TypeScript
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 };
|
|
}
|