Implement image compression for recipe photo uploads, updating file handling to support larger sizes and provide compression feedback. Adjusted UI to reflect new file size limits and compression status.

This commit is contained in:
taogaetz 2025-09-11 12:53:28 -04:00
parent f8c205e9d3
commit f46064452a
3 changed files with 298 additions and 48 deletions

View File

@ -0,0 +1,160 @@
/**
* Client-side image compression utility
* Compresses images to reduce file size while maintaining quality
*/
export interface CompressionOptions {
maxWidth?: number;
maxHeight?: number;
quality?: number;
maxSizeKB?: number;
format?: 'image/jpeg' | 'image/png' | 'image/webp';
}
export interface CompressionResult {
file: File;
originalSize: number;
compressedSize: number;
compressionRatio: number;
}
/**
* Compress an image file using HTML5 Canvas
*/
export async function compressImage(
file: File,
options: CompressionOptions = {}
): Promise<CompressionResult> {
const {
maxWidth = 1920,
maxHeight = 1080,
quality = 0.8,
maxSizeKB = 500, // Target 500KB to stay well under SvelteKit's 512KB limit
format = 'image/jpeg'
} = options;
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
img.onload = () => {
try {
// Calculate new dimensions while maintaining aspect ratio
let { width, height } = img;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
// Set canvas dimensions
canvas.width = width;
canvas.height = height;
// Draw and compress the image
ctx?.drawImage(img, 0, 0, width, height);
// Try different quality levels if file is still too large
const tryCompress = (currentQuality: number): void => {
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('Failed to compress image'));
return;
}
const sizeKB = blob.size / 1024;
// If still too large and we can reduce quality further, try again
if (sizeKB > maxSizeKB && currentQuality > 0.3) {
tryCompress(currentQuality - 0.1);
return;
}
// Create new file with compressed data
const compressedFile = new File([blob], file.name, {
type: format,
lastModified: Date.now()
});
resolve({
file: compressedFile,
originalSize: file.size,
compressedSize: blob.size,
compressionRatio: blob.size / file.size
});
},
format,
currentQuality
);
};
tryCompress(quality);
} catch (error) {
reject(error);
}
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
// Load the image
img.src = URL.createObjectURL(file);
});
}
/**
* Format file size for display
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Check if a file is an image
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
/**
* Get optimal compression settings based on file size
*/
export function getCompressionSettings(file: File): CompressionOptions {
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > 5) {
// Very large files - aggressive compression
return {
maxWidth: 1200,
maxHeight: 800,
quality: 0.6,
maxSizeKB: 400
};
} else if (sizeMB > 2) {
// Large files - moderate compression
return {
maxWidth: 1600,
maxHeight: 1000,
quality: 0.7,
maxSizeKB: 450
};
} else {
// Smaller files - light compression
return {
maxWidth: 1920,
maxHeight: 1080,
quality: 0.8,
maxSizeKB: 500
};
}
}

View File

