287 lines
7.2 KiB
TypeScript
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;
|
|
}
|
|
}
|