Skip to content

Commit f70df0c

Browse files
Algorithm5838github-actions[bot]
authored andcommitted
feat: convert user-attached chat images to WebP at 0.9 quality
1 parent 4b6fd98 commit f70df0c

2 files changed

Lines changed: 74 additions & 71 deletions

File tree

src/lib/components/chat/MessageInput.svelte

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -748,47 +748,35 @@
748748
}
749749
750750
const compressImageHandler = async (imageUrl, settings = {}, config = {}) => {
751-
// Quick shortcut so we don’t do unnecessary work.
752751
const settingsCompression = settings?.imageCompression ?? false;
753752
const configWidth = config?.file?.image_compression?.width ?? null;
754753
const configHeight = config?.file?.image_compression?.height ?? null;
755754
756-
// If neither settings nor config wants compression, return original URL.
757-
if (!settingsCompression && !configWidth && !configHeight) {
758-
return imageUrl;
759-
}
760-
761-
// Default to null (no compression unless set)
762755
let width = null;
763756
let height = null;
764757
765-
// If user/settings want compression, pick their preferred size.
766758
if (settingsCompression) {
767759
width = settings?.imageCompressionSize?.width ?? null;
768760
height = settings?.imageCompressionSize?.height ?? null;
769761
}
770762
771-
// Apply config limits as an upper bound if any
772763
if (configWidth && (width === null || width > configWidth)) {
773764
width = configWidth;
774765
}
775766
if (configHeight && (height === null || height > configHeight)) {
776767
height = configHeight;
777768
}
778769
779-
// Do the compression if required
780-
if (width || height) {
781-
return await compressImage(imageUrl, width, height);
782-
}
783-
return imageUrl;
770+
// Single-pass: resize (if needed) and convert to WebP
771+
return await compressImage(imageUrl, width, height, 'image/webp', 0.9);
784772
};
785773
786774
let reader = new FileReader();
787775
788776
reader.onload = async (event) => {
789777
let imageUrl = event.target.result;
790778
791-
// Compress the image if settings or config require it
779+
// Compress and convert to WebP in a single canvas pass
792780
imageUrl = await compressImageHandler(imageUrl, $settings, $config);
793781
794782
if ($temporaryChatEnabled) {
@@ -801,9 +789,13 @@
801789
];
802790
} else {
803791
const blob = await (await fetch(imageUrl)).blob();
804-
const compressedFile = new File([blob], file.name, { type: file.type });
792+
const webpFile = new File(
793+
[blob],
794+
file.name.replace(/\.[^.]+$/, '.webp'),
795+
{ type: 'image/webp' }
796+
);
805797
806-
uploadFileHandler(compressedFile, false);
798+
uploadFileHandler(webpFile, false);
807799
}
808800
};
809801

src/lib/utils/index.ts

