|
|
import type { createLoggerWithContext } from "@midday/logger"; |
|
|
import convert from "heic-convert"; |
|
|
import sharp from "sharp"; |
|
|
import { IMAGE_SIZES } from "./timeout"; |
|
|
|
|
|
|
|
|
|
|
|
sharp.cache({ memory: 256, files: 20, items: 100 }); |
|
|
sharp.concurrency(2); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const MAX_HEIC_FILE_SIZE = 15 * 1024 * 1024; |
|
|
|
|
|
export interface HeicConversionResult { |
|
|
buffer: Buffer; |
|
|
mimetype: "image/jpeg"; |
|
|
} |
|
|
|
|
|
export interface ImageProcessingOptions { |
|
|
maxSize?: number; |
|
|
} |
|
|
|
|
|
export interface ResizeResult { |
|
|
buffer: Buffer; |
|
|
mimetype: string; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const RESIZABLE_MIMETYPES = new Set([ |
|
|
"image/jpeg", |
|
|
"image/jpg", |
|
|
"image/png", |
|
|
"image/webp", |
|
|
"image/gif", |
|
|
"image/tiff", |
|
|
]); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function resizeImage( |
|
|
inputBuffer: ArrayBuffer, |
|
|
mimetype: string, |
|
|
logger: ReturnType<typeof createLoggerWithContext>, |
|
|
options?: ImageProcessingOptions, |
|
|
): Promise<ResizeResult> { |
|
|
const maxSize = options?.maxSize ?? IMAGE_SIZES.MAX_DIMENSION; |
|
|
|
|
|
|
|
|
if (!inputBuffer || inputBuffer.byteLength === 0) { |
|
|
throw new Error("Input buffer is empty"); |
|
|
} |
|
|
|
|
|
|
|
|
if (!RESIZABLE_MIMETYPES.has(mimetype.toLowerCase())) { |
|
|
logger.info("Skipping resize for unsupported mimetype", { mimetype }); |
|
|
return { buffer: Buffer.from(inputBuffer), mimetype }; |
|
|
} |
|
|
|
|
|
try { |
|
|
const image = sharp(Buffer.from(inputBuffer)); |
|
|
const metadata = await image.metadata(); |
|
|
|
|
|
|
|
|
const width = metadata.width ?? 0; |
|
|
const height = metadata.height ?? 0; |
|
|
if (width <= maxSize && height <= maxSize) { |
|
|
logger.info("Image already within size limits, skipping resize", { |
|
|
width, |
|
|
height, |
|
|
maxSize, |
|
|
}); |
|
|
return { buffer: Buffer.from(inputBuffer), mimetype }; |
|
|
} |
|
|
|
|
|
|
|
|
const buffer = await image |
|
|
.rotate() |
|
|
.resize({ |
|
|
width: maxSize, |
|
|
height: maxSize, |
|
|
fit: "inside", |
|
|
withoutEnlargement: true, |
|
|
}) |
|
|
.toBuffer(); |
|
|
|
|
|
logger.info("Image resized successfully", { |
|
|
originalWidth: width, |
|
|
originalHeight: height, |
|
|
maxSize, |
|
|
}); |
|
|
|
|
|
return { buffer, mimetype }; |
|
|
} catch (error) { |
|
|
logger.warn("Failed to resize image, returning original", { |
|
|
error: error instanceof Error ? error.message : "Unknown error", |
|
|
mimetype, |
|
|
}); |
|
|
|
|
|
return { buffer: Buffer.from(inputBuffer), mimetype }; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export async function convertHeicToJpeg( |
|
|
inputBuffer: ArrayBuffer, |
|
|
logger: ReturnType<typeof createLoggerWithContext>, |
|
|
options?: ImageProcessingOptions, |
|
|
): Promise<HeicConversionResult> { |
|
|
const maxSize = options?.maxSize ?? IMAGE_SIZES.MAX_DIMENSION; |
|
|
|
|
|
|
|
|
if (!inputBuffer || inputBuffer.byteLength === 0) { |
|
|
throw new Error("Input buffer is empty"); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const buffer = await sharp(Buffer.from(inputBuffer)) |
|
|
.rotate() |
|
|
.resize({ |
|
|
width: maxSize, |
|
|
height: maxSize, |
|
|
fit: "inside", |
|
|
withoutEnlargement: true, |
|
|
}) |
|
|
.toFormat("jpeg") |
|
|
.toBuffer(); |
|
|
|
|
|
logger.info("HEIC conversion successful with sharp"); |
|
|
return { buffer, mimetype: "image/jpeg" }; |
|
|
} catch (sharpError) { |
|
|
logger.warn("Sharp failed to process HEIC, falling back to heic-convert", { |
|
|
error: sharpError instanceof Error ? sharpError.message : "Unknown error", |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let decodedImage: ArrayBuffer; |
|
|
try { |
|
|
decodedImage = await convert({ |
|
|
|
|
|
buffer: new Uint8Array(inputBuffer), |
|
|
format: "JPEG", |
|
|
quality: 0.8, |
|
|
}); |
|
|
} catch (heicError) { |
|
|
|
|
|
throw new Error( |
|
|
`Failed to convert HEIC image: sharp error: ${sharpError instanceof Error ? sharpError.message : "Unknown"}, heic-convert error: ${heicError instanceof Error ? heicError.message : "Unknown"}`, |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
if (!decodedImage || decodedImage.byteLength === 0) { |
|
|
throw new Error("Decoded image is empty after heic-convert"); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const buffer = await sharp(Buffer.from(decodedImage)) |
|
|
.rotate() |
|
|
.resize({ |
|
|
width: maxSize, |
|
|
height: maxSize, |
|
|
fit: "inside", |
|
|
withoutEnlargement: true, |
|
|
}) |
|
|
.toFormat("jpeg") |
|
|
.toBuffer(); |
|
|
|
|
|
logger.info("HEIC conversion successful with heic-convert fallback"); |
|
|
return { buffer, mimetype: "image/jpeg" }; |
|
|
} catch (finalSharpError) { |
|
|
throw new Error( |
|
|
`Failed to process heic-convert output: ${finalSharpError instanceof Error ? finalSharpError.message : "Unknown error"}`, |
|
|
); |
|
|
} |
|
|
} |
|
|
} |
|
|
|