mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-10 16:40:42 +00:00
* feat: allow users to toggle client-side image resizing Client-side image resizing could only be configured in librechat.yaml and defaulted to off, so users had no way to enable it for themselves. Add a "Resize images before upload" toggle in Settings > Chat > Sending, stored per device in localStorage. When librechat.yaml sets clientImageResize.enabled, mergeFileConfig marks the value as enforced and the toggle renders the admin value read-only. Admin resize parameters still apply without locking the toggle when enabled is omitted. Also fix shouldResizeImage, which compared file size against 10% of a 512MB fallback limit and so only triggered above roughly 51MB. It now uses a 512KB floor, which lets the setting affect everyday photos. * fix: harden client image resizing * fix(client): restrict image resizing and localize toast * fix: harden client image resizing * fix(client): recognize static WebP image chunks * fix(client): clamp resized image dimensions * fix(client): enforce safe image resize uploads * fix(client): recheck duplicates after transforming uploads * fix(client): keep selected files after input reset * fix(client): disable image resizing when file config fails * fix(client): decode resize candidates without a base64 copy * fix(client): coordinate upload batches across hook instances * test(client): drop the untyped conversation from shared upload state * fix(client): start the config wait before queueing uploads * fix(client): disable the resize switch while file config is pending The switch stayed clickable during the initial file-config load even though the checked state cannot update until that query settles. * fix(client): only track upload reservations for observable state
363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
|
|
type ResizeFormat = NonNullable<ResizeOptions['format']>;
|
|
|
|
/**
|
|
* 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
|
|
};
|
|
|
|
const RESIZE_FORMAT_BY_MIME_TYPE: Partial<Record<string, ResizeFormat>> = {
|
|
'image/jpeg': 'jpeg',
|
|
'image/png': 'png',
|
|
'image/webp': 'webp',
|
|
};
|
|
|
|
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
|
|
const WEBP_ANIMATION_FLAG = 0x02;
|
|
const MAX_ANIMATION_SCAN_BYTES = 64 * 1024;
|
|
|
|
const readBytes = (file: File, start: number, length: number): Promise<Uint8Array> =>
|
|
new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
if (!(reader.result instanceof ArrayBuffer)) {
|
|
reject(new Error('Failed to read image container'));
|
|
return;
|
|
}
|
|
resolve(new Uint8Array(reader.result));
|
|
};
|
|
reader.onerror = () => reject(new Error('Failed to read image container'));
|
|
reader.readAsArrayBuffer(file.slice(start, start + length));
|
|
});
|
|
|
|
const readAscii = (bytes: Uint8Array, start: number, length: number): string =>
|
|
String.fromCharCode(...bytes.slice(start, start + length));
|
|
|
|
const hasSignature = (bytes: Uint8Array, signature: readonly number[]): boolean =>
|
|
bytes.length >= signature.length && signature.every((value, index) => bytes[index] === value);
|
|
|
|
const isAnimatedPng = async (file: File): Promise<boolean> => {
|
|
const bytes = await readBytes(file, 0, Math.min(file.size, MAX_ANIMATION_SCAN_BYTES));
|
|
if (!hasSignature(bytes, PNG_SIGNATURE)) {
|
|
return false;
|
|
}
|
|
|
|
let offset: number = PNG_SIGNATURE.length;
|
|
while (offset + 12 <= bytes.length) {
|
|
const header = bytes.subarray(offset, offset + 8);
|
|
const chunkLength = new DataView(header.buffer, header.byteOffset, header.byteLength).getUint32(
|
|
0,
|
|
);
|
|
const chunkType = readAscii(header, 4, 4);
|
|
const nextOffset = offset + 12 + chunkLength;
|
|
if (nextOffset > file.size) {
|
|
return false;
|
|
}
|
|
if (chunkType === 'acTL') {
|
|
return true;
|
|
}
|
|
if (chunkType === 'IDAT' || chunkType === 'IEND') {
|
|
return false;
|
|
}
|
|
if (nextOffset > bytes.length) {
|
|
return nextOffset < file.size;
|
|
}
|
|
|
|
offset = nextOffset;
|
|
}
|
|
|
|
return bytes.length < file.size;
|
|
};
|
|
|
|
const isAnimatedWebP = async (file: File): Promise<boolean> => {
|
|
const bytes = await readBytes(file, 0, Math.min(file.size, MAX_ANIMATION_SCAN_BYTES));
|
|
if (readAscii(bytes, 0, 4) !== 'RIFF' || readAscii(bytes, 8, 4) !== 'WEBP') {
|
|
return false;
|
|
}
|
|
|
|
let offset = 12;
|
|
while (offset + 8 <= bytes.length) {
|
|
const header = bytes.subarray(offset, offset + 8);
|
|
const chunkType = readAscii(header, 0, 4);
|
|
const chunkSize = new DataView(header.buffer, header.byteOffset, header.byteLength).getUint32(
|
|
4,
|
|
true,
|
|
);
|
|
const dataEnd = offset + 8 + chunkSize;
|
|
if (dataEnd > file.size) {
|
|
return false;
|
|
}
|
|
if (chunkType === 'ANIM' || chunkType === 'ANMF') {
|
|
return true;
|
|
}
|
|
if (chunkType === 'VP8 ' || chunkType === 'VP8L') {
|
|
return false;
|
|
}
|
|
if (chunkType === 'VP8X' && chunkSize > 0) {
|
|
const flags = bytes[offset + 8];
|
|
if (flags == null) {
|
|
return true;
|
|
}
|
|
if ((flags & WEBP_ANIMATION_FLAG) !== 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
const nextOffset = dataEnd + (chunkSize % 2);
|
|
if (nextOffset > bytes.length) {
|
|
return nextOffset < file.size;
|
|
}
|
|
offset = nextOffset;
|
|
}
|
|
|
|
return bytes.length < file.size;
|
|
};
|
|
|
|
/** Checks image containers whose animation would be flattened by canvas. */
|
|
export const isAnimatedImage = async (file: File): Promise<boolean> => {
|
|
if (file.type === 'image/apng') {
|
|
return true;
|
|
}
|
|
if (file.type === 'image/png') {
|
|
return isAnimatedPng(file);
|
|
}
|
|
if (file.type === 'image/webp') {
|
|
return isAnimatedWebP(file);
|
|
}
|
|
return false;
|
|
};
|
|
|
|
/**
|
|
* 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 != null && typeof ctx.drawImage === 'function' && typeof canvas.toBlob === 'function'
|
|
);
|
|
} 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.max(1, Math.round(width * scalingFactor)),
|
|
height: Math.max(1, Math.round(height * scalingFactor)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resizes an image file using canvas
|
|
*/
|
|
export async function resizeImage(
|
|
file: File,
|
|
options: Partial<ResizeOptions> = {},
|
|
): Promise<ResizeResult> {
|
|
if (await isAnimatedImage(file)) {
|
|
throw new Error('Animated images cannot be resized without losing animation');
|
|
}
|
|
|
|
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 sourceFormat = RESIZE_FORMAT_BY_MIME_TYPE[file.type];
|
|
const opts = {
|
|
...DEFAULT_RESIZE_OPTIONS,
|
|
format: sourceFormat ?? DEFAULT_RESIZE_OPTIONS.format,
|
|
...options,
|
|
};
|
|
const { maxWidth, maxHeight } = opts;
|
|
if (
|
|
maxWidth == null ||
|
|
maxHeight == null ||
|
|
!Number.isFinite(maxWidth) ||
|
|
!Number.isFinite(maxHeight) ||
|
|
maxWidth <= 0 ||
|
|
maxHeight <= 0
|
|
) {
|
|
reject(new Error('Resize dimensions must be finite numbers greater than zero'));
|
|
return;
|
|
}
|
|
const requestedMimeType = `image/${opts.format}`;
|
|
/** Decoding through an object URL avoids the ~33% larger base64 copy a data URL would hold */
|
|
const sourceUrl = URL.createObjectURL(file);
|
|
const img = new Image();
|
|
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(sourceUrl);
|
|
try {
|
|
const originalDimensions = { width: img.width, height: img.height };
|
|
const newDimensions = calculateDimensions(img.width, img.height, maxWidth, 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;
|
|
}
|
|
|
|
if (blob.size >= file.size) {
|
|
resolve({
|
|
file,
|
|
originalSize: file.size,
|
|
newSize: file.size,
|
|
originalDimensions,
|
|
newDimensions: originalDimensions,
|
|
compressionRatio: 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const outputFormat = RESIZE_FORMAT_BY_MIME_TYPE[blob.type];
|
|
if (blob.type !== requestedMimeType || outputFormat == null) {
|
|
resolve({
|
|
file,
|
|
originalSize: file.size,
|
|
newSize: file.size,
|
|
originalDimensions,
|
|
newDimensions: originalDimensions,
|
|
compressionRatio: 1,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Create new file with same name but potentially different extension
|
|
const extension = outputFormat === 'jpeg' ? '.jpg' : `.${outputFormat}`;
|
|
const baseName = file.name.replace(/\.[^/.]+$/, '');
|
|
const newFileName = `${baseName}${extension}`;
|
|
|
|
const resizedFile = new File([blob], newFileName, {
|
|
type: blob.type,
|
|
lastModified: Date.now(),
|
|
});
|
|
|
|
resolve({
|
|
file: resizedFile,
|
|
originalSize: file.size,
|
|
newSize: resizedFile.size,
|
|
originalDimensions,
|
|
newDimensions,
|
|
compressionRatio: resizedFile.size / file.size,
|
|
});
|
|
},
|
|
requestedMimeType,
|
|
opts.quality,
|
|
);
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
};
|
|
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(sourceUrl);
|
|
reject(new Error('Failed to load image'));
|
|
};
|
|
img.src = sourceUrl;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determines if an image uses a format supported by the resize pipeline.
|
|
* Dimensions decide whether re-encoding is necessary after the image is decoded.
|
|
*/
|
|
export function shouldResizeImage(file: File): boolean {
|
|
return RESIZE_FORMAT_BY_MIME_TYPE[file.type] != null;
|
|
}
|