Merge lane/g-capsule: M16 paths UI + tests, M17 memory capsule (CR-4 ratified)

Coordinator-verified: 169 passed independently re-run in the lane worktree;
CR-4 (retire capsule stub assertion) ratified per CR-1 precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:53:25 +10:00
commit f29e0dca59
7 changed files with 1060 additions and 14 deletions

View File

@ -1,4 +1,4 @@
"""Memory capsule: zero-backend shareable export (spec M17, lane G). STUB — lane G fills.
"""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``):
@ -16,13 +16,300 @@ Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (d
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
def build_capsule(out_dir: str | Path | None = None) -> dict:
"""Build the static shareable bundle (see module doc)."""
raise NotImplementedError("memory capsule (lane G / M17)")
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

View File

@ -0,0 +1,337 @@
"""M17 memory capsule (lane G): bundle structure, baked-JSON validity, serve.py Range.
Builds a real capsule from the synthetic project (temp data dir per conftest) against a
fabricated frontend dist (spec allows this running npm inside tests would be neither
hermetic nor fast), then boots the bundled ``serve.py`` in a subprocess and exercises it
over HTTP: 200s, JSON content types on the extension-less baked API files, and the 206
Range behavior video seeking depends on (phase-5 pitfall #4).
"""
from __future__ import annotations
import json
import shutil
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required to build the synthetic capsule fixture",
)
CAMPATH = {
"version": 1,
"keyframes": [
{"t_global": 0.2, "pos": [0.0, 1.0, 5.0], "quat": [0.0, 0.0, 0.0, 1.0], "fov": 50.0},
{"t_global": 0.8, "pos": [2.0, 1.0, 4.0], "quat": [0.0, 0.5, 0.0, 0.8660254], "fov": 42.0},
],
}
BEATS = {"tempo_bpm": 120.0, "beats_s": [0.25, 0.75], "onsets": [],
"generated_by": "festival4d.audio_features"}
SPLAT_BYTES = b"ply\nfake-splat-for-tests\n"
API_SNIPPET = '<script>window.__F4D_API_BASE__=""</script>'
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def project():
"""Synthetic project + every optional artifact seeded (beats, splat, a saved path)."""
from festival4d import config, db, synthetic
synthetic.build(duration_s=1.0, run_ffmpeg=True) # into the temp data dir (conftest)
db.init_engine()
db.init_db()
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
config.BEATS_JSON.write_text(json.dumps(BEATS))
config.SPLAT_PLY.write_bytes(SPLAT_BYTES)
saved = db.add_path("capsule sweep", json.dumps(CAMPATH))
yield {"path_id": saved.id}
# Leave the shared session data dir as later test modules expect to find it.
config.BEATS_JSON.unlink(missing_ok=True)
config.SPLAT_PLY.unlink(missing_ok=True)
db.delete_path(saved.id)
def _make_dist(root):
"""A minimal Vite-shaped dist: index.html with a module <script> + one asset."""
root.mkdir(parents=True, exist_ok=True)
(root / "assets").mkdir(exist_ok=True)
(root / "assets" / "index.js").write_text("console.log('festival4d test dist');\n")
(root / "index.html").write_text(
"<!doctype html>\n<html>\n <head>\n <meta charset=\"UTF-8\" />\n"
" <title>Festival 4D</title>\n"
" <script type=\"module\" crossorigin src=\"/assets/index.js\"></script>\n"
" </head>\n <body><div id=\"app\"></div></body>\n</html>\n"
)
return root
@pytest.fixture(scope="module")
def fake_dist(tmp_path_factory):
return _make_dist(tmp_path_factory.mktemp("frontend") / "dist")
@pytest.fixture(scope="module")
def bundle(project, fake_dist, tmp_path_factory):
from festival4d import capsule
out = tmp_path_factory.mktemp("capsule") / "bundle"
summary = capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
return out, summary
@pytest.fixture(scope="module")
def server(bundle):
"""The bundle's own serve.py, running as a real subprocess in the bundle root."""
out, _ = bundle
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
proc = subprocess.Popen(
[sys.executable, "serve.py", "--port", str(port)],
cwd=out, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
base = f"http://127.0.0.1:{port}"
try:
deadline = time.monotonic() + 15
while True:
try:
urllib.request.urlopen(f"{base}/api/manifest", timeout=1).read()
break
except OSError:
if proc.poll() is not None or time.monotonic() > deadline:
raise RuntimeError(
f"serve.py did not come up: {proc.stdout.read().decode(errors='replace')}"
)
time.sleep(0.1)
yield base
finally:
proc.terminate()
proc.wait(timeout=5)
def _get(url, headers=None):
"""GET returning (status, headers, body); headers stay a case-insensitive Message."""
req = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.status, resp.headers, resp.read()
except urllib.error.HTTPError as err:
return err.code, err.headers, err.read()
# ---------------------------------------------------------------------------
# Bundle structure
# ---------------------------------------------------------------------------
def test_bundle_structure(bundle):
from festival4d import db
out, summary = bundle
for rel in ("index.html", "serve.py", "assets/index.js",
"api/manifest", "api/events", "api/anchors",
"api/beats", "api/pointcloud", "api/splat",
# the paths listing lives at paths/index.html so per-id files can
# coexist under the same URL prefix (see capsule.py)
"api/paths/index.html"):
assert (out / rel).is_file(), f"missing {rel}"
for v in db.get_videos():
assert (out / "media" / v.filename).is_file()
assert (out / "api" / "videos" / str(v.id) / "poses").is_file()
assert (out / "api" / "audio" / str(v.id)).is_file()
assert summary["bundle"] == str(out)
assert summary["files"] >= 10
assert summary["bytes"] > 0
def test_baked_manifest_is_capsule_true_and_urls_resolve(bundle):
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
assert manifest["capsule"] is True
assert manifest["has_capture"] is False # capture routes never exist in a static bundle
assert manifest["has_poses"] is True and manifest["has_splat"] is True
assert manifest["t_global_max"] > 0
assert len(manifest["videos"]) >= 2
for v in manifest["videos"]:
# Every manifest URL must resolve inside the bundle (API_BASE == "" ⇒ root-relative).
assert v["url"].startswith("/media/")
assert (out / v["url"].lstrip("/")).is_file()
def test_baked_json_matches_live_api_shapes(bundle, project):
from festival4d import api, db
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
live = api.manifest()
assert set(manifest) == set(live) # key set identical to the live route
assert json.loads((out / "api" / "events").read_text()) == api.get_events()
assert json.loads((out / "api" / "anchors").read_text()) == api.get_anchors()
for v in db.get_videos():
baked = json.loads((out / "api" / "videos" / str(v.id) / "poses").read_text())
assert baked == api.video_poses(v.id)
assert len(baked) > 0
assert json.loads((out / "api" / "beats").read_text()) == BEATS
assert (out / "api" / "splat").read_bytes() == SPLAT_BYTES
assert (out / "api" / "pointcloud").read_bytes()[:3] == b"ply"
# Saved camera paths (M16) baked read-only so the load dropdown works zero-backend.
listing = json.loads((out / "api" / "paths" / "index.html").read_text())
assert [p["name"] for p in listing] == ["capsule sweep"]
pid = project["path_id"]
assert json.loads((out / "api" / "paths" / str(pid)).read_text())["json"] == CAMPATH
def test_index_html_injects_api_base_before_app_script(bundle):
out, _ = bundle
html = (out / "index.html").read_text()
assert html.count(API_SNIPPET) == 1
assert html.index(API_SNIPPET) < html.index('type="module"')
def test_optional_artifacts_omitted_when_absent(project, fake_dist, tmp_path):
"""Degrade, don't block: no beats/splat ⇒ those files are simply not baked."""
from festival4d import capsule, config
beats, splat = config.BEATS_JSON.read_text(), config.SPLAT_PLY.read_bytes()
config.BEATS_JSON.unlink()
config.SPLAT_PLY.unlink()
try:
out = tmp_path / "bare"
capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
assert not (out / "api" / "beats").exists()
assert not (out / "api" / "splat").exists()
manifest = json.loads((out / "api" / "manifest").read_text())
assert manifest["has_splat"] is False
assert manifest["capsule"] is True
finally:
config.BEATS_JSON.write_text(beats)
config.SPLAT_PLY.write_bytes(splat)
def test_missing_dist_errors_clearly(tmp_path):
from festival4d import capsule
with pytest.raises(RuntimeError, match="npm run build"):
capsule.build_capsule(out_dir=tmp_path / "never", dist_dir=tmp_path / "no-dist")
assert not (tmp_path / "never").exists()
def test_rebuild_overwrites_previous_capsule_but_not_strangers(bundle, fake_dist, project, tmp_path):
from festival4d import capsule
# A non-capsule, non-empty directory is refused (don't eat someone's folder).
stranger = tmp_path / "stranger"
stranger.mkdir()
(stranger / "precious.txt").write_text("do not delete")
with pytest.raises(RuntimeError, match="refusing"):
capsule.build_capsule(out_dir=stranger, dist_dir=fake_dist)
assert (stranger / "precious.txt").exists()
# Rebuilding over a previous capsule works and never double-injects the snippet.
out, _ = bundle
capsule.build_capsule(out_dir=out, dist_dir=fake_dist)
assert (out / "index.html").read_text().count(API_SNIPPET) == 1
def test_cli_capsule_dispatch(project, fake_dist, tmp_path, monkeypatch):
"""`python -m festival4d capsule` (CR-4: the stub exit-2 contract is retired)."""
from festival4d import cli, config
# The CLI has no --dist flag; point the repo-root lookup at a fabricated tree.
fake_root = tmp_path / "repo"
_make_dist(fake_root / "frontend" / "dist")
monkeypatch.setattr(config, "REPO_ROOT", fake_root)
out = tmp_path / "cli-bundle"
assert cli.main(["capsule", "--out", str(out)]) == 0
assert (out / "serve.py").is_file()
assert json.loads((out / "api" / "manifest").read_text())["capsule"] is True
# And the no-dist error path is a clear message, not a stub exit.
monkeypatch.setattr(config, "REPO_ROOT", tmp_path / "empty-repo")
with pytest.raises(RuntimeError, match="npm run build"):
cli.main(["capsule", "--out", str(tmp_path / "nope")])
# ---------------------------------------------------------------------------
# serve.py over real HTTP
# ---------------------------------------------------------------------------
def test_serve_index_and_json_content_types(server):
status, headers, body = _get(f"{server}/")
assert status == 200
assert headers["Content-Type"].startswith("text/html")
assert API_SNIPPET.encode() in body
status, headers, body = _get(f"{server}/api/manifest")
assert status == 200
assert headers["Content-Type"] == "application/json" # extension-less baked file
assert json.loads(body)["capsule"] is True
for rel, ctype in (("api/events", "application/json"),
("api/paths", "application/json"),
("api/pointcloud", "application/octet-stream"),
("api/splat", "application/octet-stream")):
status, headers, _ = _get(f"{server}/{rel}")
assert (status, headers["Content-Type"]) == (200, ctype), rel
def test_serve_video_range_206(server, bundle):
out, _ = bundle
manifest = json.loads((out / "api" / "manifest").read_text())
url = manifest["videos"][0]["url"] # "/media/<filename>"
size = (out / url.lstrip("/")).stat().st_size
# Plain GET: 200, full body, and Range support advertised (browsers probe this).
status, headers, body = _get(f"{server}{url}")
assert status == 200
assert headers["Accept-Ranges"] == "bytes"
assert int(headers["Content-Length"]) == size == len(body)
# bytes=0-100 ⇒ 206 with exactly 101 bytes and a correct Content-Range.
status, headers, body = _get(f"{server}{url}", {"Range": "bytes=0-100"})
assert status == 206
assert headers["Content-Range"] == f"bytes 0-100/{size}"
assert len(body) == 101
assert body[4:8] == b"ftyp" # mp4 magic — it's really the video's first bytes
# Open-ended seek (bytes=N-) ⇒ the tail from N.
mid = size // 2
status, headers, body = _get(f"{server}{url}", {"Range": f"bytes={mid}-"})
assert status == 206
assert headers["Content-Range"] == f"bytes {mid}-{size - 1}/{size}"
assert len(body) == size - mid
# Suffix form (bytes=-N) ⇒ the final N bytes (mp4 moov probing does this).
status, headers, body = _get(f"{server}{url}", {"Range": "bytes=-50"})
assert status == 206
assert headers["Content-Range"] == f"bytes {size - 50}-{size - 1}/{size}"
assert len(body) == 50
# Start past EOF ⇒ 416 with the total size.
status, headers, _ = _get(f"{server}{url}", {"Range": f"bytes={size + 10}-"})
assert status == 416
assert headers["Content-Range"] == f"bytes */{size}"
def test_serve_audio_bytes_and_range(server, bundle):
status, headers, body = _get(f"{server}/api/audio/1")
assert status == 200
assert headers["Content-Type"] == "audio/mp4"
assert body[4:8] == b"ftyp" # m4a container magic
# The WebAudio clock fetches whole files, but Range must still work on audio.
status, headers, part = _get(f"{server}/api/audio/1", {"Range": "bytes=0-15"})
assert status == 206 and part == body[:16]
def test_serve_missing_file_404(server):
assert _get(f"{server}/api/nope")[0] == 404
assert _get(f"{server}/media/ghost.mp4")[0] == 404

198
backend/tests/test_paths.py Normal file
View File

@ -0,0 +1,198 @@
"""Deepened coverage of the M16 saved camera-path routes (lane G).
foundation2's basic shape tests live in test_phase5_api.py; this module hardens the
contract: deep-equal roundtrips (including float fidelity and large keyframe lists),
multiple coexisting paths, name handling, 404s, and the 422 validation surface of the
frozen camPath JSON (plan/20-phase5.md contract #2).
Pure DB/API surface no synthetic fixture or ffmpeg needed. The test session's data
dir is a temp dir (conftest), and every test cleans up the rows it created so later
modules (test_phase5_api.py runs after this one alphabetically) still see an empty table.
"""
from __future__ import annotations
import json
import math
import pytest
def _campath(keyframes):
return {"version": 1, "keyframes": keyframes}
def _kf(t, pos=(0.0, 1.0, 5.0), quat=(0.0, 0.0, 0.0, 1.0), fov=50.0):
return {"t_global": t, "pos": list(pos), "quat": list(quat), "fov": fov}
SIMPLE = _campath([_kf(1.0), _kf(4.0, pos=(2, 1, 4), quat=(0, 0.3826834, 0, 0.9238795), fov=42)])
@pytest.fixture(scope="module")
def client():
from fastapi.testclient import TestClient
from festival4d import api
with TestClient(api.app) as c:
yield c
@pytest.fixture(autouse=True)
def _clean_paths(client):
"""Leave the paths table exactly as found (empty) — later modules assert on it."""
yield
for p in client.get("/api/paths").json():
client.delete(f"/api/paths/{p['id']}")
def _post(client, name, obj_or_text):
text = obj_or_text if isinstance(obj_or_text, str) else json.dumps(obj_or_text)
return client.post("/api/paths", json={"name": name, "json": text})
# ---------------------------------------------------------------------------
# Roundtrip fidelity
# ---------------------------------------------------------------------------
def test_roundtrip_preserves_float_precision(client):
# Awkward floats must survive POST -> store-as-text -> GET deep-equal (the live UI
# depends on "reload the page, load the path, land on the exact same keyframes").
path = _campath([
_kf(0.123456789012345, pos=(-1.5e-7, 2.0000000001, math.pi), fov=49.99999999),
_kf(1e3 + 1e-9, quat=(0.5, -0.5, 0.5, -0.5), fov=1e2 / 3),
])
created = _post(client, "precise", path).json()
assert created["json"] == path
assert client.get(f"/api/paths/{created['id']}").json()["json"] == path
def test_roundtrip_large_keyframe_list(client):
# A long recorded flythrough: hundreds of keyframes with non-round values.
path = _campath([
_kf(i * 0.0333 + 0.0001 * i * i,
pos=(math.sin(i), 1 + 0.01 * i, math.cos(i)),
quat=(0.0, math.sin(i / 500), 0.0, math.cos(i / 500)),
fov=30 + (i % 40) * 0.77)
for i in range(500)
])
created = _post(client, "marathon", path).json()
got = client.get(f"/api/paths/{created['id']}").json()["json"]
assert got == path
assert len(got["keyframes"]) == 500
def test_empty_keyframes_is_a_valid_path(client):
# The director degrades to {"version":1,"keyframes":[]} — that must be saveable.
resp = _post(client, "empty", _campath([]))
assert resp.status_code == 200
assert resp.json()["json"] == _campath([])
def test_extra_keys_in_campath_survive_roundtrip(client):
# Validation is a floor, not a strainer: unknown fields (e.g. the director's "note")
# ride along verbatim because the JSON is stored as text.
path = {**_campath([_kf(2.0)]), "note": "auto-director v1"}
created = _post(client, "annotated", path).json()
assert created["json"] == path
# ---------------------------------------------------------------------------
# Listing / multiple paths / name handling
# ---------------------------------------------------------------------------
def test_multiple_paths_coexist_and_list_in_id_order(client):
ids = [_post(client, name, SIMPLE).json()["id"] for name in ("a", "b", "c")]
listing = client.get("/api/paths").json()
assert [p["id"] for p in listing] == sorted(ids)
assert [p["name"] for p in listing] == ["a", "b", "c"]
# Listing is the summary shape only — the (potentially large) JSON stays off it.
assert all(set(p) == {"id", "name", "created_at"} for p in listing)
# Deleting the middle one leaves the neighbors intact and loadable.
assert client.delete(f"/api/paths/{ids[1]}").status_code == 200
left = client.get("/api/paths").json()
assert [p["id"] for p in left] == [ids[0], ids[2]]
assert client.get(f"/api/paths/{ids[2]}").json()["json"] == SIMPLE
def test_duplicate_names_are_allowed_distinct_rows(client):
a = _post(client, "same name", SIMPLE).json()
b = _post(client, "same name", SIMPLE).json()
assert a["id"] != b["id"]
assert [p["name"] for p in client.get("/api/paths").json()] == ["same name", "same name"]
def test_names_preserved_verbatim(client):
# Unicode, emoji, quotes, leading/trailing whitespace — stored and returned as-is.
for name in ("🎥 drop №5 — “final”", " padded ", "a" * 300):
created = _post(client, name, SIMPLE).json()
assert created["name"] == name
assert client.get(f"/api/paths/{created['id']}").json()["name"] == name
def test_created_at_is_an_iso_timestamp(client):
from datetime import datetime
created = _post(client, "stamped", SIMPLE).json()
# Parses as ISO-8601; the frontend shows it as a tooltip.
assert isinstance(datetime.fromisoformat(created["created_at"]), datetime)
# ---------------------------------------------------------------------------
# 404s
# ---------------------------------------------------------------------------
def test_get_and_delete_unknown_id_404(client):
assert client.get("/api/paths/424242").status_code == 404
assert client.delete("/api/paths/424242").status_code == 404
def test_deleted_path_stays_deleted(client):
pid = _post(client, "doomed", SIMPLE).json()["id"]
assert client.delete(f"/api/paths/{pid}").json() == {"deleted": pid}
assert client.get(f"/api/paths/{pid}").status_code == 404
assert client.delete(f"/api/paths/{pid}").status_code == 404
# ---------------------------------------------------------------------------
# Validation (422) — the frozen camPath JSON contract, edge by edge
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"body",
[
"", # empty text
"not json at all {",
json.dumps(None),
json.dumps([_kf(0.0)]), # top-level array, not object
json.dumps("a string"),
json.dumps({"keyframes": []}), # version missing
json.dumps({"version": "1", "keyframes": []}), # version wrong type
json.dumps({"version": 2, "keyframes": []}), # wrong version
json.dumps({"version": 1}), # keyframes missing
json.dumps({"version": 1, "keyframes": {}}), # keyframes not a list
json.dumps(_campath(["not a dict"])),
json.dumps(_campath([{k: v for k, v in _kf(0.0).items() if k != "t_global"}])),
json.dumps(_campath([{k: v for k, v in _kf(0.0).items() if k != "fov"}])),
json.dumps(_campath([{**_kf(0.0), "t_global": "0.0"}])), # time as string
json.dumps(_campath([{**_kf(0.0), "pos": [0.0, 1.0]}])), # pos wrong arity
json.dumps(_campath([{**_kf(0.0), "quat": [0, 0, 0, 1, 0]}])), # quat wrong arity
json.dumps(_campath([{**_kf(0.0), "pos": 5}])), # pos not a list
json.dumps(_campath([_kf(0.0), {**_kf(1.0), "quat": None}])), # one bad among good
],
)
def test_invalid_campath_json_422(client, body):
assert client.post("/api/paths", json={"name": "bad", "json": body}).status_code == 422
def test_validation_failure_creates_no_row(client):
assert client.get("/api/paths").json() == []
assert _post(client, "bad", "{").status_code == 422
assert client.get("/api/paths").json() == []
def test_missing_request_fields_422(client):
assert client.post("/api/paths", json={"json": json.dumps(SIMPLE)}).status_code == 422
assert client.post("/api/paths", json={"name": "x"}).status_code == 422
# "json" is the wire key (contract #3) — the internal alias must not be accepted.
assert client.post(
"/api/paths", json={"name": "x", "path_json": json.dumps(SIMPLE)}
).status_code == 422

