Images¶
How the webapp handles image uploads, rendering, and downloads.
Image Uploads¶
All file uploads use presigned URLs — the webapp never sends file bytes through the backend.
Upload Flow¶
sequenceDiagram
participant W as Webapp
participant B as Backend API
participant R as Cloudflare R2
W->>B: POST /api/v1/uploads/presigned-url
B-->>W: { upload_url, file_url, expires_in_seconds, max_file_size }
W->>R: PUT file directly (presigned URL)
R-->>W: 200 OK
Note over B,R: File lands in temp/ prefix
W->>B: Create entity (product, style, etc.)
B->>R: Relocate temp/ → media/{resource_id}/file.{ext}
Upload Module¶
The upload system lives in src/lib/uploads/:
| Path | Purpose |
|---|---|
services/ |
Presigned URL requests, chunk upload logic |
hooks/ |
React hooks for upload orchestration |
store/ |
Zustand store for upload state |
utils/ |
Validation, file helpers |
Hooks¶
| Hook | Purpose |
|---|---|
useFileUpload |
Main orchestration — validates, requests presigned URLs, uploads, tracks progress |
useUploadUrls |
Exposes list of completed upload URLs |
useUploadProgress |
Real-time progress percentage for active uploads |
Upload Groups¶
| Group | Entity |
|---|---|
product-images |
Product photos |
style-images |
Style reference images |
subject-images |
Subject photos |
background-images |
Custom backgrounds |
Validation¶
| Constraint | Value |
|---|---|
| Max file size | 50 MB |
| Allowed MIME types | image/jpeg, image/png, image/webp, image/gif, image/avif |
| Max files per batch | 10 |
Retry Policy¶
| Parameter | Value |
|---|---|
| Max attempts | 3 |
| Backoff | Exponential (1s, 2s, 4s) |
| Max delay | 10s |
| Jitter | Enabled |
For full backend processing details after upload, see Product Ingestion.
Image Rendering¶
The Rule¶
Always import SafeImage as Image — never use raw next/image.
// Correct — used across 30+ components
import Image from "@/components/Image/SafeImage";
// Wrong — do not use
import Image from "next/image";
Rendering Pipeline¶
flowchart LR
A[DB relative path] --> B["getServerFileUrl()"]
B --> C[SafeImage + preset]
C --> D[cloudflareLoader]
D --> E[Cloudflare CDN]
Three Primitives¶
| Component | Import | When to use |
|---|---|---|
SafeImage |
import Image from "@/components/Image/SafeImage" |
Default for all image display — grids, cards, thumbnails, sidebars |
NoOptimizedImage |
import NoOptimizedImage from "@/components/Image/NoOptimizedImage" |
When the user needs the original uncompressed image (zoom views, pixel-level inspection). Shows optimized first, then swaps to full-quality |
ZoomableImage |
import ZoomableImage from "@/components/ui/zoomable-image" |
Interactive inspection — zoom (wheel/double-click), pan, state persistence. Uses NoOptimizedImage internally |
Image Presets¶
Defined in src/constants/image-presets.ts:
| Preset | sizes |
quality |
Use case |
|---|---|---|---|
xs |
48px |
80 | Icons, nav avatars, mini chips |
sm |
64px |
80 | Table rows, thumbnail strips, selectors |
md |
160px |
80 | Small previews, product list cards |
lg |
(max-width: 768px) 50vw, 320px |
85 | Grid cards, generation previews |
xl |
(max-width: 768px) 100vw, 480px |
85 | Detail sidebars, dialog previews |
full |
(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw |
85 | Full-width gallery, review canvas |
hq |
100vw |
95 | Zoom views, modals, exports |
URL Resolution¶
| Function | Location | Purpose |
|---|---|---|
getServerFileUrl(path) |
src/lib/utils/index.ts |
Prepends CDN_URL to a relative DB path |
extractServerPath(url) |
src/lib/utils/index.ts |
Strips CDN_URL to get the relative path back |
Note: Paths can be legacy (
images/products/...) or canonical (media/{id}/file.{ext}). Both resolve correctly via the CDN. New uploads always use canonical paths.
Cloudflare Loader¶
src/lib/cloudflareLoader.ts transforms CDN URLs into Cloudflare Image Transformations format:
Width is clamped to one of: 48, 96, 160, 256, 320, 480, 640, 1024, 1280, 1920.
In local development (MinIO), the loader is a no-op — images are served unprocessed.
Blur Placeholders¶
rgbDataURL(r, g, b) from src/lib/utils/rgbDataURL.ts generates a 1x1 GIF data URL used as a blur placeholder:
| Context | Color | Usage |
|---|---|---|
| Grid cards | (235, 235, 235) |
placeholder="blur" blurDataURL={rgbDataURL(235, 235, 235)} |
| Detail views | (220, 220, 220) |
placeholder="blur" blurDataURL={rgbDataURL(220, 220, 220)} |
Error Handling¶
SafeImage: Shows a loading spinner while loading, thenImageNotFoundon error. Resets automatically whensrcchangesNoOptimizedImage: Two-tier fallback — unoptimized fails → optimized fallback → both fail →ImageNotFound
Supporting Components¶
| Component | Purpose |
|---|---|
ImageNotFound |
Error fallback with ImageOff icon, responsive sizing via ResizeObserver |
Skeleton |
Pulse animation placeholder for layout stability during loading |
Image Downloads¶
The Rule¶
Programmatic image downloads MUST use getCorsProxyUrl() or downloadImage() from src/lib/utils/download.ts. Never fetch() a CDN URL directly — it will fail with a CORS error in the browser.
Why the Proxy Exists¶
R2/CDN responses don't include Access-Control-Allow-Origin headers. The proxy at /api/proxy/image fetches server-side (no CORS restriction) and streams the response back through the webapp's same-origin endpoint.
Download Utilities¶
All from src/lib/utils/download.ts:
| Function | Purpose | When to use |
|---|---|---|
getCorsProxyUrl(url) |
Wraps a cross-origin URL in /api/proxy/image?url=... |
When you need a fetchable URL for a cross-origin image (canvas, blob creation) |
downloadImage(url, filename) |
Downloads any image to the user's device | Single image download with custom filename |
downloadShotRevision(shotId) |
Downloads a shot via backend API with auth | Shot downloads (applies export config, format conversion) |
exportApprovedShots(params) |
Exports approved shots as ZIP via backend API | Bulk export by product IDs or filters |
Proxy Details¶
| Parameter | Value |
|---|---|
| Max response size | 200 MB |
| Allowed Content-Types | image/*, video/* |
| SSRF protection | Blocks private IP ranges |
| Cache header | 1 year |
| Route | src/app/api/proxy/image/route.ts |
Note
downloadShotRevision and exportApprovedShots go through the backend API (not the proxy) because they need authentication and server-side processing. Only direct CDN image fetches need the CORS proxy.
Source Files¶
| File | Contents |
|---|---|
src/components/Image/SafeImage.tsx |
Default image component |
src/components/Image/NoOptimizedImage.tsx |
Full-quality swap component |
src/components/Image/ImageNotFound.tsx |
Error fallback |
src/components/ui/zoomable-image.tsx |
Zoom + pan component |
src/constants/image-presets.ts |
Preset definitions |
src/lib/cloudflareLoader.ts |
Cloudflare CDN URL transformer |
src/lib/utils/index.ts |
getServerFileUrl, extractServerPath |
src/lib/utils/rgbDataURL.ts |
Blur placeholder generator |
src/lib/utils/download.ts |
Download + CORS proxy utilities |
src/app/api/proxy/image/route.ts |
CORS proxy endpoint |