Lines changed: 65 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -359,14 +359,15 @@ async function resizeImageToDataURL(
359359
img: HTMLImageElement,
360360
width: number,
361361
height: number,
362-
mimeType = 'image/jpeg'
362+
mimeType = 'image/jpeg',
363+
quality?: number
363364
): Promise<string> {
364365
const canvas = document.createElement('canvas');
365366
canvas.width = width;
366367
canvas.height = height;
367368
canvas.getContext('2d')?.drawImage(img, 0, 0, width, height);
368369

369-
const toDataURL = () => canvas.toDataURL(mimeType);
370+
const toDataURL = () => canvas.toDataURL(mimeType, quality);
370371

371372
if (
372373
!resizeImageWarmupDone &&
@@ -375,74 +376,84 @@ async function resizeImageToDataURL(
375376
) {
376377
resizeImageWarmupDone = true;
377378
return new Promise((resolve) => {
378-
canvas.toBlob((blob) => {
379-
if (!blob) {
380-
resolve(toDataURL());
381-
return;
382-
}
383-
const reader = new FileReader();
384-
reader.onload = () => resolve(String(reader.result));
385-
reader.onerror = () => resolve(toDataURL());
386-
reader.readAsDataURL(blob);
387-
}, mimeType);
379+
canvas.toBlob(
380+
(blob) => {
381+
if (!blob) {
382+
resolve(toDataURL());
383+
return;
384+
}
385+
const reader = new FileReader();
386+
reader.onload = () => resolve(String(reader.result));
387+
reader.onerror = () => resolve(toDataURL());
388+
reader.readAsDataURL(blob);
389+
},
390+
mimeType,
391+
quality
392+
);
388393
});
389394
}
390395
return Promise.resolve(toDataURL());
391396
}
392397

393-
export const compressImage = async (imageUrl, maxWidth, maxHeight) => {
398+
/**
399+
* Calculate constrained dimensions preserving aspect ratio (pure function).
400+
*/
401+
function calcResize(
402+
srcW: number,
403+
srcH: number,
404+
maxW?: number | null,
405+
maxH?: number | null
406+
): { width: number; height: number; resized: boolean } {
407+
let w = srcW,
408+
h = srcH;
409+
if (maxW && maxH) {
410+
if (w > maxW || h > maxH) {
411+
if (w / h > maxW / maxH) {
412+
h = Math.round((maxW * h) / w);
413+
w = maxW;
414+
} else {
415+
w = Math.round((maxH * w) / h);
416+
h = maxH;
417+
}
418+
return { width: w, height: h, resized: true };
419+
}
420+
} else if (maxW && w > maxW) {
421+
h = Math.round((maxW * h) / w);
422+
w = maxW;
423+
return { width: w, height: h, resized: true };
424+
} else if (maxH && h > maxH) {
425+
w = Math.round((maxH * w) / h);
426+
h = maxH;
427+
return { width: w, height: h, resized: true };
428+
}
429+
return { width: w, height: h, resized: false };
430+
}
431+
432+
export const compressImage = async (
433+
imageUrl: string,
434+
maxWidth?: number | null,
435+
maxHeight?: number | null,
436+
outputFormat?: string,
437+
quality?: number
438+
) => {
394439
return new Promise((resolve, reject) => {
395440
const img = new Image();
396441
img.onload = async () => {
397-
let width = img.width;
398-
let height = img.height;
442+
const { width, height, resized } = calcResize(img.width, img.height, maxWidth, maxHeight);
399443

400-
// Maintain aspect ratio while resizing
401-
402-
if (maxWidth && maxHeight) {
403-
// Resize with both dimensions defined (preserves aspect ratio)
404-
405-
if (width <= maxWidth && height <= maxHeight) {
406-
resolve(imageUrl);
407-
return;
408-
}
409-
410-
if (width / height > maxWidth / maxHeight) {
411-
height = Math.round((maxWidth * height) / width);
412-
width = maxWidth;
413-
} else {
414-
width = Math.round((maxHeight * width) / height);
415-
height = maxHeight;
416-
}
417-
} else if (maxWidth) {
418-
// Only maxWidth defined
419-
420-
if (width <= maxWidth) {
421-
resolve(imageUrl);
422-
return;
423-
}
424-
425-
height = Math.round((maxWidth * height) / width);
426-
width = maxWidth;
427-
} else if (maxHeight) {
428-
// Only maxHeight defined
429-
430-
if (height <= maxHeight) {
431-
resolve(imageUrl);
432-
return;
433-
}
434-
435-
width = Math.round((maxHeight * width) / height);
436-
height = maxHeight;
444+
if (!resized && !outputFormat) {
445+
resolve(imageUrl);
446+
return;
437447
}
438448

439-
const mimeType = imageUrl.match(/^data:([^;]+);/)?.[1] ?? 'image/jpeg';
440-
resolve(await resizeImageToDataURL(img, width, height, mimeType));
449+
const mimeType = outputFormat || (imageUrl.match(/^data:([^;]+);/)?.[1] ?? 'image/jpeg');
450+
resolve(await resizeImageToDataURL(img, width, height, mimeType, quality));
441451
};
442452
img.onerror = (error) => reject(error);
443453
img.src = imageUrl;
444454
});
445455
};
456+
446457
export const generateInitialsImage = (name) => {
447458
const canvas = document.createElement('canvas');
448459
const ctx = canvas.getContext('2d');

0 commit comments

Comments
 (0)