feat(static): build script — dist/<app>/ + manifest-only media tree, baked API JSON, _redirects
Run via 'python -m tools.build_static' (avoids tools/http.py shadowing stdlib http). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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())
|
||||
@@ -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 → /<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"]
|
||||
|
||||
|
||||
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))
|
||||
Reference in New Issue
Block a user