DO3 — Asset & Image Pipeline
Scope: How images and other static assets are ingested, transformed, served, and audited on the Melmastoon platform. Covers Cloudflare Images, responsive breakpoints, AVIF/WebP, Low Quality Image Placeholders (LQIP), art direction, alt text process, and the copyright manifest.
1. Overview
Source (designer / photographer / tenant)
↓
Upload endpoint → services/media-service
↓
Cloudflare Images (store + transform)
↓
Edge delivery (Cloudflare CDN, global PoPs)
↓
Client: <Image> component → srcset + LQIP + lazy-load
All user-facing images MUST be served via Cloudflare Images. Raw origin URLs (GCS buckets, local file paths) are prohibited in any rendered HTML.
2. Cloudflare Images: setup and configuration
Account: melmastoon-platform (managed by DevOps)
Delivery URL pattern: https://imagedelivery.net/{account_hash}/{image_id}/{variant}
2.1 Image variants
| Variant name | Width | Format | Quality | Use |
|---|---|---|---|---|
thumb | 200 px | WebP | 70 | Thumbnail, list card, avatar |
card | 480 px | WebP | 80 | Room card, property card |
card@2x | 960 px | WebP | 75 | Retina room/property card |
hero | 1440 px | AVIF / WebP | 80 | Hero block full-width |
hero-sm | 768 px | AVIF / WebP | 80 | Hero on mobile |
og | 1200×630 px | JPEG | 85 | OpenGraph og:image |
gallery | 1200 px | WebP | 80 | Gallery carousel |
blur | 32 px | WebP | 20 | LQIP (blurred placeholder) |
original | — | original format | 100 | Download / admin only; not served publicly |
AVIF priority: AVIF is the preferred format for hero images (25–50% smaller than WebP). The <Image> component serves AVIF to browsers that accept it, WebP as fallback, JPEG as final fallback via Accept header negotiation at the Cloudflare edge.
2.2 Image transformation API
services/media-service wraps the Cloudflare Images Upload API. Tenants upload via the backoffice; guests upload via guest portal (avatar only).
POST /api/media/upload
Authorization: Bearer {operator_token}
Content-Type: multipart/form-data
Body: { file, alt_text, copyright_holder, license, expires_at? }
Response:
{ image_id, variants: { thumb, card, hero, blur }, width, height, format }
Validation at upload:
- Max file size: 20 MB
- Accepted formats: JPEG, PNG, WebP, AVIF, HEIC (auto-converted)
- Min dimensions: 800×600 px (for
herovariant to be usable) - NSFW scan: Cloudflare Images content moderation API (auto-reject on violation)
3. Responsive images in the <Image> component
packages/ui/src/components/Image.tsx wraps next/image (web) or expo-image (mobile):
<Image
imageId="abc123"
alt="A double room at Hotel Kabul with mountain view"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
priority={isAboveFold}
aspectRatio={16/9}
lqip={image.blur_url}
/>
Internal behaviour:
- Generates
srcsetfrom Cloudflare Images variants - Renders
<img src={lqip} style={{ filter: 'blur(8px)' }}>as placeholder - On load: fades in full-resolution image; removes LQIP
Priority loading: priority={true} on above-fold images (Hero, first Room Card) disables lazy-load and adds fetchpriority="high".
4. LQIP (Low Quality Image Placeholder)
Every image uploaded generates a blur variant (32 px wide). This variant's base64 data URL is stored in the Image entity alongside the image_id.
image.blur_data_url = "data:image/webp;base64,UklGR..."
Web: <img src={blur_data_url}> displayed while full image loads. CSS transition: opacity 0.3s for fade.
Mobile (React Native): expo-image placeholder={{ uri: blur_data_url, blurhash: ... }}.
Desktop (Electron): Same as web via Electron's renderer Chromium.
5. Art direction
Art direction = serving different image crops or entirely different images based on viewport.
Implemented via <picture> element:
<picture>
<source media="(max-width: 640px)" srcset="{hero-sm_url}">
<source media="(min-width: 641px)" srcset="{hero_url}">
<img src="{hero_url}" alt="...">
</picture>
When to use art direction:
- Hero blocks: portrait crop on mobile, landscape on desktop
- Property card: square crop for map pin, landscape for list
- Tenant logo: horizontal lockup on desktop, square mark on mobile
Workflow: Designer provides two crops in Figma (landscape + portrait) with annotation [art-direction]. The Figma export script generates two separate media uploads; the BlockSpec MediaRef stores both { landscape: imageId, portrait: imageId }.
6. Breakpoints and sizes attribute
| Surface | Breakpoints |
|---|---|
| Web (Next.js) | sm: 640px, md: 768px, lg: 1024px, xl: 1280px, 2xl: 1536px |
| Desktop (Electron) | Fixed window min-width 1024px; treat as lg |
| Mobile (React Native) | Expo's useWindowDimensions() |
| Kiosk | Fixed screen dimensions per device; static variant selection |
Standard sizes values by use-case:
| Image use-case | sizes |
|---|---|
| Hero (full-width) | 100vw |
| Room card (grid-3 desktop / full mobile) | (max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw |
| Gallery carousel | (max-width: 640px) 100vw, 80vw |
| Avatar | 48px |
| OG image | 1200px (not responsive; used in <meta> only) |
7. Illustration and SVG assets
Illustrations (from Figma Illustrations file) are exported as SVG and stored in packages/ui/src/assets/illustrations/. They are:
- Inlined at build time (Vite/Next.js SVG plugin) for web
- Bundled as React Native SVG components via
react-native-svgfor mobile
Color: Illustration SVG uses CSS variables (fill: var(--mel-color-primary-default)) so they inherit tenant color tokens at runtime.
Alt text: Illustrations used decoratively have aria-hidden="true". Illustrations conveying meaning (empty-state art) have aria-label on their container.
8. Static assets (fonts, icons)
Fonts: Self-hosted in packages/design-tokens/public/fonts/. Loaded via next/font/local (web) or bundled (mobile/desktop). Font files:
Estedad-Variable.woff2(Pashto/Dari/Farsi + Latin subset)Inter-Variable.woff2(Latin primary)- Subsets generated by
pyftsubsetfor production (Latin + Persian + Arabic code points only)
Icons: Phosphor Icons are tree-shaken at build time. Only imported icons are bundled. Do not import @phosphor-icons/react wholesale — always import individual icons:
import { HouseSimple, MagnifyingGlass } from '@phosphor-icons/react';
CI check: @ghasi/icon-audit script runs on CI to detect wildcard Phosphor imports.
9. Copyright manifest
Every image on the platform must have an associated copyright record in media-service:
| Field | Type | Required |
|---|---|---|
copyright_holder | string | ✅ |
license | 'cc0' | 'cc-by' | 'tenant-owned' | 'platform-licensed' | 'stock' | ✅ |
stock_source | string | Only if license: 'stock' |
expires_at | ISO8601 | null | If license has expiry |
attribution_required | boolean | If license: 'cc-by' |
attribution_text | string | Required if attribution_required: true |
Enforcement:
- Images with
expires_atin the past are automatically watermarked in all variants by Cloudflare Images worker attribution_required: trueimages must have an attribution caption rendered below the image (enforced in<Image>component)- Platform-licensed images may not be downloaded by tenants (download endpoint requires
license: 'tenant-owned')
10. Performance requirements
| Metric | Target |
|---|---|
| Hero image LCP | ≤ 2.5 s (LCP = hero image on property detail) |
| Image lazy-load threshold | rootMargin: 200px (load before entering viewport) |
| LQIP → full image transition | ≤ 300 ms opacity CSS transition |
WebP fallback for all <img> | 100% (enforced by <Image> component; no raw <img> allowed) |
| Uncached image first byte | ≤ 100 ms (Cloudflare edge; global CDN) |
CI enforcement: Lighthouse CI checks hero image weight ≤ 200 kB for hero variant. @ghasi/image-audit script checks for raw <img> tags without the <Image> component.
11. Alt text process
| Image type | Alt text responsibility | Validation |
|---|---|---|
| Property photos (uploaded by tenant) | Tenant provides at upload | Required field; min 5 chars |
| Room photos | Tenant provides at upload | Required field |
| Illustrations | Platform-authored (in SVG <title>) | Reviewed in design QA |
| User avatar | Auto-generated: "Avatar for [Display Name]" | No tenant input needed |
| Marketing images (meta web) | Platform copywriter authors | Reviewed in content QA |
RTL alt text: Alt text is locale-specific (alt: LocaleString). Pashto alt text is required for any image used on ps or dr locale pages. Missing locales fall back to 'en'.