build_capsule copies frontend/dist (clear error if missing), copies referenced media, bakes every read-only API response extension-less at the live API's relative paths (manifest with capsule:true + has_capture:false, events, anchors, poses, beats, pointcloud, splat, audio via the same lazy extract_audio_hq cache, plus M16 saved paths — listing baked as api/paths/index.html so per-id files can share the prefix), injects window.__F4D_API_BASE__="" ahead of the app script, and ships a stdlib serve.py (206 Range slicing incl. suffix ranges + 416, JSON content types for the extension-less api/* files). Optional artifacts degrade by omission. test_capsule.py: bundle structure, baked-JSON deep-equal vs the live route functions, injection position, degrade/overwrite-guard/no-dist errors, CLI dispatch (CR-4), and serve.py exercised over real HTTP in a subprocess (200/206/416/404, content types, mp4+m4a magic bytes). Suite: 169 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""Memory capsule: zero-backend shareable export (spec M17, lane G).
|
|
|
|
Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (default
|
|
``config.CAPSULE_DIR``):
|
|
|
|
- copy ``frontend/dist`` (error clearly if missing — tell the user to ``npm run build``);
|
|
- copy media from ``config.RAW_DIR``;
|
|
- bake every read-only API response to files at the *same relative paths*
|
|
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/beats``,
|
|
``api/videos/{id}/poses``, ``api/pointcloud``, ``api/splat``, ``api/audio/{id}`` —
|
|
extracting audio via ``ingest.extract_audio_hq`` if not yet cached);
|
|
- inject ``<script>window.__F4D_API_BASE__=""</script>`` into the copied ``index.html``;
|
|
- set ``"capsule": true`` in the baked manifest (the frontend hides all write UI);
|
|
- ship a stdlib ``serve.py`` in the bundle root that serves with HTTP Range support
|
|
(video seeking needs 206s; plain ``http.server`` can't — pitfall #4).
|
|
|
|
Skip what doesn't exist (no splat -> no ``api/splat``) — degrade, don't block.
|
|
Returns a summary dict (bundle path, file count, bytes) for the CLI log.
|
|
|
|
Layout mirror (why paths look the way they do): the frontend resolves every URL as
|
|
``API_BASE + path`` with API_BASE == "" in a capsule, so requests hit the bundle root:
|
|
``fetch("/api/manifest")`` (no extension — bake the file extension-less; serve.py maps
|
|
extension-less ``api/*`` files to a JSON content type), ``video.src = "/media/<filename>"``
|
|
(manifest ``url`` fields are ``/media/{filename}`` — media files are copied under
|
|
``media/``), PLY loader hits ``/api/pointcloud``, the WebAudio clock fetches
|
|
``/api/audio/{id}``. Saved camera paths (M16) are read-only in a capsule, so
|
|
``api/paths`` + ``api/paths/{id}`` are baked too — the load dropdown keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import shutil
|
|
from pathlib import Path
|
|
|
|
from festival4d import config, db
|
|
|
|
log = logging.getLogger("festival4d.capsule")
|
|
|
|
# Injected verbatim (phase-5 contract #5): a runtime override that beats the build-time
|
|
# VITE_API_BASE, turning the same dist bundle zero-backend. Must land in index.html before
|
|
# the app module reads it (modules are deferred, but we inject ahead of the first <script>
|
|
# anyway so execution order is obvious).
|
|
_API_BASE_SNIPPET = '<script>window.__F4D_API_BASE__=""</script>'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# serve.py shipped in the bundle root (stdlib-only, Range-capable — pitfall #4)
|
|
# ---------------------------------------------------------------------------
|
|
SERVE_PY = r'''#!/usr/bin/env python3
|
|
"""Serve this Festival 4D memory capsule locally.
|
|
|
|
Plain `python -m http.server` cannot answer HTTP Range requests, and <video>
|
|
seeking needs 206 partial responses — so the capsule ships this tiny stdlib
|
|
server instead. No dependencies beyond Python 3.8+.
|
|
|
|
python serve.py [--port 8080] [--host 127.0.0.1]
|
|
|
|
Then open http://127.0.0.1:8080/ in a browser.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import re
|
|
from functools import partial
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
_RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
|
|
|
|
|
class _Slice:
|
|
"""File wrapper that reads at most `length` bytes (the body of a 206)."""
|
|
|
|
def __init__(self, f, length):
|
|
self._f = f
|
|
self._left = length
|
|
|
|
def read(self, n=-1):
|
|
if self._left <= 0:
|
|
return b""
|
|
if n < 0 or n > self._left:
|
|
n = self._left
|
|
data = self._f.read(n)
|
|
self._left -= len(data)
|
|
return data
|
|
|
|
def close(self):
|
|
self._f.close()
|
|
|
|
|
|
class RangeHandler(SimpleHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
|
|
def guess_type(self, path):
|
|
# Baked API responses are extension-less files at the live API's relative
|
|
# paths; give them the content types the live server would have used.
|
|
url = self.path.split("?", 1)[0].split("#", 1)[0].lstrip("/")
|
|
if "." not in url.rsplit("/", 1)[-1]:
|
|
if url.startswith("api/audio/"):
|
|
return "audio/mp4"
|
|
if url in ("api/pointcloud", "api/splat"):
|
|
return "application/octet-stream"
|
|
if url.startswith("api/"):
|
|
return "application/json"
|
|
return super().guess_type(path)
|
|
|
|
def send_head(self):
|
|
path = self.translate_path(self.path)
|
|
if os.path.isdir(path):
|
|
# Directory -> index.html (the SPA entry), stock behavior.
|
|
return super().send_head()
|
|
try:
|
|
f = open(path, "rb")
|
|
except OSError:
|
|
self.send_error(404, "File not found")
|
|
return None
|
|
try:
|
|
size = os.fstat(f.fileno()).st_size
|
|
start, end, status = 0, size - 1, 200
|
|
m = _RANGE.match(self.headers.get("Range", "").strip())
|
|
if m and (m.group(1) or m.group(2)):
|
|
if m.group(1):
|
|
start = int(m.group(1))
|
|
if m.group(2):
|
|
end = min(int(m.group(2)), size - 1)
|
|
else: # suffix form: bytes=-N (the final N bytes)
|
|
start = max(size - int(m.group(2)), 0)
|
|
if start >= size:
|
|
self.send_response(416)
|
|
self.send_header("Content-Range", "bytes */%d" % size)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
f.close()
|
|
return None
|
|
status = 206
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", self.guess_type(path))
|
|
self.send_header("Accept-Ranges", "bytes")
|
|
self.send_header("Content-Length", str(end - start + 1))
|
|
if status == 206:
|
|
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
|
|
self.end_headers()
|
|
f.seek(start)
|
|
return _Slice(f, end - start + 1)
|
|
except Exception:
|
|
f.close()
|
|
raise
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description="Serve this Festival 4D capsule (Range-capable).")
|
|
ap.add_argument("--host", default="127.0.0.1")
|
|
ap.add_argument("--port", type=int, default=8080)
|
|
args = ap.parse_args()
|
|
handler = partial(RangeHandler, directory=ROOT)
|
|
httpd = ThreadingHTTPServer((args.host, args.port), handler)
|
|
print("Festival 4D capsule -> http://%s:%d/" % (args.host, args.port))
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
'''
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
def _bake_json(path: Path, obj) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(obj, indent=2))
|
|
|
|
|
|
def _inject_api_base(index_html: Path) -> None:
|
|
"""Insert the runtime API-base override into the copied index.html."""
|
|
html = index_html.read_text()
|
|
if "__F4D_API_BASE__" in html:
|
|
return # already injected (rebuild over an old bundle copy)
|
|
i = html.find("<script")
|
|
if i == -1:
|
|
i = html.find("</head>")
|
|
if i != -1:
|
|
html = html[:i] + _API_BASE_SNIPPET + "\n " + html[i:]
|
|
else: # no <script> and no </head> — degenerate page; prepend
|
|
html = _API_BASE_SNIPPET + "\n" + html
|
|
index_html.write_text(html)
|
|
|
|
|
|
def _prepare_out_dir(out: Path) -> None:
|
|
"""Refuse to clobber a directory that isn't (or wasn't) a capsule."""
|
|
if not out.exists():
|
|
return
|
|
contents = list(out.iterdir())
|
|
is_previous_capsule = (out / "serve.py").exists() or (out / "index.html").exists()
|
|
if contents and not is_previous_capsule:
|
|
raise RuntimeError(
|
|
f"output dir {out} exists and does not look like a previous capsule — "
|
|
"refusing to overwrite; pass an empty/new --out"
|
|
)
|
|
shutil.rmtree(out)
|
|
|
|
|
|
def _summary(out: Path) -> dict:
|
|
files = [p for p in out.rglob("*") if p.is_file()]
|
|
return {
|
|
"bundle": str(out),
|
|
"files": len(files),
|
|
"bytes": sum(p.stat().st_size for p in files),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
def build_capsule(out_dir: str | Path | None = None,
|
|
dist_dir: str | Path | None = None) -> dict:
|
|
"""Build the static shareable bundle (see module doc).
|
|
|
|
``dist_dir`` overrides the frontend build location (default
|
|
``<repo>/frontend/dist``) — used by tests to point at a fabricated dist.
|
|
"""
|
|
# The API module is the single source of truth for response shapes — its route
|
|
# functions are plain callables returning the exact dicts the live server sends,
|
|
# so baking through them can never drift from the frozen contract.
|
|
from festival4d import api, ingest
|
|
|
|
dist = Path(dist_dir) if dist_dir is not None else config.REPO_ROOT / "frontend" / "dist"
|
|
if not (dist / "index.html").exists():
|
|
raise RuntimeError(
|
|
f"frontend build not found at {dist} — run `cd frontend && npm run build` first"
|
|
)
|
|
|
|
out = Path(out_dir) if out_dir is not None else config.CAPSULE_DIR
|
|
_prepare_out_dir(out)
|
|
|
|
db.init_engine()
|
|
db.init_db()
|
|
videos = db.get_videos()
|
|
|
|
# 1. Frontend bundle + runtime API-base override.
|
|
shutil.copytree(dist, out)
|
|
_inject_api_base(out / "index.html")
|
|
|
|
# 2. Media (only the files the manifest references — RAW_DIR may hold strays).
|
|
media_dir = out / "media"
|
|
media_dir.mkdir(parents=True, exist_ok=True)
|
|
for v in videos:
|
|
src = config.RAW_DIR / v.filename
|
|
if src.exists():
|
|
shutil.copy2(src, media_dir / v.filename)
|
|
else:
|
|
log.warning("capsule: source video missing, skipped: %s", src)
|
|
|
|
# 3. Baked read-only API responses, at the SAME relative paths the live API serves
|
|
# (extension-less on purpose — the frontend fetches e.g. `${API_BASE}/api/manifest`).
|
|
api_dir = out / "api"
|
|
manifest = api.manifest()
|
|
manifest["capsule"] = True # frontend hides all write UI (contract #5)
|
|
manifest["has_capture"] = False # capture routes never exist in a static bundle
|
|
_bake_json(api_dir / "manifest", manifest)
|
|
_bake_json(api_dir / "events", api.get_events())
|
|
_bake_json(api_dir / "anchors", api.get_anchors())
|
|
for v in videos:
|
|
_bake_json(api_dir / "videos" / str(v.id) / "poses", api.video_poses(v.id))
|
|
|
|
# Saved camera paths (M16) — read-only in a capsule, so the load dropdown still works.
|
|
# A filesystem can't hold both a FILE `api/paths` and per-id files under `api/paths/`,
|
|
# so the listing is baked as the directory's index.html: GET /api/paths gets the stock
|
|
# 301 -> /api/paths/ -> index.html (fetch follows redirects), and serve.py's URL-based
|
|
# content-type override still labels it application/json.
|
|
_bake_json(api_dir / "paths" / "index.html", api.get_paths())
|
|
for p in db.get_paths():
|
|
_bake_json(api_dir / "paths" / str(p.id), api.get_path(p.id))
|
|
|
|
# Optional artifacts: bake when present, silently omit when not (the frontend already
|
|
# handles the 404s serve.py will answer for the missing files — degrade, don't block).
|
|
if config.BEATS_JSON.exists():
|
|
_bake_json(api_dir / "beats", json.loads(config.BEATS_JSON.read_text()))
|
|
if config.POINTS_PLY.exists():
|
|
api_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(config.POINTS_PLY, api_dir / "pointcloud")
|
|
if config.SPLAT_PLY.exists():
|
|
api_dir.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(config.SPLAT_PLY, api_dir / "splat")
|
|
|
|
# 4. Soundtracks for the WebAudio master clock: reuse the live server's lazy cache,
|
|
# extracting on demand exactly like GET /api/audio/{id} would have.
|
|
import subprocess
|
|
|
|
for v in videos:
|
|
src = config.RAW_DIR / v.filename
|
|
cached = config.AUDIO_HQ_DIR / f"{v.id}.m4a"
|
|
if src.exists() and (not cached.exists() or cached.stat().st_mtime < src.stat().st_mtime):
|
|
try:
|
|
ingest.extract_audio_hq(src, cached)
|
|
except (subprocess.CalledProcessError, RuntimeError, OSError) as exc:
|
|
log.warning("capsule: no extractable audio for video %s: %s", v.id, exc)
|
|
if cached.exists():
|
|
audio_out = api_dir / "audio" / str(v.id)
|
|
audio_out.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(cached, audio_out)
|
|
|
|
# 5. The Range-capable server (pitfall #4).
|
|
serve_py = out / "serve.py"
|
|
serve_py.write_text(SERVE_PY)
|
|
serve_py.chmod(serve_py.stat().st_mode | 0o111)
|
|
|
|
summary = _summary(out)
|
|
log.info("capsule: baked %(files)d files (%(bytes)d bytes) at %(bundle)s", summary)
|
|
return summary
|