| import type { Sharp } from "sharp"; |
| import sharp from "sharp"; |
| import type { MessageFile } from "$lib/types/Message"; |
| import { z, type util } from "zod"; |
|
|
| export interface ImageProcessorOptions<TMimeType extends string = string> { |
| supportedMimeTypes: TMimeType[]; |
| preferredMimeType: TMimeType; |
| maxSizeInMB: number; |
| maxWidth: number; |
| maxHeight: number; |
| } |
| export type ImageProcessor<TMimeType extends string = string> = (file: MessageFile) => Promise<{ |
| image: Buffer; |
| mime: TMimeType; |
| }>; |
|
|
| export function createImageProcessorOptionsValidator<TMimeType extends string = string>( |
| defaults: ImageProcessorOptions<TMimeType> |
| ) { |
| return z |
| .object({ |
| supportedMimeTypes: z |
| .array( |
| z.enum<string, [TMimeType, ...TMimeType[]]>([ |
| defaults.supportedMimeTypes[0], |
| ...defaults.supportedMimeTypes.slice(1), |
| ]) |
| ) |
| .default(defaults.supportedMimeTypes), |
| preferredMimeType: z |
| .enum([defaults.supportedMimeTypes[0], ...defaults.supportedMimeTypes.slice(1)]) |
| .default(defaults.preferredMimeType as util.noUndefined<TMimeType>), |
| maxSizeInMB: z.number().positive().default(defaults.maxSizeInMB), |
| maxWidth: z.number().int().positive().default(defaults.maxWidth), |
| maxHeight: z.number().int().positive().default(defaults.maxHeight), |
| }) |
| .default(defaults); |
| } |
|
|
| export function makeImageProcessor<TMimeType extends string = string>( |
| options: ImageProcessorOptions<TMimeType> |
| ): ImageProcessor<TMimeType> { |
| return async (file) => { |
| const { supportedMimeTypes, preferredMimeType, maxSizeInMB, maxWidth, maxHeight } = options; |
| const { mime, value } = file; |
|
|
| const buffer = Buffer.from(value, "base64"); |
| let sharpInst = sharp(buffer); |
|
|
| const metadata = await sharpInst.metadata(); |
| if (!metadata) throw Error("Failed to read image metadata"); |
| const { width, height } = metadata; |
| if (width === undefined || height === undefined) throw Error("Failed to read image size"); |
|
|
| const tooLargeInSize = width > maxWidth || height > maxHeight; |
| const tooLargeInBytes = buffer.byteLength > maxSizeInMB * 1000 * 1000; |
|
|
| const outputMime = chooseMimeType(supportedMimeTypes, preferredMimeType, mime, { |
| preferSizeReduction: tooLargeInBytes, |
| }); |
|
|
| |
| if (tooLargeInSize || tooLargeInBytes) { |
| const size = chooseImageSize({ |
| mime: outputMime, |
| width, |
| height, |
| maxWidth, |
| maxHeight, |
| maxSizeInMB, |
| }); |
| if (size.width !== width || size.height !== height) { |
| sharpInst = resizeImage(sharpInst, size.width, size.height); |
| } |
| } |
|
|
| |
| |
| |
| |
| if (outputMime !== mime || tooLargeInBytes) { |
| sharpInst = convertImage(sharpInst, outputMime); |
| } |
|
|
| const processedImage = await sharpInst.toBuffer(); |
| return { image: processedImage, mime: outputMime }; |
| }; |
| } |
|
|
| const outputFormats = ["png", "jpeg", "webp", "avif", "tiff", "gif"] as const; |
| type OutputImgFormat = (typeof outputFormats)[number]; |
| const isOutputFormat = (format: string): format is (typeof outputFormats)[number] => |
| outputFormats.includes(format as OutputImgFormat); |
|
|
| export function convertImage(sharpInst: Sharp, outputMime: string): Sharp { |
| const [type, format] = outputMime.split("/"); |
| if (type !== "image") throw Error(`Requested non-image mime type: ${outputMime}`); |
| if (!isOutputFormat(format)) { |
| throw Error(`Requested to convert to an unsupported format: ${format}`); |
| } |
|
|
| return sharpInst[format](); |
| } |
|
|
| |
| |
| |
| |
| const blocklistedMimes = ["image/heic", "image/heif"]; |
|
|
| |
| const mimesBySizeDesc = [ |
| "image/png", |
| "image/tiff", |
| "image/gif", |
| "image/jpeg", |
| "image/webp", |
| "image/avif", |
| ]; |
|
|
| |
| |
| |
| |
| function chooseMimeType<T extends readonly string[]>( |
| supportedMimes: T, |
| preferredMime: string, |
| mime: string, |
| { preferSizeReduction }: { preferSizeReduction: boolean } |
| ): T[number] { |
| if (!supportedMimes.includes(preferredMime)) { |
| const supportedMimesStr = supportedMimes.join(", "); |
| throw Error( |
| `Preferred format "${preferredMime}" not found in supported mimes: ${supportedMimesStr}` |
| ); |
| } |
|
|
| const [type] = mime.split("/"); |
| if (type !== "image") throw Error(`Received non-image mime type: ${mime}`); |
|
|
| if (supportedMimes.includes(mime) && !preferSizeReduction) return mime; |
|
|
| if (blocklistedMimes.includes(mime)) throw Error(`Received blocklisted mime type: ${mime}`); |
|
|
| const smallestMime = mimesBySizeDesc.findLast((m) => supportedMimes.includes(m)); |
| return smallestMime ?? preferredMime; |
| } |
|
|
| interface ImageSizeOptions { |
| mime: string; |
| width: number; |
| height: number; |
| maxWidth: number; |
| maxHeight: number; |
| maxSizeInMB: number; |
| } |
|
|
| |
| export function chooseImageSize({ |
| mime, |
| width, |
| height, |
| maxWidth, |
| maxHeight, |
| maxSizeInMB, |
| }: ImageSizeOptions): { width: number; height: number } { |
| const biggestDiscrepency = Math.max(1, width / maxWidth, height / maxHeight); |
|
|
| let selectedWidth = Math.ceil(width / biggestDiscrepency); |
| let selectedHeight = Math.ceil(height / biggestDiscrepency); |
|
|
| do { |
| const estimatedSize = estimateImageSizeInBytes(mime, selectedWidth, selectedHeight); |
| if (estimatedSize < maxSizeInMB * 1024 * 1024) { |
| return { width: selectedWidth, height: selectedHeight }; |
| } |
| selectedWidth = Math.floor(selectedWidth / 1.1); |
| selectedHeight = Math.floor(selectedHeight / 1.1); |
| } while (selectedWidth > 1 && selectedHeight > 1); |
|
|
| throw Error(`Failed to resize image to fit within ${maxSizeInMB}MB`); |
| } |
|
|
| const mimeToCompressionRatio: Record<string, number> = { |
| "image/png": 1 / 2, |
| "image/jpeg": 1 / 10, |
| "image/webp": 1 / 4, |
| "image/avif": 1 / 5, |
| "image/tiff": 1, |
| "image/gif": 1 / 5, |
| }; |
|
|
| |
| |
| |
| |
| function estimateImageSizeInBytes(mime: string, width: number, height: number): number { |
| const compressionRatio = mimeToCompressionRatio[mime]; |
| if (!compressionRatio) throw Error(`Unsupported image format: ${mime}`); |
|
|
| const bitsPerPixel = 32; |
| const bytesPerPixel = bitsPerPixel / 8; |
| const uncompressedSize = width * height * bytesPerPixel; |
|
|
| return uncompressedSize * compressionRatio; |
| } |
|
|
| export function resizeImage(sharpInst: Sharp, maxWidth: number, maxHeight: number): Sharp { |
| return sharpInst.resize({ width: maxWidth, height: maxHeight, fit: "inside" }); |
| } |
|
|