mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🖼️ 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:
parent
d9a0fe03ed
commit
42977ac0d0
7 changed files with 480 additions and 10 deletions
84
client/src/hooks/Files/useClientResize.ts
Normal file
84
client/src/hooks/Files/useClientResize.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
108
client/src/utils/__tests__/imageResize.test.ts
Normal file
108
client/src/utils/__tests__/imageResize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
215
client/src/utils/imageResize.ts
Normal file
215
client/src/utils/imageResize.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue