Files
human-experience-filter-art/tools/build_static.py
T
BenStullsBets b83758fbca feat(content): about.html — intent, provenance, honest limits; static-build copy
English-first page mirroring credits.html (for-everyone / tech arc
underwater→drones→space / built-with-LLMs / honest-it's-imperfect). Header
link + about.link i18n key; about.html + flash.js added to PUBLIC_ASSETS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:37:00 -07:00

122 lines
4.7 KiB
Python

"""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 → /<app>/ (308)
<app>/ the app (served at benstull.art/<app>/)
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
<clip base.mp4 / transition morphs / audio>
The app lives under a path segment (so apex can redirect to it) and uses RELATIVE
asset urls, which resolve correctly under the `/<app>/` 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",
"credits.html", "credits.js", "about.html", "flash.js"]
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))