🖼️ feat: Add Optional Client-Side Image Resizing to Prevent Upload Errors (#7909)

* 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
This commit is contained in:
Rakshit Tiwari 2025-06-24 20:13:29 +05:30 committed by GitHub
parent d9a0fe03ed
commit 42977ac0d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 480 additions and 10 deletions

View file

@ -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<ResizeOptions>,
): 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<ResizeOptions> = {
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;

View file

@ -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;

View file

@ -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);
});
});
});

View file

@ -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<ResizeOptions> = {},
): Promise<ResizeResult> {
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;
}

View file

@ -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

View file

@ -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<typeof fileConfigSchema> | unde
mergedConfig.avatarSizeLimit = mbToBytes(dynamic.avatarSizeLimit);
}
// Merge clientImageResize configuration
if (dynamic.clientImageResize !== undefined) {
mergedConfig.clientImageResize = {
...mergedConfig.clientImageResize,
...dynamic.clientImageResize,
};
}
if (!dynamic.endpoints) {
return mergedConfig;
}

View file

@ -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;
};