Files
illusory-mapp/packages/objectstorage/src/processors/image-processor.ts

287 lines
7.2 KiB
TypeScript

import sharp from "sharp";
import type { FileProcessingResult, ImageProcessingOptions } from "../data";
/**
* Process images with compression, resizing, format conversion, and thumbnail generation
*/
export async function processImage(
buffer: Buffer | Uint8Array,
options: ImageProcessingOptions = {},
): Promise<FileProcessingResult> {
try {
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(buffer);
let processedBuffer = inputBuffer;
let thumbnailBuffer: Buffer | undefined;
const metadata: Record<string, any> = {};
// Initialize Sharp instance
const image = sharp(inputBuffer);
const originalMetadata = await image.metadata();
// Store original metadata
metadata.original = {
width: originalMetadata.width,
height: originalMetadata.height,
format: originalMetadata.format,
size: inputBuffer.length,
colorSpace: originalMetadata.space,
channels: originalMetadata.channels,
density: originalMetadata.density,
hasAlpha: originalMetadata.hasAlpha,
};
// Apply transformations
let transformedImage = image;
// Resize if requested
if (options.resize) {
const { width, height, fit = "cover" } = options.resize;
transformedImage = transformedImage.resize(width, height, {
fit: fit as keyof sharp.FitEnum,
withoutEnlargement: true, // Don't enlarge smaller images
});
metadata.processed = {
...metadata.processed,
resized: true,
targetWidth: width,
targetHeight: height,
fit,
};
}
// Apply format conversion and quality settings
const outputFormat = options.format || "webp";
const quality = options.quality || 85;
switch (outputFormat) {
case "jpeg":
transformedImage = transformedImage.jpeg({
quality,
progressive: true,
mozjpeg: true, // Use mozjpeg encoder for better compression
});
break;
case "png":
transformedImage = transformedImage.png({
quality,
compressionLevel: 9,
progressive: true,
});
break;
case "webp":
transformedImage = transformedImage.webp({
quality,
effort: 6, // Max compression effort
});
break;
case "avif":
transformedImage = transformedImage.avif({
quality,
effort: 6,
});
break;
default:
// Keep original format but apply quality if possible
if (originalMetadata.format === "jpeg") {
transformedImage = transformedImage.jpeg({ quality });
} else if (originalMetadata.format === "png") {
transformedImage = transformedImage.png({ quality });
}
}
// Generate processed image
processedBuffer = await transformedImage.toBuffer();
// Get final metadata
const finalMetadata = await sharp(processedBuffer).metadata();
metadata.processed = {
...metadata.processed,
width: finalMetadata.width,
height: finalMetadata.height,
format: outputFormat,
size: processedBuffer.length,
quality,
compressionRatio: inputBuffer.length / processedBuffer.length,
};
// Generate thumbnail if requested
if (options.generateThumbnail) {
const thumbSize = options.thumbnailSize || {
width: 300,
height: 300,
};
thumbnailBuffer = await sharp(inputBuffer)
.resize(thumbSize.width, thumbSize.height, {
fit: "cover",
position: "center",
})
.webp({ quality: 80 })
.toBuffer();
const thumbMetadata = await sharp(thumbnailBuffer).metadata();
metadata.thumbnail = {
width: thumbMetadata.width,
height: thumbMetadata.height,
format: "webp",
size: thumbnailBuffer.length,
};
}
// Add processing stats
metadata.processing = {
processedAt: new Date().toISOString(),
sizeSaving: inputBuffer.length - processedBuffer.length,
sizeSavingPercentage:
((inputBuffer.length - processedBuffer.length) /
inputBuffer.length) *
100,
processingTime: Date.now(), // You'd measure this properly in production
};
return {
processed: true,
originalFile: inputBuffer,
processedFile: processedBuffer,
thumbnail: thumbnailBuffer,
metadata,
};
} catch (error) {
return {
processed: false,
error: `Image processing failed: ${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* Extract image metadata without processing
*/
export async function extractImageMetadata(
buffer: Buffer | Uint8Array,
): Promise<Record<string, any>> {
try {
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(buffer);
const image = sharp(inputBuffer);
const metadata = await image.metadata();
return {
width: metadata.width,
height: metadata.height,
format: metadata.format,
size: inputBuffer.length,
colorSpace: metadata.space,
channels: metadata.channels,
density: metadata.density,
hasAlpha: metadata.hasAlpha,
isAnimated: metadata.pages && metadata.pages > 1,
orientation: metadata.orientation,
};
} catch (error) {
throw new Error(
`Failed to extract image metadata: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Generate multiple sizes for responsive images
*/
export async function generateResponsiveSizes(
buffer: Buffer | Uint8Array,
sizes: Array<{ name: string; width: number; height?: number }> = [
{ name: "small", width: 400 },
{ name: "medium", width: 800 },
{ name: "large", width: 1200 },
{ name: "xlarge", width: 1920 },
],
): Promise<Record<string, Buffer>> {
const results: Record<string, Buffer> = {};
const inputBuffer = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
try {
for (const size of sizes) {
const resized = await sharp(inputBuffer)
.resize(size.width, size.height, {
fit: "inside",
withoutEnlargement: true,
})
.webp({ quality: 85 })
.toBuffer();
results[size.name] = resized;
}
return results;
} catch (error) {
throw new Error(
`Failed to generate responsive sizes: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Create an optimized avatar image
*/
export async function processAvatar(
buffer: Buffer | Uint8Array,
size: number = 200,
): Promise<Buffer> {
try {
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(buffer);
return await sharp(inputBuffer)
.resize(size, size, { fit: "cover", position: "center" })
.webp({ quality: 90 })
.toBuffer();
} catch (error) {
throw new Error(
`Avatar processing failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Remove EXIF data from images for privacy
*/
export async function stripExifData(
buffer: Buffer | Uint8Array,
): Promise<Buffer> {
try {
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(buffer);
return await sharp(inputBuffer)
.rotate() // Auto-rotate based on EXIF, then removes EXIF
.toBuffer();
} catch (error) {
throw new Error(
`EXIF stripping failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Validate if buffer contains a valid image
*/
export async function validateImage(
buffer: Buffer | Uint8Array,
): Promise<boolean> {
try {
const inputBuffer = Buffer.isBuffer(buffer)
? buffer
: Buffer.from(buffer);
const metadata = await sharp(inputBuffer).metadata();
return !!(metadata.width && metadata.height && metadata.format);
} catch {
return false;
}
}