Skip to main content

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 nameWidthFormatQualityUse
thumb200 pxWebP70Thumbnail, list card, avatar
card480 pxWebP80Room card, property card
card@2x960 pxWebP75Retina room/property card
hero1440 pxAVIF / WebP80Hero block full-width
hero-sm768 pxAVIF / WebP80Hero on mobile
og1200×630 pxJPEG85OpenGraph og:image
gallery1200 pxWebP80Gallery carousel
blur32 pxWebP20LQIP (blurred placeholder)
originaloriginal format100Download / 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 hero variant 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:

  1. Generates srcset from Cloudflare Images variants
  2. Renders <img src={lqip} style={{ filter: 'blur(8px)' }}> as placeholder
  3. 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

SurfaceBreakpoints
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()
KioskFixed screen dimensions per device; static variant selection

Standard sizes values by use-case:

Image use-casesizes
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
Avatar48px
OG image1200px (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-svg for 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 pyftsubset for 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.


Every image on the platform must have an associated copyright record in media-service:

FieldTypeRequired
copyright_holderstring
license'cc0' | 'cc-by' | 'tenant-owned' | 'platform-licensed' | 'stock'
stock_sourcestringOnly if license: 'stock'
expires_atISO8601 | nullIf license has expiry
attribution_requiredbooleanIf license: 'cc-by'
attribution_textstringRequired if attribution_required: true

Enforcement:

  • Images with expires_at in the past are automatically watermarked in all variants by Cloudflare Images worker
  • attribution_required: true images 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

MetricTarget
Hero image LCP≤ 2.5 s (LCP = hero image on property detail)
Image lazy-load thresholdrootMargin: 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 typeAlt text responsibilityValidation
Property photos (uploaded by tenant)Tenant provides at uploadRequired field; min 5 chars
Room photosTenant provides at uploadRequired field
IllustrationsPlatform-authored (in SVG <title>)Reviewed in design QA
User avatarAuto-generated: "Avatar for [Display Name]"No tenant input needed
Marketing images (meta web)Platform copywriter authorsReviewed 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'.


References