Merge main (lane G M16/M17 + CR-4) into lane/e-director2; reconcile CRs

Conflicts resolved: (a) test_phase5_api.py — with lane G's capsule AND
lane E's features/direct all landed, the CLI stub loop is empty, so
test_cli_dispatches_stubs_gracefully is retired with a pointer comment to
the real dispatch coverage (test_capsule.py / test_audio_features.py /
test_director.py); (b) CHANGE_REQUESTS.md — lane G keeps the ratified
CR-4; lane E's entry renumbered to CR-5 and updated for the merged
reality. Merged tree: 189 passed (169 main + 21 lane E - 1 retired),
frontend build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:58:22 +10:00
commit 9fdc5d2317
12 changed files with 1838 additions and 85 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

@ -53,7 +53,7 @@ def test_beats_404_then_serves_file(client):
def test_director_returns_valid_campath(client):
# CR-4: lane E's M11 landed, so on the synthetic fixture (events + poses present) the
# CR-5: lane E's M11 landed, so on the synthetic fixture (events + poses present) the
# response is a REAL path — assert the frozen camPath shape (holds stubbed or implemented).
# The empty+note degradation (no events / no poses) is covered in test_director.py.
data = client.post("/api/director", json={}).json()
@ -100,12 +100,7 @@ def test_anchor_patch_label_and_color(client):
assert client.patch("/api/anchors/99999", json={"label": "x"}).status_code == 404
def test_cli_dispatches_stubs_gracefully():
# The still-stubbed subcommands degrade per the house pattern: exit code 2, no traceback.
# CR-4: `features` and `direct` left this list when lane E landed M10/M11 (their real
# dispatch is covered in test_audio_features.py / test_director.py); lane G drops
# `capsule` when M17 lands.
from festival4d import cli
for cmd in (["capsule"],):
assert cli.main(cmd) == 2
# foundation2's test_cli_dispatches_stubs_gracefully asserted `cli.main(<cmd>) == 2` while the
# phase-5 subcommands were stubs. Every one has now landed, so the test retired (CR-4 lane G for
# `capsule`, CR-5 lane E for `features`/`direct`): the real CLI dispatches are covered in
# test_capsule.py, test_audio_features.py, and test_director.py respectively.

View File

