202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
/**
|
|
* image-utils.ts — Browser-side image validation, reading, resizing, and processing.
|
|
*
|
|
* Pure utilities shared by chat mode (drag/paste → base64 inline) and terminal mode
|
|
* (drag/paste → upload). All functions are side-effect-free except for Canvas usage
|
|
* in resizeImageInBrowser.
|
|
*
|
|
* Observability:
|
|
* - console.warn on validation failure (wrong MIME type, oversized file)
|
|
* - Errors thrown with descriptive messages for upstream catch handlers
|
|
*/
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
const ALLOWED_MIME_TYPES = new Set([
|
|
"image/jpeg",
|
|
"image/png",
|
|
"image/gif",
|
|
"image/webp",
|
|
]);
|
|
|
|
/** Raw file size limit before base64 encoding (20 MB) */
|
|
const MAX_RAW_FILE_SIZE = 20 * 1024 * 1024;
|
|
|
|
/** Maximum base64 payload size after encoding/resize (4.5 MB) */
|
|
const MAX_BASE64_PAYLOAD_SIZE = 4.5 * 1024 * 1024;
|
|
|
|
/** Maximum image dimension (width or height) before resize triggers */
|
|
const MAX_DIMENSION = 2000;
|
|
|
|
/** Maximum number of pending images per message */
|
|
export const MAX_PENDING_IMAGES = 5;
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface PendingImage {
|
|
/** Unique identifier for removal */
|
|
id: string;
|
|
/** Base64-encoded image data (no data URI prefix) */
|
|
data: string;
|
|
/** MIME type of the image */
|
|
mimeType: string;
|
|
/** Blob URL for efficient thumbnail rendering — must be revoked on cleanup */
|
|
previewUrl: string;
|
|
}
|
|
|
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
|
|
export function validateImageFile(file: File): {
|
|
valid: boolean;
|
|
error?: string;
|
|
} {
|
|
if (!ALLOWED_MIME_TYPES.has(file.type)) {
|
|
const error = `Unsupported image type: ${file.type || "unknown"}. Accepted: JPEG, PNG, GIF, WebP.`;
|
|
console.warn("[image-utils] validation failed:", error);
|
|
return { valid: false, error };
|
|
}
|
|
|
|
if (file.size > MAX_RAW_FILE_SIZE) {
|
|
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
|
|
const error = `Image too large (${sizeMB} MB). Maximum: 20 MB.`;
|
|
console.warn("[image-utils] validation failed:", error);
|
|
return { valid: false, error };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
// ─── File Reading ─────────────────────────────────────────────────────────────
|
|
|
|
export function readFileAsBase64(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
const arrayBuffer = reader.result as ArrayBuffer;
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
let binary = "";
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
resolve(btoa(binary));
|
|
};
|
|
reader.onerror = () => reject(new Error("Failed to read image file"));
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
}
|
|
|
|
// ─── Resize ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resize an image if its dimensions exceed MAX_DIMENSION or its payload exceeds
|
|
* MAX_BASE64_PAYLOAD_SIZE.
|
|
*
|
|
* For GIF/WebP: skip resize if the base64 payload is already under the byte limit
|
|
* (canvas strips animation frames). If over limit, convert to JPEG.
|
|
*
|
|
* Re-checks final payload size after resize; rejects if still over limit.
|
|
*/
|
|
export async function resizeImageInBrowser(
|
|
base64: string,
|
|
mimeType: string,
|
|
): Promise<{ data: string; mimeType: string }> {
|
|
const payloadBytes = base64.length * 0.75; // approximate decoded size
|
|
|
|
// For animated formats (GIF/WebP), preserve if under limit
|
|
if (
|
|
(mimeType === "image/gif" || mimeType === "image/webp") &&
|
|
payloadBytes <= MAX_BASE64_PAYLOAD_SIZE
|
|
) {
|
|
return { data: base64, mimeType };
|
|
}
|
|
|
|
// Load image to check dimensions
|
|
const img = await loadImage(base64, mimeType);
|
|
const needsDimensionResize =
|
|
img.width > MAX_DIMENSION || img.height > MAX_DIMENSION;
|
|
const needsPayloadResize = payloadBytes > MAX_BASE64_PAYLOAD_SIZE;
|
|
|
|
if (!needsDimensionResize && !needsPayloadResize) {
|
|
return { data: base64, mimeType };
|
|
}
|
|
|
|
// Determine output format — animated formats convert to JPEG when resized
|
|
const outputMime =
|
|
mimeType === "image/gif" || mimeType === "image/webp"
|
|
? "image/jpeg"
|
|
: mimeType;
|
|
|
|
// Calculate target dimensions
|
|
let targetWidth = img.width;
|
|
let targetHeight = img.height;
|
|
|
|
if (needsDimensionResize) {
|
|
const scale = Math.min(
|
|
MAX_DIMENSION / img.width,
|
|
MAX_DIMENSION / img.height,
|
|
);
|
|
targetWidth = Math.round(img.width * scale);
|
|
targetHeight = Math.round(img.height * scale);
|
|
}
|
|
|
|
// Canvas resize
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = targetWidth;
|
|
canvas.height = targetHeight;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) throw new Error("Canvas 2D context not available");
|
|
|
|
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
|
|
|
|
// Encode — JPEG gets quality 0.85, PNG is lossless
|
|
const quality = outputMime === "image/jpeg" ? 0.85 : undefined;
|
|
const dataUrl = canvas.toDataURL(outputMime, quality);
|
|
const resizedBase64 = dataUrl.split(",")[1];
|
|
|
|
// Re-check payload size
|
|
const finalBytes = resizedBase64.length * 0.75;
|
|
if (finalBytes > MAX_BASE64_PAYLOAD_SIZE) {
|
|
throw new Error(
|
|
`Image still exceeds 4.5 MB after resize (${(finalBytes / (1024 * 1024)).toFixed(1)} MB). Try a smaller image.`,
|
|
);
|
|
}
|
|
|
|
return { data: resizedBase64, mimeType: outputMime };
|
|
}
|
|
|
|
// ─── Orchestrator ─────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Single entry point: validate → read → resize.
|
|
* Used by both chat mode and terminal mode.
|
|
*/
|
|
export async function processImageFile(
|
|
file: File,
|
|
): Promise<{ data: string; mimeType: string }> {
|
|
const validation = validateImageFile(file);
|
|
if (!validation.valid) {
|
|
throw new Error(validation.error);
|
|
}
|
|
|
|
const base64 = await readFileAsBase64(file);
|
|
return resizeImageInBrowser(base64, file.type);
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function loadImage(
|
|
base64: string,
|
|
mimeType: string,
|
|
): Promise<HTMLImageElement> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
img.onload = () => resolve(img);
|
|
img.onerror = () => reject(new Error("Failed to decode image"));
|
|
img.src = `data:${mimeType};base64,${base64}`;
|
|
});
|
|
}
|
|
|
|
/** Generate a short unique ID for pending image tracking */
|
|
export function generateImageId(): string {
|
|
return `img-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|