1c7f2b7785
CC BY / CC BY-SA footage needs its author + license shown to the viewer, and the installation alters every frame (a derivative). Per-clip license/source already shipped in clips.json but only surfaced in the dev panel and internal review pages — the public experience showed nothing, so the static benstull.art site was not attribution-compliant. Option A — add a manifest-driven credits/colophon (credits.html + credits.js): the page fetches the same clips the experience uses (baked clips.json in static, the live API otherwise) and renders one attribution block per clip (title, license, source), HTML-escaped, sorted, never silently dropping a clip. An "ⓘ Credits & licenses" link in the header (localised en/es/fr/ja) makes it reachable from the work — what CC's "reasonable to the medium" calls for. Audio is all PD/CC0, credited as a courtesy block. Option D1 — the colophon declares the altered work itself is offered under CC BY-SA 4.0 and states the footage is modified, satisfying the ShareAlike + "indicate changes" obligations for the BY-SA sources. Non-commercial. Ships in the static build (added to PUBLIC_ASSETS). Verified: 41/41 clips credited incl. all 7 CC-BY/BY-SA clips, 0 missing a license. Tests: node units for creditEntries/creditsListHtml + escaping + page structure/declaration; pytest build-output assertion; Playwright e2e (page lists attributions, header links through). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
4.6 KiB
Python
122 lines
4.6 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"]
|
|
|
|
|
|
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))
|