import fs from "node:fs/promises"; import path from "node:path"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime, } from "../media/constants.js"; import { resizeToJpeg } from "../media/image-ops.js"; import { detectMime, extensionForMime } from "../media/mime.js"; export async function loadWebMedia( mediaUrl: string, maxBytes?: number, ): Promise<{ buffer: Buffer; contentType?: string; kind: MediaKind; fileName?: string; }> { if (mediaUrl.startsWith("file://")) { mediaUrl = mediaUrl.replace("file://", ""); } const optimizeAndClampImage = async (buffer: Buffer, cap: number) => { const originalSize = buffer.length; const optimized = await optimizeImageToJpeg(buffer, cap); if (optimized.optimizedSize < originalSize && shouldLogVerbose()) { logVerbose( `Optimized media from ${(originalSize / (1024 * 1024)).toFixed(2)}MB to ${(optimized.optimizedSize / (1024 * 1024)).toFixed(2)}MB (side≤${optimized.resizeSide}px, q=${optimized.quality})`, ); } if (optimized.buffer.length > cap) { throw new Error( `Media could not be reduced below ${(cap / (1024 * 1024)).toFixed(0)}MB (got ${( optimized.buffer.length / (1024 * 1024) ).toFixed(2)}MB)`, ); } return { buffer: optimized.buffer, contentType: "image/jpeg", kind: "image" as const, }; }; if (/^https?:\/\//i.test(mediaUrl)) { let fileName: string | undefined; try { const url = new URL(mediaUrl); const base = path.basename(url.pathname); fileName = base || undefined; } catch { // ignore parse errors; leave undefined } const res = await fetch(mediaUrl); if (!res.ok || !res.body) { throw new Error(`Failed to fetch media: HTTP ${res.status}`); } const array = Buffer.from(await res.arrayBuffer()); const contentType = await detectMime({ buffer: array, headerMime: res.headers.get("content-type"), filePath: mediaUrl, }); if (fileName && !path.extname(fileName) && contentType) { const ext = extensionForMime(contentType); if (ext) fileName = `${fileName}${ext}`; } const kind = mediaKindFromMime(contentType); const cap = Math.min( maxBytes ?? maxBytesForKind(kind), maxBytesForKind(kind), ); if (kind === "image") { // Skip optimization for GIFs to preserve animation if (contentType === "image/gif") { if (array.length > cap) { throw new Error( `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( array.length / (1024 * 1024) ).toFixed(2)}MB)`, ); } return { buffer: array, contentType, kind, fileName }; } return { ...(await optimizeAndClampImage(array, cap)), fileName }; } if (array.length > cap) { throw new Error( `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( array.length / (1024 * 1024) ).toFixed(2)}MB)`, ); } return { buffer: array, contentType: contentType ?? undefined, kind, fileName, }; } // Local path const data = await fs.readFile(mediaUrl); const mime = await detectMime({ buffer: data, filePath: mediaUrl }); const kind = mediaKindFromMime(mime); let fileName = path.basename(mediaUrl) || undefined; if (fileName && !path.extname(fileName) && mime) { const ext = extensionForMime(mime); if (ext) fileName = `${fileName}${ext}`; } const cap = Math.min( maxBytes ?? maxBytesForKind(kind), maxBytesForKind(kind), ); if (kind === "image") { // Skip optimization for GIFs to preserve animation if (mime === "image/gif") { if (data.length > cap) { throw new Error( `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( data.length / (1024 * 1024) ).toFixed(2)}MB)`, ); } return { buffer: data, contentType: mime, kind, fileName }; } return { ...(await optimizeAndClampImage(data, cap)), fileName }; } if (data.length > cap) { throw new Error( `Media exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( data.length / (1024 * 1024) ).toFixed(2)}MB)`, ); } return { buffer: data, contentType: mime, kind, fileName }; } export async function optimizeImageToJpeg( buffer: Buffer, maxBytes: number, ): Promise<{ buffer: Buffer; optimizedSize: number; resizeSide: number; quality: number; }> { // Try a grid of sizes/qualities until under the limit. const sides = [2048, 1536, 1280, 1024, 800]; const qualities = [80, 70, 60, 50, 40]; let smallest: { buffer: Buffer; size: number; resizeSide: number; quality: number; } | null = null; for (const side of sides) { for (const quality of qualities) { const out = await resizeToJpeg({ buffer, maxSide: side, quality, withoutEnlargement: true, }); const size = out.length; if (!smallest || size < smallest.size) { smallest = { buffer: out, size, resizeSide: side, quality }; } if (size <= maxBytes) { return { buffer: out, optimizedSize: size, resizeSide: side, quality, }; } } } if (smallest) { return { buffer: smallest.buffer, optimizedSize: smallest.size, resizeSide: smallest.resizeSide, quality: smallest.quality, }; } throw new Error("Failed to optimize image"); }