@ -1,19 +1,332 @@
// Anchor manager & friend tags (spec M13, lane F). STUB — lane F fills THIS FILE ONLY.
// Anchor manager & friend tags (spec M13, lane F).
//
// Pre-wired by foundation2: the hidden ⚓ toggle (#btn-anchors) and the empty #anchor-panel
// container in index.html, this module's import + init() call in main.js, the hooks
// `scene3d.focusOn(x, y, z)` (jump-to) and `PATCH ${state.apiBase}/api/anchors/{id}`
// {label?, color?} (rename/recolor), plus DELETE /api/anchors/{id} from phase 4.
// A collapsible panel (pre-wired container #anchor-panel, toggle #btn-anchors) listing every
// anchor in state.anchors: color dot (recolor), label (rename), jump-to (scene3d.focusOn),
// delete. Mutations go through PATCH/DELETE ${state.apiBase}/api/anchors/{id}; after any
// mutation we update state.anchors and emit("anchors-changed") so scene3d + the video
// overlays refresh (main.js wires that event to scene3d.refreshAnchors; overlays read
// state.anchors every frame).
//
// When implementing: unhide the button, set `ready = true`; build the collapsible list
// (color dot, label, jump-to, rename, recolor, delete) from state.anchors; after any
// mutation update state.anchors and emit("anchors-changed") so scene3d + overlays refresh.
// The Tag flow reuses the M8 bbox-annotation flow WITHOUT an event (annotate.js is
// frozen — drive it, don't edit it) and names the resulting anchor.
// Capsule mode: give every MUTATING control the `write-ui` class — body.capsule hides that
// class globally (contract #5); the read-only list + jump-to stay available.
// Tag (friend tags): drives the FROZEN M8 annotation flow WITHOUT an event.
// annotate.js was written null-event safe on purpose: startAnnotateMode() only needs the
// registered cells, and _submit POSTs /api/annotations with event_id: null, which the backend
// resolves to an INDEPENDENT anchor ("Annotations with no event stay independent").
// Flow: prompt for the friend's name -> annotator.close() (clears any currentEvent) ->
// annotator.startAnnotateMode() -> the user drags a bbox over the friend on any video ->
// the resolved anchor arrives via emit("anchors-changed", anchor) -> we PATCH the pending
// name + a palette color onto it and stop annotate mode. Esc (or Tag again) cancels.
//
// Capsule mode (contract #5): every MUTATING control carries class="write-ui" so a baked
// bundle hides it; the read-only list + jump-to stay available.
import { state, emit, on } from "./state.js";
import { scene3d } from "./scene3d.js";
import { annotator } from "./annotate.js";
const DEFAULT_COLOR = "#59d499";
// Friendly tag palette (mirrors the scene's per-video accent hues).
const TAG_COLORS = ["#ff5d73", "#4dd0ff", "#c9a2ff", "#ffcf5d", "#7dffb0", "#ff9d5d"];
const CSS = `
#anchor-panel {
position: absolute; top: 3.1rem; left: 0.6rem; width: 244px; z-index: 7;
background: rgba(15,20,32,0.94); border: 1px solid var(--border); border-radius: 9px;
font-size: 0.78rem; box-shadow: 0 8px 26px rgba(0,0,0,0.5); backdrop-filter: blur(5px);
max-height: min(60vh, 420px); display: flex; flex-direction: column;
}
.anch-head {
display: flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border); flex: 0 0 auto;
}
.anch-head strong { flex: 1 1 auto; font-size: 0.8rem; }
.anch-n { color: var(--dim); font-weight: 400; }
.anch-x {
background: none; border: none; color: var(--dim); cursor: pointer;
font-size: 0.85rem; padding: 0 0.2rem;
}
.anch-x:hover { color: var(--text); }
.anch-hint { padding: 0.3rem 0.55rem 0; line-height: 1.35; min-height: 0; flex: 0 0 auto; }
.anch-hint:empty { display: none; }
.anch-list {
padding: 0.4rem 0.45rem 0.5rem; overflow-y: auto; display: flex;
flex-direction: column; gap: 0.12rem; flex: 1 1 auto;
}
.anch-empty { color: var(--dim); padding: 0.1rem 0.15rem 0.2rem; line-height: 1.4; }
.anch-row {
display: flex; align-items: center; gap: 0.4rem; padding: 0.22rem 0.3rem;
border-radius: 6px;
}
.anch-row:hover { background: rgba(255,255,255,0.05); }
.anch-dot {
width: 12px; height: 12px; border-radius: 4px; flex: 0 0 auto; position: relative;
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
}
.anch-color {
position: absolute; inset: 0; opacity: 0; width: 100%; height: 100%;
cursor: pointer; padding: 0; border: none;
}
.anch-label {
flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
cursor: pointer;
}
.anch-label:hover { color: var(--accent); }
.anch-btn {
background: none; border: none; color: var(--dim); cursor: pointer;
font-size: 0.78rem; padding: 0 0.15rem; flex: 0 0 auto; line-height: 1;
}
.anch-btn:hover { color: var(--text); }
.anch-del:hover { color: var(--bad); }
`;
function esc(s) {
return String(s ?? "").replace(
/[&<>"']/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])
);
}
async function patchAnchor(id, body) {
const resp = await fetch(`${state.apiBase}/api/anchors/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
/** Replace (or append) an anchor in state.anchors and notify every consumer. */
function upsertIntoState(anchor) {
const i = state.anchors.findIndex((a) => a.id === anchor.id);
if (i >= 0) state.anchors[i] = anchor;
else state.anchors.push(anchor);
emit("anchors-changed", anchor);
}
export const anchorPanel = {
ready: false,
init() {}, // ponytail: stub — lane F replaces the body
open: false,
_panel: null,
_btn: null,
_pendingTag: null, // { name, color, knownIds:Set<number> } while a friend tag is in flight
init() {
this._btn = document.getElementById("btn-anchors");
this._panel = document.getElementById("anchor-panel");
if (!this._btn || !this._panel) return; // pre-wired DOM missing — stay hidden, degrade
const style = document.createElement("style");
style.textContent = CSS;
document.head.appendChild(style);
this._panel.innerHTML = `
<div class="anch-head">
<strong> Anchors <span class="anch-n"></span></strong>
<button class="btn anch-tag write-ui" title="Tag a friend: drag a box over them on any video"> Tag</button>
<button class="anch-x" title="Collapse"></button>
</div>
<div class="anch-hint dim"></div>
<div class="anch-list"></div>`;
this._panel.querySelector(".anch-x").addEventListener("click", () => this.toggle(false));
this._panel.querySelector(".anch-tag").addEventListener("click", () => {
if (this._pendingTag) this._cancelTag();
else this._startTag();
});
this._btn.addEventListener("click", () => this.toggle());
// Any anchor mutation anywhere (this panel, annotate.js, a pending friend tag resolving).
on("anchors-changed", (payload) => this._onAnchorsChanged(payload));
// Esc cancels a pending tag (main.js also maps Esc to free-roam; both are fine together).
window.addEventListener("keydown", (e) => {
if (e.code === "Escape" && this._pendingTag) this._cancelTag();
});
this._render();
this._btn.style.display = ""; // unhide: the module is live
this.ready = true;
},
toggle(force) {
if (!this._panel) return;
this.open = force != null ? !!force : !this.open;
this._panel.style.display = this.open ? "" : "none";
this._btn?.classList.toggle("active", this.open);
if (this.open) this._render();
},
// --- friend tag flow ------------------------------------------------------------------
_startTag() {
let name;
try {
name = window.prompt("Friend's name:", "");
} catch {
name = null; // prompt() unavailable (headless) — nothing to do
}
name = (name || "").trim();
if (!name) return;
const color = TAG_COLORS[state.anchors.length % TAG_COLORS.length];
this._pendingTag = { name, color, knownIds: new Set(state.anchors.map((a) => a.id)) };
try {
annotator.close(); // clear any currentEvent so the annotation POSTs event_id: null
annotator.startAnnotateMode();
} catch (err) {
console.warn("[anchorPanel] could not start annotate mode", err);
this._pendingTag = null;
this._hint(`<span class="bad">tag mode failed: ${esc(err.message)}</span>`);
return;
}
this._tagButton(true);
this._hint(`drag a box over <b>${esc(name)}</b> on any video · Esc cancels`);
this.toggle(true);
},
_cancelTag() {
this._pendingTag = null;
try {
annotator.stopAnnotateMode();
} catch {
/* already stopped */
}
this._tagButton(false);
this._hint("");
},
_tagButton(active) {
const b = this._panel?.querySelector(".anch-tag");
if (!b) return;
b.textContent = active ? "✕ Cancel" : " Tag";
b.classList.toggle("active", active);
},
_onAnchorsChanged(payload) {
const tag = this._pendingTag;
if (tag && payload && payload.id != null && !tag.knownIds.has(payload.id)) {
// The bbox the user just drew resolved into a brand-new anchor: name + color it.
this._pendingTag = null;
this._finishTag(tag, payload);
}
this._render();
},
async _finishTag(tag, anchor) {
try {
annotator.stopAnnotateMode();
} catch {
/* fine */
}
this._tagButton(false);
try {
const updated = await patchAnchor(anchor.id, { label: tag.name, color: tag.color });
upsertIntoState(updated);
this._hint(`tagged <b>${esc(tag.name)}</b> — visible in 3D and on every video`);
} catch (err) {
this._hint(`<span class="bad">naming the tag failed: ${esc(err.message)}</span>`);
}
},
// --- list rendering + row actions ------------------------------------------------------
_hint(html) {
const h = this._panel?.querySelector(".anch-hint");
if (h) h.innerHTML = html;
},
_render() {
if (!this._panel) return;
const n = this._panel.querySelector(".anch-n");
if (n) n.textContent = `(${state.anchors.length})`;
const list = this._panel.querySelector(".anch-list");
if (!list) return;
if (state.anchors.length === 0) {
list.innerHTML =
`<div class="anch-empty">no anchors yet — <b> Tag</b> a friend,` +
` or annotate an event on the timeline</div>`;
return;
}
list.innerHTML = state.anchors
.map((a) => {
const color = a.color || DEFAULT_COLOR;
return (
`<div class="anch-row" data-id="${a.id}">` +
`<span class="anch-dot" style="background:${esc(color)}">` +
`<input type="color" class="anch-color write-ui" value="${esc(color)}" title="Recolor"></span>` +
`<span class="anch-label" title="Jump to ${esc(a.label || "anchor")}">${esc(a.label) || "(unnamed)"}</span>` +
`<button class="anch-btn anch-jump" title="Jump to in 3D">◎</button>` +
`<button class="anch-btn anch-rename write-ui" title="Rename">✎</button>` +
`<button class="anch-btn anch-del write-ui" title="Delete">✕</button></div>`
);
})
.join("");
for (const row of list.querySelectorAll(".anch-row")) {
const id = Number(row.dataset.id);
row.querySelector(".anch-jump").addEventListener("click", () => this._jump(id));
row.querySelector(".anch-label").addEventListener("click", () => this._jump(id));
row.querySelector(".anch-rename").addEventListener("click", () => this._rename(id));
row.querySelector(".anch-del").addEventListener("click", () => this._delete(id));
const colorInput = row.querySelector(".anch-color");
// Live-preview the dot while the picker is open; PATCH once on commit.
colorInput.addEventListener("input", () => {
row.querySelector(".anch-dot").style.background = colorInput.value;
});
colorInput.addEventListener("change", () => this._recolor(id, colorInput.value));
}
},
_find(id) {
return state.anchors.find((a) => a.id === id);
},
_jump(id) {
const a = this._find(id);
if (!a) return;
try {
scene3d.focusOn(a.x, a.y, a.z);
} catch (err) {
console.warn("[anchorPanel] focusOn failed", err);
}
},
async _rename(id) {
const a = this._find(id);
if (!a) return;
let name;
try {
name = window.prompt("Rename anchor:", a.label || "");
} catch {
name = null;
}
if (name == null) return; // cancelled
name = name.trim();
if (!name || name === a.label) return;
try {
upsertIntoState(await patchAnchor(id, { label: name }));
} catch (err) {
this._hint(`<span class="bad">rename failed: ${esc(err.message)}</span>`);
}
},
async _recolor(id, color) {
if (!this._find(id)) return;
try {
upsertIntoState(await patchAnchor(id, { color }));
} catch (err) {
this._hint(`<span class="bad">recolor failed: ${esc(err.message)}</span>`);
this._render(); // roll the previewed dot back to the stored color
}
},
async _delete(id) {
try {
const resp = await fetch(`${state.apiBase}/api/anchors/${id}`, { method: "DELETE" });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const i = state.anchors.findIndex((a) => a.id === id);
if (i >= 0) state.anchors.splice(i, 1);
emit("anchors-changed", null);
} catch (err) {
this._hint(`<span class="bad">delete failed: ${esc(err.message)}</span>`);
}
},
};

View File

@ -1,19 +1,281 @@
// Moment FX (spec M15, lane F). STUB — lane F fills THIS FILE ONLY.
// Moment FX (spec M15, lane F).
//
// Pre-wired by foundation2: the hidden ✨ toggle (#btn-fx in index.html), this module's
// import + init() call in main.js, and a per-frame `fx.update(tGlobal)` call from the master
// loop (after transport.tick(), before scene3d.update()).
// main.js calls fx.update(tGlobal) every animation frame (after transport.tick(), before
// scene3d.update()). While the ✨ toggle is on we watch the playhead cross event markers
// (state.events) by tracking the previous tGlobal:
// - crossing works in play AND scrub (scrub = a stream of small forward seeks, so plain
// prev<t_e<=t detection covers it);
// - a backward jump just resets the tracker (no retro-fire);
// - a big forward jump (> JUMP_GAP_S) is a seek, not a scrub — reset, don't machine-gun
// every event in between.
// pyro/confetti -> a ~1 s additive particle burst at the event's resolved anchor. Events
// don't carry an anchor id in state, but the backend names an event's resolved anchor
// `event.description or event.event_type`, so we map event->anchor by that label; when no
// anchor matches we fall back to the stage centroid (0, 0.5, 0) (documented fallback).
// bass_drop -> pulse the point cloud / splat scale for ~a beat (60/tempo from
// GET /api/beats when present, else 0.5 s — pitfall #5), restoring the exact prior scale.
//
// When implementing: unhide the button, set `ready = true`. update() watches the playhead
// crossing event markers (state.events) — detect crossings in play AND scrub (track the
// previous tGlobal; a seek emits "seek" if you need to reset). On `pyro`/`confetti` fire a
// short particle burst at the event's resolved anchor, falling back to the stage centroid;
// on `bass_drop` pulse the point-cloud/splat scale for ~a beat (beats via GET /api/beats
// when present — no beats file, use ~0.5 s; pitfall #5). Scene access ONLY through
// scene3d.addObject/removeObject. Keep >= 30 fps on the synthetic scene.
// Scene access is ONLY through scene3d.addObject/removeObject (plus read-only looks at
// scene3d.points/.splat for the pulse). Hot-path discipline: burst slots + their typed
// arrays are allocated once, spawns recycle slots, update() allocates nothing, and the whole
// body is guarded — a throw here would kill the app's master loop, so on error FX disables
// itself instead of ever throwing.
import * as THREE from "three";
import { state } from "./state.js";
import { scene3d } from "./scene3d.js";
const MAX_BURSTS = 6; // concurrent burst cap
const PARTICLES = 160; // per burst
const BURST_LIFE_S = 1.0;
const JUMP_GAP_S = 2.0; // forward delta above this = seek/jump, not playback or scrub
const MAX_FIRE_PER_FRAME = 2;
const STAGE_CENTROID = { x: 0, y: 0.5, z: 0 };
const PYRO_COLORS = [0xffd27a, 0xff9d5d, 0xff5d3c, 0xfff3c4];
const CONFETTI_COLORS = [0xff5d73, 0x4dd0ff, 0xc9a2ff, 0xffcf5d, 0x7dffb0, 0xff9d5d];
export const fx = {
ready: false,
init() {}, // ponytail: stub — lane F replaces the body
update(tGlobal) {}, // called every animation frame by main.js
enabled: false,
_prevT: null,
_lastNow: 0,
_bursts: null, // preallocated slot pool (lazy, first enable)
_pulse: null, // { obj, base, t0, dur } while a bass_drop pulse is live
_beatLen: 0.5, // seconds; refined from /api/beats when available
_dead: false, // a runtime error tripped the breaker — stay inert
init() {
const btn = document.getElementById("btn-fx");
if (!btn) return; // pre-wired DOM missing — degrade to hidden
btn.addEventListener("click", () => {
this.enabled = !this.enabled;
btn.classList.toggle("active", this.enabled);
if (this.enabled) {
this._ensurePool();
this._loadBeats();
} else {
this._clearAll();
}
});
btn.style.display = ""; // unhide: the module is live
this.ready = true;
},
/** Called by main.js every animation frame. MUST NEVER THROW (it would kill the loop). */
update(tGlobal) {
if (this._dead) return;
try {
const now = performance.now() / 1000;
const dt = Math.min(0.1, Math.max(0, now - this._lastNow)); // clamp tab-stall spikes
this._lastNow = now;
if (!this.enabled || typeof tGlobal !== "number") {
this._prevT = tGlobal;
return;
}
// --- playhead crossing detection ---
const prev = this._prevT;
this._prevT = tGlobal;
if (prev != null && tGlobal > prev && tGlobal - prev <= JUMP_GAP_S) {
let fired = 0;
const events = state.events || [];
for (let i = 0; i < events.length && fired < MAX_FIRE_PER_FRAME; i++) {
const ev = events[i];
const te = ev?.t_global_s;
if (typeof te !== "number" || te <= prev || te > tGlobal) continue;
if (ev.event_type === "pyro" || ev.event_type === "confetti") {
this._spawnBurst(ev.event_type, this._anchorForEvent(ev));
fired++;
} else if (ev.event_type === "bass_drop") {
this._startPulse(now);
fired++;
}
}
}
this._animateBursts(now, dt);
this._animatePulse(now);
} catch (err) {
// Breaker: never let FX take the app down. Clean up best-effort and go inert.
console.warn("[fx] runtime error — disabling FX", err);
this._dead = true;
this.enabled = false;
try {
this._clearAll();
document.getElementById("btn-fx")?.classList.remove("active");
} catch {
/* stay inert */
}
}
},
// --- event -> anchor -----------------------------------------------------------------
/** Best-effort event->anchor from state alone: the backend labels an event's resolved
* anchor with `description or event_type`. Fallback: stage centroid. */
_anchorForEvent(ev) {
const anchors = state.anchors || [];
for (const a of anchors) {
if (a.label && (a.label === ev.description || a.label === ev.event_type)) return a;
}
return STAGE_CENTROID;
},
// --- particle bursts -------------------------------------------------------------------
_ensurePool() {
if (this._bursts) return;
this._bursts = [];
for (let i = 0; i < MAX_BURSTS; i++) {
const positions = new Float32Array(PARTICLES * 3);
const colors = new Float32Array(PARTICLES * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.09,
vertexColors: true,
transparent: true,
opacity: 1,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false; // positions mutate in place; skip stale-bounds culling
this._bursts.push({
points,
positions,
velocities: new Float32Array(PARTICLES * 3),
active: false,
t0: 0,
gravity: 0,
});
}
},
_spawnBurst(type, at) {
if (!this._bursts) this._ensurePool();
const slot = this._bursts.find((b) => !b.active);
if (!slot) return; // concurrency cap reached — drop, don't grow
const pyro = type === "pyro";
const palette = pyro ? PYRO_COLORS : CONFETTI_COLORS;
const { positions, velocities } = slot;
const color = slot.points.geometry.getAttribute("color");
const c = new THREE.Color();
for (let i = 0; i < PARTICLES; i++) {
const j = i * 3;
positions[j] = at.x + (Math.random() - 0.5) * 0.1;
positions[j + 1] = at.y + (Math.random() - 0.5) * 0.1;
positions[j + 2] = at.z + (Math.random() - 0.5) * 0.1;
// Random direction; pyro shoots up-and-out fast, confetti pops gently and flutters down.
const theta = Math.random() * Math.PI * 2;
const up = Math.random();
const speed = pyro ? 2.5 + Math.random() * 3 : 1 + Math.random() * 1.6;
const horiz = pyro ? 0.55 : 0.9;
velocities[j] = Math.cos(theta) * speed * horiz;
velocities[j + 1] = (pyro ? 0.5 + up * 0.8 : 0.4 + up * 0.6) * speed;
velocities[j + 2] = Math.sin(theta) * speed * horiz;
c.setHex(palette[(Math.random() * palette.length) | 0]);
color.array[j] = c.r;
color.array[j + 1] = c.g;
color.array[j + 2] = c.b;
}
slot.points.geometry.getAttribute("position").needsUpdate = true;
color.needsUpdate = true;
slot.points.material.opacity = 1;
slot.gravity = pyro ? 2.2 : 4.5;
slot.t0 = performance.now() / 1000;
slot.active = true;
scene3d.addObject(slot.points);
},
_animateBursts(now, dt) {
if (!this._bursts) return;
for (const b of this._bursts) {
if (!b.active) continue;
const age = now - b.t0;
if (age >= BURST_LIFE_S) {
b.active = false;
scene3d.removeObject(b.points);
continue;
}
const { positions, velocities } = b;
const g = b.gravity * dt;
for (let j = 0; j < positions.length; j += 3) {
positions[j] += velocities[j] * dt;
positions[j + 1] += velocities[j + 1] * dt;
positions[j + 2] += velocities[j + 2] * dt;
velocities[j + 1] -= g;
}
b.points.geometry.getAttribute("position").needsUpdate = true;
b.points.material.opacity = 1 - age / BURST_LIFE_S;
}
},
// --- bass-drop pulse ---------------------------------------------------------------------
_startPulse(now) {
const obj = scene3d.splat ?? scene3d.points; // read-only handles; scale restored after
if (!obj) return; // nothing loaded (yet) — degrade silently
if (this._pulse && this._pulse.obj === obj) {
this._pulse.t0 = now; // retrigger: keep the ORIGINAL base scale, restart the envelope
return;
}
if (this._pulse) this._endPulse(); // different object (fallback swapped) — restore old one
this._pulse = { obj, base: obj.scale.x, t0: now, dur: this._beatLen };
},
_animatePulse(now) {
const p = this._pulse;
if (!p) return;
const k = (now - p.t0) / p.dur;
if (k >= 1) {
this._endPulse();
return;
}
// One smooth swell-and-settle over the beat.
const amp = 0.16 * Math.sin(Math.PI * Math.min(1, Math.max(0, k)));
p.obj.scale.setScalar(p.base * (1 + amp));
},
_endPulse() {
const p = this._pulse;
if (p) {
try {
p.obj.scale.setScalar(p.base); // restore the exact prior scale
} catch {
/* object may have been torn down */
}
}
this._pulse = null;
},
_clearAll() {
if (this._bursts) {
for (const b of this._bursts) {
if (b.active) {
b.active = false;
scene3d.removeObject(b.points);
}
}
}
this._endPulse();
},
async _loadBeats() {
if (this._beatsLoaded) return;
this._beatsLoaded = true;
try {
const resp = await fetch(`${state.apiBase}/api/beats`);
if (!resp.ok) return; // 404 until features runs — keep the 0.5 s default (pitfall #5)
const beats = await resp.json();
if (typeof beats?.tempo_bpm === "number" && beats.tempo_bpm > 30) {
this._beatLen = 60 / beats.tempo_bpm;
}
} catch {
/* no beats — default stands */
}
},
};

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

@ -1,18 +1,117 @@
// Photo mode (spec M14, lane F). STUB — lane F fills THIS FILE ONLY.
// Photo mode (spec M14, lane F).
//
// Pre-wired by foundation2: the hidden 📷 button (#btn-photo in index.html), this module's
// import + init() call in main.js, and the hook `scene3d.setHelpersVisible(false)` which
// hides grid/axes/frusta/path-lines/keyframe-gizmos/anchor-labels in one call (restore with
// true).
// 📷 / "P": pause the transport if playing, hide every helper (grid/axes/frusta/gizmos/anchor
// labels — scene3d.setHelpersVisible(false), one call), render ONE frame at 3840 px wide
// (preserving the pane's aspect) by temporarily resizing scene3d.renderer with pixelRatio 1,
// snapshot it via canvas.toBlob (the copy is taken synchronously at call time, so it is safe
// to restore the renderer immediately after — encoding continues async), download
// festival4d-photo.png, then restore helpers / size / pixelRatio / camera aspect / play state.
//
// When implementing: unhide the button, set `ready = true`, bind key "P" too; on trigger
// pause the transport, setHelpersVisible(false), render ONE frame at 3840-wide (preserve
// aspect) to an offscreen WebGLRenderTarget via scene3d.renderer/scene/camera, download the
// PNG, then restore everything (helpers, renderer size, play state). Works in free-roam and
// follow-cam; with a splat present it renders the splat (no splat -> the point cloud —
// pitfall #5).
// Splat note (pitfall #5): the DropInViewer is a plain child of scene3d.scene and renders with
// the same renderer.render(scene, camera) call, so no splat-specific branch exists here; live
// verification on a trained splat is deferred to the coordinator.
//
// No write-ui class: photo mode is read-only and stays available in capsule bundles.
import { state } from "./state.js";
import { transport } from "./transport.js";
import { scene3d } from "./scene3d.js";
const PHOTO_WIDTH = 3840;
export const photoMode = {
ready: false,
init() {}, // ponytail: stub — lane F replaces the body
_busy: false,
init() {
const btn = document.getElementById("btn-photo");
if (!btn) return; // pre-wired DOM missing — degrade to hidden
btn.addEventListener("click", () => this.shoot());
window.addEventListener("keydown", (e) => {
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
if (e.code === "KeyP" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
this.shoot();
}
});
btn.style.display = ""; // unhide: the module is live
this.ready = true;
},
/** Take the hi-res still. Synchronous through the render + snapshot so no animation frame
* (main.js loop) can interleave between resize, render, and capture. */
shoot() {
if (this._busy) return;
const renderer = scene3d.renderer;
const camera = scene3d.camera;
if (!renderer || !camera || !scene3d.scene) return; // scene not booted yet
this._busy = true;
// --- save everything we are about to touch ---
const wasPlaying = state.playing;
const prevHelpers = scene3d.helpersVisible;
const prevRatio = renderer.getPixelRatio();
const prevSize = { w: 0, h: 0 };
{
// getSize wants a Vector2; a duck-typed target keeps this module three-free.
const v = { x: 0, y: 0, set(x, y) { this.x = x; this.y = y; return this; } };
renderer.getSize(v);
prevSize.w = v.x;
prevSize.h = v.y;
}
const prevAspect = camera.aspect;
try {
if (wasPlaying) transport.pause();
scene3d.setHelpersVisible(false);
// setHelpersVisible hides grid/axes/gizmos/label-sprites immediately, but the per-video
// rig groups (frusta/markers/path lines) get their visibility recomputed inside
// scene3d.update(). Run one update at the CURRENT size so the rigs honor the flag
// before the hi-res render below.
scene3d.update();
const w = Math.max(1, prevSize.w);
const h = Math.max(1, prevSize.h);
const outW = PHOTO_WIDTH;
const outH = Math.max(1, Math.round((PHOTO_WIDTH * h) / w));
renderer.setPixelRatio(1);
renderer.setSize(outW, outH, false);
camera.aspect = outW / outH;
camera.updateProjectionMatrix();
renderer.render(scene3d.scene, camera);
// Snapshot NOW (same task as the render — the WebGL buffer is still valid).
renderer.domElement.toBlob((blob) => {
try {
if (!blob) {
console.warn("[photoMode] toBlob returned null (buffer too large for this GPU?)");
return;
}
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "festival4d-photo.png";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 10_000);
} finally {
this._busy = false;
}
}, "image/png");
} catch (err) {
console.warn("[photoMode] capture failed", err);
this._busy = false;
} finally {
// --- restore ALL of it (snapshot already copied; safe immediately) ---
try {
renderer.setPixelRatio(prevRatio);
renderer.setSize(prevSize.w, prevSize.h, false);
camera.aspect = prevAspect;
camera.updateProjectionMatrix();
scene3d.setHelpersVisible(prevHelpers);
if (wasPlaying) transport.play();
} catch (err) {
console.warn("[photoMode] restore failed", err);
}
}
},
};

View File

@ -73,23 +73,39 @@ Instead append an entry here and work around it locally; the integration agent a
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 E — 2026-07-17 — Update stub-era assertions in test_phase5_api.py (M10/M11 landed)
### 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:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Exact CR-1
situation: a stub-contract assertion superseded by the milestone landing; the real CLI
dispatch is covered in test_capsule.py. Suite 127 → **169** on the merged tree.
### CR-5 — lane E — 2026-07-17 — Retire remaining stub-era assertions in test_phase5_api.py (M10/M11 landed)
- **File:** `backend/tests/test_phase5_api.py::test_cli_dispatches_stubs_gracefully` and
`::test_director_degrades_to_valid_empty_campath`
- **Problem:** Both tests assert the *stub* contracts of lane E's entrypoints, correct only while
M10/M11 were unimplemented. With `run_features`/`generate_path` landed: (a)
`cli.main(["features"])` / `["direct"]` no longer raise `NotImplementedError`, so they exit 0,
not 2; (b) `POST /api/director` on the synthetic fixture (which has events + poses) returns a
real non-empty camPath, so `keyframes == []` / `"note" in data` fail. Identical in kind to
**CR-1** (lane D, approved Round 1: "a test file, not a frozen contract file").
- **Proposed change:** (a) shrink the CLI stub loop to `["capsule"]` (lane G, still stubbed —
lane G drops it when M17 lands); (b) rename the director test to
`test_director_returns_valid_campath` asserting the frozen camPath *shape* (version 1,
keyframe key-set), which holds in both stub and implemented states; the empty+note degradation
itself is now covered by lane E's own `test_director.py` (no-events / no-poses cases).
- **Workaround in place:** applied the minimal edit directly per the CR-1 precedent (test file,
not a frozen contract file; `api.py`/`cli.py` byte-for-byte unchanged). Suite green
(127 baseline + lane E tests, zero failures). If the coordinator prefers the stricter reading
of the freeze, revert the test edit and re-apply at integration2 — lane E's modules are
unaffected either way.
- **Problem:** Both assert lane E's *stub* contracts, correct only while M10/M11 were
unimplemented. With `run_features`/`generate_path` landed: (a) `cli.main(["features"])` /
`["direct"]` no longer raise `NotImplementedError`, so they exit 0, not 2; (b)
`POST /api/director` on the synthetic fixture (events + poses present) returns a real
non-empty camPath, so `keyframes == []` / `"note" in data` fail. Identical in kind to
**CR-1** and to **CR-4** (lane G, coordinator-ratified the same day for `capsule`).
- **Proposed change:** (a) with CR-4 already applied, lane E's landing empties the CLI stub
loop entirely — retire `test_cli_dispatches_stubs_gracefully` (a comment points at the real
dispatch coverage: test_capsule.py / test_audio_features.py / test_director.py); (b) rename
the director test to `test_director_returns_valid_campath`, asserting the frozen camPath
*shape* (version 1, exact keyframe key-set — holds stubbed or implemented); the empty+note
degradation is now covered by lane E's own `test_director.py` (no-events / no-poses cases).
- **Workaround in place:** applied the minimal edit directly per the CR-1/CR-4 precedent (test
file, not a frozen contract file; `api.py`/`cli.py` byte-for-byte unchanged), including the
merge reconciliation with CR-4's side of the same loop. Suite green on the merged tree.
- **Decision:** (pending — coordinator/integration2)

View File

@ -31,18 +31,22 @@ suite floor 121 — was 127 at lane start after foundation2's tests).
first→last keyframe at forced rate 1, downloads `festival4d-cut.webm`, restores playhead/
rate/play/path/follow state, releases tap + tracks. Button disabled < 2 keyframes (chained
onto main.js's `camPath.onChange` — no frozen edits).
- [x] Suite: `uv run pytest backend/tests -p no:warnings` → **148 passed, 0 failed**
(127 baseline + 21 lane-E). `cd frontend && npm run build` → clean (pre-existing chunk-size
warning only).
- [x] Suite on the merged tree (main incl. lane G merged into this branch):
`uv run pytest backend/tests -p no:warnings`**189 passed, 0 failed** (169 on main after
lane G + 21 lane-E 1 retired stub test). Pre-merge lane-only figure was 148 (127 + 21).
`cd frontend && npm run build` → clean (pre-existing chunk-size warning only).
**Change request filed:** **CR-4** (plan/CHANGE_REQUESTS.md) — two stub-era assertions in
**Change request filed:** **CR-5** (plan/CHANGE_REQUESTS.md) — two stub-era assertions in
`test_phase5_api.py` asserted lane E's *unimplemented* behavior (CLI exit 2; empty director
path) and fail by design once M10/M11 land. Applied the minimal test edit directly per the
approved **CR-1 precedent** (test file ≠ frozen contract file; api.py/cli.py untouched):
CLI stub loop → `["capsule"]` only; director test now asserts the frozen camPath *shape*
(valid stubbed or implemented). Flagging loudly since my brief read the freeze strictly —
if the coordinator prefers, revert `backend/tests/test_phase5_api.py` and re-apply at
integration2; the suite will then show those 2 stub-era failures until adjudication.
**CR-1 precedent** — and, mid-lane, main moved under me with lane G's **coordinator-ratified
CR-4** doing the identical thing for `capsule`, which confirms the pattern. Merged main into
`lane/e-director2` and reconciled: the CLI stub loop is now empty (all three subcommands
real) so `test_cli_dispatches_stubs_gracefully` is retired with a pointer comment; the
director test is renamed `test_director_returns_valid_campath` asserting the frozen camPath
shape. api.py/cli.py byte-for-byte untouched. If the coordinator prefers the stricter
reading, revert `backend/tests/test_phase5_api.py` and re-apply at integration2; the suite
will then show those stub-era failures until adjudication.
**Coordinator must verify live in a FOCUSED browser** (this session had no browser; rAF/
captureStream need focus — pitfall #2):

34
plan/status/lane-F.md Normal file
View File

@ -0,0 +1,34 @@
# Status — lane-F (social & fun UX: M13 anchor manager + friend tags, M14 photo mode, M15 moment FX)
## Round 1 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** Round 4 of plan/DIRECTIVES.md (phase 5b begins; foundation2 merged; worktree rule followed — this lane ran in its own worktree on branch `lane/f-social`).
**Ownership kept:** only `frontend/src/anchorPanel.js`, `frontend/src/photoMode.js`, `frontend/src/fx.js`, and this file were touched. No frozen file edited; no CHANGE_REQUESTS entry needed.
**Friend-tag design choice (M13):** the Tag flow DRIVES the frozen M8 annotator without an event — no workaround needed. Rationale: `annotate.js` is null-event safe by construction (`startAnnotateMode()` only needs the registered cells; `_submit` POSTs `event_id: ev ? ev.id : null`; every panel-content access is optional-chained), and the backend explicitly supports it (`api.py`: "Annotations with no event stay independent (each makes its own anchor)"). Flow: prompt for the friend's name → `annotator.close()` (clears any currentEvent) → `annotator.startAnnotateMode()` → user drags a bbox over the friend on ANY video → the resolved anchor arrives via `emit("anchors-changed", anchor)` → anchorPanel PATCHes the pending name + a palette color onto it and stops annotate mode. Esc or Tag again cancels. This reuses the real M8 ray-cast/nearest-point resolution (friends land ON the point cloud), keeps annotate.js untouched, and is one drag for the user.
**Acceptance checklist — ALL verified live** (worktree servers: backend :8010 with an isolated copy of the synthetic data — the main project DB was never touched — + Vite :5175 with `VITE_API_BASE`; browser = Claude Code Browser pane, loop driven with the `__f4d` pump per pitfall #2 where noted):
- [x] M13 create → rename → recolor → jump → delete roundtrip live: Tag with prompt "Zoe" + real 35×30 px drag on cam0 → anchor id 5 `{label:"Zoe", color:"#7dffb0"}` created via the frozen annotator; ✎ rename → `"Zoe B"`; color input change → `#ff00aa`; label-click jump → `scene3d.controls.target == (1.60, 0.00, -6.50) ==` anchor pos exactly, follow-cam detached; ✕ delete → state.anchors 5→4, row removed.
- [x] M13 labels visible in 3D + video overlays: screenshot shows the pink "Zoe B" sphere+label centered in the 3D scene after jump AND "Zoe B" x-ray markers on cam0 + cam2 overlays.
- [x] M13 anchors survive reload: full page reload → anchor id 5 still `{label:"Zoe B", color:"#ff00aa"}` from GET /api/anchors.
- [x] M13 capsule mode: with `body.capsule`, tag/rename/delete/recolor controls hidden (`offsetParent === null`), panel + list + jump-to remain visible.
- [x] M14 PNG ≥3840 wide, aspect preserved, no helpers: intercepted the real toBlob → PNG 3840×3093 px (351 KB) from a 704×567 pane (aspect 1.2416 preserved); rendered the captured blob full-screen and screenshotted it: point cloud + anchor SPHERES only (spheres stay by scene3d design — "friend tags belong in photos"), zero grid/axes/frusta/path-lines/label-sprites.
- [x] M14 restore: pixelRatio 1→1, size 704×567, camera.aspect 1.2416, helpersVisible true all restored; shot MID-PLAY → transport paused for the shot and `state.playing === true` after (play-state restored). Works in free-roam; follow-cam uses the same render path (aspect saved/restored around the shot).
- [x] M15 pyro fires on PLAY crossing: seek 9.5 → play → burst spawned exactly at t=10.00, opacity 1→0.01 over its 1.0 s life across 53 pumped frames, then removed from the scene (`allRemoved: true`).
- [x] M15 confetti fires on SCRUB crossing: paused discrete seeks 16.4→16.6→16.8→16.95→17.1 → burst fired at the 17.1 step (crossing 17.0). Backward jump to 15.0 fired nothing (tracker reset).
- [x] M15 bass_drop pulse: play across t=3 → `scene3d.points.scale` swelled to 1.1599 and restored to exactly 1.0 after ~one beat (0.5 s default; tempo from GET /api/beats when it exists).
- [x] M15 performance: worst case (all 6 burst slots active + pulse) full frame body (`transport.tick` + `fx.update` + `scene3d.update`) = **0.101 ms/frame CPU** over 300 iterations — ~330× inside the 33 ms/30 fps budget. Pool preallocated once; `update()` allocates nothing; toggle-off removes all objects and restores scale (verified: 0 active, 0 in scene, scale 1).
- [x] fx.update can never kill the master loop: whole body in try/catch → on error logs once, disables itself, cleans up (breaker `_dead` verified false throughout).
- [x] Zero console errors across the entire live session.
- [x] Regression: `uv run pytest backend/tests -p no:warnings`**127 passed** (floor met; backend untouched). `cd frontend && npm run build` clean (pre-existing >500 kB chunk warning only).
**Notes / deferred to coordinator (integration2):**
- M14 splat path: the DropInViewer is a scene child and renders with the same `renderer.render(scene, camera)` call, so photo mode has no splat branch — but live verification ON A TRAINED SPLAT is deferred (synthetic project has point cloud only).
- M15 event→anchor mapping: events carry no anchor id in state, so fx maps event→anchor by the backend's labeling convention (resolved anchor label = `description or event_type`), falling back to the stage centroid (0, 0.5, 0). Both pyro/confetti fired at the centroid in this test (no event-resolved anchors existed); please verify a burst lands ON an anchor after annotating a pyro/confetti event.
- M12 interaction: fx pulse writes `points/splat.scale` between fx.update and scene3d.update — same-frame, restored exactly; no interaction with export expected, but a combined FX+export pass is worth one look.
- Real-focused-browser eyeball (bursts/pulse at full rAF rate) — the pane verifications above used the pump; a human glance in a focused window is the usual final check.
**Blockers:** none.
**Next:** merge `lane/f-social``main` (coordinator/integration2 discretion).

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.