added file domain logic, updated drizzle package
This commit is contained in:
286
packages/objectstorage/src/processors/image-processor.ts
Normal file
286
packages/objectstorage/src/processors/image-processor.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user