feat: add client-side image optimization (WebP variants)
Browse files- Create image-optimizer.ts: resize images to 3 WebP variants
(thumb 400px, medium 1000px, full 2000px) using OffscreenCanvas
- Update upload.ts to optimize before uploading to backend
- Graceful fallback to original file if optimization unsupported
Made-with: Cursor
frontend/src/editor/image-optimizer.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Client-side image optimization.
|
| 3 |
+
*
|
| 4 |
+
* Resizes images and converts them to WebP before upload,
|
| 5 |
+
* reducing bandwidth and storage costs significantly.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
interface OptimizedVariant {
|
| 9 |
+
blob: Blob;
|
| 10 |
+
width: number;
|
| 11 |
+
height: number;
|
| 12 |
+
suffix: string;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export interface OptimizedImage {
|
| 16 |
+
original: { width: number; height: number };
|
| 17 |
+
variants: OptimizedVariant[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
const VARIANTS = [
|
| 21 |
+
{ suffix: "thumb", maxWidth: 400, quality: 0.7 },
|
| 22 |
+
{ suffix: "medium", maxWidth: 1000, quality: 0.8 },
|
| 23 |
+
{ suffix: "full", maxWidth: 2000, quality: 0.85 },
|
| 24 |
+
] as const;
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Optimize an image file: resize to 3 WebP variants + extract dimensions.
|
| 28 |
+
* Returns the variants sorted small to large.
|
| 29 |
+
*/
|
| 30 |
+
export async function optimizeImage(file: File): Promise<OptimizedImage> {
|
| 31 |
+
const bitmap = await createImageBitmap(file);
|
| 32 |
+
const { width: origW, height: origH } = bitmap;
|
| 33 |
+
|
| 34 |
+
const variants: OptimizedVariant[] = [];
|
| 35 |
+
|
| 36 |
+
for (const { suffix, maxWidth, quality } of VARIANTS) {
|
| 37 |
+
// Skip variant if original is smaller than this tier
|
| 38 |
+
if (origW <= maxWidth && suffix !== "full") continue;
|
| 39 |
+
|
| 40 |
+
const scale = Math.min(1, maxWidth / origW);
|
| 41 |
+
const w = Math.round(origW * scale);
|
| 42 |
+
const h = Math.round(origH * scale);
|
| 43 |
+
|
| 44 |
+
const canvas = new OffscreenCanvas(w, h);
|
| 45 |
+
const ctx = canvas.getContext("2d");
|
| 46 |
+
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
| 47 |
+
|
| 48 |
+
ctx.drawImage(bitmap, 0, 0, w, h);
|
| 49 |
+
|
| 50 |
+
const blob = await canvas.convertToBlob({
|
| 51 |
+
type: "image/webp",
|
| 52 |
+
quality,
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
variants.push({ blob, width: w, height: h, suffix });
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Always ensure at least one variant (the full size)
|
| 59 |
+
if (variants.length === 0) {
|
| 60 |
+
const canvas = new OffscreenCanvas(origW, origH);
|
| 61 |
+
const ctx = canvas.getContext("2d");
|
| 62 |
+
if (!ctx) throw new Error("Canvas 2D context unavailable");
|
| 63 |
+
|
| 64 |
+
ctx.drawImage(bitmap, 0, 0);
|
| 65 |
+
|
| 66 |
+
const blob = await canvas.convertToBlob({
|
| 67 |
+
type: "image/webp",
|
| 68 |
+
quality: 0.85,
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
variants.push({ blob, width: origW, height: origH, suffix: "full" });
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
bitmap.close();
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
original: { width: origW, height: origH },
|
| 78 |
+
variants,
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Check if the browser supports WebP encoding via OffscreenCanvas.
|
| 84 |
+
*/
|
| 85 |
+
export function supportsWebpOptimization(): boolean {
|
| 86 |
+
try {
|
| 87 |
+
return typeof OffscreenCanvas !== "undefined";
|
| 88 |
+
} catch {
|
| 89 |
+
return false;
|
| 90 |
+
}
|
| 91 |
+
}
|
frontend/src/editor/upload.ts
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
/**
|
| 2 |
* Upload an image file to the backend.
|
| 3 |
-
*
|
|
|
|
| 4 |
*/
|
| 5 |
export async function uploadImage(file: File): Promise<string> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
const form = new FormData();
|
| 7 |
form.append("file", file);
|
| 8 |
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
optimizeImage,
|
| 3 |
+
supportsWebpOptimization,
|
| 4 |
+
} from "./image-optimizer";
|
| 5 |
+
|
| 6 |
/**
|
| 7 |
* Upload an image file to the backend.
|
| 8 |
+
* Optimizes to WebP variants client-side before uploading.
|
| 9 |
+
* Returns the URL of the largest variant for use in the editor.
|
| 10 |
*/
|
| 11 |
export async function uploadImage(file: File): Promise<string> {
|
| 12 |
+
// Try client-side optimization first
|
| 13 |
+
if (supportsWebpOptimization() && file.type.startsWith("image/")) {
|
| 14 |
+
try {
|
| 15 |
+
const optimized = await optimizeImage(file);
|
| 16 |
+
const urls: string[] = [];
|
| 17 |
+
|
| 18 |
+
for (const variant of optimized.variants) {
|
| 19 |
+
const ext = "webp";
|
| 20 |
+
const name = file.name.replace(/\.[^.]+$/, "");
|
| 21 |
+
const optimizedFile = new File(
|
| 22 |
+
[variant.blob],
|
| 23 |
+
`${name}-${variant.suffix}.${ext}`,
|
| 24 |
+
{ type: "image/webp" }
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
const url = await uploadSingleFile(optimizedFile);
|
| 28 |
+
urls.push(url);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
// Return the largest variant URL
|
| 32 |
+
return urls[urls.length - 1];
|
| 33 |
+
} catch (err) {
|
| 34 |
+
console.warn("[upload] Client-side optimization failed, uploading original:", err);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return uploadSingleFile(file);
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
async function uploadSingleFile(file: File): Promise<string> {
|
| 42 |
const form = new FormData();
|
| 43 |
form.append("file", file);
|
| 44 |
|