Skip to content

Media pipeline

The portal media pipeline converts raw uploaded images into optimized WebP variants, stores them in the GitHub repository, and serves them through a Netlify CDN-cached API route. Understanding this pipeline is useful when debugging media display issues or adding images manually.

Images are processed by the @drawnagency/authoring CLI. Run it from the client repo root:

Terminal window
pnpm exec authoring process-images --project .

The command reads raw images from _ingest/images/ and produces output in assets/images/. For each source image it generates WebP variants at 640, 1080 and 1920 px wide. Those widths are hardcoded in the CLI — it does not read media.sizes from site-config.json, so changing that key has no effect on process-images (it does still govern the in-editor upload pipeline).

Each processed image is assigned a 16-character hash ID derived from its content. The manifest file src/content/image-manifest.json maps each hash ID to its output folder and metadata (original filename, dimensions, MIME type). Section JSON files reference images by their hash ID in the imageId field.

Raw images go into _ingest/images/ before processing. This directory is gitignored — only the processed output in assets/images/ gets committed to the repository.

When the /populate-site skill runs, it downloads images from websites directly into _ingest/images/. Images embedded in PDFs (logos, label artwork, pattern graphics) cannot be extracted automatically; you must provide them manually by copying the files into _ingest/images/ and re-running process-images.

Processed images are committed to the GitHub repository as regular files. The portal serves them through /api/media/{imageId}/{width}.webp.

That API route:

  1. Resolves the imageId to a file path using the in-memory manifest cache (loaded once per function cold start).
  2. Fetches the WebP file from GitHub using the GitHub App installation token — not the GITHUB_TOKEN PAT. This means the images are fetched server-side and the GitHub repo can remain private.
  3. Returns the image with Netlify-CDN-Cache-Control: public, durable, immutable so Netlify’s CDN caches it at the edge. After the first request, subsequent requests for the same image are served from the CDN without hitting the function.

Because the URL contains a content-hash ID, cached responses remain valid until the image is replaced with a new file (which produces a new hash and a new URL).

To add an image outside of the /populate-site workflow:

  1. Drop the source file into _ingest/images/.
  2. Run pnpm exec authoring process-images --project .
  3. Note the hash ID assigned to the image in src/content/image-manifest.json.
  4. Reference the hash ID in your section JSON as "imageId": "<hash>".
  5. Commit assets/images/, src/content/image-manifest.json, and any updated section files.

Video shares the same manifest (src/content/image-manifest.json) as images, but processes and stores differently — process-images explicitly skips video files (it points you at the command below instead), and files above a size threshold never touch the git repository at all.

Terminal window
pnpm exec authoring upload-video <file> --project <path> --width <n> --height <n> [--duration <s>] [--poster <path>]

--width and --height are required — the CLI doesn’t probe the file for dimensions, so pass the video’s real pixel dimensions (used for the player’s aspect-ratio box). --duration and --poster are optional; --poster is a still image, encoded to WebP and written to assets/images/<folder>/poster.webp, matching the auto-generated poster the editor produces on upload.

The CLI always uploads straight to the shared portal-assets Cloudflare R2 bucket (sites/{siteId}/media/{hash}/original.{ext}) and writes a manifest entry (kind: "video", storage: "assets"), so the result is indistinguishable from a video uploaded through the editor. It requires R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE and PORTAL_SITE_ID in the environment — the CLI always signs directly with R2 credentials, regardless of whether the running site itself is in platform or standalone mode (see Environment variables).

  • 5 MB or less — small, silent loops go through the same git-committed path as images, referenced from the manifest by hash id. There’s no CLI command for this tier; it’s only reached through the editor’s own drag-and-drop upload.
  • Over 5 MB, up to 200 MB — routed to dedicated asset storage instead of the git repository, via assets: r2Assets() in portal.config.mjs. This is what upload-video always targets, and what the editor’s own upload switches to once a file crosses the 5 MB threshold.
  • Over 200 MB — rejected outright; compress or trim the file first.

Never commit a video file directly into the repository outside of this pipeline. Even a small loop needs a manifest entry and a content-hash id to be selectable in the editor or referenced from a video or media section’s imageId — a loose file dropped into assets/ with no manifest entry isn’t usable.

Bucket objects are uploaded with Cache-Control: max-age=31536000, immutable. That is safe because object keys are content-hashed — replacing a video produces a new hash and therefore a new URL, so a cached response can never go stale.

The header is not optional and not merely advisory. The presigned upload URL signs Cache-Control alongside Content-Type, so an upload that omits it is rejected with a 403 rather than silently producing an uncacheable object. This matters because the previous host (Supabase Storage) defaulted uploads without the header to no-cache, and served that regardless of stored metadata on plans without CDN purging — every play of every video became billable origin egress, including a plain page reload.

Ambient (autoplaying, looping) videos also render with preload="none" so an off-screen loop does not fetch during initial page load. autoplay overrides the hint once the browser decides to play, so playback is unaffected — only the timing of the fetch moves. The poster frame paints in the meantime.