Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { logVerbose, shouldLogVerbose } from "../globals.js"; | |
| import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; | |
| import { fetchRemoteMedia } from "../media/fetch.js"; | |
| import { | |
| convertHeicToJpeg, | |
| hasAlphaChannel, | |
| optimizeImageToPng, | |
| resizeToJpeg, | |
| } from "../media/image-ops.js"; | |
| import { detectMime, extensionForMime } from "../media/mime.js"; | |
| import { resolveUserPath } from "../utils.js"; | |
| export type WebMediaResult = { | |
| buffer: Buffer; | |
| contentType?: string; | |
| kind: MediaKind; | |
| fileName?: string; | |
| }; | |
| type WebMediaOptions = { | |
| maxBytes?: number; | |
| optimizeImages?: boolean; | |
| }; | |
| const HEIC_MIME_RE = /^image\/hei[cf]$/i; | |
| const HEIC_EXT_RE = /\.(heic|heif)$/i; | |
| const MB = 1024 * 1024; | |
| function formatMb(bytes: number, digits = 2): string { | |
| return (bytes / MB).toFixed(digits); | |
| } | |
| function formatCapLimit(label: string, cap: number, size: number): string { | |
| return `${label} exceeds ${formatMb(cap, 0)}MB limit (got ${formatMb(size)}MB)`; | |
| } | |
| function formatCapReduce(label: string, cap: number, size: number): string { | |
| return `${label} could not be reduced below ${formatMb(cap, 0)}MB (got ${formatMb(size)}MB)`; | |
| } | |
| function isHeicSource(opts: { contentType?: string; fileName?: string }): boolean { | |
| if (opts.contentType && HEIC_MIME_RE.test(opts.contentType.trim())) { | |
| return true; | |
| } | |
| if (opts.fileName && HEIC_EXT_RE.test(opts.fileName.trim())) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| function toJpegFileName(fileName?: string): string | undefined { | |
| if (!fileName) { | |
| return undefined; | |
| } | |
| const trimmed = fileName.trim(); | |
| if (!trimmed) { | |
| return fileName; | |
| } | |
| const parsed = path.parse(trimmed); | |
| if (!parsed.ext || HEIC_EXT_RE.test(parsed.ext)) { | |
| return path.format({ dir: parsed.dir, name: parsed.name || trimmed, ext: ".jpg" }); | |
| } | |
| return path.format({ dir: parsed.dir, name: parsed.name, ext: ".jpg" }); | |
| } | |
| type OptimizedImage = { | |
| buffer: Buffer; | |
| optimizedSize: number; | |
| resizeSide: number; | |
| format: "jpeg" | "png"; | |
| quality?: number; | |
| compressionLevel?: number; | |
| }; | |
| function logOptimizedImage(params: { originalSize: number; optimized: OptimizedImage }): void { | |
| if (!shouldLogVerbose()) { | |
| return; | |
| } | |
| if (params.optimized.optimizedSize >= params.originalSize) { | |
| return; | |
| } | |
| if (params.optimized.format === "png") { | |
| logVerbose( | |
| `Optimized PNG (preserving alpha) from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px)`, | |
| ); | |
| return; | |
| } | |
| logVerbose( | |
| `Optimized media from ${formatMb(params.originalSize)}MB to ${formatMb(params.optimized.optimizedSize)}MB (side≤${params.optimized.resizeSide}px, q=${params.optimized.quality})`, | |
| ); | |
| } | |
| async function optimizeImageWithFallback(params: { | |
| buffer: Buffer; | |
| cap: number; | |
| meta?: { contentType?: string; fileName?: string }; | |
| }): Promise<OptimizedImage> { | |
| const { buffer, cap, meta } = params; | |
| const isPng = meta?.contentType === "image/png" || meta?.fileName?.toLowerCase().endsWith(".png"); | |
| const hasAlpha = isPng && (await hasAlphaChannel(buffer)); | |
| if (hasAlpha) { | |
| const optimized = await optimizeImageToPng(buffer, cap); | |
| if (optimized.buffer.length <= cap) { | |
| return { ...optimized, format: "png" }; | |
| } | |
| if (shouldLogVerbose()) { | |
| logVerbose( | |
| `PNG with alpha still exceeds ${formatMb(cap, 0)}MB after optimization; falling back to JPEG`, | |
| ); | |
| } | |
| } | |
| const optimized = await optimizeImageToJpeg(buffer, cap, meta); | |
| return { ...optimized, format: "jpeg" }; | |
| } | |
| async function loadWebMediaInternal( | |
| mediaUrl: string, | |
| options: WebMediaOptions = {}, | |
| ): Promise<WebMediaResult> { | |
| const { maxBytes, optimizeImages = true } = options; | |
| // Use fileURLToPath for proper handling of file:// URLs (handles file://localhost/path, etc.) | |
| if (mediaUrl.startsWith("file://")) { | |
| try { | |
| mediaUrl = fileURLToPath(mediaUrl); | |
| } catch { | |
| throw new Error(`Invalid file:// URL: ${mediaUrl}`); | |
| } | |
| } | |
| const optimizeAndClampImage = async ( | |
| buffer: Buffer, | |
| cap: number, | |
| meta?: { contentType?: string; fileName?: string }, | |
| ) => { | |
| const originalSize = buffer.length; | |
| const optimized = await optimizeImageWithFallback({ buffer, cap, meta }); | |
| logOptimizedImage({ originalSize, optimized }); | |
| if (optimized.buffer.length > cap) { | |
| throw new Error(formatCapReduce("Media", cap, optimized.buffer.length)); | |
| } | |
| const contentType = optimized.format === "png" ? "image/png" : "image/jpeg"; | |
| const fileName = | |
| optimized.format === "jpeg" && meta && isHeicSource(meta) | |
| ? toJpegFileName(meta.fileName) | |
| : meta?.fileName; | |
| return { | |
| buffer: optimized.buffer, | |
| contentType, | |
| kind: "image" as const, | |
| fileName, | |
| }; | |
| }; | |
| const clampAndFinalize = async (params: { | |
| buffer: Buffer; | |
| contentType?: string; | |
| kind: MediaKind; | |
| fileName?: string; | |
| }): Promise<WebMediaResult> => { | |
| // If caller explicitly provides maxBytes, trust it (for channels that handle large files). | |
| // Otherwise fall back to per-kind defaults. | |
| const cap = maxBytes !== undefined ? maxBytes : maxBytesForKind(params.kind); | |
| if (params.kind === "image") { | |
| const isGif = params.contentType === "image/gif"; | |
| if (isGif || !optimizeImages) { | |
| if (params.buffer.length > cap) { | |
| throw new Error(formatCapLimit(isGif ? "GIF" : "Media", cap, params.buffer.length)); | |
| } | |
| return { | |
| buffer: params.buffer, | |
| contentType: params.contentType, | |
| kind: params.kind, | |
| fileName: params.fileName, | |
| }; | |
| } | |
| return { | |
| ...(await optimizeAndClampImage(params.buffer, cap, { | |
| contentType: params.contentType, | |
| fileName: params.fileName, | |
| })), | |
| }; | |
| } | |
| if (params.buffer.length > cap) { | |
| throw new Error(formatCapLimit("Media", cap, params.buffer.length)); | |
| } | |
| return { | |
| buffer: params.buffer, | |
| contentType: params.contentType ?? undefined, | |
| kind: params.kind, | |
| fileName: params.fileName, | |
| }; | |
| }; | |
| if (/^https?:\/\//i.test(mediaUrl)) { | |
| const fetched = await fetchRemoteMedia({ url: mediaUrl }); | |
| const { buffer, contentType, fileName } = fetched; | |
| const kind = mediaKindFromMime(contentType); | |
| return await clampAndFinalize({ buffer, contentType, kind, fileName }); | |
| } | |
| // Expand tilde paths to absolute paths (e.g., ~/Downloads/photo.jpg) | |
| if (mediaUrl.startsWith("~")) { | |
| mediaUrl = resolveUserPath(mediaUrl); | |
| } | |
| // 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}`; | |
| } | |
| } | |
| return await clampAndFinalize({ | |
| buffer: data, | |
| contentType: mime, | |
| kind, | |
| fileName, | |
| }); | |
| } | |
| export async function loadWebMedia(mediaUrl: string, maxBytes?: number): Promise<WebMediaResult> { | |
| return await loadWebMediaInternal(mediaUrl, { | |
| maxBytes, | |
| optimizeImages: true, | |
| }); | |
| } | |
| export async function loadWebMediaRaw( | |
| mediaUrl: string, | |
| maxBytes?: number, | |
| ): Promise<WebMediaResult> { | |
| return await loadWebMediaInternal(mediaUrl, { | |
| maxBytes, | |
| optimizeImages: false, | |
| }); | |
| } | |
| export async function optimizeImageToJpeg( | |
| buffer: Buffer, | |
| maxBytes: number, | |
| opts: { contentType?: string; fileName?: string } = {}, | |
| ): Promise<{ | |
| buffer: Buffer; | |
| optimizedSize: number; | |
| resizeSide: number; | |
| quality: number; | |
| }> { | |
| // Try a grid of sizes/qualities until under the limit. | |
| let source = buffer; | |
| if (isHeicSource(opts)) { | |
| try { | |
| source = await convertHeicToJpeg(buffer); | |
| } catch (err) { | |
| throw new Error(`HEIC image conversion failed: ${String(err)}`, { cause: err }); | |
| } | |
| } | |
| 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) { | |
| try { | |
| const out = await resizeToJpeg({ | |
| buffer: source, | |
| 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, | |
| }; | |
| } | |
| } catch { | |
| // Continue trying other size/quality combinations | |
| } | |
| } | |
| } | |
| if (smallest) { | |
| return { | |
| buffer: smallest.buffer, | |
| optimizedSize: smallest.size, | |
| resizeSide: smallest.resizeSide, | |
| quality: smallest.quality, | |
| }; | |
| } | |
| throw new Error("Failed to optimize image"); | |
| } | |
| export { optimizeImageToPng }; | |