Skip to content

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:

/cdn-cgi/image/width={w},quality={q},format=auto/{path}

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, then ImageNotFound on error. Resets automatically when src changes
  • NoOptimizedImage: 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