From 42977ac0d049bde5dcd054eabc7902ec62e7fdf5 Mon Sep 17 00:00:00 2001 From: Rakshit Tiwari Date: Tue, 24 Jun 2025 20:13:29 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=96=BC=EF=B8=8F=20feat:=20Add=20Optional?= =?UTF-8?q?=20Client-Side=20Image=20Resizing=20to=20Prevent=20Upload=20Err?= =?UTF-8?q?ors=20(#7909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add optional client-side image resizing to prevent upload errors * Addressing comments from author * Addressing eslint errors * Fixing the naming to clientresize from clientsideresize --- client/src/hooks/Files/useClientResize.ts | 84 +++++++ client/src/hooks/Files/useFileHandling.ts | 49 +++- .../src/utils/__tests__/imageResize.test.ts | 108 +++++++++ client/src/utils/imageResize.ts | 215 ++++++++++++++++++ librechat.example.yaml | 6 + packages/data-provider/src/file-config.ts | 22 ++ packages/data-provider/src/types/files.ts | 6 + 7 files changed, 480 insertions(+), 10 deletions(-) create mode 100644 client/src/hooks/Files/useClientResize.ts create mode 100644 client/src/utils/__tests__/imageResize.test.ts create mode 100644 client/src/utils/imageResize.ts diff --git a/client/src/hooks/Files/useClientResize.ts b/client/src/hooks/Files/useClientResize.ts new file mode 100644 index 0000000000..1da3848aeb --- /dev/null +++ b/client/src/hooks/Files/useClientResize.ts @@ -0,0 +1,84 @@ +import { mergeFileConfig } from 'librechat-data-provider'; +import { useCallback } from 'react'; +import { useGetFileConfig } from '~/data-provider'; +import { + resizeImage, + shouldResizeImage, + supportsClientResize, + type ResizeOptions, + type ResizeResult, +} from '~/utils/imageResize'; + +/** + * Hook for client-side image resizing functionality + * Integrates with LibreChat's file configuration system + */ +export const useClientResize = () => { + const { data: fileConfig = null } = useGetFileConfig({ + select: (data) => mergeFileConfig(data), + }); + + // Safe access to clientImageResize config with fallbacks + // eslint-disable-next-line react-hooks/exhaustive-deps + const config = (fileConfig as any)?.clientImageResize ?? { + enabled: false, + maxWidth: 1900, + maxHeight: 1900, + quality: 0.92, + }; + const isEnabled = config?.enabled ?? false; + + /** + * Resizes an image if client-side resizing is enabled and supported + * @param file - The image file to resize + * @param options - Optional resize options to override defaults + * @returns Promise resolving to either the resized file result or original file + */ + const resizeImageIfNeeded = useCallback( + async ( + file: File, + options?: Partial, + ): Promise<{ file: File; resized: boolean; result?: ResizeResult }> => { + // Return original file if resizing is disabled + if (!isEnabled) { + return { file, resized: false }; + } + + // Return original file if browser doesn't support resizing + if (!supportsClientResize()) { + console.warn('Client-side image resizing not supported in this browser'); + return { file, resized: false }; + } + + // Return original file if it doesn't need resizing + if (!shouldResizeImage(file)) { + return { file, resized: false }; + } + + try { + const resizeOptions: Partial = { + maxWidth: config?.maxWidth, + maxHeight: config?.maxHeight, + quality: config?.quality, + ...options, + }; + + const result = await resizeImage(file, resizeOptions); + return { file: result.file, resized: true, result }; + } catch (error) { + console.warn('Client-side image resizing failed:', error); + return { file, resized: false }; + } + }, + [isEnabled, config], + ); + + return { + isEnabled, + isSupported: supportsClientResize(), + config, + resizeImageIfNeeded, + }; +}; + +export default useClientResize; diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 9e03f29334..7f74a02733 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -18,6 +18,7 @@ import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import { useChatContext } from '~/Providers/ChatContext'; import { useToastContext } from '~/Providers/ToastContext'; import { logger, validateFiles } from '~/utils'; +import useClientResize from './useClientResize'; import { processFileForUpload } from '~/utils/heicConverter'; import { useDelayedUploadToast } from './useDelayedUploadToast'; import useUpdateFiles from './useUpdateFiles'; @@ -41,6 +42,7 @@ const useFileHandling = (params?: UseFileHandling) => { const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles( params?.fileSetter ?? setFiles, ); + const { resizeImageIfNeeded } = useClientResize(); const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; @@ -298,7 +300,7 @@ const useFileHandling = (params?: UseFileHandling) => { } // Process file for HEIC conversion if needed - const processedFile = await processFileForUpload( + const heicProcessedFile = await processFileForUpload( originalFile, 0.9, (conversionProgress) => { @@ -311,23 +313,50 @@ const useFileHandling = (params?: UseFileHandling) => { }, ); - // If file was converted, update with new file and preview - if (processedFile !== originalFile) { + let finalProcessedFile = heicProcessedFile; + + // Apply client-side resizing if available and appropriate + if (heicProcessedFile.type.startsWith('image/')) { + try { + const resizeResult = await resizeImageIfNeeded(heicProcessedFile); + finalProcessedFile = resizeResult.file; + + // Show toast notification if image was resized + if (resizeResult.resized && resizeResult.result) { + const { originalSize, newSize, compressionRatio } = resizeResult.result; + const originalSizeMB = (originalSize / (1024 * 1024)).toFixed(1); + const newSizeMB = (newSize / (1024 * 1024)).toFixed(1); + const savedPercent = Math.round((1 - compressionRatio) * 100); + + showToast({ + message: `Image resized: ${originalSizeMB}MB → ${newSizeMB}MB (${savedPercent}% smaller)`, + status: 'success', + duration: 3000, + }); + } + } catch (resizeError) { + console.warn('Image resize failed, using original:', resizeError); + // Continue with HEIC processed file if resizing fails + } + } + + // If file was processed (HEIC converted or resized), update with new file and preview + if (finalProcessedFile !== originalFile) { URL.revokeObjectURL(initialPreview); // Clean up original preview - const newPreview = URL.createObjectURL(processedFile); + const newPreview = URL.createObjectURL(finalProcessedFile); const updatedExtendedFile: ExtendedFile = { ...initialExtendedFile, - file: processedFile, - type: processedFile.type, + file: finalProcessedFile, + type: finalProcessedFile.type, preview: newPreview, - progress: 0.5, // Conversion complete, ready for upload - size: processedFile.size, + progress: 0.5, // Processing complete, ready for upload + size: finalProcessedFile.size, }; replaceFile(updatedExtendedFile); - const isImage = processedFile.type.split('/')[0] === 'image'; + const isImage = finalProcessedFile.type.split('/')[0] === 'image'; if (isImage) { loadImage(updatedExtendedFile, newPreview); continue; @@ -335,7 +364,7 @@ const useFileHandling = (params?: UseFileHandling) => { await startUpload(updatedExtendedFile); } else { - // File wasn't converted, proceed with original + // File wasn't processed, proceed with original const isImage = originalFile.type.split('/')[0] === 'image'; const tool_resource = initialExtendedFile.tool_resource ?? params?.additionalMetadata?.tool_resource; diff --git a/client/src/utils/__tests__/imageResize.test.ts b/client/src/utils/__tests__/imageResize.test.ts new file mode 100644 index 0000000000..c09d2293d1 --- /dev/null +++ b/client/src/utils/__tests__/imageResize.test.ts @@ -0,0 +1,108 @@ +/** + * Tests for client-side image resizing utility + */ + +import { shouldResizeImage, supportsClientResize } from '../imageResize'; + +// Mock browser APIs for testing +Object.defineProperty(global, 'HTMLCanvasElement', { + value: function () { + return { + getContext: () => ({ + drawImage: jest.fn(), + }), + toBlob: jest.fn(), + }; + }, + writable: true, +}); + +Object.defineProperty(global, 'FileReader', { + value: function () { + return { + readAsDataURL: jest.fn(), + }; + }, + writable: true, +}); + +Object.defineProperty(global, 'Image', { + value: function () { + return {}; + }, + writable: true, +}); + +describe('imageResize utility', () => { + describe('supportsClientResize', () => { + it('should return true when all required APIs are available', () => { + const result = supportsClientResize(); + expect(result).toBe(true); + }); + + it('should return false when HTMLCanvasElement is not available', () => { + const originalCanvas = global.HTMLCanvasElement; + // @ts-ignore + delete global.HTMLCanvasElement; + + const result = supportsClientResize(); + expect(result).toBe(false); + + global.HTMLCanvasElement = originalCanvas; + }); + }); + + describe('shouldResizeImage', () => { + it('should return true for large image files', () => { + const largeImageFile = new File([''], 'test.jpg', { + type: 'image/jpeg', + lastModified: Date.now(), + }); + + // Mock large file size + Object.defineProperty(largeImageFile, 'size', { + value: 100 * 1024 * 1024, // 100MB + writable: false, + }); + + const result = shouldResizeImage(largeImageFile, 50 * 1024 * 1024); // 50MB limit + expect(result).toBe(true); + }); + + it('should return false for small image files', () => { + const smallImageFile = new File([''], 'test.jpg', { + type: 'image/jpeg', + lastModified: Date.now(), + }); + + // Mock small file size + Object.defineProperty(smallImageFile, 'size', { + value: 1024, // 1KB + writable: false, + }); + + const result = shouldResizeImage(smallImageFile, 50 * 1024 * 1024); // 50MB limit + expect(result).toBe(false); + }); + + it('should return false for non-image files', () => { + const textFile = new File([''], 'test.txt', { + type: 'text/plain', + lastModified: Date.now(), + }); + + const result = shouldResizeImage(textFile); + expect(result).toBe(false); + }); + + it('should return false for GIF files', () => { + const gifFile = new File([''], 'test.gif', { + type: 'image/gif', + lastModified: Date.now(), + }); + + const result = shouldResizeImage(gifFile); + expect(result).toBe(false); + }); + }); +}); diff --git a/client/src/utils/imageResize.ts b/client/src/utils/imageResize.ts new file mode 100644 index 0000000000..3be6e8d8c0 --- /dev/null +++ b/client/src/utils/imageResize.ts @@ -0,0 +1,215 @@ +/** + * Client-side image resizing utility for LibreChat + * Resizes images to prevent backend upload errors while maintaining quality + */ + +export interface ResizeOptions { + maxWidth?: number; + maxHeight?: number; + quality?: number; + format?: 'jpeg' | 'png' | 'webp'; +} + +export interface ResizeResult { + file: File; + originalSize: number; + newSize: number; + originalDimensions: { width: number; height: number }; + newDimensions: { width: number; height: number }; + compressionRatio: number; +} + +/** + * Default resize options based on backend 'high' resolution settings + * Backend 'high' uses maxShortSide=768, maxLongSide=2000 + * We use slightly smaller values to ensure no backend resizing is triggered + */ +const DEFAULT_RESIZE_OPTIONS: ResizeOptions = { + maxWidth: 1900, // Slightly less than backend maxLongSide=2000 + maxHeight: 1900, // Slightly less than backend maxLongSide=2000 + quality: 0.92, // High quality while reducing file size + format: 'jpeg', // Most compatible format +}; + +/** + * Checks if the browser supports canvas-based image resizing + */ +export function supportsClientResize(): boolean { + try { + // Check for required APIs + if (typeof HTMLCanvasElement === 'undefined') return false; + if (typeof FileReader === 'undefined') return false; + if (typeof Image === 'undefined') return false; + + // Test canvas creation + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + return !!(ctx && ctx.drawImage && canvas.toBlob); + } catch { + return false; + } +} + +/** + * Calculates new dimensions while maintaining aspect ratio + */ +function calculateDimensions( + originalWidth: number, + originalHeight: number, + maxWidth: number, + maxHeight: number, +): { width: number; height: number } { + const { width, height } = { width: originalWidth, height: originalHeight }; + + // If image is smaller than max dimensions, don't upscale + if (width <= maxWidth && height <= maxHeight) { + return { width, height }; + } + + // Calculate scaling factor + const widthRatio = maxWidth / width; + const heightRatio = maxHeight / height; + const scalingFactor = Math.min(widthRatio, heightRatio); + + return { + width: Math.round(width * scalingFactor), + height: Math.round(height * scalingFactor), + }; +} + +/** + * Resizes an image file using canvas + */ +export function resizeImage( + file: File, + options: Partial = {}, +): Promise { + return new Promise((resolve, reject) => { + // Check browser support + if (!supportsClientResize()) { + reject(new Error('Browser does not support client-side image resizing')); + return; + } + + // Only process image files + if (!file.type.startsWith('image/')) { + reject(new Error('File is not an image')); + return; + } + + const opts = { ...DEFAULT_RESIZE_OPTIONS, ...options }; + const reader = new FileReader(); + + reader.onload = (event) => { + const img = new Image(); + + img.onload = () => { + try { + const originalDimensions = { width: img.width, height: img.height }; + const newDimensions = calculateDimensions( + img.width, + img.height, + opts.maxWidth!, + opts.maxHeight!, + ); + + // If no resizing needed, return original file + if ( + newDimensions.width === originalDimensions.width && + newDimensions.height === originalDimensions.height + ) { + resolve({ + file, + originalSize: file.size, + newSize: file.size, + originalDimensions, + newDimensions, + compressionRatio: 1, + }); + return; + } + + // Create canvas and resize + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + + canvas.width = newDimensions.width; + canvas.height = newDimensions.height; + + // Use high-quality image smoothing + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + + // Draw resized image + ctx.drawImage(img, 0, 0, newDimensions.width, newDimensions.height); + + // Convert to blob + canvas.toBlob( + (blob) => { + if (!blob) { + reject(new Error('Failed to create blob from canvas')); + return; + } + + // Create new file with same name but potentially different extension + const extension = opts.format === 'jpeg' ? '.jpg' : `.${opts.format}`; + const baseName = file.name.replace(/\.[^/.]+$/, ''); + const newFileName = `${baseName}${extension}`; + + const resizedFile = new File([blob], newFileName, { + type: `image/${opts.format}`, + lastModified: Date.now(), + }); + + resolve({ + file: resizedFile, + originalSize: file.size, + newSize: resizedFile.size, + originalDimensions, + newDimensions, + compressionRatio: resizedFile.size / file.size, + }); + }, + `image/${opts.format}`, + opts.quality, + ); + } catch (error) { + reject(error); + } + }; + + img.onerror = () => reject(new Error('Failed to load image')); + img.src = event.target?.result as string; + }; + + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); +} + +/** + * Determines if an image should be resized based on size and dimensions + */ +export function shouldResizeImage( + file: File, + fileSizeLimit: number = 512 * 1024 * 1024, // 512MB default +): boolean { + // Don't resize if file is already small + if (file.size < fileSizeLimit * 0.1) { + // Less than 10% of limit + return false; + } + + // Don't process non-images + if (!file.type.startsWith('image/')) { + return false; + } + + // Don't process GIFs (they might be animated) + if (file.type === 'image/gif') { + return false; + } + + return true; +} diff --git a/librechat.example.yaml b/librechat.example.yaml index 17aeafd82d..de28dcc32e 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -300,6 +300,12 @@ endpoints: # imageGeneration: # Image Gen settings, either percentage or px # percentage: 100 # px: 1024 +# # Client-side image resizing to prevent upload errors +# clientImageResize: +# enabled: false # Enable/disable client-side image resizing (default: false) +# maxWidth: 1900 # Maximum width for resized images (default: 1900) +# maxHeight: 1900 # Maximum height for resized images (default: 1900) +# quality: 0.92 # JPEG quality for compression (0.0-1.0, default: 0.92) # # See the Custom Configuration Guide for more information on Assistants Config: # # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint diff --git a/packages/data-provider/src/file-config.ts b/packages/data-provider/src/file-config.ts index f09fce7cc5..08cf99fbe6 100644 --- a/packages/data-provider/src/file-config.ts +++ b/packages/data-provider/src/file-config.ts @@ -192,6 +192,12 @@ export const fileConfig = { }, serverFileSizeLimit: defaultSizeLimit, avatarSizeLimit: mbToBytes(2), + clientImageResize: { + enabled: false, + maxWidth: 1900, + maxHeight: 1900, + quality: 0.92, + }, checkType: function (fileType: string, supportedTypes: RegExp[] = supportedMimeTypes) { return supportedTypes.some((regex) => regex.test(fileType)); }, @@ -232,6 +238,14 @@ export const fileConfigSchema = z.object({ px: z.number().min(0).optional(), }) .optional(), + clientImageResize: z + .object({ + enabled: z.boolean().optional(), + maxWidth: z.number().min(0).optional(), + maxHeight: z.number().min(0).optional(), + quality: z.number().min(0).max(1).optional(), + }) + .optional(), }); /** Helper function to safely convert string patterns to RegExp objects */ @@ -260,6 +274,14 @@ export function mergeFileConfig(dynamic: z.infer | unde mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit); } + // Merge clientImageResize configuration + if (dynamic.clientImageResize !== undefined) { + mergedConfig.clientImageResize = { + ...mergedConfig.clientImageResize, + ...dynamic.clientImageResize, + }; + } + if (!dynamic.endpoints) { return mergedConfig; } diff --git a/packages/data-provider/src/types/files.ts b/packages/data-provider/src/types/files.ts index bae3e783bf..95b74a4216 100644 --- a/packages/data-provider/src/types/files.ts +++ b/packages/data-provider/src/types/files.ts @@ -48,6 +48,12 @@ export type FileConfig = { }; serverFileSizeLimit?: number; avatarSizeLimit?: number; + clientImageResize?: { + enabled?: boolean; + maxWidth?: number; + maxHeight?: number; + quality?: number; + }; checkType?: (fileType: string, supportedTypes: RegExp[]) => boolean; };