View File

@ -99,9 +99,10 @@ def test_anchor_patch_label_and_color(client):
def test_cli_dispatches_stubs_gracefully():
# The three new subcommands exist and degrade per the house pattern: exit code 2,
# no traceback, while their lane entrypoints are stubs.
# The new subcommands exist and degrade per the house pattern: exit code 2,
# no traceback, while their lane entrypoints are stubs. `capsule` landed
# (lane G / M17, CR-4) — its CLI dispatch is covered in test_capsule.py.
from festival4d import cli
for cmd in (["features"], ["direct"], ["capsule"]):
for cmd in (["features"], ["direct"]):
assert cli.main(cmd) == 2

View File

@ -1,4 +1,4 @@
// Server-side camera paths UI (spec M16, lane G). STUB — lane G fills THIS FILE ONLY.
// Server-side camera paths UI (spec M16, lane G).
//
// Pre-wired by foundation2: the hidden controls in index.html — #btn-path-save (💾),
// #path-load-select (dropdown), #btn-path-del (🗑) — this module's import + init() call in
@ -6,13 +6,120 @@
// GET /api/paths/{id} ({..., json: <camPath JSON>}), POST /api/paths {name, json:<text>}
// (422 on invalid), DELETE /api/paths/{id}.
//
// When implementing: unhide the controls, set `ready = true`. Save = prompt for a name,
// POST camPath.toJSON(); load = fetch the selected path and camPath.fromJSON(json); delete =
// DELETE the selected id. Refresh the dropdown after every mutation. #btn-path-save and
// #btn-path-del already carry the `write-ui` class, so capsule mode (contract #5) hides
// them for free — the load dropdown may stay if you bake paths in, else hide it too.
// Save = prompt for a name, POST camPath.toJSON(); load = fetch the selected path and
// camPath.fromJSON(json); delete = DELETE the selected id; the dropdown refreshes after
// every mutation. #btn-path-save / #btn-path-del carry `write-ui`, so capsule mode
// (contract #5) hides them for free; the load dropdown stays — the capsule bakes
// api/paths + api/paths/{id}, so loading keeps working zero-backend. If the listing
// fetch fails in a capsule (older bundle without baked paths), the dropdown hides too.
import { state } from "./state.js";
import { camPath } from "./camPath.js";
const PLACEHOLDER = "— saved paths —";
function el(id) {
return document.getElementById(id);
}
async function api(path, options) {
const resp = await fetch(state.apiBase + path, options);
if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`);
return resp.json();
}
export const pathsStore = {
ready: false,
init() {}, // ponytail: stub — lane G replaces the body
init() {
const saveBtn = el("btn-path-save");
const select = el("path-load-select");
const delBtn = el("btn-path-del");
if (!saveBtn || !select || !delBtn) return;
saveBtn.addEventListener("click", () => this.save());
delBtn.addEventListener("click", () => this.remove());
select.addEventListener("change", () => this.load());
saveBtn.style.display = "";
select.style.display = "";
delBtn.style.display = "";
this.ready = true;
this.refresh(); // async initial fill of the dropdown
},
/** Rebuild the dropdown from GET /api/paths, keeping the selection when it survives. */
async refresh(selectId = null) {
const select = el("path-load-select");
const keep = selectId != null ? String(selectId) : select.value;
let paths;
try {
paths = await api("/api/paths");
} catch (err) {
console.warn("[festival4d] saved-paths listing unavailable", err);
if (state.capsule) select.style.display = "none"; // baked bundle without paths
return;
}
select.innerHTML = "";
const ph = document.createElement("option");
ph.value = "";
ph.textContent = PLACEHOLDER;
select.appendChild(ph);
for (const p of paths) {
const opt = document.createElement("option");
opt.value = String(p.id);
opt.textContent = p.name;
opt.title = p.created_at ?? "";
select.appendChild(opt);
}
select.value = [...select.options].some((o) => o.value === keep) ? keep : "";
},
/** 💾 Save the current camera path under a prompted name. */
async save() {
if (!camPath.keyframes.length) {
window.alert("No keyframes to save — press K (or ⏺+) to add some first.");
return;
}
const name = window.prompt("Name this camera path:", `path ${new Date().toLocaleString()}`);
if (!name || !name.trim()) return;
try {
const created = await api("/api/paths", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), json: camPath.toJSON() }),
});
await this.refresh(created.id); // leave the new path selected
} catch (err) {
console.error("[festival4d] saving camera path failed", err);
}
},
/** Load the selected saved path into camPath. */
async load() {
const id = el("path-load-select").value;
if (!id) return;
try {
const data = await api(`/api/paths/${id}`);
camPath.fromJSON(data.json);
} catch (err) {
console.error("[festival4d] loading camera path failed", err);
this.refresh(); // it may have been deleted elsewhere — resync the dropdown
}
},
/** 🗑 Delete the selected saved path. */
async remove() {
const select = el("path-load-select");
const id = select.value;
if (!id) return;
const name = select.options[select.selectedIndex]?.textContent ?? id;
if (!window.confirm(`Delete saved path "${name}"?`)) return;
try {
await api(`/api/paths/${id}`, { method: "DELETE" });
} catch (err) {
console.error("[festival4d] deleting camera path failed", err);
}
await this.refresh();
},
};

View File

@ -72,3 +72,18 @@ Instead append an entry here and work around it locally; the integration agent a
pre-existing route/shape/column and the timebase + pose contracts are unchanged; the annotation
response only gained a field. Suite 101 → **103** (added supersede + delete tests).
- **Decision:** **APPLIED** (phase4, 2026-07-16). Fixes the sole Round-3 wart; strictly additive.
### CR-4 — lane G — 2026-07-17 — Drop obsolete `capsule` stub assertion in test_phase5_api.py
- **File:** `backend/tests/test_phase5_api.py::test_cli_dispatches_stubs_gracefully`
- **Problem:** The foundation2 test asserts `cli.main(["capsule"]) == 2`, i.e. the *stub*
contract (`build_capsule` raises `NotImplementedError`). With M17 landed the entrypoint is
real, so the CLI no longer exits 2 — the assertion fails by design, exactly like CR-1 when
lane D landed M7.
- **Proposed change:** remove only `["capsule"]` from the loop (features/direct stay — lane E
still stubbed) with a comment pointing at the landed coverage. The capsule CLI dispatch is
now covered for real in `backend/tests/test_capsule.py` (builds a bundle via
`cli.main(["capsule", "--out", ...])`, and asserts the clear no-dist error path).
- **Workaround in place:** none possible without perverting `build_capsule` semantics (the
spec requires a clear "run npm run build" error, not `NotImplementedError`); applied the
minimal edit directly per the CR-1 precedent, flagged here for integration2 to ratify.
- **Decision:** (integration2 fills this)

101
plan/status/lane-G.md Normal file
View File

@ -0,0 +1,101 @@
# Status — lane G (persistence & capsule, branch `lane/g-capsule`)
## Round 1 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** Round 4 of plan/DIRECTIVES.md (phase 5 begins; foundation2 merged;
worktree rule followed — this lane runs in its own git worktree).
**Acceptance checklist**
M16 — server-side camera paths:
- [x] Routes implemented — by foundation2 (per the phase-5 ownership table); lane G deepened
coverage only, per scope.
- [x] `backend/tests/test_paths.py` — 30 tests: roundtrip deep-equal incl. float precision +
a 500-keyframe path, multiple paths coexist / id-ordered listing / summary-only shape,
duplicate + unicode + whitespace + 300-char names verbatim, ISO `created_at`, empty-keyframes
path valid (director degrade must be saveable), extra JSON keys ride along, get/delete 404s,
delete idempotence, an 18-case parametrized 422 matrix over the frozen camPath contract,
422 creates no row, missing/aliased request fields 422. Cleans up its rows (test_phase5_api
runs later and asserts an empty table).
- [x] `frontend/src/pathsStore.js` filled: 💾 save = name prompt + POST `camPath.toJSON()`;
dropdown load = GET `/api/paths/{id}` + `camPath.fromJSON(data.json)`; 🗑 delete with
confirm; dropdown refreshed after every mutation (new save left selected); controls
unhidden + `ready = true`. Capsule mode: save/del hide via `write-ui`; the load dropdown
stays (paths are baked read-only — below) and hides itself if the listing fetch fails.
- [ ] Live save → reload page → load reproduces keyframes exactly — NEEDS LIVE BROWSER (list
for coordinator below). The exact-roundtrip property is locked server-side by
`test_roundtrip_preserves_float_precision` / `test_roundtrip_large_keyframe_list`.
M17 — memory capsule:
- [x] `backend/festival4d/capsule.py::build_capsule` per the frozen contract: copies
`frontend/dist` (clear "run `cd frontend && npm run build`" error if missing), copies the
manifest-referenced media to `media/`, bakes every read-only API response extension-less at
the live API's relative paths (`api/manifest` with `capsule: true` + `has_capture: false`,
`api/events`, `api/anchors`, `api/videos/{id}/poses`, `api/beats`/`api/pointcloud`/
`api/splat` when present, `api/audio/{id}` via the same lazy `ingest.extract_audio_hq`
cache the live route uses), injects `<script>window.__F4D_API_BASE__=""</script>` ahead of
the app `<script>`, ships stdlib `serve.py` (HTTP/1.1, 206 Range slicing incl. `bytes=N-`
and suffix `bytes=-N`, 416 past-EOF, `Accept-Ranges`, JSON content types for the
extension-less `api/*` files). Optional artifacts degrade by omission; refuses to clobber
a non-capsule output dir; rebuild over a previous capsule is idempotent (no double-inject).
Bonus within scope: M16 saved paths are baked read-only (`api/paths/{id}`, listing at
`api/paths/index.html` — a filesystem can't hold `paths` as both file and dir; the stock
301 → `/api/paths/` → index.html keeps `fetch("/api/paths")` working unchanged).
- [x] `backend/tests/test_capsule.py` — 12 tests: bundle structure; baked JSON deep-equal vs the
live route functions (shape drift impossible — capsule bakes *through* `api.py`); manifest
key-set == live route, urls resolve inside the bundle; injection present exactly once and
before the module script; degrade-when-absent; no-dist error; overwrite guard; CLI dispatch
(`cli.main(["capsule","--out",…]) == 0`, hermetic via monkeypatched REPO_ROOT); serve.py
exercised over real HTTP in a subprocess — 200 + Accept-Ranges, `bytes=0-100` → 206 with
`Content-Range: bytes 0-100/{size}` and 101 body bytes (mp4 `ftyp` magic verified),
open-ended + suffix ranges, 416, audio 200 `audio/mp4` + ranged, 404s.
- [x] End-to-end proof WITHOUT a browser (evidence below).
**Quality bar**
- `uv run pytest backend/tests -p no:warnings`**169 passed** (floor 127 + 30 paths + 12
capsule), zero failures.
- `cd frontend && npm run build` → clean, only the pre-existing >500 kB chunk-size warning.
**E2E capsule evidence (exact commands, run in the worktree):**
```
$ cd frontend && npm run build # ✓ built in 860ms
$ uv run python -m festival4d synthetic # 3 videos, 3106 points, 7 events, 4 anchors
$ uv run python -m festival4d capsule
capsule: baked 18 files (6826705 bytes) at <worktree>/data/capsule
$ cd data/capsule && python3 serve.py --port 8901
$ curl -s http://127.0.0.1:8901/ | grep -o 'window.__F4D_API_BASE__=""'
window.__F4D_API_BASE__=""
$ curl -si http://127.0.0.1:8901/api/manifest
HTTP/1.1 200 OK / Content-Type: application/json
capsule: True | videos: 3 | t_global_max: 21.37 | has_poses: True
$ curl -si -H "Range: bytes=0-100" http://127.0.0.1:8901/media/cam0.mp4
HTTP/1.1 206 Partial Content / Accept-Ranges: bytes / Content-Length: 101
Content-Range: bytes 0-100/1533609
$ curl -s -H "Range: bytes=500000-500099" .../media/cam0.mp4 # mid-file seek
206, received 100B
$ curl -s http://127.0.0.1:8901/api/audio/1
200, audio/mp4, 440201 bytes, magic `ftypM4A`
$ curl -sL http://127.0.0.1:8901/api/paths # [] final:200 application/json
$ curl .../api/pointcloud → 200 46800B; .../api/beats → 404 (degrade: no analysis run)
```
**Blockers / change requests:** none blocking. **CR-4 filed** (plan/CHANGE_REQUESTS.md):
`test_phase5_api.py::test_cli_dispatches_stubs_gracefully` asserted the *stub* exit-2 contract
for `capsule`; with M17 landed that assertion fails by design (CR-1 precedent). Applied the
minimal edit (removed only `["capsule"]`; features/direct untouched for lane E) — integration2
to ratify. Capsule CLI dispatch is now covered for real in test_capsule.py.
Small note for integration2 (frozen file, not edited): `.gitignore` covers `data/raw/` +
`data/work/` but not `data/capsule/` or `data/project.db`, so a default capsule build leaves a
~7 MB untracked tree; consider ignoring `data/` wholesale (or adding the two entries).
**Needs live-browser verification (for coordinator / integration2):**
1. Capsule acceptance proper: `python serve.py` in the bundle, open it focused — app loads with
**zero requests to :8000** (network tab), videos play *and seek*, 3D scene + timeline work,
🎧 spatial audio works, all `.write-ui` controls absent (body.capsule), saved-path dropdown
loads a baked path.
2. M16 live flow: save a path (💾, name prompt) → reload page → load from dropdown → camera
lands on the exact keyframes; delete removes it from the dropdown.
3. pathsStore dropdown behavior with an *older* capsule (no baked paths): dropdown hides itself.
**Next:** hand to coordinator for merge; not merging to main per lane instructions this round.