Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
| 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, | |
| }); | |
| // Resize if necessary | |
| 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); | |
| } | |
| } | |
| // Convert format if necessary | |
| // We always want to convert the image when the file was too large in bytes | |
| // so we can guarantee that ideal options are used, which are expected when | |
| // choosing the image size | |
| 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](); | |
| } | |
| // heic/heif requires proprietary license | |
| // TODO: blocking heif may be incorrect considering it also supports av1, so we should instead | |
| // detect the compression method used via sharp().metadata().compression | |
| // TODO: consider what to do about animated formats: apng, gif, animated webp, ... | |
| const blocklistedMimes = ["image/heic", "image/heif"]; | |
| /** Sorted from largest to smallest */ | |
| const mimesBySizeDesc = [ | |
| "image/png", | |
| "image/tiff", | |
| "image/gif", | |
| "image/jpeg", | |
| "image/webp", | |
| "image/avif", | |
| ]; | |
| /** | |
| * Defaults to preferred format or uses existing mime if supported | |
| * When preferSizeReduction is true, it will choose the smallest format that is supported | |
| **/ | |
| 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; | |
| } | |
| /** Resizes the image to fit within the specified size in MB by guessing the output size */ | |
| 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, | |
| }; | |
| /** | |
| * Guesses the side of an image in MB based on its format and dimensions | |
| * Should guess the worst case | |
| **/ | |
| function estimateImageSizeInBytes(mime: string, width: number, height: number): number { | |
| const compressionRatio = mimeToCompressionRatio[mime]; | |
| if (!compressionRatio) throw Error(`Unsupported image format: ${mime}`); | |
| const bitsPerPixel = 32; // Assuming 32-bit color depth for 8-bit R G B A | |
| 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" }); | |
| } | |