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).

A GIF over media.maxFileSize uploaded through the editor’s media library is converted to video in a Web Worker (packages/primitives/src/media/gif-to-video.ts: gifuct-js decode, WebCodecs encode, mediabunny mux; H.264 MP4 first, VP9 WebM as the fallback). The video is then routed inline as an ordinary kind: "video" item — through the processing queue if it fits under maxFileSize, direct to the bucket if not. When the bucket upload answers no_assets, or the video is over maxAssetSize, the editor re-encodes once at a lower bitrate with the longest side capped at 1920 px; only if that still doesn’t fit is the editor shown an error. The original GIF is not kept.

A GIF the converter reports as transparent (a composited pixel with alpha below 255) or unsupported (no H.264 or VP9 encoder) is stored raw instead. On a site with assets configured, the original goes straight to the bucket (media/{hash}/original.gif, the same direct-upload flow as large video) and is stored as kind: "animated", storage: "assets", with a first-frame poster.webp committed where video posters live. The media resolver points <img> at the bucket URL, /api/media/{id}/original.gif redirects there, and the library thumbnail is the poster. Without assets, the editor refuses the file with a message naming the reason. The MCP connector’s get_media_file can’t fetch a bucket-stored GIF, the same as a bucket video. Neither the connector’s upload_media nor the authoring CLI converts GIFs.

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.