export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 8 * 1024 * 1024; export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024; export const CHAT_IMAGE_MAX_SIDE = 1920; const MAX_PIXELS = 2_500_000; const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); type CompressOptions = { maxInputBytes?: number; maxOutputBytes?: number; maxDimension?: number; }; function loadImageFromUrl(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('图片读取失败')); img.src = src; }); } function computeOutputMime(inputType: string) { if (ACCEPTED_IMAGE_MIME.has(inputType)) { return 'image/jpeg'; } return 'image/jpeg'; } function canvasToFile(canvas: HTMLCanvasElement, mime: string, quality: number, fileName: string): Promise { return new Promise((resolve, reject) => { canvas.toBlob( (blob) => { if (!blob) { reject(new Error('图片压缩失败')); return; } resolve(new File([blob], fileName, { type: mime })); }, mime, quality, ); }); } function fitDimensions(width: number, height: number, maxSide: number) { if (width <= maxSide && height <= maxSide && width * height <= MAX_PIXELS) { return { width, height }; } const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(MAX_PIXELS / (width * height))); return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)), }; } function drawImageToCanvas(img: HTMLImageElement, width: number, height: number) { const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('图片处理环境不可用'); } ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return canvas; } async function encodeCanvasWithinLimit( canvas: HTMLCanvasElement, outputType: string, targetName: string, maxOutputBytes: number, ) { let quality = 0.86; let candidate = await canvasToFile(canvas, outputType, quality, targetName); while (candidate.size > maxOutputBytes && quality > 0.5) { quality = Math.max(0.5, quality - 0.08); candidate = await canvasToFile(canvas, outputType, quality, targetName); } return candidate; } export async function compressImageForUpload( file: File, options: CompressOptions = {}, ): Promise { const maxInputBytes = options.maxInputBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; const maxOutputBytes = options.maxOutputBytes ?? CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES; const maxDimension = options.maxDimension ?? CHAT_IMAGE_MAX_SIDE; if (!file.type.startsWith('image/')) { throw new Error('只支持图片文件'); } if (file.size > maxInputBytes) { throw new Error(`单张图片大小不能超过 ${(maxInputBytes / (1024 * 1024)).toFixed(0)}MB`); } if (file.type === 'image/gif') { if (file.size > maxOutputBytes) { throw new Error(`GIF 当前不支持自动压缩,请使用 JPEG/PNG/WEBP,且单张不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`); } return file; } const objectUrl = URL.createObjectURL(file); try { const img = await loadImageFromUrl(objectUrl); let { width, height } = fitDimensions(img.width, img.height, maxDimension); const needsResize = width !== img.width || height !== img.height; if (!needsResize && file.size <= maxOutputBytes) { return file; } const outputType = computeOutputMime(file.type); const targetName = `${file.name.replace(/\.[^.]+$/, '') || 'image'}.jpg`; let canvas = drawImageToCanvas(img, width, height); let candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes); while (candidate.size > maxOutputBytes && Math.max(width, height) > 640) { width = Math.max(1, Math.round(width * 0.85)); height = Math.max(1, Math.round(height * 0.85)); canvas = drawImageToCanvas(img, width, height); candidate = await encodeCanvasWithinLimit(canvas, outputType, targetName, maxOutputBytes); } if (candidate.size > maxOutputBytes) { throw new Error(`图片压缩后仍过大,请上传更小尺寸图片,目标不超过 ${(maxOutputBytes / (1024 * 1024)).toFixed(0)}MB`); } return candidate; } finally { URL.revokeObjectURL(objectUrl); } }