478 lines · 17.3 KB · TypeScript
RawDownload
/** * Downscale + re-encode for image attachments that are too big for where * they're headed. Two consumers share the same pipeline: * * - The prompt stash persists images as base64 in localStorage (~5MB origin *   quota), so `compressImageForStash` targets a per-image character budget. * - The composer accepts pasted/dropped images larger than the provider's *   `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES` wire cap and shrinks them to fit *   via `compressImageToByteLimit` instead of rejecting the paste. * * Supported images already within budget pass through untouched. HEIC/HEIF * photos are decoded to JPEG first because providers cannot consume them. */ /** * Longest edge kept when an image has to be re-encoded. Sized so a typical * retina screenshot (3024px wide) stays legible rather than being halved. */const MAX_DIMENSION = 2048;/** Base64 budget for a single stashed image (~975KB of binary). */export const MAX_STASH_IMAGE_DATA_URL_CHARS = 1_300_000;/** * Ceiling on the *source* file handed to the re-encoder. File size is a * proxy for pixel count, and decoding hundreds of megapixels into an * ImageBitmap can OOM the tab — beyond this we refuse rather than risk it. */export const MAX_COMPRESSIBLE_SOURCE_BYTES = 50 * 1024 * 1024;const MAX_HEIC_DECODE_PIXELS = 64_000_000;const MAX_HEIC_METADATA_BYTES = 1024 * 1024;/** * Quality ladder tried in order until the encoded image fits the budget. * The floor stays high enough to avoid visible blocking on UI screenshots; * if even that overflows we drop resolution instead of quality. */const QUALITY_STEPS = [0.92, 0.85, 0.78, 0.68] as const;/** Extra downscale passes applied when even the lowest quality overflows. */const FALLBACK_SCALE_STEPS = [0.75, 0.55] as const;const HEIC_IMAGE_MIME_TYPE = /^image\/hei(?:c|f)$/i;const HEIC_IMAGE_EXTENSION = /\.(?:heic|heif)$/i; type ImageSize = { width: number; height: number }; export interface CompressedStashImage {  imageSize?: ImageSize;  dataUrl: string;  mimeType: string;  sizeBytes: number;  /** True when the payload was re-encoded rather than stored verbatim. */  recompressed: boolean;} /** * Why an image could not be compressed. Callers report these differently: * "too large" is a budget outcome, "unreadable" is a decode failure. */export type ImageCompressionFailureReason = "too-large" | "unreadable"; export type CompressStashImageResult =  | { ok: true; image: CompressedStashImage }  | { ok: false; reason: ImageCompressionFailureReason }; export type CompressImageFileResult =  | { ok: true; file: File; recompressed: boolean; imageSize?: ImageSize }  | { ok: false; reason: ImageCompressionFailureReason }; /** Finder and some browsers omit the MIME type when dragging HEIC photos. */export function isHeicImageFile(file: Pick<File, "name" | "type">): boolean {  if (HEIC_IMAGE_MIME_TYPE.test(file.type)) {    return true;  }  return (    (file.type === "" || file.type.toLowerCase() === "application/octet-stream") &&    HEIC_IMAGE_EXTENSION.test(file.name)  );} interface HeicMetadataBox {  payloadOffset: number;  endOffset: number;} function findHeicMetadataBox(  view: DataView,  startOffset: number,  endOffset: number,  type: number,): HeicMetadataBox | null {  let offset = startOffset;  while (offset + 8 <= endOffset) {    let size = view.getUint32(offset);    let headerSize = 8;    if (size === 1) {      if (offset + 16 > endOffset) return null;      const extendedSize = view.getBigUint64(offset + 8);      if (extendedSize > BigInt(Number.MAX_SAFE_INTEGER)) return null;      size = Number(extendedSize);      headerSize = 16;    } else if (size === 0) {      size = endOffset - offset;    }    if (size < headerSize || size > endOffset - offset) return null;     const nextOffset = offset + size;    if (view.getUint32(offset + 4) === type) {      return { payloadOffset: offset + headerSize, endOffset: nextOffset };    }    offset = nextOffset;  }  return null;} /** Read HEIC image dimensions before the decoder allocates full RGBA buffers. */async function validateHeicImageDimensions(  file: File,): Promise<ImageCompressionFailureReason | null> {  const metadata = await file.slice(0, MAX_HEIC_METADATA_BYTES).arrayBuffer();  const view = new DataView(metadata);  const meta = findHeicMetadataBox(view, 0, view.byteLength, 0x6d657461);  if (!meta || meta.payloadOffset + 4 > meta.endOffset) return "unreadable";  const properties = findHeicMetadataBox(view, meta.payloadOffset + 4, meta.endOffset, 0x69707270);  if (!properties) return "unreadable";  const containers = findHeicMetadataBox(    view,    properties.payloadOffset,    properties.endOffset,    0x6970636f,  );  if (!containers) return "unreadable";   let offset = containers.payloadOffset;  let foundImageDimensions = false;  while (offset < containers.endOffset) {    const image = findHeicMetadataBox(view, offset, containers.endOffset, 0x69737065);    if (!image) break;    if (image.payloadOffset + 12 > image.endOffset) return "unreadable";    const width = view.getUint32(image.payloadOffset + 4);    const height = view.getUint32(image.payloadOffset + 8);    if (width === 0 || height === 0) return "unreadable";    if (width > MAX_HEIC_DECODE_PIXELS / height) return "too-large";    foundImageDimensions = true;    offset = image.endOffset;  }  return foundImageDimensions ? null : "unreadable";} /** Chunked so a large image can't blow the argument limit of `fromCharCode`. */const BASE64_CHUNK_SIZE = 0x8000; function bytesToBase64(bytes: Uint8Array): string {  let binary = "";  for (let offset = 0; offset < bytes.length; offset += BASE64_CHUNK_SIZE) {    binary += String.fromCharCode(...bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));  }  return btoa(binary);} /** * Blob → base64 data URL. Uses `arrayBuffer()` rather than `FileReader` so * the module works anywhere `Blob` does (including non-DOM test runners). */async function blobToDataUrl(blob: File | Blob, mimeTypeOverride?: string): Promise<string> {  const buffer = await blob.arrayBuffer();  const mimeType = mimeTypeOverride || blob.type || "application/octet-stream";  return `data:${mimeType};base64,${bytesToBase64(new Uint8Array(buffer))}`;} /** Approximate decoded byte count for a base64 data URL. */function dataUrlByteLength(dataUrl: string): number {  const commaIndex = dataUrl.indexOf(",");  const payload = commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);  const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;  return Math.max(0, Math.floor((payload.length * 3) / 4) - padding);} /** Base64 payload of a data URL decoded back into a `File`. */export function dataUrlToFile(dataUrl: string, name: string, mimeType: string): File {  const payload = dataUrl.slice(dataUrl.indexOf(",") + 1);  const binary = atob(payload);  const bytes = new Uint8Array(binary.length);  for (let index = 0; index < binary.length; index += 1) {    bytes[index] = binary.charCodeAt(index);  }  return new File([bytes], name, { type: mimeType });} /** * Re-encoding changes the container, so a name like `shot.png` would lie * about its contents. Swap the extension to match the encoded mime type. */function fileNameForMimeType(name: string, mimeType: string): string {  const extension = mimeType === "image/webp" ? ".webp" : ".jpg";  const dotIndex = name.lastIndexOf(".");  const base = dotIndex > 0 ? name.slice(0, dotIndex) : name;  return `${base}${extension}`;} function canRecompress(): boolean {  return (    typeof createImageBitmap === "function" &&    (typeof OffscreenCanvas === "function" || typeof document !== "undefined")  );} interface Canvas2D {  canvas: OffscreenCanvas | HTMLCanvasElement;  context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D;} function createCanvas(width: number, height: number): Canvas2D | null {  if (typeof OffscreenCanvas === "function") {    const canvas = new OffscreenCanvas(width, height);    const context = canvas.getContext("2d");    if (!context) return null;    return { canvas, context };  }  if (typeof document === "undefined") return null;  const canvas = document.createElement("canvas");  canvas.width = width;  canvas.height = height;  const context = canvas.getContext("2d");  if (!context) return null;  return { canvas, context };} /** * WebP is preferred: at matched visual quality it lands roughly 25-35% * smaller than JPEG, so the same budget buys more resolution and detail — * and it keeps alpha, so screenshots with transparency survive intact. * Browsers that can't encode it silently fall back to JPEG. */async function encodeCanvas(  canvas: OffscreenCanvas | HTMLCanvasElement,  quality: number,  mimeType: string,  budgetChars: number,): Promise<{ dataUrl: string | null; mimeType: string } | null> {  if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) {    const dataUrl = canvas.toDataURL(mimeType, quality);    // toDataURL silently returns a PNG when the requested type is unsupported.    if (!dataUrl.startsWith(`data:${mimeType}`)) return null;    return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType };  }  const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality });  if (blob.type && blob.type !== mimeType) return null;  const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3);  if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType };  return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType };} /** * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping * quality down until the data URL fits `budgetChars`. */async function encodeWithinBudget(  bitmap: ImageBitmap,  maxDimension: number,  budgetChars: number,  preferredMimeType?: "image/jpeg",): Promise<{ dataUrl: string; mimeType: string; imageSize: ImageSize } | null> {  const scale = Math.min(1, maxDimension / Math.max(bitmap.width, bitmap.height));  const width = Math.max(1, Math.round(bitmap.width * scale));  const height = Math.max(1, Math.round(bitmap.height * scale));  const target = createCanvas(width, height);  if (!target) return null;   // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to  // happen before drawing and depends on which codec we end up using.  const mimeType =    preferredMimeType ??    ((await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0))      ? "image/webp"      : "image/jpeg");   if (mimeType === "image/jpeg") {    target.context.fillStyle = "#ffffff";    target.context.fillRect(0, 0, width, height);  }  target.context.drawImage(bitmap, 0, 0, width, height);   for (const quality of QUALITY_STEPS) {    const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars);    if (!encoded) break;    if (encoded.dataUrl !== null) {      return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType, imageSize: { width, height } };    }  }  return null;} type ReencodeResult =  | { ok: true; dataUrl: string; mimeType: string; imageSize: ImageSize }  | { ok: false; reason: ImageCompressionFailureReason }; /** * Shared re-encode loop: decodes `file`, then walks the quality ladder and * fallback downscale passes until an encoding fits `budgetChars`. */async function reencodeWithinBudget(  file: File,  budgetChars: number,  preferredMimeType?: "image/jpeg",): Promise<ReencodeResult> {  if (!canRecompress()) {    return { ok: false, reason: "too-large" };  }   let bitmap: ImageBitmap;  try {    bitmap = await createImageBitmap(file);  } catch {    return { ok: false, reason: "unreadable" };  }   try {    // Each pass shrinks relative to the *previous target*, capped by    // MAX_DIMENSION. Scaling a fixed ceiling instead would be a no-op for    // images already smaller than that ceiling — the fallback passes would    // all resolve to the source size and never actually reduce resolution.    const baseDimension = Math.min(MAX_DIMENSION, Math.max(bitmap.width, bitmap.height));    // Tracks whether the *last* attempt threw, so a run of encoder failures    // is reported as unreadable while a run of merely-too-big results is    // reported as too-large.    let encodeFailed = false;    for (const dimensionScale of [1, ...FALLBACK_SCALE_STEPS]) {      const targetDimension = Math.max(1, Math.round(baseDimension * dimensionScale));      let encoded: Awaited<ReturnType<typeof encodeWithinBudget>>;      try {        encoded = await encodeWithinBudget(bitmap, targetDimension, budgetChars, preferredMimeType);      } catch {        // Canvas allocation, drawing, or the codec itself can throw — often        // precisely *because* the target is too big (OOM on a large bitmap).        // Keep trying the smaller fallback scales rather than giving up: a        // reduced pass may well succeed. The exception must never escape,        // though, since callers finalize state after this returns and a        // throw would strand it (e.g. a stash entry stuck "still saving").        encodeFailed = true;        continue;      }      encodeFailed = false;      if (encoded && encoded.dataUrl.length <= budgetChars) {        return {          ok: true,          dataUrl: encoded.dataUrl,          mimeType: encoded.mimeType,          imageSize: encoded.imageSize,        };      }    }    return { ok: false, reason: encodeFailed ? "unreadable" : "too-large" };  } finally {    bitmap.close();  }} /** * Produces the payload to persist for a stashed image. * * Small images are stored verbatim (preserving PNG transparency and exact * pixels). Anything over budget is downscaled and re-encoded; if it still * doesn't fit after the fallback passes, reports a failure so the caller * can record it as dropped. */export async function compressImageForStash(  file: File,  budgetChars: number = MAX_STASH_IMAGE_DATA_URL_CHARS,): Promise<CompressStashImageResult> {  let originalDataUrl: string;  try {    originalDataUrl = await blobToDataUrl(file);  } catch {    return { ok: false, reason: "unreadable" };  }  if (originalDataUrl.length <= budgetChars) {    return {      ok: true,      image: {        dataUrl: originalDataUrl,        mimeType: file.type,        sizeBytes: file.size,        recompressed: false,      },    };  }  const reencoded = await reencodeWithinBudget(file, budgetChars);  if (!reencoded.ok) {    return reencoded;  }  return {    ok: true,    image: {      dataUrl: reencoded.dataUrl,      mimeType: reencoded.mimeType,      sizeBytes: dataUrlByteLength(reencoded.dataUrl),      recompressed: true,      imageSize: reencoded.imageSize,    },  };} /** * Shrinks `file` until its binary size fits `maxBytes`, returning a new * `File` (WebP or JPEG). Files already within the limit pass through * untouched, preserving their exact bytes and format. Sources above * `MAX_COMPRESSIBLE_SOURCE_BYTES` are refused outright — decoding them is * the risk, so no amount of output budget makes them safe. An internally * converted image can provide its original source size when the intermediate * format expands beyond that ceiling. */export async function compressImageToByteLimit(  file: File,  maxBytes: number,  options?: { preferredMimeType?: "image/jpeg"; sourceSizeBytes?: number },): Promise<CompressImageFileResult> {  if (file.size <= maxBytes) {    return { ok: true, file, recompressed: false };  }  if ((options?.sourceSizeBytes ?? file.size) > MAX_COMPRESSIBLE_SOURCE_BYTES) {    return { ok: false, reason: "too-large" };  }  // The re-encode loop budgets in data-URL characters. Base64 turns 3 bytes  // into 4 chars; flooring keeps the budget a hair conservative instead of  // admitting an encoding right at the byte cap.  const budgetChars = Math.floor(maxBytes / 3) * 4;  const reencoded = await reencodeWithinBudget(file, budgetChars, options?.preferredMimeType);  if (!reencoded.ok) {    return reencoded;  }  return {    ok: true,    file: dataUrlToFile(      reencoded.dataUrl,      fileNameForMimeType(file.name || "image", reencoded.mimeType),      reencoded.mimeType,    ),    recompressed: true,    imageSize: reencoded.imageSize,  };} /** * Converts HEIC/HEIF photos to provider-compatible JPEG before applying the * attachment size limit. The decoder is loaded only when such a photo arrives. */export async function prepareImageForAttachment(  file: File,  maxBytes: number,): Promise<CompressImageFileResult> {  if (!isHeicImageFile(file)) {    return compressImageToByteLimit(file, maxBytes);  }   if (file.size > MAX_COMPRESSIBLE_SOURCE_BYTES) {    return { ok: false, reason: "too-large" };  }   let converted: Blob;  try {    const dimensionError = await validateHeicImageDimensions(file);    if (dimensionError) {      return { ok: false, reason: dimensionError };    }    const { heicTo } = await import("heic-to/csp");    converted = await heicTo({ blob: file, type: "image/jpeg", quality: QUALITY_STEPS[0] });  } catch {    return { ok: false, reason: "unreadable" };  }   const jpeg = new File([converted], fileNameForMimeType(file.name || "image", "image/jpeg"), {    type: "image/jpeg",    lastModified: file.lastModified,  });  const result = await compressImageToByteLimit(jpeg, maxBytes, {    preferredMimeType: "image/jpeg",    sourceSizeBytes: file.size,  });   return result.ok ? { ...result, recompressed: true } : result;}