@ -2,6 +2,12 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown'; import { renderMarkdownToHTML } from '$lib/utils/markdown';
import {
compressImage,
formatFileSize,
isImageFile,
getCompressionSettings
} from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -13,9 +19,15 @@
let instructionsText = $state(''); let instructionsText = $state('');
let formSubmitted = $state(false); let formSubmitted = $state(false);
let showDeleteConfirm = $state(false); let showDeleteConfirm = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>( let selectedPhoto = $state<{
null file: File;
); preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showMarkdownHelp = $state(false); let showMarkdownHelp = $state(false);
let showIngredientHelp = $state(false); let showIngredientHelp = $state(false);
@ -40,34 +52,63 @@
}); });
// Photo handling functions // Photo handling functions
function handlePhotoChange(event: Event) { async function handlePhotoChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (file) { if (file) {
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
lastError = 'Photo must be less than 10MB';
input.value = '';
return;
}
// Validate file type // Validate file type
if (!file.type.startsWith('image/')) { if (!isImageFile(file)) {
lastError = 'Please select a valid image file'; lastError = 'Please select a valid image file';
input.value = ''; input.value = '';
return; return;
} }
// Create preview URL // Validate file size (50MB limit for original files)
const preview = URL.createObjectURL(file); if (file.size > 50 * 1024 * 1024) {
selectedPhoto = { lastError = 'Photo must be less than 50MB';
file, input.value = '';
preview, return;
name: file.name, }
size: file.size
};
lastError = null; lastError = null;
try {
// Show loading state
const preview = URL.createObjectURL(file);
selectedPhoto = {
file,
preview,
name: file.name,
originalSize: file.size,
compressedSize: file.size,
compressionRatio: 1,
isCompressed: false
};
// Compress the image
const compressionSettings = getCompressionSettings(file);
const result = await compressImage(file, compressionSettings);
// Update with compressed file
selectedPhoto = {
file: result.file,
preview: URL.createObjectURL(result.file),
name: file.name,
originalSize: result.originalSize,
compressedSize: result.compressedSize,
compressionRatio: result.compressionRatio,
isCompressed: result.compressionRatio < 0.95
};
// Clean up the original preview URL
URL.revokeObjectURL(preview);
} catch (error) {
console.error('Error compressing image:', error);
lastError = 'Failed to process image. Please try a different file.';
input.value = '';
selectedPhoto = null;
}
} }
} }
@ -496,7 +537,7 @@
/> />
<div class="label"> <div class="label">
<span class="label-text-alt text-sm text-base-content/60"> <span class="label-text-alt text-sm text-base-content/60">
Supports: JPG, PNG, GIF, WebP (max 10MB) Supports: JPG, PNG, GIF, WebP (max 50MB, auto-compressed for upload)
</span> </span>
</div> </div>
</label> </label>
@ -510,9 +551,15 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">{selectedPhoto.name}</p> <p class="text-sm font-medium">{selectedPhoto.name}</p>
<p class="text-xs text-base-content/60"> <div class="space-y-1 text-xs text-base-content/60">
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB <p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
</p> {#if selectedPhoto.isCompressed}
<p class="text-success">
Compressed from {formatFileSize(selectedPhoto.originalSize)}
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
</p>
{/if}
</div>
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}> <button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
Remove Photo Remove Photo
</button> </button>

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { renderMarkdownToHTML } from '$lib/utils/markdown'; import { renderMarkdownToHTML } from '$lib/utils/markdown';
import { compressImage, formatFileSize, isImageFile, getCompressionSettings } from '$lib/utils/imageCompression';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
@ -11,9 +12,15 @@
let descriptionText = $state(''); let descriptionText = $state('');
let instructionsText = $state(''); let instructionsText = $state('');
let formSubmitted = $state(false); let formSubmitted = $state(false);
let selectedPhoto = $state<{ file: File; preview: string; name: string; size: number } | null>( let selectedPhoto = $state<{
null file: File;
); preview: string;
name: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
isCompressed: boolean;
} | null>(null);
let showIngredientHelp = $state(false); let showIngredientHelp = $state(false);
let showMarkdownHelp = $state(false); let showMarkdownHelp = $state(false);
@ -325,34 +332,64 @@
} }
// Photo handling functions // Photo handling functions
function handlePhotoChange(event: Event) { async function handlePhotoChange(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
if (file) { if (file) {
// Validate file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
lastError = 'Photo must be less than 10MB';
input.value = '';
return;
}
// Validate file type // Validate file type
if (!file.type.startsWith('image/')) { if (!isImageFile(file)) {
lastError = 'Please select a valid image file'; lastError = 'Please select a valid image file';
input.value = ''; input.value = '';
return; return;
} }
// Create preview URL // Validate file size (50MB limit for original files)
const preview = URL.createObjectURL(file); if (file.size > 50 * 1024 * 1024) {
selectedPhoto = { lastError = 'Photo must be less than 50MB';
file, input.value = '';
preview, return;
name: file.name, }
size: file.size
};
lastError = null; lastError = null;
try {
// Show loading state
const preview = URL.createObjectURL(file);
selectedPhoto = {
file,
preview,
name: file.name,
originalSize: file.size,
compressedSize: file.size,
compressionRatio: 1,
isCompressed: false
};
// Compress the image
const compressionSettings = getCompressionSettings(file);
const result = await compressImage(file, compressionSettings);
// Update with compressed file
selectedPhoto = {
file: result.file,
preview: URL.createObjectURL(result.file),
name: file.name,
originalSize: result.originalSize,
compressedSize: result.compressedSize,
compressionRatio: result.compressionRatio,
isCompressed: result.compressionRatio < 0.95
};
// Clean up the original preview URL
URL.revokeObjectURL(preview);
} catch (error) {
console.error('Error compressing image:', error);
lastError = 'Failed to process image. Please try a different file.';
input.value = '';
selectedPhoto = null;
}
} }
} }
@ -446,7 +483,7 @@
/> />
<div class="label"> <div class="label">
<span class="label-text-alt text-sm text-base-content/60"> <span class="label-text-alt text-sm text-base-content/60">
Supports: JPG, PNG, GIF, WebP (max 10MB) Supports: JPG, PNG, GIF, WebP (max 50MB, auto-compressed for upload)
</span> </span>
</div> </div>
</label> </label>
@ -460,9 +497,15 @@
</div> </div>
<div class="flex-1"> <div class="flex-1">
<p class="text-sm font-medium">{selectedPhoto.name}</p> <p class="text-sm font-medium">{selectedPhoto.name}</p>
<p class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60 space-y-1">
{(selectedPhoto.size / 1024 / 1024).toFixed(2)} MB <p>Upload size: {formatFileSize(selectedPhoto.compressedSize)}</p>
</p> {#if selectedPhoto.isCompressed}
<p class="text-success">
Compressed from {formatFileSize(selectedPhoto.originalSize)}
({((1 - selectedPhoto.compressionRatio) * 100).toFixed(1)}% reduction)
</p>
{/if}
</div>
<button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}> <button type="button" class="btn mt-2 btn-sm btn-error" onclick={removePhoto}>
Remove Photo Remove Photo
</button> </button>