diff --git a/src/lib/resize-utils.ts b/src/lib/resize-utils.ts index 4fc06da..44bb143 100644 --- a/src/lib/resize-utils.ts +++ b/src/lib/resize-utils.ts @@ -58,13 +58,71 @@ export function createIsMobile( return () => isMobile(windowWidth()); } +/** + * Converts an image to WebP format without resizing + * @param file Original image file + * @param quality WebP quality (0-1), default 0.85 + * @returns Converted image as WebP Blob + */ +export async function convertToWebP( + file: File | Blob, + quality: number = 0.85 +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + reject(new Error("Failed to get canvas context")); + return; + } + + img.onload = () => { + canvas.width = img.width; + canvas.height = img.height; + + ctx.drawImage(img, 0, 0); + + canvas.toBlob( + (blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Failed to create blob from canvas")); + } + }, + "image/webp", + quality + ); + }; + + img.onerror = () => { + reject(new Error("Failed to load image")); + }; + + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + img.src = e.target.result as string; + } else { + reject(new Error("Failed to read file")); + } + }; + reader.onerror = () => { + reject(new Error("FileReader error")); + }; + reader.readAsDataURL(file); + }); +} + /** * Resizes an image file to a maximum width/height while maintaining aspect ratio * @param file Original image file * @param maxWidth Maximum width in pixels * @param maxHeight Maximum height in pixels - * @param quality JPEG quality (0-1), default 0.85 - * @returns Resized image as Blob + * @param quality WebP quality (0-1), default 0.85 + * @returns Resized image as WebP Blob */ export async function resizeImage( file: File | Blob, @@ -110,7 +168,7 @@ export async function resizeImage( reject(new Error("Failed to create blob from canvas")); } }, - "image/jpeg", + "image/webp", quality ); }; diff --git a/src/lib/s3upload.ts b/src/lib/s3upload.ts index b192745..37c67fd 100644 --- a/src/lib/s3upload.ts +++ b/src/lib/s3upload.ts @@ -1,10 +1,11 @@ /** * S3 Upload Utility for SolidStart * Uploads files to S3 using pre-signed URLs from tRPC + * Automatically converts images to WebP format for better compression */ import { api } from "~/lib/api"; -import { resizeImage } from "~/lib/resize-utils"; +import { resizeImage, convertToWebP } from "~/lib/resize-utils"; export default async function AddImageToS3( file: Blob | File, @@ -14,46 +15,61 @@ export default async function AddImageToS3( try { const filename = (file as File).name; - const { uploadURL, key } = await api.misc.getPreSignedURL.mutate({ - type, - title, - filename - }); - - console.log("url: " + uploadURL, "key: " + key); - const ext = /^.+\.([^.]+)$/.exec(filename); let contentType = "application/octet-stream"; + let isImage = false; + let isVideo = false; if (ext) { const extension = ext[1].toLowerCase(); if (["mp4", "webm", "mov", "quicktime"].includes(extension)) { contentType = extension === "mov" ? "video/quicktime" : `video/${extension}`; - } else { - contentType = `image/${extension}`; + isVideo = true; + } else if (["jpg", "jpeg", "png", "gif", "webp"].includes(extension)) { + isImage = true; } } + let fileToUpload: Blob | File = file; + let finalFilename = filename; + + // Convert images to WebP for better compression + if (isImage) { + contentType = "image/webp"; + fileToUpload = await convertToWebP(file, 0.85); + finalFilename = filename.replace(/\.[^.]+$/, ".webp"); + } + + const { uploadURL, key } = await api.misc.getPreSignedURL.mutate({ + type, + title, + filename: finalFilename + }); + + console.log("url: " + uploadURL, "key: " + key); + const uploadResponse = await fetch(uploadURL, { method: "PUT", headers: { "Content-Type": contentType }, - body: file as File + body: fileToUpload }); if (!uploadResponse.ok) { throw new Error("Failed to upload file to S3"); } - // Only create thumbnails for images - const isImage = contentType.startsWith("image/"); + // Create thumbnails for images (blog posts only) if (type === "blog" && isImage) { try { const thumbnail = await resizeImage(file, 200, 200, 0.8); - const thumbnailFilename = filename.replace(/(\.[^.]+)$/, "-small$1"); + const thumbnailFilename = finalFilename.replace( + /(\.[^.]+)$/, + "-small$1" + ); const { uploadURL: thumbnailUploadURL } = await api.misc.getPreSignedURL.mutate({ @@ -65,7 +81,7 @@ export default async function AddImageToS3( const thumbnailUploadResponse = await fetch(thumbnailUploadURL, { method: "PUT", headers: { - "Content-Type": "image/jpeg" + "Content-Type": "image/webp" }, body: thumbnail });