From 77cb04bb8c8c418cf1309b16d31bb49c19864d90 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 07:43:49 -0700 Subject: [PATCH] =?UTF-8?q?feat(static):=20build=20script=20=E2=80=94=20di?= =?UTF-8?q?st//=20+=20manifest-only=20media=20tree,=20baked=20API=20J?= =?UTF-8?q?SON,=20=5Fredirects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run via 'python -m tools.build_static' (avoids tools/http.py shadowing stdlib http). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 4 ++ tests/test_build_static.py | 50 ++++++++++++++++ tools/build_static.py | 120 +++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 tests/test_build_static.py create mode 100644 tools/build_static.py diff --git a/.gitignore b/.gitignore index eb3b221..536d360 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,7 @@ simulator/static/review_audio.html # Editor annotation/comment-thread metadata (not project content) .threads/ + +# Static build outputs (tools/build_static.py) +/dist/ +/dist-media/ diff --git a/tests/test_build_static.py b/tests/test_build_static.py new file mode 100644 index 0000000..335fbb2 --- /dev/null +++ b/tests/test_build_static.py @@ -0,0 +1,50 @@ +import json + +from tools.build_static import build_static + + +def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path): + out = tmp_path / "dist" + media = tmp_path / "media" + result = build_static( + out, media, + media_base="https://static.benstull.art/", + app_path="/human-experience-simulator", + ) + app_dir = out / "human-experience-simulator" + + # frontend present under the app path; dev/author surfaces excluded + assert (app_dir / "index.html").exists() + assert (app_dir / "app.js").exists() + assert (app_dir / "scrub.js").exists() + assert (app_dir / "alteration.js").exists() + assert (app_dir / "config.js").exists() + assert not (app_dir / "author.html").exists() + assert not list(app_dir.glob("review*.html")) + + # apex + no-slash redirect rules + redirects = (out / "_redirects").read_text() + assert "/ /human-experience-simulator/ 308" in redirects + assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects + + # baked JSON matches the API shape + clips = json.loads((app_dir / "clips.json").read_text()) + assert "clips" in clips and clips["clips"] + ring = json.loads((app_dir / "ring.json").read_text()) + assert "scales" in ring and "transitions" in ring + assert all("audio" in s for s in ring["scales"]) # needed by client alteration + versions = json.loads((app_dir / "media-versions.json").read_text()) + assert "versions" in versions + + # config.js points at R2 + static mode + cfg = (app_dir / "config.js").read_text() + assert "https://static.benstull.art/" in cfg and "static: true" in cfg + + # media tree holds ONLY referenced files — no masters/mezzanines + synced = {p.name for p in media.rglob("*.mp4")} + assert synced, "expected synced media" + assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced) + # every versioned file exists in the media tree + for f in versions["versions"]: + assert (media / f).exists(), f"missing synced media: {f}" + assert result["media_files"] == sum(1 for _ in media.rglob("*") if _.is_file()) diff --git a/tools/build_static.py b/tools/build_static.py new file mode 100644 index 0000000..c09cf2d --- /dev/null +++ b/tools/build_static.py @@ -0,0 +1,120 @@ +"""Build the fully-static deployable for Cloudflare (Pages + R2). + +Emits the Pages output tree and a separate R2 media sync tree: + + out_dir/ + _redirects apex + no-slash → // (308) + / the app (served at benstull.art//) + index.html app.js scrub.js i18n.js alteration.js style.css + config.js GENERATED: { mediaBase, static: true } + clips.json ring.json media-versions.json baked API responses + media_out/ R2 upload tree — ONLY manifest-referenced files + + +The app lives under a path segment (so apex can redirect to it) and uses RELATIVE +asset urls, which resolve correctly under the `//` prefix. The baked JSON is +produced through the real app (TestClient) so it is byte-identical to the API. The +media tree contains ONLY referenced files — never the master/mezzanine pipeline +sources. Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in +deploy/cloudflare/ — this script only produces artifacts. + +Run as a MODULE so the repo root (not tools/) is on sys.path — otherwise the +sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import: + + .venv/bin/python -m tools.build_static +""" +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +from fastapi.testclient import TestClient + +from simulator.app import MEDIA_DIR, create_app + +STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static" +# Frontend files that ship; everything else in static/ (author*, review*) is dev-only. +PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"] + + +def _bake_api(app_dir: Path) -> dict: + app = create_app() + client = TestClient(app) + clips = client.get("/api/clips").json() + ring = client.get("/api/ring").json() + versions = client.get("/api/media-versions").json() + (app_dir / "clips.json").write_text(json.dumps(clips, ensure_ascii=False)) + (app_dir / "ring.json").write_text(json.dumps(ring, ensure_ascii=False)) + (app_dir / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False)) + return versions["versions"] + + +def _write_config(app_dir: Path, media_base: str) -> None: + (app_dir / "config.js").write_text( + "// GENERATED by tools/build_static.py — do not edit in dist/.\n" + f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n' + ) + + +def _write_redirects(out: Path, seg: str) -> None: + # Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset + # urls resolve under the prefix). Cloudflare Pages `_redirects` syntax. + (out / "_redirects").write_text( + f"/ /{seg}/ 308\n" + f"/{seg} /{seg}/ 308\n" + ) + + +def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict: + out = Path(out_dir) + media = Path(media_out) + seg = app_path.strip("/") + app_dir = out / seg + for d in (out, media): + if d.exists(): + shutil.rmtree(d) + app_dir.mkdir(parents=True) + media.mkdir(parents=True) + + for name in PUBLIC_ASSETS: + src = STATIC / name + if src.exists(): + shutil.copy2(src, app_dir / name) + + versions = _bake_api(app_dir) + _write_config(app_dir, media_base) + _write_redirects(out, seg) + + # Sync ONLY referenced media: `versions` covers clip bases + transition morphs; + # add each scale's audio explicitly. Never the master/mezzanine pipeline sources. + referenced = set(versions.keys()) + ring = json.loads((app_dir / "ring.json").read_text()) + for s in ring.get("scales", []): + if s.get("audio"): + referenced.add(f"audio/{s['audio']}") + n = 0 + for rel in sorted(referenced): + src = MEDIA_DIR / rel + if not src.exists(): + raise FileNotFoundError(f"referenced media missing on disk: {rel}") + dst = media / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + n += 1 + + return {"out": str(out), "app_dir": str(app_dir), "media": str(media), + "media_files": n, "app_path": app_path} + + +if __name__ == "__main__": + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--out", default="dist") + ap.add_argument("--media-out", default="dist-media") + ap.add_argument("--media-base", default="https://static.benstull.art/") + ap.add_argument("--app-path", default="/human-experience-simulator") + a = ap.parse_args() + r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path) + print(json.dumps(r, indent=2))