# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design

**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.

## Goal

Publish the Human Experience **Simulator** publicly at
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
`benstull.art` apex **redirecting** to that path for now.

The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
static frontend plus video/audio from a local `/media/` mount. The public target
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
stays as the dev/authoring surface — this is a *new, additional* public target, not
a migration of the dev box.

## Why this shape (the constraint that forces it)

Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
media.** (Verified against Cloudflare docs 2026-06-30.)

## Decisions (locked)

- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
  redirects** there (302/308 redirect, not a rewrite).
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
  client-side (see Component 2).
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
  graduated prefetch is sufficient. Deferred, not rejected.
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
  this **entire project** is exempt from the typical Wiggleverse deployment
  conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
  and the §9 prod-promotion gate do not bind it. It is a static public art target
  hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
  rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)

## Served media — what actually ships

The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
the `master.mp4` (20 files, incl. the 91–117 MB `coast_birdrock` set) and
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
served set is:

- the `base.mp4` clip bases (one per pool member),
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
  kept < 25 MB),
- the **10 audio `.mp3`** layers (~12 MB).

≈ **~2 GB** to R2. The build computes the exact set by walking the manifest — it
**must not** blindly sync `sample_media/` (that would push the masters too).

## Components

### 1. Static build (`tools/` — new build script → `dist/`)

Replaces the server with baked artifacts:

- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
  fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
  - `clips.json` ← `GET /api/clips`
  - `ring.json` ← `GET /api/ring`
  - `media-versions.json` ← `GET /api/media-versions` (file → content-hash)
  Generate these by importing the existing builders (`simulator/clips.py`,
  `build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
  `i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
  (`author.*`, `review*.html`, `/api/author/*`).
- Output is deterministic and re-runnable; checked by the integration test below.

### 2. Client-side dynamic logic (`simulator/static/app.js`)

The only server-computed behavior is the **random clip pick on landing**
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
label jumps) uses the client-side pick and that **no code path depends on a live
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
`/api/alteration` is verified client-resolvable or dropped from the public build.

### 3. Media URL base + CORS (`simulator/static/app.js`)

- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
  **`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
  `?v=<hash>` content-hash versioning. A single const, fed from the baked config so
  localhost dev still works against the FastAPI `/media/` mount.
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
  clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
  blob, and any clip that falls back to the *direct network* `<video>` path feeding
  WebGL **taints the GL texture** without CORS. So:
  - R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
  - `<video>` elements carry **`crossOrigin="anonymous"`**.
  Without this, plain playback works but the *dream effect* throws a security error.

### 4. Prefetch (unchanged)

Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
after first fetch; egress is free, so the background fill is free. No code change
beyond the `MEDIA_BASE` swap (Component 3).

### 5. Caching headers

Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
manual purge. Pages assets keep normal Cloudflare defaults.

### 6. Deploy & redirect

- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
  Serve the app under the `/human-experience-simulator` path.
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
  `static.benstull.art` as the public custom domain, apply the CORS policy.
- **Apex redirect:** `benstull.art/` → `/human-experience-simulator` via a
  Cloudflare redirect rule (or Pages `_redirects`).

## Testing

- **Build integration test:** run the build against the real manifest; assert
  `dist/` contains the expected static files, the three baked JSONs match the live
  API output, and the media sync tree contains **only** manifest-referenced files
  (no masters/mezzanines, nothing > some sanity bound).
- **E2E (Playwright) against the static build:** point the existing suite at the
  built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
  server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
  scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
  The E2E browser tier is required per the §9 pipeline (handbook).
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
  R2 custom domain before flipping the apex redirect.

## Out of scope / deferred

- Service Worker / cross-session caching (deferred, not rejected).
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
  tracked elsewhere).
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
