Compare commits

..

No commits in common. "main" and "lane/f-social" have entirely different histories.

38 changed files with 78 additions and 3915 deletions

View File

@ -26,22 +26,6 @@
"port": 5173,
"autoPort": false
},
{
"name": "festifun-web-alt",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--prefix",
"frontend",
"--",
"--port",
"5177",
"--strictPort"
],
"port": 5177,
"autoPort": false
},
{
"name": "festifun-web-dist",
"runtimeExecutable": "uv",

2
.gitignore vendored
View File

@ -7,5 +7,3 @@ __pycache__/
.DS_Store
frontend/dist/
.env
data/capsule/
.claude/worktrees/

View File

@ -23,17 +23,6 @@ Phase 5 (foundation2; shapes frozen in plan/20-phase5.md contracts):
DELETE /api/paths/{id}
PATCH /api/anchors/{id} {label?, color?} -> updated anchor dict
Phase 6 (foundation3 / M18; shapes frozen in plan/30-phase6.md contract #2):
GET /api/tracks -> [{id, marker_key, label, color, points:[{t_global_s,x,y,z,
quality,views}]}] (points ordered by t_global_s)
PATCH /api/tracks/{id} {label?, color?} -> updated track dict (404 on missing)
DELETE /api/tracks/{id} -> {deleted: id} (404 on missing)
POST /api/tracks/solve -> {result, tracks[, note]} runs lane H's detect->solve
(tracker_solve.run_solve); while lane H is stubbed it
degrades to a VALID empty result + "note" (house pattern).
manifest gains "has_tracks": bool.
Source videos in ``data/raw`` are mounted at ``/media`` via Starlette ``StaticFiles``,
which supports HTTP Range (required for ``<video>`` seeking; verify ``curl -H "Range:
bytes=0-100"`` -> 206). Stubbed lane entrypoints (events detection, M8 annotation
@ -129,11 +118,6 @@ class PathIn(BaseModel):
path_json: str = Field(alias="json")
class TrackPatch(BaseModel):
label: str | None = None
color: str | None = None
# ---------------------------------------------------------------------------
# Serialization helpers
# ---------------------------------------------------------------------------
@ -178,24 +162,6 @@ def _event_dict(e) -> dict:
}
def _track_dict(t) -> dict:
"""A track with its points inlined (phase 6 contract #2), ordered by t_global_s."""
return {
"id": t.id,
"marker_key": t.marker_key,
"label": t.label,
"color": t.color,
"points": [
{
"t_global_s": p.t_global_s,
"x": p.x, "y": p.y, "z": p.z,
"quality": p.quality, "views": p.views,
}
for p in db.get_track_points(t.id)
],
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@ -217,8 +183,6 @@ def manifest() -> dict:
"has_capture": capture.capture_enabled(),
"has_poses": db.has_poses(),
"has_splat": config.SPLAT_PLY.exists(),
"has_tracks": db.has_tracks(), # phase 6 (M18): any solved friend track ⇒ frontend loads them
# Always False from the live server; the capsule (M17) bakes a manifest with true,
# which makes the frontend hide all write UI (phase-5 contract #5).
"capsule": False,
@ -501,52 +465,3 @@ def delete_anchor(anchor_id: int) -> dict:
if not db.delete_anchor(anchor_id):
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
return {"deleted": anchor_id}
# ---------------------------------------------------------------------------
# Phase 6 (foundation3 / M18). Friend tracks. Shapes frozen in plan/30-phase6.md contract #2.
# ---------------------------------------------------------------------------
@app.get("/api/tracks")
def get_tracks() -> list[dict]:
"""All friend tracks with their points inlined (contract #2)."""
return [_track_dict(t) for t in db.get_tracks()]
@app.patch("/api/tracks/{track_id}")
def patch_track(track_id: int, payload: TrackPatch) -> dict:
"""Rename / recolor a track in place (M21 friends panel)."""
try:
track = db.patch_track(track_id, label=payload.label, color=payload.color)
except KeyError:
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
return _track_dict(track)
@app.delete("/api/tracks/{track_id}")
def delete_track(track_id: int) -> dict:
"""Delete a track and its points (M21)."""
if not db.delete_track(track_id):
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
return {"deleted": track_id}
@app.post("/api/tracks/solve")
def solve_tracks() -> dict:
"""Run marker detection + track solving (M19+M20) and return the resulting tracks.
Dispatches into lane H's ``tracker_solve.run_solve`` (which itself consumes
``tracker_detect``'s detections). While lane H is stubbed both raise
``NotImplementedError``; we catch it and degrade to a VALID empty result + a note, so the
frozen route never 500s the house pattern (mirrors ``POST /api/events/detect``)."""
from festival4d import tracker_solve
try:
result = tracker_solve.run_solve()
except NotImplementedError as exc:
log.info("track solving not implemented yet: %s", exc)
return {
"result": None,
"note": "friend-track solving not implemented yet (lane H / M19-M20)",
"tracks": [_track_dict(t) for t in db.get_tracks()],
}
return {"result": result, "tracks": [_track_dict(t) for t in db.get_tracks()]}

View File

@ -1,4 +1,4 @@
"""Audio features: beats & onsets (spec M10, lane E).
"""Audio features: beats & onsets (spec M10, lane E). STUB — lane E fills the body.
Contract (frozen at foundation2 merge phase-5 contract #1):
@ -15,173 +15,11 @@ and onset-strength detection (librosa is already a dependency), and writes
All times are ``t_global`` seconds. Quiet / short / beatless audio must produce a
*valid, empty* result (``tempo_bpm: null``, empty lists) never a crash. Returns a
small summary dict for the CLI log. ``GET /api/beats`` serves the written file.
Implementation notes
--------------------
- Analysis runs on the reference's local timeline and maps to ``t_global`` via the frozen
:func:`config.t_global_from_video` (identity for a true reference: offset 0, drift 0;
correct either way if we ever fall back to a non-zero-offset "reference").
- ``HOP_LENGTH = 160`` (10 ms at 16 kHz): librosa's default 512-sample hop is a 32 ms
grid at this rate, which quantizes beats past the ±50 ms acceptance bound. 10 ms frames
recover the synthetic 120 BPM pulse grid to ~30 ms / ±0.001 BPM (see test_audio_features).
- Reported ``tempo_bpm`` is the median inter-beat interval of the tracked beats (more
accurate than the tracker's coarse tempo prior) and ``null`` when < 2 beats are found.
- Missing prerequisites (no videos ingested / WAV absent) degrade to a summary dict with a
``note`` and write nothing mirroring ``events_ai.run_events`` house style; degenerate
*content* (silence, too short, beatless) writes the valid-empty JSON above.
"""
from __future__ import annotations
import json
import logging
import wave
from pathlib import Path
import numpy as np
from festival4d import config, db
log = logging.getLogger("festival4d.audio_features")
GENERATED_BY = "festival4d.audio_features"
HOP_LENGTH = 160 # analysis hop in samples: 10 ms at the 16 kHz ingest rate
MIN_DURATION_S = 1.0 # anything shorter is "no usable audio" -> valid-empty result
SILENCE_PEAK = 1e-4 # |peak| below this is treated as silence -> valid-empty result
def _empty_result() -> dict:
"""The valid-empty beats.json body (contract #1) for silent/short/beatless audio."""
return {
"tempo_bpm": None,
"beats_s": [],
"onsets": [],
"generated_by": GENERATED_BY,
}
def _select_reference(videos: list) -> "object | None":
"""The reference video (spec §2: offset 0). Fall back to the smallest |offset|, else first."""
if not videos:
return None
exact = [v for v in videos if v.offset_ms == 0]
if exact:
return exact[0]
with_offset = [v for v in videos if v.offset_ms is not None]
if with_offset:
return min(with_offset, key=lambda v: abs(v.offset_ms))
return videos[0]
def _read_wav_mono(path: Path) -> tuple[np.ndarray, int]:
"""Read a 16-bit PCM WAV (the ingest format) as float64 mono in [-1, 1]."""
with wave.open(str(path), "rb") as w:
sr = w.getframerate()
channels = w.getnchannels()
frames = w.readframes(w.getnframes())
pcm = np.frombuffer(frames, dtype="<i2").astype(np.float64) / 32767.0
if channels > 1:
pcm = pcm.reshape(-1, channels).mean(axis=1)
return pcm, sr
def analyze(y: np.ndarray, sr: int) -> dict:
"""Beat/tempo + onset-strength analysis of a mono signal on its *local* timeline.
Returns the contract #1 dict with times in local (signal) seconds — the caller maps
them to ``t_global``. Degenerate input (short/silent/beatless) returns the valid-empty
shape; this function never raises on quiet or short audio.
"""
import librosa # deferred: keeps `festival4d` import light for the API/CLI
y = np.asarray(y, dtype=np.float32).reshape(-1)
if len(y) < MIN_DURATION_S * sr or float(np.max(np.abs(y), initial=0.0)) < SILENCE_PEAK:
return _empty_result()
try:
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP_LENGTH)
_tempo, beat_frames = librosa.beat.beat_track(
onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH
)
beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP_LENGTH)
onset_frames = librosa.onset.onset_detect(
onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH
)
onset_times = librosa.frames_to_time(onset_frames, sr=sr, hop_length=HOP_LENGTH)
except Exception as exc: # librosa internals on weird signals: degrade, never crash
log.warning("audio_features: analysis failed (%s) — returning empty result", exc)
return _empty_result()
beats = [float(t) for t in np.atleast_1d(beat_times)]
if len(beats) >= 2:
tempo_bpm: float | None = float(60.0 / np.median(np.diff(beats)))
elif beats:
tempo_bpm = float(np.atleast_1d(_tempo)[0]) or None
else:
tempo_bpm = None
env_max = float(onset_env.max()) if len(onset_env) else 0.0
onsets = []
if env_max > 0:
for frame, t in zip(np.atleast_1d(onset_frames), np.atleast_1d(onset_times)):
strength = float(onset_env[int(frame)]) / env_max
onsets.append({"t_global_s": float(t), "strength": min(1.0, max(0.0, strength))})
return {
"tempo_bpm": tempo_bpm,
"beats_s": beats,
"onsets": onsets,
"generated_by": GENERATED_BY,
}
def run_features() -> dict:
"""Analyze the reference soundtrack and write ``data/work/beats.json`` (see module doc)."""
videos = db.get_videos()
reference = _select_reference(videos)
if reference is None:
log.warning("audio_features: no videos in the project — run ingest first")
return {"status": "no_videos", "note": "no videos in the project (run ingest first)"}
wav_path = config.AUDIO_DIR / f"{reference.id}.wav" # ingest.audio_wav_path convention
if not wav_path.exists():
log.warning("audio_features: reference WAV missing at %s — run ingest first", wav_path)
return {
"status": "no_audio",
"note": f"reference audio not found ({wav_path.name}) — run `python -m festival4d ingest`",
}
y, sr = _read_wav_mono(wav_path)
result = analyze(y, sr)
# Map local (reference) times onto the master timeline via the frozen timebase helpers.
# For a true reference (offset 0, drift 0) this is the identity.
offset_ms = reference.offset_ms or 0.0
drift_ppm = reference.drift_ppm or 0.0
if offset_ms != 0.0 or drift_ppm != 0.0:
result["beats_s"] = [
float(config.t_global_from_video(t, offset_ms, drift_ppm)) for t in result["beats_s"]
]
for onset in result["onsets"]:
onset["t_global_s"] = float(
config.t_global_from_video(onset["t_global_s"], offset_ms, drift_ppm)
)
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
config.BEATS_JSON.write_text(json.dumps(result, indent=2))
log.info(
"audio_features: %s beats (tempo %s BPM), %s onsets -> %s",
len(result["beats_s"]),
f"{result['tempo_bpm']:.1f}" if result["tempo_bpm"] else "null",
len(result["onsets"]),
config.BEATS_JSON,
)
return {
"status": "ok",
"reference_video_id": reference.id,
"tempo_bpm": result["tempo_bpm"],
"beats": len(result["beats_s"]),
"onsets": len(result["onsets"]),
"beats_json": str(config.BEATS_JSON),
}
raise NotImplementedError("audio features (lane E / M10)")

View File

@ -1,4 +1,4 @@
"""Memory capsule: zero-backend shareable export (spec M17, lane G).
"""Memory capsule: zero-backend shareable export (spec M17, lane G). STUB — lane G fills.
Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (default
``config.CAPSULE_DIR``):
@ -6,7 +6,7 @@ Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (d
- copy ``frontend/dist`` (error clearly if missing tell the user to ``npm run build``);
- copy media from ``config.RAW_DIR``;
- bake every read-only API response to files at the *same relative paths*
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/tracks``, ``api/beats``,
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/beats``,
``api/videos/{id}/poses``, ``api/pointcloud``, ``api/splat``, ``api/audio/{id}``
extracting audio via ``ingest.extract_audio_hq`` if not yet cached);
- inject ``<script>window.__F4D_API_BASE__=""</script>`` into the copied ``index.html``;
@ -16,302 +16,13 @@ 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
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())
# Friend tracks (M18/M20) — read-only in a capsule so ribbons + follow still work zero-backend.
_bake_json(api_dir / "tracks", api.get_tracks())
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
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)")

View File

@ -5,7 +5,7 @@ to (in ``ingest.py``, ``audio_sync.py``, ``sfm.py``, ``events_ai.py``); they nev
edit this file or ``api.py``.
Subcommands: ``synthetic | ingest | sync | reconstruct | events | features | direct |
track | capsule | serve``.
capsule | serve``.
Every subcommand dispatches to a single lane entrypoint. While a lane is still a stub,
its entrypoint raises :class:`NotImplementedError`; we catch that here and print a clear
@ -76,23 +76,6 @@ def _cmd_direct(args: argparse.Namespace) -> int:
return 0
def _cmd_track(args: argparse.Namespace) -> int:
"""Detect wearable markers and solve them into 3D friend tracks (lane H / M19-M20).
Runs detect -> solve by default; ``--detect-only`` / ``--solve-only`` run one stage.
Both stages dispatch into lane-H stubs, so while unimplemented this prints the
"not implemented yet" message and exits 2 (same as every other stubbed subcommand)."""
from festival4d import tracker_detect, tracker_solve
if not args.solve_only:
result = tracker_detect.run_detect()
log.info("track detect: %s", result)
if not args.detect_only:
result = tracker_solve.run_solve()
log.info("track solve: %s", result)
return 0
def _cmd_capsule(args: argparse.Namespace) -> int:
from festival4d import capsule
@ -151,14 +134,6 @@ def build_parser() -> argparse.ArgumentParser:
help="seconds of lead-in before each event (default 2.0)")
p_dir.set_defaults(func=_cmd_direct)
p_trk = sub.add_parser("track", help="detect markers + solve friend tracks (lane H / M19-M20)")
trk_mode = p_trk.add_mutually_exclusive_group()
trk_mode.add_argument("--detect-only", dest="detect_only", action="store_true",
help="run marker detection only (write detections.json)")
trk_mode.add_argument("--solve-only", dest="solve_only", action="store_true",
help="run track solving only (from an existing detections.json)")
p_trk.set_defaults(func=_cmd_track, detect_only=False, solve_only=False)
p_cap = sub.add_parser("capsule", help="bake a zero-backend shareable bundle (lane G / M17)")
p_cap.add_argument("--out", default=None, help="output dir (default data/capsule/)")
p_cap.set_defaults(func=_cmd_capsule)

View File

@ -46,8 +46,6 @@ POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cl
SPLAT_PLY = WORK_DIR / "splat.ply" # optional 3DGS splat (trained externally, e.g. via MODELBEAST — see docs/modelbeast-crossover.md); viewer prefers it over the point cloud
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
DETECTIONS_JSON = WORK_DIR / "detections.json" # marker detections (phase 6 / M19, contract #3)
TRACK_TRUTH_JSON = WORK_DIR / "track_truth.json" # synthetic marker ground truth (phase 6 / M18)
# ---------------------------------------------------------------------------
# Constants

View File

@ -13,8 +13,6 @@ Schema (spec §2)::
events(id, t_global_s, duration_s, event_type, confidence, description, source)
annotations(id, video_id FK, t_video_s, x0,y0,x1,y1, resolved_anchor_id FK NULL)
paths(id, name, created_at, path_json) # phase 5 (M16): saved camPath JSON, as text
tracks(id, marker_key, label, color, created_at) # phase 6 (M18/M20): friend tracks
track_points(id, track_id FK, t_global_s, x,y,z, quality, views) # ordered by t_global_s
Engine management: :func:`init_engine` (re)binds the module to a SQLite file. It defaults
to ``config.DB_PATH`` but tests point it at a temp file. Helpers open and commit their own
@ -145,43 +143,6 @@ class SavedPath(Base):
path_json: Mapped[str] = mapped_column(String, nullable=False)
class Track(Base):
"""A friend track (phase 6 contract #2): a wearable marker's identity + metadata.
``marker_key`` is the detector-emitted identity ``"hue:<opencv_hue>"`` for a color
marker or ``"code:<id>"`` for a blink badge (see ``tracker_detect``). The per-timestep
3D path lives in :class:`TrackPoint`. ``label`` / ``color`` are user-editable.
"""
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
marker_key: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str | None] = mapped_column(String, nullable=True)
color: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[str] = mapped_column(String, nullable=False)
class TrackPoint(Base):
"""One solved position of a track at a master-timeline instant (phase 6 contract #2).
Coordinates are **Three.js scene space**. ``views`` = number of cameras used to solve the
point (1 single-view ground-plane fallback, contract #5); ``quality`` is 0..1.
Points are always read ordered by ``t_global_s``.
"""
__tablename__ = "track_points"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False, index=True)
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
x: Mapped[float] = mapped_column(Float, nullable=False)
y: Mapped[float] = mapped_column(Float, nullable=False)
z: Mapped[float] = mapped_column(Float, nullable=False)
quality: Mapped[float | None] = mapped_column(Float, nullable=True)
views: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ---------------------------------------------------------------------------
# Engine / session management
# ---------------------------------------------------------------------------
@ -547,107 +508,6 @@ def delete_path(path_id: int) -> bool:
return True
# ---------------------------------------------------------------------------
# Friend tracks (phase 6 / M18 contract #2). Written by the solver (lane H / M20),
# read by the API + frontend. TrackPoints are always returned ordered by t_global_s.
# ---------------------------------------------------------------------------
def add_track(marker_key: str, label: str | None = None, color: str | None = None) -> Track:
"""Insert a track row (no points yet) and return it."""
with session_scope() as s:
track = Track(marker_key=marker_key, label=label, color=color, created_at=_now_iso())
s.add(track)
s.flush()
s.refresh(track)
return track
def get_tracks() -> list[Track]:
"""All tracks, ordered by id."""
with session_scope() as s:
return list(s.scalars(select(Track).order_by(Track.id)))
def get_track(track_id: int) -> Track | None:
with session_scope() as s:
return s.get(Track, track_id)
def get_track_points(track_id: int) -> list[TrackPoint]:
"""A track's points, ordered by ``t_global_s`` (contract #2)."""
with session_scope() as s:
return list(
s.scalars(
select(TrackPoint)
.where(TrackPoint.track_id == track_id)
.order_by(TrackPoint.t_global_s)
)
)
def set_track_points(track_id: int, points: Iterable[dict]) -> int:
"""Replace all points for a track with ``points`` (solver output, M20). Returns the count.
Each dict needs ``t_global_s, x, y, z`` and optionally ``quality`` (default None) and
``views`` (default None). Atomic: the delete + inserts share one transaction, so a caller
that raises mid-way never leaves a half-written track. Raises ``KeyError`` if the track
is unknown.
"""
rows = list(points)
with session_scope() as s:
if s.get(Track, track_id) is None:
raise KeyError(f"no track with id={track_id}")
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
for p in rows:
s.add(
TrackPoint(
track_id=track_id,
t_global_s=float(p["t_global_s"]),
x=float(p["x"]), y=float(p["y"]), z=float(p["z"]),
quality=None if p.get("quality") is None else float(p["quality"]),
views=None if p.get("views") is None else int(p["views"]),
)
)
return len(rows)
def patch_track(track_id: int, label: str | None = None, color: str | None = None) -> Track:
"""Rename / recolor a track (M21 panel). Only provided fields change.
Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
raise KeyError(f"no track with id={track_id}")
if label is not None:
track.label = label
if color is not None:
track.color = color
s.flush()
s.refresh(track)
return track
def delete_track(track_id: int) -> bool:
"""Delete a track and its points. Returns ``True`` if a row was deleted, else ``False``.
SQLite doesn't cascade by default, so the points are removed explicitly.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
return False
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
s.delete(track)
return True
def has_tracks() -> bool:
"""Whether any track exists (drives ``manifest.has_tracks``)."""
with session_scope() as s:
return s.scalar(select(Track.id).limit(1)) is not None
# ---------------------------------------------------------------------------
# Annotations
# ---------------------------------------------------------------------------

View File

@ -1,4 +1,4 @@
"""Auto-director: events -> keyframed camera path (spec M11, lane E).
"""Auto-director: events -> keyframed camera path (spec M11, lane E). STUB — lane E fills.
Contract (frozen at foundation2 merge phase-5 contract #2): :func:`generate_path`
returns a **camPath-compatible** dict::
@ -15,138 +15,11 @@ for each, choose the registered camera with a pose nearest the event time; keyfr
keyframe times to the nearest beat (no beats file -> no snapping degrade, don't block);
FOV from that camera's intrinsics (``2 * atan(height / (2 * fy))`` in degrees, the same
formula as ``scene3d.snapTo``).
Degradation (house style): no events -> valid empty path; no registered poses -> valid
empty path with a clear ``note`` (the response always round-trips through
``camPath.fromJSON``); no beats.json -> same path, just unsnapped. Never a crash.
"""
from __future__ import annotations
import json
import logging
import math
from festival4d import config, db
from festival4d.geometry import colmap_to_threejs, mat_to_quat
log = logging.getLogger("festival4d.director")
DEFAULT_EVENT_DURATION_S = 1.0 # used when an event has no duration_s
def _empty(note: str | None = None) -> dict:
path: dict = {"version": 1, "keyframes": []}
if note:
path["note"] = note
log.info("director: %s -> empty path", note)
return path
def _load_beats() -> list[float]:
"""The beat grid from beats.json, if valid; otherwise [] (no snapping — pitfall #5)."""
if not config.BEATS_JSON.exists():
return []
try:
beats = json.loads(config.BEATS_JSON.read_text()).get("beats_s", [])
return sorted(float(b) for b in beats)
except (ValueError, TypeError, AttributeError) as exc:
log.warning("director: unreadable beats.json (%s) — skipping beat snap", exc)
return []
def _snap_to_beat(t: float, beats: list[float]) -> float:
"""Nearest beat to t (beats sorted ascending); t unchanged when the grid is empty."""
if not beats:
return t
return min(beats, key=lambda b: abs(b - t))
def _nearest_pose(poses: list, t_video: float):
"""The registered pose nearest a local time, and its |dt|. Poses are t_video_s-sorted."""
best = min(poses, key=lambda p: abs(p.t_video_s - t_video))
return best, abs(best.t_video_s - t_video)
def _keyframe(pose, video, t_global: float) -> dict:
"""Build one frozen-shape keyframe from a COLMAP pose row (contract #2).
The COLMAP [w,x,y,z] world->camera pose goes through the frozen
``geometry.colmap_to_threejs`` (never inline pitfall #1); the resulting Three.js
world rotation matrix becomes the camPath quaternion, reordered to Three.js [x,y,z,w].
"""
position, rotation = colmap_to_threejs(
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
)
qw, qx, qy, qz = mat_to_quat(rotation) # [w,x,y,z] of the *Three.js* world rotation
fov_deg = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy))) # scene3d.snapTo
return {
"t_global": float(t_global),
"pos": [float(v) for v in position],
"quat": [float(qx), float(qy), float(qz), float(qw)], # Three.js order [x,y,z,w]
"fov": float(fov_deg),
}
def generate_path(top_n: int = 8, lead_s: float = 2.0) -> dict:
"""Build a camPath JSON dict from the detected events (see module doc)."""
events = db.get_events()
if not events:
return _empty("no events to direct (run `python -m festival4d events`)")
videos = db.get_videos()
# Registered poses per video (interpolated rows are excluded: the director only cuts
# to cameras that were actually solved at that moment).
tracks: dict[int, list] = {}
for video in videos:
registered = [p for p in db.get_poses(video.id) if p.registered]
if registered:
tracks[video.id] = registered
if not tracks:
return _empty("no registered camera poses (run `python -m festival4d reconstruct`)")
video_by_id = {v.id: v for v in videos}
# Top-N events by confidence, ties -> earlier; then shoot them in chronological order.
ranked = sorted(events, key=lambda e: (-(e.confidence or 0.0), e.t_global_s, e.id))
chosen = sorted(ranked[: max(0, int(top_n))], key=lambda e: (e.t_global_s, e.id))
beats = _load_beats()
keyframes: list[dict] = []
for event in chosen:
# The registered camera whose pose track comes closest to the event time.
best_id, best_dt = None, None
for vid, poses in sorted(tracks.items()): # sorted -> deterministic tie-break
video = video_by_id[vid]
tv_event = config.t_video_from_global(
event.t_global_s, video.offset_ms or 0.0, video.drift_ppm or 0.0
)
_, dt = _nearest_pose(poses, tv_event)
if best_dt is None or dt < best_dt:
best_id, best_dt = vid, dt
video = video_by_id[best_id]
poses = tracks[best_id]
duration = event.duration_s if event.duration_s is not None else DEFAULT_EVENT_DURATION_S
for t_key in (event.t_global_s - lead_s, event.t_global_s + duration):
t_key = _snap_to_beat(max(0.0, t_key), beats)
tv_key = config.t_video_from_global(
t_key, video.offset_ms or 0.0, video.drift_ppm or 0.0
)
pose, _ = _nearest_pose(poses, tv_key)
keyframes.append(_keyframe(pose, video, t_key))
# Chronological, and collapse keyframes that landed on the same instant (adjacent events
# snapping to a shared beat) — camPath needs a monotone timeline.
keyframes.sort(key=lambda k: k["t_global"])
deduped: list[dict] = []
for kf in keyframes:
if deduped and abs(kf["t_global"] - deduped[-1]["t_global"]) < 1e-6:
continue
deduped.append(kf)
log.info(
"director: %d event(s) -> %d keyframe(s)%s",
len(chosen), len(deduped), " (beat-snapped)" if beats else "",
)
return {"version": 1, "keyframes": deduped}
raise NotImplementedError("auto-director (lane E / M11)")

View File

@ -11,17 +11,11 @@ testable without real footage:
- **Point cloud**: random points on the stage box + ground plane, written to
``points.ply`` in the exact binary-little-endian format the real COLMAP path produces.
- **Seeded events + anchors** so the timeline and overlays have data immediately.
- **Two moving markers** (phase 6 / M18) composited into every camera's video *through the
same camera projection the poses use*, plus ``track_truth.json`` the 3D ground truth lane
H recovers. Marker A is a solid magenta disc (color mode, ``hue:150``); marker B is a
blinking bright disc speaking the frozen OOK protocol with ID 5 (``code:5``). See
:func:`marker_position`, :func:`render_marker_overlay`, :func:`build_track_truth`.
- **``ground_truth.json``**: offsets, drift, audio pulse (bang) times, events, and stage
corners part of the frozen contract (lanes A and D assert against it).
FROZEN CONTRACT after foundation. The module is factored so the numeric pieces
(audio shifts, poses, PLY round-trip, marker geometry) are unit-testable without invoking
ffmpeg.
(audio shifts, poses, PLY round-trip) are unit-testable without invoking ffmpeg.
"""
from __future__ import annotations
@ -37,8 +31,8 @@ from pathlib import Path
import numpy as np
from festival4d import config, db, tracker_detect
from festival4d.geometry import mat_to_quat, quat_to_mat, slerp_pose
from festival4d import config, db
from festival4d.geometry import mat_to_quat
log = logging.getLogger("festival4d.synthetic")
@ -369,219 +363,6 @@ def read_ply(path: Path) -> tuple[np.ndarray, np.ndarray]:
return points, colors
# ---------------------------------------------------------------------------
# Moving markers (phase 6 / M18). Two wearable markers travel known 3D paths across the
# stage; each camera's frame shows them at the pixel its OWN pose projects the 3D point to,
# so the rendered pixels and ``track_truth.json`` are consistent BY CONSTRUCTION — the marker
# pixel is derived from the ground-truth 3D point through the same projection lane H inverts.
#
# Marker A — solid magenta (#ff00ff) disc, color mode, marker_key "hue:150". Slow circle
# (radius 2, y≈1.5) centered on the stage.
# Marker B — blinking bright disc, blink mode, ID 5, marker_key "code:5". A different path;
# the LED follows the frozen OOK protocol (tracker_detect) in GLOBAL time, so
# every camera sees the same on/off state at the same t_global.
# Discs are small (radius ≈2.5% of frame height ⇒ ≪2% frame area) and composited AFTER all
# existing content, so the 189-test floor (offsets/events/poses/SfM) is untouched (pitfall #6).
# ---------------------------------------------------------------------------
MARKER_A_KEY = tracker_detect.color_marker_key(tracker_detect.MAGENTA_OPENCV_HUE) # "hue:150"
MARKER_A_HEX = "#ff00ff"
MARKER_A_BGR = (255, 0, 255) # OpenCV BGR for magenta (drawn AFTER the hue filter)
MARKER_B_ID = 5
MARKER_B_KEY = tracker_detect.code_marker_key(MARKER_B_ID) # "code:5"
MARKER_B_ON_BGR = (255, 255, 255) # bright white while the LED is ON
# testsrc2's base pattern contains solid full-saturation magenta AND cyan colour bars — the
# EXACT two marker hues (contract #1) — plus other bright bars. Left as-is they swamp lane H's
# colour detection (the magenta bar is a far bigger "magenta blob" than marker A's disc) and
# starve marker B's white-blink luminance contrast. So the base is MUTED before the markers are
# composited on top: saturation crushed (no high-saturation magenta/cyan survives) and dimmed
# (the bright-white blink disc pops in luminance). The fully-saturated discs are then the only
# high-S magenta/cyan and the only bright small blobs in frame — the fixture lane H is graded on
# is actually solvable by construction. Tests assert geometry/audio/PLY, never base pixels.
BASE_SATURATION = 0.28 # hue-filter saturation multiplier applied to the base
BASE_BRIGHTNESS = -0.28 # eq brightness offset applied to the base (dim it)
MARKER_DISC_HEIGHT_FRAC = 0.025 # disc radius / frame height (≈9 px @ 360 -> 0.11% area)
MARKER_A_PERIOD_S = 20.0 # one slow loop across the fixture span
MARKER_B_PERIOD_S = 16.0 # different path + rate so the two never lock together
MARKER_TRUTH_DT_S = 0.05 # track_truth.json sampling step (t_global seconds)
def marker_position(marker_key: str, t_global: float) -> tuple[float, float, float]:
"""Ground-truth 3D position (Three.js scene space) of a marker at ``t_global`` seconds.
Pure and total over all real ``t_global`` (the markers exist for the whole span). This is
THE ground truth: it is what ``track_truth.json`` records and what a correct solver
recovers. FROZEN lane H's acceptance is measured against these paths.
"""
if marker_key == MARKER_A_KEY:
theta = 2.0 * np.pi * t_global / MARKER_A_PERIOD_S
return (2.0 * float(np.cos(theta)), 1.5, 2.0 * float(np.sin(theta)))
if marker_key == MARKER_B_KEY:
phi = 2.0 * np.pi * t_global / MARKER_B_PERIOD_S
return (
2.4 * float(np.sin(phi)),
1.0 + 0.4 * float(np.sin(2.0 * phi)),
-0.8 + 1.2 * float(np.cos(phi)),
)
raise KeyError(f"unknown marker_key {marker_key!r}")
MARKER_KEYS = (MARKER_A_KEY, MARKER_B_KEY)
def _interp_pose(poses: list[dict], t_video: float):
"""Interpolate a COLMAP pose ``(q, t, (fx,fy,cx,cy))`` at ``t_video`` from ``camera_track``
output (ascending by ``t_video_s``). Mirrors ``resolve._pose_at`` / the frontend ``poseAt``
exactly slerp the rotation, lerp the translation via the FROZEN ``geometry.slerp_pose``
so the marker pixels a solver back-projects land on the same 3D point. Clamps (no extrap.).
"""
def tup(p):
return ([p["qw"], p["qx"], p["qy"], p["qz"]],
[p["tx"], p["ty"], p["tz"]],
(p["fx"], p["fy"], p["cx"], p["cy"]))
if t_video <= poses[0]["t_video_s"]:
return tup(poses[0])
if t_video >= poses[-1]["t_video_s"]:
return tup(poses[-1])
lo, hi = 0, len(poses) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if poses[mid]["t_video_s"] <= t_video:
lo = mid
else:
hi = mid
a, b = poses[lo], poses[hi]
span = b["t_video_s"] - a["t_video_s"]
alpha = (t_video - a["t_video_s"]) / span if span > 1e-9 else 0.0
q, t = slerp_pose(
[a["qw"], a["qx"], a["qy"], a["qz"]], [a["tx"], a["ty"], a["tz"]],
[b["qw"], b["qx"], b["qy"], b["qz"]], [b["tx"], b["ty"], b["tz"]],
alpha,
)
return list(q), list(t), (a["fx"], a["fy"], a["cx"], a["cy"])
def project_point(q, t, intr, X) -> tuple[float, float, float] | None:
"""Project world point ``X`` through a COLMAP world->camera pose (the inverse of
``geometry.ray_from_pixel``). Returns ``(px, py, z_cam)`` in pixels, or ``None`` if the
point is behind the camera. COLMAP camera axes: +x right, +y down, +z forward.
"""
R = quat_to_mat(q) # world -> cam
Xc = R @ np.asarray(X, dtype=np.float64).reshape(3) + np.asarray(t, dtype=np.float64).reshape(3)
z = float(Xc[2])
if z <= 1e-6:
return None
fx, fy, cx, cy = intr
return fx * float(Xc[0]) / z + cx, fy * float(Xc[1]) / z + cy, z
def _disc_radius_px(height: int) -> int:
return max(6, int(round(MARKER_DISC_HEIGHT_FRAC * height)))
def render_marker_overlay(out_dir: Path, poses: list[dict], offset_ms: float,
width: int, height: int, fps: float, duration_s: float) -> int:
"""Write a transparent BGRA PNG per frame with the two markers drawn at their projected
pixels (phase 6 / M18). Composited over one camera's clip by :func:`render_video`.
For frame ``k`` at local ``t_video = k/fps``: convert to ``t_global`` with the FROZEN
``config.t_global_from_video``, look up each marker's 3D truth at ``t_global``, and project
it through this camera's pose interpolated at ``t_video``. Marker A is always drawn; marker
B is drawn only when its LED is ON per the OOK schedule (``tracker_detect.led_on``, in
global time). Off-frame / behind-camera projections are simply not drawn. Returns frame count.
"""
import cv2
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
n = int(round(duration_s * fps))
for k in range(n):
t_video = k / fps
t_global = config.t_global_from_video(t_video, offset_ms, 0.0)
q, t, intr = _interp_pose(poses, t_video)
img = np.zeros((height, width, 4), dtype=np.uint8) # transparent BGRA
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_A_KEY, t_global),
MARKER_A_BGR, radius)
if tracker_detect.led_on(t_global, MARKER_B_ID):
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_B_KEY, t_global),
MARKER_B_ON_BGR, radius)
if not cv2.imwrite(str(out_dir / f"{k:05d}.png"), img):
raise RuntimeError(f"failed to write marker overlay frame: {out_dir}/{k:05d}.png")
return n
def _draw_marker_disc(img, q, t, intr, X, bgr, radius) -> None:
import cv2
proj = project_point(q, t, intr, X)
if proj is None:
return
px, py, _ = proj
h, w = img.shape[:2]
if not (0.0 <= px < w and 0.0 <= py < h):
return # off this camera's frame ⇒ not visible here
cv2.circle(img, (int(round(px)), int(round(py))), radius,
(int(bgr[0]), int(bgr[1]), int(bgr[2]), 255), thickness=-1, lineType=cv2.LINE_8)
def build_track_truth(g_lo: float, g_hi: float, dt: float = MARKER_TRUTH_DT_S) -> dict:
"""The ``track_truth.json`` body: each marker's 3D path sampled over ``[g_lo, g_hi]``.
Shape (spec M18): ``{"markers": [{"marker_key", "points": [{"t_global_s","x","y","z"}]}]}``
in Three.js scene space. ``generated_by`` is added for provenance (extra keys are fine).
"""
n = max(1, int(round((g_hi - g_lo) / dt)) + 1)
markers = []
for key in MARKER_KEYS:
points = []
for i in range(n):
tg = g_lo + i * dt
x, y, z = marker_position(key, tg)
points.append({"t_global_s": round(tg, 6), "x": x, "y": y, "z": z})
markers.append({"marker_key": key, "points": points})
return {"markers": markers, "generated_by": "festival4d.synthetic"}
def render_blink_clip(out_path: Path, marker_id: int = MARKER_B_ID, fps: float = 30.0,
duration_s: float = 3.0, width: int = 320, height: int = 240) -> Path:
"""Render a small clip of a single blinking badge at frame center (phase 6 / M24 helper).
A fixed-position bright disc blinking ``marker_id`` in the frozen OOK protocol on a black
background no camera geometry, so lane K's ``badge_selftest --synthetic`` can round-trip
the ID through ``tracker_detect``'s blink decoder without a full fixture. No audio track.
"""
import cv2
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required to render a blink clip")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
cx, cy = width // 2, height // 2
n = int(round(duration_s * fps))
with tempfile.TemporaryDirectory() as tmp:
frames_dir = Path(tmp)
for k in range(n):
t = k / fps
img = np.zeros((height, width, 3), dtype=np.uint8) # black background
if tracker_detect.led_on(t, marker_id):
cv2.circle(img, (cx, cy), radius, (255, 255, 255), thickness=-1,
lineType=cv2.LINE_8)
cv2.imwrite(str(frames_dir / f"{k:05d}.png"), img)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-framerate", str(fps), "-start_number", "0",
"-i", str(frames_dir / "%05d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "20",
"-movflags", "+faststart",
str(out_path),
]
subprocess.run(cmd, check=True)
return out_path
# ---------------------------------------------------------------------------
# Video rendering (ffmpeg)
# ---------------------------------------------------------------------------
@ -615,30 +396,25 @@ def _has_drawtext() -> bool:
def render_video(out_path: Path, wav_path: Path, cam_index: int,
width: int, height: int, fps: float, duration_s: float,
poses: list[dict] | None = None, offset_ms: float = 0.0) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``.
When ``poses`` is given (this camera's ``camera_track``), the two moving markers (M18) are
composited on top via a per-frame RGBA overlay produced by :func:`render_marker_overlay`
(drawn AFTER the base visual + label, so existing behavior/tests are untouched). ``offset_ms``
aligns the marker schedule to global time for this camera.
"""
width: int, height: int, fps: float, duration_s: float) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``."""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required for the synthetic fixture")
out_path.parent.mkdir(parents=True, exist_ok=True)
# Per-camera hue shift for distinctness, then mute saturation + brightness so the base
# never collides with the fully-saturated markers composited on top (see BASE_* above).
hue = cam_index * 47
vf = f"hue=h={hue}:s={BASE_SATURATION},eq=brightness={BASE_BRIGHTNESS}"
vf = f"hue=h={hue}"
font = _find_font()
if font and _has_drawtext():
vf += (f",drawtext=fontfile='{font}':text='CAM {cam_index}':"
"x=24:y=24:fontsize=44:fontcolor=white:box=1:boxcolor=black@0.5")
src = f"testsrc2=size={width}x{height}:rate={fps}:duration={duration_s}"
common_out = [
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "28",
"-profile:v", "baseline", "-level", "3.1",
"-movflags", "+faststart",
@ -646,33 +422,7 @@ def render_video(out_path: Path, wav_path: Path, cam_index: int,
"-shortest",
str(out_path),
]
if poses is None:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
*common_out,
]
subprocess.run(cmd, check=True)
return
# Markers: pre-render the RGBA overlay frames, then composite them over the base visual.
with tempfile.TemporaryDirectory() as ov:
ov_dir = Path(ov)
render_marker_overlay(ov_dir, poses, offset_ms, width, height, fps, duration_s)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src, # 0: base video
"-i", str(wav_path), # 1: audio
"-framerate", str(fps), "-start_number", "0",
"-i", str(ov_dir / "%05d.png"), # 2: marker overlay
"-filter_complex", f"[0:v]{vf}[bg];[bg][2:v]overlay=0:0:eof_action=pass[vo]",
"-map", "[vo]", "-map", "1:a",
*common_out,
]
subprocess.run(cmd, check=True)
subprocess.run(cmd, check=True)
def ffprobe_video(path: Path) -> dict:
@ -731,15 +481,11 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
filename = f"cam{i}.mp4"
out_path = raw / filename
clip = slice_for_offset(master, sr, offset_ms, duration)
poses = camera_track(i, duration, fps, w, h)
if run_ffmpeg:
wav_path = tmp / f"cam{i}.wav"
write_wav(wav_path, clip, sr)
# Pass this camera's poses + offset so the M18 markers render through the same
# projection lane H inverts (the marker pixel derives from its 3D truth).
render_video(out_path, wav_path, i, w, h, fps, duration,
poses=poses, offset_ms=float(offset_ms))
render_video(out_path, wav_path, i, w, h, fps, duration)
probed = ffprobe_video(out_path)
vw, vh, vfps, vdur = (probed["width"], probed["height"],
probed["fps"], probed["duration_s"])
@ -750,7 +496,7 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
filename=filename, duration_s=vdur, fps=vfps, width=vw, height=vh,
offset_ms=float(offset_ms), drift_ppm=0.0, sync_confidence=1.0,
)
db.set_poses(video.id, poses)
db.set_poses(video.id, camera_track(i, duration, fps, w, h))
videos_gt.append({
"video_id": video.id, "filename": filename,
"offset_ms": float(offset_ms), "drift_ppm": 0.0,
@ -791,28 +537,17 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
gt_path = work / "ground_truth.json"
gt_path.write_text(json.dumps(ground_truth, indent=2))
# Friend-track ground truth (M18): the two markers' 3D paths over the union of the
# cameras' global coverage. Written for both ffmpeg and no-ffmpeg builds — it is pure
# geometry (the same paths the markers are rendered from), so tests can check it cheaply.
offsets_s = [o / 1000.0 for o in config.SYNTH_OFFSETS_MS]
track_truth = build_track_truth(min(offsets_s), max(offsets_s) + duration)
tt_path = work / "track_truth.json"
tt_path.write_text(json.dumps(track_truth, indent=2))
summary = {
"base_dir": str(base),
"videos": [v["filename"] for v in videos_gt],
"points_ply": str(work / "points.ply"),
"point_count": int(len(points)),
"ground_truth": str(gt_path),
"track_truth": str(tt_path),
"markers": [m["marker_key"] for m in track_truth["markers"]],
"events": len(SEED_EVENTS),
"anchors": len(STAGE_CORNERS),
}
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors, %d markers",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS),
len(track_truth["markers"]))
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS))
return summary

View File

@ -1,171 +0,0 @@
"""Marker detection (spec M19, lane H) — STUB with the frozen contracts baked in.
foundation3 ships this file with:
- the **concrete, foundation-owned protocol constants + pure encoders** every side of the
system must agree on (the blink OOK protocol #4, the color-mode marker-key convention #1).
``synthetic.py`` renders the fixture markers with these, ``scripts/badge_selftest.py``
(lane K) imports this decoder path, and ``hardware/badge/protocol.h`` (lane K) duplicates
the constants with a documented copy-check. **One source of truth lives here.**
- a **stub** :func:`run_detect` that raises ``NotImplementedError``. Lane H fills the body
(and adds ``backend/tests/test_tracker_detect.py``); it must not change the constants or
the emitted ``marker_key`` conventions, only implement the detection logic.
Nothing in this module raises at import time ``synthetic.py`` imports the constants and the
``encode_word`` / ``led_on`` helpers, so the import must stay side-effect-free and light
(no OpenCV at module scope; import ``cv2`` inside ``run_detect``).
================================================================================
FROZEN CONTRACTS (plan/30-phase6.md copy, do not reinterpret)
================================================================================
Contract #1 — Marker hues (color mode)
--------------------------------------
Magenta ``#ff00ff`` and cyan ``#00ffff`` are the two supported fixture hues; real shoots may
configure others via ``FESTIVAL4D_MARKER_HUES`` (comma-separated hex). Detection converts to
HSV and gates on **saturation AND value before hue** (pitfall #2 — stage lighting lies about
color). A color blob's ``marker_key`` is ``"hue:<H>"`` where ``<H>`` is the **OpenCV hue**
(0..179) of the configured marker color, i.e. ``round(matplotlib_hue_degrees / 2)``:
magenta ``hue:150``, cyan ``hue:90``. Use :func:`color_marker_key` never format the
string by hand.
Contract #2 — Schema (what the solver, not the detector, writes; here for context)
----------------------------------------------------------------------------------
``tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)``
``track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
quality FLOAT, views INTEGER)`` points ordered by ``t_global_s``;
``views`` = cameras used (1 ground-plane fallback); coordinates in **Three.js scene space**.
Contract #3 — detections.json (this module's OUTPUT — write via `db`? No: a JSON file)
--------------------------------------------------------------------------------------
Write ``config.DETECTIONS_JSON`` (``data/work/detections.json``) with EXACTLY this shape::
{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
"cx": float, "cy": float, "area": float,
"conf": float 0..1}]},
"generated_by": "festival4d.tracker_detect"}
``cx`` / ``cy`` are **normalized 0..1** (the bbox-annotation convention: ``cx = px / width``,
``cy = py / height``). ``area`` is the blob area in pixels. Detections at ``t_video`` (the
frame's local time) — the solver converts to ``t_global`` with the FROZEN
``config.t_global_from_video`` helper (pitfall #1). No markers / dark / markerless video →
a valid-empty ``{"videos": {}, "generated_by": ...}``, never a crash.
Contract #4 — Blink protocol (OOK, camera-decodable)
----------------------------------------------------
Bit period **133 ms** (4 frames @ 30 fps). One **word** = preamble ``11100`` + 6-bit ID
(MSB first) + even parity = **12 bits**, repeated continuously (~1.6 s/word). ID **63** is
reserved for the sync beacon. Decoding needs **24 fps** video and **1 full word** of
visibility. Decode by **integrating brightness per bit window using the video's real fps**
(``meta.fps``) never by counting frames (pitfall #3). Emit ``marker_key = "code:<id>"``
via :func:`code_marker_key`. The constants below are the byte-identical source for
``hardware/badge/protocol.h`` (lane K).
Contract #5 — Ground plane (solver context)
-------------------------------------------
Synthetic ground is ``y = 0`` (scene space). Real projects estimate it as the
5th-percentile-y horizontal plane of the point cloud. Single-view fallback intersects the
pixel ray with this plane; such points get ``views = 1`` and ``quality 0.5``.
"""
from __future__ import annotations
import logging
import os
log = logging.getLogger("festival4d.tracker_detect")
# ---------------------------------------------------------------------------
# Contract #4 — blink OOK protocol. FROZEN. Byte-identical to hardware/badge/protocol.h.
# ---------------------------------------------------------------------------
BLINK_BIT_PERIOD_S: float = 0.133 # 133 ms bit period (≈4 frames @ 30 fps)
BLINK_PREAMBLE: tuple[int, ...] = (1, 1, 1, 0, 0) # start-of-word marker
BLINK_ID_BITS: int = 6 # payload width (0..63)
BLINK_WORD_BITS: int = len(BLINK_PREAMBLE) + BLINK_ID_BITS + 1 # +1 parity = 12
BLINK_BEACON_ID: int = 63 # reserved: the sync/calibration beacon
BLINK_MIN_FPS: float = 24.0 # decoder needs at least this frame rate
# ---------------------------------------------------------------------------
# Contract #1 — color mode marker hues. OpenCV hue is 0..179 (degrees / 2).
# ---------------------------------------------------------------------------
MAGENTA_OPENCV_HUE: int = 150 # #ff00ff
CYAN_OPENCV_HUE: int = 90 # #00ffff
DEFAULT_MARKER_HUES_HEX: tuple[str, ...] = ("#ff00ff", "#00ffff")
def encode_word(marker_id: int) -> list[int]:
"""The 12-bit OOK word for ``marker_id`` (contract #4). Pure; no I/O.
``preamble(11100) + 6-bit ID MSB-first + even-parity bit``. The parity bit is chosen so
the **whole word has an even number of 1-bits** (parity over preamble+ID). This is the
exact bit sequence a badge emits, ``synthetic.py`` renders, and lane H's decoder inverts.
>>> encode_word(5)
[1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1]
"""
mid = int(marker_id)
if not 0 <= mid < (1 << BLINK_ID_BITS):
raise ValueError(f"marker_id {marker_id} out of range 0..{(1 << BLINK_ID_BITS) - 1}")
id_bits = [(mid >> (BLINK_ID_BITS - 1 - i)) & 1 for i in range(BLINK_ID_BITS)]
body = list(BLINK_PREAMBLE) + id_bits
parity = sum(body) % 2 # even parity: word weight (incl. parity) is even
return body + [parity]
def led_on(t_seconds: float, marker_id: int, epoch: float = 0.0) -> bool:
"""Whether the badge LED for ``marker_id`` is lit at wall-clock time ``t_seconds``.
The word repeats continuously from ``epoch``; a single physical badge blinks in real
(global) time, so every camera that captures a frame at the same ``t_global`` sees the
same LED state. Used by the synthetic fixture to render marker B faithfully.
"""
bit_index = int((t_seconds - epoch) // BLINK_BIT_PERIOD_S)
word = encode_word(marker_id)
return bool(word[bit_index % BLINK_WORD_BITS])
def color_marker_key(opencv_hue: int) -> str:
"""The ``marker_key`` for a color blob of the given OpenCV hue (contract #1)."""
return f"hue:{int(opencv_hue)}"
def code_marker_key(marker_id: int) -> str:
"""The ``marker_key`` for a decoded blink ID (contract #4)."""
return f"code:{int(marker_id)}"
def marker_hues_hex() -> list[str]:
"""Configured color-mode hues as hex strings (``FESTIVAL4D_MARKER_HUES`` or the default)."""
env = os.environ.get("FESTIVAL4D_MARKER_HUES")
if env:
hues = [h.strip() for h in env.split(",") if h.strip()]
if hues:
return hues
return list(DEFAULT_MARKER_HUES_HEX)
# ---------------------------------------------------------------------------
# Lane H fills these. Signatures FROZEN.
# ---------------------------------------------------------------------------
def run_detect(sample_fps: float = 8.0) -> dict:
"""Detect markers in every video and write ``detections.json`` (spec M19, contract #3).
Lane H implements:
1. Sample frames per video with the ``frames.py`` machinery at ~``sample_fps`` fps
(document the exact rate; ~8 fps comfortably oversamples the 133 ms bit period).
2. **Color mode:** BGRHSV; gate on saturation AND value *before* hue (pitfall #2);
connected components per configured hue (:func:`marker_hues_hex`); blob centroid
normalized ``cx``/``cy``; ``marker_key = color_marker_key(<opencv_hue>)``.
3. **Blink mode:** track bright blobs across the sampled window; integrate brightness
per :data:`BLINK_BIT_PERIOD_S` window using the video's **real fps** (``meta.fps``,
pitfall #3); correlate against :data:`BLINK_PREAMBLE`; check even parity; emit
``marker_key = code_marker_key(<id>)``.
4. Write ``config.DETECTIONS_JSON`` in the contract-#3 shape. No markers / dark /
markerless valid-empty ``{"videos": {}, "generated_by": "festival4d.tracker_detect"}``.
Returns a summary dict (per-video detection counts, decoded blink IDs). Never crash on a
markerless or unreadable video log and continue.
"""
raise NotImplementedError(
"marker detection not implemented yet (lane H / M19) — fill tracker_detect.run_detect"
)

View File

@ -1,72 +0,0 @@
"""Track solving (spec M20, lane H) — STUB with the frozen contract in the docstring.
foundation3 ships the stub; lane H fills :func:`run_solve` (and adds
``backend/tests/test_tracker_solve.py``). This is pure orchestration over the FROZEN
geometry primitives it must NEVER inline pose math (read ``resolve.py`` first; it is the
exact per-timestep pattern), and it writes through the ``db`` track helpers only.
================================================================================
FROZEN CONTRACTS (plan/30-phase6.md copy, do not reinterpret)
================================================================================
Input ``config.DETECTIONS_JSON`` (contract #3, written by ``tracker_detect.run_detect``)::
{"videos": {"<video_id>": [{"t_video_s", "marker_key", "cx", "cy", "area", "conf"}]},
"generated_by": "festival4d.tracker_detect"}
``cx``/``cy`` are normalized 0..1; multiply by the video's width/height for pixel coords.
Output the ``tracks`` / ``track_points`` tables (contract #2), via ``db`` helpers::
tracks(id, marker_key, label, color, created_at)
track_points(id, track_id, t_global_s, x, y, z, quality, views) # ordered by t_global_s
# coordinates in Three.js scene space; views = cameras used (1 ⇒ ground-plane fallback)
Algorithm (spec M20 the parts that bite):
1. Load detections; group by ``marker_key``.
2. **Time alignment is the whole game** (pitfall #1): a detection is found at ``t_video``;
association happens at ``t_global``. Convert with the FROZEN
``config.t_global_from_video(t_video, offset_ms, drift_ppm)`` ONLY never re-derive it.
3. Per time bucket (**0.25 s** of ``t_global``), collect same-``marker_key`` detections
across cameras at matching ``t_global``:
- **2+ views triangulate** through ``geometry`` primitives exactly as ``resolve.py``
does (``geometry.ray_from_pixel`` per view + ``geometry.triangulate_rays``, or a
multi-ray least squares built on them). Record ``views`` and a ``quality`` derived
from the residual/gap.
- **1 view ray ground plane** (contract #5): the synthetic ground is ``y = 0``;
real projects estimate the 5th-percentile-y horizontal plane of the point cloud and
record it in the result dict. Such points get ``views = 1`` and ``quality 0.5``.
4. Smooth each track (moving average or Catmull-Rom your call; **document which**).
5. Solid color for hue markers, palette color for code markers.
6. Write via ``db.add_track`` + ``db.set_track_points``. **Re-solve replaces that marker's
track** (idempotent never stack duplicates on repeated runs).
7. Degradation: no detections zero tracks + a note; never crash.
``POST /api/tracks/solve`` and ``python -m festival4d track`` both run detectsolve
end-to-end once this and ``tracker_detect`` are filled.
"""
from __future__ import annotations
import logging
log = logging.getLogger("festival4d.tracker_solve")
# Poses are keyed every ~0.5 s; bucket association at 0.25 s (spec M20). Kept here so lane H
# and any test reference one number.
BUCKET_S: float = 0.25
GROUND_PLANE_Y: float = 0.0 # synthetic fixture ground (contract #5, scene space)
SINGLE_VIEW_MAX_QUALITY: float = 0.5 # ground-plane fallback quality cap (contract #5)
def run_solve() -> dict:
"""Solve detections into 3D friend tracks (spec M20, see module docstring).
Lane H implements the full detect-output ``tracks``/``track_points`` pipeline described
above. Returns a summary dict (tracks written, per-track point counts + coverage, the
ground-plane used, and any degradation note). Idempotent: re-running replaces each
marker's track rather than stacking duplicates.
"""
raise NotImplementedError(
"track solving not implemented yet (lane H / M20) — fill tracker_solve.run_solve"
)

View File

@ -34,10 +34,7 @@ def test_manifest_shape(client):
data = client.get("/api/manifest").json()
# Exact-set lock: additive manifest fields are fine, but must be added here consciously
# (this assertion has already caught two silent drifts — has_splat and has_capture).
# phase 6 (foundation3 / M18): has_tracks added — see plan/CHANGE_REQUESTS.md CR-6.
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture",
"capsule", "has_tracks"}
assert data["has_tracks"] is False # synthetic build seeds no solved tracks (solver = lane H)
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture", "capsule"}
assert data["has_poses"] is True
assert data["capsule"] is False # live server is never a capsule (M17 bakes true)
assert data["has_capture"] is False # capture routes are opt-in (FESTIVAL4D_CAPTURE)

View File

@ -1,171 +0,0 @@
"""M10 acceptance tests (lane E): beat/onset analysis of the reference ingest WAV.
Built on the synthetic fixture + a real ingest run (per the phase-5 test pattern): the
fixture's master audio carries a known 120 BPM click grid (``synthetic.BEAT_INTERVAL_S``
= 0.5 s), so acceptance is objective beats within ±50 ms of the grid, tempo within
±2 BPM. Silence / short / missing audio must degrade per the house style, never crash.
"""
from __future__ import annotations
import json
import shutil
import numpy as np
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required to build the fixture + run ingest",
)
BEAT_GRID_S = 0.5 # synthetic.BEAT_INTERVAL_S — the fixture's known pulse grid
TRUE_TEMPO_BPM = 60.0 / BEAT_GRID_S # 120
@pytest.fixture(scope="module")
def project():
"""Full synthetic project + ingest (produces the 16 kHz WAVs in config.AUDIO_DIR)."""
from festival4d import config, ingest, synthetic
synthetic.build(run_ffmpeg=True) # default 20 s — enough signal for tempo tracking
ingest.run_ingest()
config.BEATS_JSON.unlink(missing_ok=True)
yield config
config.BEATS_JSON.unlink(missing_ok=True) # don't leak state into later test modules
def _grid_error_s(t: float) -> float:
"""Distance from t to the nearest 0.5 s grid line."""
return abs(t - round(t / BEAT_GRID_S) * BEAT_GRID_S)
# ---------------------------------------------------------------------------
# The real thing: known pulse grid recovered from the ingest WAV.
# ---------------------------------------------------------------------------
def test_run_features_recovers_synthetic_pulse_grid(project):
from festival4d import audio_features
summary = audio_features.run_features()
assert summary["status"] == "ok"
assert project.BEATS_JSON.exists()
data = json.loads(project.BEATS_JSON.read_text())
# Contract #1 structure lock: exactly these keys.
assert set(data) == {"tempo_bpm", "beats_s", "onsets", "generated_by"}
assert data["generated_by"] == "festival4d.audio_features"
# Tempo within ±2 BPM of the known 120 BPM grid.
assert data["tempo_bpm"] == pytest.approx(TRUE_TEMPO_BPM, abs=2.0)
# Beats: a healthy number over 20 s @ 120 BPM, each within ±50 ms of the grid,
# strictly increasing, inside the reference window (t_global == reference local time).
beats = data["beats_s"]
assert len(beats) >= 20
assert beats == sorted(beats)
# AAC encode/decode pads the clip tail slightly, so allow a small margin past 20 s.
assert all(0.0 <= b <= 20.5 for b in beats)
worst = max(_grid_error_s(b) for b in beats)
assert worst <= 0.050, f"worst beat off grid by {worst * 1000:.1f} ms"
# Onsets: present, valid strengths, times on the master timeline.
onsets = data["onsets"]
assert len(onsets) > 0
assert all(set(o) == {"t_global_s", "strength"} for o in onsets)
assert all(0.0 <= o["strength"] <= 1.0 for o in onsets)
assert any(o["strength"] >= 0.9 for o in onsets) # normalized: the peak onset is ~1.0
assert all(0.0 <= o["t_global_s"] <= 20.5 for o in onsets)
# The summary mirrors the file.
assert summary["beats"] == len(beats)
assert summary["onsets"] == len(onsets)
assert summary["reference_video_id"] == 1 # cam0 (offset 0) is the reference
def test_onsets_catch_the_ground_truth_bangs(project):
"""The three loud synthetic bangs (3, 10, 17 s) must appear as strong onsets."""
from festival4d import audio_features, synthetic
audio_features.run_features()
data = json.loads(project.BEATS_JSON.read_text())
onset_times = [o["t_global_s"] for o in data["onsets"]]
for bang in synthetic.BANG_TIMES_S:
assert any(abs(t - bang) <= 0.075 for t in onset_times), f"bang at {bang}s not detected"
# ---------------------------------------------------------------------------
# Degradation: silence / short / missing input — valid output or clear note, never a crash.
# ---------------------------------------------------------------------------
def test_silence_produces_valid_empty_json(project):
from festival4d import audio_features, synthetic
wav = project.AUDIO_DIR / "1.wav"
original = wav.read_bytes()
try:
synthetic.write_wav(wav, np.zeros(16_000 * 5, dtype=np.float32), 16_000)
summary = audio_features.run_features()
assert summary["status"] == "ok"
data = json.loads(project.BEATS_JSON.read_text())
assert data == {
"tempo_bpm": None,
"beats_s": [],
"onsets": [],
"generated_by": "festival4d.audio_features",
}
finally:
wav.write_bytes(original)
project.BEATS_JSON.unlink(missing_ok=True)
def test_too_short_audio_produces_valid_empty_json(project):
from festival4d import audio_features, synthetic
wav = project.AUDIO_DIR / "1.wav"
original = wav.read_bytes()
try:
rng = np.random.default_rng(3)
synthetic.write_wav(wav, rng.standard_normal(4_000).astype(np.float32) * 0.5, 16_000)
audio_features.run_features()
data = json.loads(project.BEATS_JSON.read_text())
assert data["tempo_bpm"] is None and data["beats_s"] == [] and data["onsets"] == []
finally:
wav.write_bytes(original)
project.BEATS_JSON.unlink(missing_ok=True)
def test_missing_wav_degrades_with_note(project):
from festival4d import audio_features
wav = project.AUDIO_DIR / "1.wav"
moved = wav.with_suffix(".wav.bak")
wav.rename(moved)
try:
summary = audio_features.run_features() # must not raise
assert summary["status"] == "no_audio"
assert "ingest" in summary["note"]
assert not project.BEATS_JSON.exists() # nothing stale written
finally:
moved.rename(wav)
def test_analyze_is_pure_and_beatless_noise_is_empty():
"""Unit-level: analyze() on beatless noise returns the valid-empty shape."""
from festival4d import audio_features
rng = np.random.default_rng(11)
y = (rng.standard_normal(16_000 * 6) * 0.2).astype(np.float32) # 6 s of plain noise
data = audio_features.analyze(y, 16_000)
assert set(data) == {"tempo_bpm", "beats_s", "onsets", "generated_by"}
# librosa may or may not hallucinate a weak pulse in noise; the hard requirement is a
# valid, well-typed result — not a crash.
assert isinstance(data["beats_s"], list)
assert all(isinstance(b, float) for b in data["beats_s"])
def test_cli_features_runs_end_to_end(project):
"""`python -m festival4d features` dispatches into the real implementation now."""
from festival4d import cli
assert cli.main(["features"]) == 0
assert project.BEATS_JSON.exists()
project.BEATS_JSON.unlink()

View File

@ -1,339 +0,0 @@
"""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",
# phase 6 (foundation3 / M18): friend tracks baked read-only — CR-6.
"api/tracks",
"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

View File

@ -1,242 +0,0 @@
"""M11 acceptance tests (lane E): deterministic auto-director -> frozen camPath JSON.
Runs on the synthetic fixture WITHOUT ffmpeg (poses + events + DB only): generate_path
never touches media files. The synthetic seed events have known times/confidences, so
event selection, keyframe placement, beat snapping, and the pose conversion are all
checked against ground truth. The quaternion test locks the [x,y,z,w] Three.js order via
the frozen geometry.colmap_to_threejs this repo's #1 historical bug source.
"""
from __future__ import annotations
import json
import math
import numpy as np
import pytest
# Synthetic seed events (synthetic.SEED_EVENTS): (t_global, type, confidence)
# 3.0 bass_drop .94 | 6.0 crowd_wave .72 | 8.0 quiet .61 | 10.0 pyro .88
# 13.0 light_show .90 | 17.0 confetti .81 | 19.0 artist .77
TOP3_TIMES = [3.0, 10.0, 13.0] # confidences .94, .88, .90 -> top 3
KEYFRAME_KEYS = {"t_global", "pos", "quat", "fov"}
@pytest.fixture()
def project():
"""Fresh synthetic project (no ffmpeg) per test — several tests mutate the DB."""
from festival4d import config, synthetic
synthetic.build(run_ffmpeg=False)
config.BEATS_JSON.unlink(missing_ok=True) # unsnapped by default; tests opt in
yield config
config.BEATS_JSON.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Structure lock: the frozen camPath shape (contract #2).
# ---------------------------------------------------------------------------
def test_output_matches_frozen_campath_shape(project):
from festival4d import director
path = director.generate_path()
assert set(path) == {"version", "keyframes"} # exact-set lock (no stray fields on success)
assert path["version"] == 1
assert len(path["keyframes"]) > 0
for kf in path["keyframes"]:
assert set(kf) == KEYFRAME_KEYS # exact-set lock per keyframe
assert isinstance(kf["t_global"], float)
assert len(kf["pos"]) == 3 and all(isinstance(v, float) for v in kf["pos"])
assert len(kf["quat"]) == 4 and all(isinstance(v, float) for v in kf["quat"])
assert abs(math.hypot(*kf["quat"][:3], kf["quat"][3]) - 1.0) < 1e-6 # unit quaternion
assert 10.0 < kf["fov"] < 120.0
times = [kf["t_global"] for kf in path["keyframes"]]
assert times == sorted(times)
assert len(set(times)) == len(times) # strictly monotone (dedup after snapping)
# JSON-serializable end to end (the API returns this dict verbatim).
json.dumps(path)
def test_covers_the_top_n_events_with_lead_and_duration(project):
from festival4d import director
path = director.generate_path(top_n=3, lead_s=2.0)
times = [kf["t_global"] for kf in path["keyframes"]]
# Each top-3 event contributes keys at t-2.0 and t+duration (1.0 in the fixture):
# 3.0 -> {1.0, 4.0}; 10.0 -> {8.0, 11.0}; 13.0 -> {11.0, 14.0}. The shared 11.0
# collapses to one keyframe -> exactly 5, and every event is bracketed.
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
for t_event in TOP3_TIMES:
assert any(t <= t_event for t in times) and any(t >= t_event for t in times)
def test_full_top_n_covers_every_event(project):
from festival4d import director
path = director.generate_path(top_n=8, lead_s=2.0) # fixture has 7 events
times = [kf["t_global"] for kf in path["keyframes"]]
for t_event in [3.0, 6.0, 8.0, 10.0, 13.0, 17.0, 19.0]:
assert min(abs(t - (t_event - 2.0)) for t in times) < 1e-9
assert min(abs(t - (t_event + 1.0)) for t in times) < 1e-9
def test_confidence_ties_break_to_the_earlier_event(project):
from festival4d import db, director
db.clear_events()
db.add_event(t_global_s=15.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
db.add_event(t_global_s=5.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
path = director.generate_path(top_n=1, lead_s=2.0)
assert [kf["t_global"] for kf in path["keyframes"]] == [3.0, 6.0] # the t=5 event won
def test_lead_time_clamps_at_zero(project):
from festival4d import db, director
db.clear_events()
db.add_event(t_global_s=0.5, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
path = director.generate_path(top_n=1, lead_s=2.0)
assert [kf["t_global"] for kf in path["keyframes"]] == [0.0, 1.5]
# ---------------------------------------------------------------------------
# Pose conversion: through the frozen geometry.colmap_to_threejs, quat order [x,y,z,w].
# ---------------------------------------------------------------------------
def test_keyframe_pose_matches_frozen_conversion(project):
from festival4d import db, director
from festival4d.geometry import colmap_to_threejs, mat_to_quat, quat_to_mat
path = director.generate_path(top_n=1, lead_s=2.0) # top event: bass_drop @ 3.0
kf = path["keyframes"][0] # t_global = 1.0
# Every synthetic event time is an exact cam0 pose time (offset 0), so the chosen
# camera is video 1 and the keyframe pose is cam0's registered row at t_video_s=1.0.
pose = next(p for p in db.get_poses(1) if abs(p.t_video_s - 1.0) < 1e-9)
expected_pos, expected_rot = colmap_to_threejs(
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
)
assert np.allclose(kf["pos"], expected_pos, atol=1e-9)
# Quaternion order lock: reordering [x,y,z,w] -> [w,x,y,z] must reproduce the Three.js
# world rotation matrix (up to the quaternion double cover).
x, y, z, w = kf["quat"]
assert np.allclose(quat_to_mat([w, x, y, z]), expected_rot, atol=1e-9)
expected_q = mat_to_quat(expected_rot) # [w,x,y,z], canonical w >= 0
q_out = np.array([w, x, y, z])
if np.dot(q_out, expected_q) < 0:
q_out = -q_out
assert np.allclose(q_out, expected_q, atol=1e-9)
# FOV from the chosen camera's intrinsics: 2*atan(height / (2*fy)) in degrees.
video = db.get_video(1)
expected_fov = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy)))
assert kf["fov"] == pytest.approx(expected_fov, abs=1e-9)
def test_unregistered_poses_are_excluded(project):
from festival4d import db, director
# Mark every cam0 pose unregistered: the director must cut to cam1/cam2 instead.
poses = db.get_poses(1)
db.set_poses(1, [
{"frame_idx": p.frame_idx, "t_video_s": p.t_video_s,
"qw": p.qw, "qx": p.qx, "qy": p.qy, "qz": p.qz,
"tx": p.tx, "ty": p.ty, "tz": p.tz,
"fx": p.fx, "fy": p.fy, "cx": p.cx, "cy": p.cy, "registered": False}
for p in poses
])
path = director.generate_path(top_n=1, lead_s=2.0)
assert len(path["keyframes"]) == 2
cam0_centers = {
tuple(np.round(np.asarray(_center(p)), 6)) for p in poses
}
for kf in path["keyframes"]:
assert tuple(np.round(kf["pos"], 6)) not in cam0_centers
def _center(pose):
from festival4d.geometry import colmap_to_threejs
pos, _ = colmap_to_threejs([pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz])
return pos
# ---------------------------------------------------------------------------
# Beat snapping (M10 -> M11), and degradation without it.
# ---------------------------------------------------------------------------
def test_keyframes_snap_to_beats_when_beats_exist(project):
from festival4d import director
grid = [round(k * 0.75, 4) for k in range(0, 28)] # 0.75 s grid != the keyframe times
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
project.BEATS_JSON.write_text(json.dumps({
"tempo_bpm": 80.0, "beats_s": grid, "onsets": [],
"generated_by": "festival4d.audio_features",
}))
path = director.generate_path(top_n=3, lead_s=2.0)
times = [kf["t_global"] for kf in path["keyframes"]]
assert len(times) > 0
assert all(any(abs(t - b) < 1e-9 for b in grid) for t in times) # every key ON a beat
assert times != [1.0, 4.0, 8.0, 11.0, 14.0] # actually moved vs the unsnapped grid
def test_no_beats_file_means_no_snapping(project):
from festival4d import director
assert not project.BEATS_JSON.exists()
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
def test_corrupt_beats_file_degrades_to_unsnapped(project):
from festival4d import director
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
project.BEATS_JSON.write_text("{ not json")
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
# ---------------------------------------------------------------------------
# Degradation: empty inputs -> valid empty path (still camPath.fromJSON-safe).
# ---------------------------------------------------------------------------
def test_no_events_yields_valid_empty_path(project):
from festival4d import db, director
db.clear_events()
path = director.generate_path()
assert path["version"] == 1 and path["keyframes"] == []
assert "events" in path["note"]
def test_no_registered_poses_yields_valid_empty_path_with_note(project):
from festival4d import db, director
for video in db.get_videos():
db.set_poses(video.id, [])
path = director.generate_path()
assert path["version"] == 1 and path["keyframes"] == []
assert "poses" in path["note"]
# ---------------------------------------------------------------------------
# Dispatch: the API route and CLI now run the real implementation.
# ---------------------------------------------------------------------------
def test_api_director_route_returns_real_path(project):
from fastapi.testclient import TestClient
from festival4d import api
with TestClient(api.app) as client:
data = client.post("/api/director", json={"top_n": 3, "lead_s": 2.0}).json()
assert data["version"] == 1
assert [kf["t_global"] for kf in data["keyframes"]] == [1.0, 4.0, 8.0, 11.0, 14.0]
assert all(set(kf) == KEYFRAME_KEYS for kf in data["keyframes"])
def test_cli_direct_prints_campath_json(project, capsys):
from festival4d import cli
assert cli.main(["direct", "--top-n", "2", "--lead-s", "1.0"]) == 0
printed = json.loads(capsys.readouterr().out)
assert printed["version"] == 1 and len(printed["keyframes"]) > 0

View File

@ -1,198 +0,0 @@
"""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

@ -52,15 +52,13 @@ def test_beats_404_then_serves_file(client):
config.BEATS_JSON.unlink()
def test_director_returns_valid_campath(client):
# 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.
def test_director_degrades_to_valid_empty_campath(client):
# While lane E is stubbed the response must still round-trip through camPath.fromJSON:
# a valid version-1 path with zero keyframes, plus a "note" marking the degradation.
data = client.post("/api/director", json={}).json()
assert data["version"] == 1
assert isinstance(data["keyframes"], list)
for kf in data["keyframes"]:
assert set(kf) == {"t_global", "pos", "quat", "fov"}
assert data["keyframes"] == []
assert "note" in data
assert client.post("/api/director", json={"top_n": 3, "lead_s": 1.0}).status_code == 200
@ -100,7 +98,10 @@ def test_anchor_patch_label_and_color(client):
assert client.patch("/api/anchors/99999", json={"label": "x"}).status_code == 404
# 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.
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.
from festival4d import cli
for cmd in (["features"], ["direct"], ["capsule"]):
assert cli.main(cmd) == 2

View File

@ -1,351 +0,0 @@
"""foundation3 (phase 6 / M18) contract tests — the ground three lanes stand on.
Covers what foundation3 owns and freezes, NOT the lane implementations (detection = lane H's
test_tracker_detect.py; solving = test_tracker_solve.py; ribbons = lane J's live-browser
evidence):
A. tracks / track_points DB helpers (contract #2) — round-trips, replace + delete semantics.
B. /api/tracks routes (contract #2) + POST /api/tracks/solve degradation + manifest.has_tracks.
C. The frozen OOK blink protocol + color marker_key convention (contracts #1, #4) — pure,
no OpenCV. These constants are byte-identical to hardware/badge/protocol.h (lane K).
D. The synthetic fixture (M18 step 1): track_truth.json shape, marker paths, and the one
that matters most a projection ROUND-TRIP proving the rendered marker pixels
back-project to the ground-truth 3D point through the frozen geometry primitives. If this
passes, lane H's M20 recovery (median 3D error < 0.3) is achievable by construction.
Hermetic: no ffmpeg (geometry only), temp data dir from conftest. DB-touching tests clean up
after themselves so later modules see the tables as they found them.
"""
from __future__ import annotations
import math
import shutil
import numpy as np
import pytest
from festival4d import config, db, synthetic, tracker_detect
_HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
# ===========================================================================
# A. DB helpers (contract #2)
# ===========================================================================
def _use_temp_db(tmp_path):
db.init_engine(tmp_path / "t.db")
db.reset_db()
def test_add_and_get_tracks_ordered_by_id(tmp_path):
_use_temp_db(tmp_path)
a = db.add_track("hue:150", label="Alice", color="#ff00ff")
b = db.add_track("code:5")
assert db.has_tracks() is True
tracks = db.get_tracks()
assert [t.id for t in tracks] == sorted(t.id for t in tracks) == [a.id, b.id]
assert tracks[0].marker_key == "hue:150" and tracks[0].label == "Alice"
assert tracks[1].label is None and tracks[1].color is None # optional fields default None
assert db.get_track(a.id).marker_key == "hue:150"
assert db.get_track(999999) is None
def test_has_tracks_false_on_empty(tmp_path):
_use_temp_db(tmp_path)
assert db.has_tracks() is False
assert db.get_tracks() == []
def test_set_track_points_replaces_and_orders(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("code:5")
# Insert out of order; helper must return them sorted by t_global_s (contract #2).
n = db.set_track_points(t.id, [
{"t_global_s": 2.0, "x": 1, "y": 2, "z": 3, "quality": 0.9, "views": 2},
{"t_global_s": 1.0, "x": 0, "y": 0, "z": 0, "quality": 0.4, "views": 1},
])
assert n == 2
pts = db.get_track_points(t.id)
assert [p.t_global_s for p in pts] == [1.0, 2.0]
assert pts[0].views == 1 and pts[0].quality == 0.4
assert pts[1].views == 2
# Re-solve replaces (idempotent — never stacks duplicates, per M20 contract).
db.set_track_points(t.id, [{"t_global_s": 5.0, "x": 9, "y": 9, "z": 9}])
pts = db.get_track_points(t.id)
assert len(pts) == 1 and pts[0].t_global_s == 5.0
assert pts[0].quality is None and pts[0].views is None # omitted optionals -> None
def test_set_track_points_unknown_track_raises(tmp_path):
_use_temp_db(tmp_path)
with pytest.raises(KeyError):
db.set_track_points(424242, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
def test_patch_track_partial_and_missing(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("hue:150", label="Old", color="#111111")
db.patch_track(t.id, label="New") # color untouched
got = db.get_track(t.id)
assert got.label == "New" and got.color == "#111111"
db.patch_track(t.id, color="#00ffff")
assert db.get_track(t.id).color == "#00ffff"
with pytest.raises(KeyError):
db.patch_track(424242, label="x")
def test_delete_track_removes_points(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("code:5")
db.set_track_points(t.id, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
assert db.delete_track(t.id) is True
assert db.get_track(t.id) is None
assert db.get_track_points(t.id) == [] # points cascade (SQLite doesn't by default)
assert db.delete_track(t.id) is False # already gone
# ===========================================================================
# B. /api/tracks routes (contract #2)
# ===========================================================================
@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()
def _api_db(client):
"""Point the API at a clean per-test db and restore the shared engine afterwards."""
import tempfile
from pathlib import Path
d = Path(tempfile.mkdtemp(prefix="tracks-api-"))
db.init_engine(d / "api.db")
db.reset_db()
yield
db.init_engine(config.DB_PATH)
def test_get_tracks_shape(client, _api_db):
t = db.add_track("hue:150", label="Alice", color="#ff00ff")
db.set_track_points(t.id, [
{"t_global_s": 1.0, "x": 0.1, "y": 1.5, "z": 2.0, "quality": 0.9, "views": 2},
{"t_global_s": 1.25, "x": 0.2, "y": 1.5, "z": 1.9, "quality": 0.5, "views": 1},
])
listing = client.get("/api/tracks").json()
assert len(listing) == 1
row = listing[0]
assert set(row) == {"id", "marker_key", "label", "color", "points"}
assert row["marker_key"] == "hue:150" and row["label"] == "Alice"
assert [p["t_global_s"] for p in row["points"]] == [1.0, 1.25] # ordered
assert set(row["points"][0]) == {"t_global_s", "x", "y", "z", "quality", "views"}
assert row["points"][1]["views"] == 1
def test_patch_and_delete_routes(client, _api_db):
t = db.add_track("code:5")
patched = client.patch(f"/api/tracks/{t.id}", json={"label": "Bob", "color": "#00ffff"}).json()
assert patched["label"] == "Bob" and patched["color"] == "#00ffff"
# partial patch leaves the other field
assert client.patch(f"/api/tracks/{t.id}", json={"color": "#fff"}).json()["label"] == "Bob"
assert client.patch("/api/tracks/999999", json={"label": "x"}).status_code == 404
assert client.delete(f"/api/tracks/{t.id}").json() == {"deleted": t.id}
assert client.get("/api/tracks").json() == []
assert client.delete(f"/api/tracks/{t.id}").status_code == 404
def test_solve_degrades_while_stubbed(client, _api_db):
# Lane H is a stub (NotImplementedError). The frozen route must NOT 500 — it returns a
# valid empty result + a note (the house pattern, mirrors POST /api/events/detect).
resp = client.post("/api/tracks/solve")
assert resp.status_code == 200
body = resp.json()
assert body["result"] is None
assert "not implemented" in body["note"].lower()
assert body["tracks"] == [] # no tracks solved yet
def test_manifest_has_tracks_reflects_db(client, _api_db):
assert client.get("/api/manifest").json()["has_tracks"] is False
db.add_track("hue:150")
assert client.get("/api/manifest").json()["has_tracks"] is True
# ===========================================================================
# C. Frozen blink OOK protocol + color marker_key (contracts #1, #4)
# ===========================================================================
def test_protocol_constants_match_spec():
assert tracker_detect.BLINK_BIT_PERIOD_S == 0.133
assert tracker_detect.BLINK_PREAMBLE == (1, 1, 1, 0, 0)
assert tracker_detect.BLINK_ID_BITS == 6
assert tracker_detect.BLINK_WORD_BITS == 12 # 5 preamble + 6 id + 1 parity
assert tracker_detect.BLINK_BEACON_ID == 63
assert tracker_detect.BLINK_MIN_FPS == 24.0
assert tracker_detect.MAGENTA_OPENCV_HUE == 150 # #ff00ff, hue 300°/2
assert tracker_detect.CYAN_OPENCV_HUE == 90 # #00ffff, hue 180°/2
def test_encode_word_id5_exact_and_even_parity():
word = tracker_detect.encode_word(5)
assert word == [1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1] # preamble + 000101 + parity
assert len(word) == 12
for mid in range(0, 64):
w = tracker_detect.encode_word(mid)
assert tuple(w[:5]) == tracker_detect.BLINK_PREAMBLE
assert sum(w) % 2 == 0 # even parity over the whole word
# 6-bit ID MSB-first recovers the value
recovered = sum(bit << (5 - i) for i, bit in enumerate(w[5:11]))
assert recovered == mid
def test_encode_word_rejects_out_of_range():
for bad in (-1, 64, 100):
with pytest.raises(ValueError):
tracker_detect.encode_word(bad)
def test_led_on_matches_encoded_word_windows():
# The LED state at bit-window k equals bit k of the repeating word (global time).
word = tracker_detect.encode_word(5)
period = tracker_detect.BLINK_BIT_PERIOD_S
for k in range(len(word) * 2): # two full words
t_mid = (k + 0.5) * period # sample mid-window
assert tracker_detect.led_on(t_mid, 5) == bool(word[k % len(word)])
def test_marker_key_helpers_and_hue_env(monkeypatch):
assert tracker_detect.color_marker_key(150) == "hue:150"
assert tracker_detect.code_marker_key(5) == "code:5"
monkeypatch.delenv("FESTIVAL4D_MARKER_HUES", raising=False)
assert tracker_detect.marker_hues_hex() == ["#ff00ff", "#00ffff"]
monkeypatch.setenv("FESTIVAL4D_MARKER_HUES", "#ff0000, #00ff00")
assert tracker_detect.marker_hues_hex() == ["#ff0000", "#00ff00"]
# ===========================================================================
# D. Synthetic fixture: track_truth shape, marker paths, projection round-trip
# ===========================================================================
def test_track_truth_shape_and_both_markers():
tt = synthetic.build_track_truth(0.0, 2.0, dt=0.05)
assert set(tt) >= {"markers"}
keys = {m["marker_key"] for m in tt["markers"]}
assert keys == {"hue:150", "code:5"} # marker A (color) + marker B (blink)
for m in tt["markers"]:
ts = [p["t_global_s"] for p in m["points"]]
assert ts == sorted(ts) # monotonically increasing
assert len(ts) == len(set(ts)) # no dup timesteps
assert set(m["points"][0]) == {"t_global_s", "x", "y", "z"} # Three.js scene space
def test_marker_paths_and_unknown_key():
# Marker A: a circle radius 2 at y=1.5 (contract, spec M18).
for tg in (0.0, 3.7, 11.2):
x, y, z = synthetic.marker_position("hue:150", tg)
assert y == pytest.approx(1.5)
assert math.hypot(x, z) == pytest.approx(2.0, abs=1e-9)
# Marker B follows a different path (never identical to A) so the two never lock.
assert synthetic.marker_position("code:5", 3.7) != synthetic.marker_position("hue:150", 3.7)
with pytest.raises(KeyError):
synthetic.marker_position("code:99", 0.0)
def test_marker_discs_are_small():
# Both discs together must be well under 2% of frame area (spec M18: ≤2%, pitfall #6).
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
r = synthetic._disc_radius_px(h)
two_discs = 2 * math.pi * r * r
assert two_discs / (w * h) < 0.02
def test_projection_round_trip_recovers_ground_truth():
"""THE foundation contract: rendered marker pixels back-project to the 3D truth.
For each marker at several t_global: project its ground-truth 3D point through every
camera's interpolated pose (exactly as render_marker_overlay draws the pixel), then treat
the in-frame pixels as a perfect detector's output and triangulate them back through the
FROZEN geometry primitives. Recovery must be ~exact on the noise-free fixture this is
what makes lane H's M20 (median 3D error < 0.3) reachable by construction.
"""
from festival4d import geometry
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
fps, dur = float(config.SYNTH_FPS), config.SYNTH_DURATION_S
# Poses per camera, generated the same way build() does (no DB, no ffmpeg needed).
cam_poses = [synthetic.camera_track(i, dur, fps, w, h)
for i in range(len(config.SYNTH_OFFSETS_MS))]
worst = 0.0
checked = 0
for key in synthetic.MARKER_KEYS:
for t_global in (3.0, 6.5, 10.0, 14.25, 17.5): # inside every camera's valid span
truth = np.array(synthetic.marker_position(key, t_global))
rays = []
for i, offset_ms in enumerate(config.SYNTH_OFFSETS_MS):
t_video = config.t_video_from_global(t_global, offset_ms, 0.0)
q, t, intr = synthetic._interp_pose(cam_poses[i], t_video)
proj = synthetic.project_point(q, t, intr, truth)
if proj is None:
continue
px, py, _ = proj
if not (0.0 <= px < w and 0.0 <= py < h):
continue # off this camera's frame
fx, fy, cx, cy = intr
rays.append(geometry.ray_from_pixel(q, t, fx, fy, cx, cy, px, py))
assert len(rays) >= 2, f"{key} @ {t_global}s: need 2+ views, got {len(rays)}"
point, gap = geometry.triangulate_rays(rays[0][0], rays[0][1], rays[1][0], rays[1][1])
err = float(np.linalg.norm(point - truth))
worst = max(worst, err)
checked += 1
assert checked >= 8
# Noise-free synthetic geometry: recovery is essentially exact, far under the 0.3 M20 bar.
assert worst < 1e-3, f"worst round-trip error {worst:.2e} (>1e-3)"
@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg/ffprobe required to render the fixture video")
def test_rendered_marker_a_is_the_dominant_magenta_blob(tmp_path):
"""The base-muting contract (BASE_SATURATION / BASE_BRIGHTNESS) actually renders a solvable
fixture: testsrc2's own solid magenta/cyan colour bars must NOT out-mass marker A's disc, or
lane H's colour detector locks onto a bar and M19/M20 fail. This emulates that detector —
largest saturated-magenta blob in a real rendered frame and asserts it lands on the disc.
Guards against a future un-muting of the base swamping detection.
"""
import cv2
dur = 2.0
synthetic.build(base_dir=tmp_path, duration_s=dur, run_ffmpeg=True)
try:
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
poses = synthetic.camera_track(0, dur, float(config.SYNTH_FPS), w, h)
t_video = 1.0
q, t, intr = synthetic._interp_pose(poses, t_video)
t_global = config.t_global_from_video(t_video, config.SYNTH_OFFSETS_MS[0], 0.0)
proj = synthetic.project_point(q, t, intr,
synthetic.marker_position(synthetic.MARKER_A_KEY, t_global))
assert proj and 0 <= proj[0] < w and 0 <= proj[1] < h, "marker A not in cam0 frame at t=1s"
cap = cv2.VideoCapture(str(tmp_path / "raw" / "cam0.mp4"))
cap.set(cv2.CAP_PROP_POS_MSEC, t_video * 1000.0)
ok, img = cap.read()
cap.release()
assert ok, "could not read cam0 frame"
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
hh, ss, vv = hsv[:, :, 0].astype(int), hsv[:, :, 1], hsv[:, :, 2]
# OpenCV-hue magenta ≈150, gated on high saturation AND value (pitfall #2).
mask = (((hh >= 145) & (hh <= 155)) & (ss > 170) & (vv > 170)).astype(np.uint8)
n, _lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
assert n > 1, "no saturated-magenta blob at all — marker A not rendered?"
biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
cx, cy = cent[biggest]
dist = math.hypot(cx - proj[0], cy - proj[1])
assert dist < 10.0, (
f"dominant magenta blob at ({cx:.0f},{cy:.0f}) is {dist:.0f}px from marker A's "
f"projected centre ({proj[0]:.0f},{proj[1]:.0f}) — base bars are swamping the disc"
)
finally:
db.init_engine(config.DB_PATH) # build() rebound the engine; restore the shared one

View File

@ -3,12 +3,11 @@
Parking lot for post-v0.1.0 extensions (spec §5 future-extensions). Not committed work — a place
to capture direction so the prototype's scope stays honest. Roughly ordered by payoff-to-effort.
> **Status 2026-07-17 (evening):** splatting ✅ shipped (see `modelbeast-crossover.md`);
> spatial audio ✅ shipped (🎧, WebAudio master clock). Cinematic export ✅ **M12**,
> multi-modal audio analysis ✅ **M10/M11**, anchor manager ✅ **M13**, path persistence ✅
> **M16** — all SHIPPED in phase 5 (`plan/20-phase5.md`, suite floor 189). Still parked here:
> realtime ingest, object/artist tracking, per-project workspaces, sync-graph visualization,
> per-moment splats (crossover doc §3), and the new hardware ideas below.
> **Status 2026-07-17:** splatting ✅ shipped (see `modelbeast-crossover.md`); spatial audio ✅
> shipped (🎧, WebAudio master clock). Cinematic export → **M12**, multi-modal audio analysis →
> **M10/M11**, anchor manager → **M13**, path persistence → **M16** — all now specced in
> `plan/20-phase5.md`. Still parked here: realtime ingest, object/artist tracking,
> per-project workspaces, sync-graph visualization, per-moment splats (crossover doc §3).
## Neural rendering — Gaussian Splatting
Replace the sparse COLMAP point cloud with a **3D Gaussian Splatting** model for photorealistic
@ -41,32 +40,6 @@ Extend M8 (manual bbox → 3D anchor) with automatic per-frame tracking (e.g. a
tracker), so an anchor follows the lead performer across time instead of being static. Enables
a "follow artist" auto-camera mode in the 3D viewer.
## Stereo / spatial-video ingest (split the eyes, pin the scale)
Spatial video (iPhone 15 Pro+ MV-HEVC, QooCam, Canon dual-fisheye) is two synced views with a
**factory-known baseline**. Ingest tweak: detect multi-view files and split L/R into two
pipeline videos (ffmpeg can demux the streams; identical offset, shared audio). Payoff beyond
one extra viewpoint: COLMAP reconstructions are scale-free, and a known stereo baseline anchors
**metric scale** for the whole scene — real-meter units for anchors, paths, and spatial audio
distances. Cheap ingest change, high leverage.
## Depth-camera seeding (small venues only)
RGB-D cams (ZED 2i, OAK-D, LiDAR phones) can seed/regularize splat training (depth-supervised
3DGS → fewer floaters, faster convergence) and densify the point cloud without SfM guessing.
Honest constraint: consumer depth dies past ~1015 m (LiDAR ~5 m), so this only pays at club /
small-venue range, not a festival main stage. Ingest: accept a per-video depth track or
per-frame depth PNGs alongside the RGB.
## Microcontroller rig kit (Arduino as crew, not camera)
An ESP32-CAM is not concert-grade — but a microcontroller on the rig earns its place three ways:
- **Sync/calibration beacon:** an LED blinking a known pattern visible to all cameras =
visual ground truth to validate the audio sync (and a free calibration target).
- **Servo pan/tilt or slider mount:** one slowly-sweeping fixed camera contributes rich
parallax from a single device; the per-frame pose track already handles moving cameras.
- **Record start/stop trigger** for the whole rig.
DIY camera *nodes* should be Raspberry Pi + **Global Shutter** camera module (kills
rolling-shutter wobble under fast stage lights) pushing into the existing `/capture` endpoint
(`FESTIVAL4D_CAPTURE=1`) — which was built for exactly this.
## Quality-of-life
- **Standalone anchor manager** — the delete UI currently lives in the event correction panel;
a dedicated always-available anchor list would decouple anchor management from events.

View File

@ -252,16 +252,12 @@
title="Moment FX: bursts and pulses on event markers (M15)">✨</button>
<button class="btn" id="btn-anchors" style="display:none"
title="Anchors & friend tags (M13)">⚓</button>
<button class="btn" id="btn-friends" style="display:none"
title="Friend tracks: ribbons, moving labels, follow-a-friend (M21/M22)">👣 Friends</button>
<input type="file" id="path-file" accept="application/json,.json" hidden />
</div>
<div id="scene-hint">drag to orbit · 19 snap to camera · K add keyframe · Esc detach</div>
<div id="annot-panel" class="write-ui"></div>
<!-- M13 anchor manager mounts here (lane F); hidden until anchorPanel.js is filled -->
<div id="anchor-panel" style="display:none"></div>
<!-- M21 friends panel mounts here (lane J); hidden until friendTracks.js is filled -->
<div id="friends-panel" style="display:none"></div>
</div>
<div id="grid-pane">
<div id="video-grid"></div>

View File

@ -1,70 +1,16 @@
// Auto-director UI (spec M11, lane E).
// Auto-director UI (spec M11, lane E). STUB — lane E fills THIS FILE ONLY.
//
// Pre-wired by foundation2: the hidden 🎬 button (#btn-director in index.html) and this
// module's import + init() call in main.js. This module wires the click: POST /api/director
// -> the response is ALWAYS a valid camPath JSON (frozen contract #2) -> load it with
// camPath.fromJSON and play it via the existing #btn-path flow (never edit camPath.js).
// Zero keyframes means "no events / no poses yet" — surfaced on the button, never a crash
// (pitfall #5). Failures degrade the same way; the button always recovers.
import { state } from "./state.js";
import { camPath } from "./camPath.js";
const BTN_LABEL = "🎬 Auto-path";
let flashTimer = null;
/** Show a transient message on the button, then restore the label. */
function flash(btn, text, ms = 2200) {
btn.textContent = text;
clearTimeout(flashTimer);
flashTimer = setTimeout(() => (btn.textContent = BTN_LABEL), ms);
}
async function requestPath(btn) {
btn.disabled = true;
btn.textContent = "🎬 …";
try {
const resp = await fetch(`${state.apiBase}/api/director`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}), // server defaults: top_n=8, lead_s=2.0
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const path = await resp.json();
if (!Array.isArray(path.keyframes) || path.keyframes.length === 0) {
// Valid-empty path: no events/poses yet (or still stubbed). Surface, don't crash.
console.info("[director] empty path from /api/director", path.note ?? "");
flash(btn, "🎬 no events yet");
return;
}
camPath.fromJSON(path); // replaces any existing keyframes; updates the Key counter
flash(btn, `🎬 ${path.keyframes.length} keys`);
// Play through the existing #btn-path flow so its UI state (⏹ label, transport
// auto-start) stays consistent with a manually loaded path.
const playBtn = document.getElementById("btn-path");
if (playBtn && !camPath.playing && path.keyframes.length >= 2) playBtn.click();
} catch (err) {
console.error("[director] /api/director failed", err);
flash(btn, "🎬 error — see console");
} finally {
btn.disabled = false;
}
}
// Pre-wired by foundation2: the hidden 🎬 button (#btn-director in index.html), and this
// module's import + init() call in main.js. main.js does NOT wire the click — you do,
// inside init().
//
// When implementing: unhide the button (el.style.display = ""), set `ready = true`; on click
// POST {top_n?, lead_s?} to `${state.apiBase}/api/director` and load the response with
// camPath.fromJSON(...) — never edit camPath.js (frozen). The response is ALWAYS a valid
// camPath JSON; zero keyframes means "no events yet / stubbed" — surface that in the UI,
// don't crash (pitfall #5). Play it via the existing #btn-path flow.
export const director = {
ready: false,
init() {
const btn = document.getElementById("btn-director");
if (!btn) return; // markup missing: stay hidden, degrade silently
btn.addEventListener("click", () => {
if (btn.disabled) return;
requestPath(btn);
});
btn.style.display = ""; // unhide: this lane has landed
this.ready = true;
},
ready: false, // flips to true when the lane implements this module
init() {}, // ponytail: stub — lane E replaces the body
};

View File

@ -1,211 +1,18 @@
// Cinematic export (spec M12, lane E).
// Cinematic export (spec M12, lane E). STUB — lane E fills THIS FILE ONLY.
//
// Pre-wired by foundation2: the hidden ⏺ button (#btn-export in index.html), this module's
// import + init() call in main.js, and the transport hooks transport.captureAudioStream()
// (a MediaStream tapping the live WebAudio mix — post-gain, spatial included) +
// transport.releaseAudioStream() when done.
// import + init() call in main.js, and the transport hooks
// `transport.captureAudioStream()` -> MediaStream tapping the live WebAudio mix (post-gain,
// spatial included) and `transport.releaseAudioStream()` when done.
//
// Flow: seek to the first camPath keyframe, play the path via the existing #btn-path flow
// (so its UI state stays truthful), record scene3d's canvas captureStream(30) muxed with the
// audio tap through MediaRecorder (vp9/opus webm, fallbacks below), stop at the last
// keyframe, download `festival4d-cut.webm`, then restore ALL prior UI/transport state.
// Export runs at rate 1 regardless of the user's speed (the recording is wall-clock, so the
// file duration must equal the path's time span); the prior rate is restored afterwards.
// Degradation: fewer than 2 keyframes -> button disabled; no decoded WebAudio track -> the
// tap is silent but the export still works (pitfall #5); clicking ⏺ mid-export stops early
// and still produces a valid file. NOTE (pitfall #2): canvas.captureStream only delivers
// real frames in a FOCUSED tab — a hidden/backgrounded pane records a frozen image.
import { state } from "./state.js";
import { transport } from "./transport.js";
import { camPath } from "./camPath.js";
import { scene3d } from "./scene3d.js";
const BTN_LABEL = "⏺ Export";
const MIME_CANDIDATES = [
"video/webm;codecs=vp9,opus", // the spec'd target
"video/webm;codecs=vp8,opus",
"video/webm",
];
function pickMime() {
if (typeof MediaRecorder === "undefined") return null;
return MIME_CANDIDATES.find((m) => MediaRecorder.isTypeSupported(m)) ?? null;
}
function download(blob, filename) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
/** Toggle the shared #btn-path flow (main.js owns its UI state) into the desired state. */
function setPathPlaying(playing) {
if (camPath.playing === playing) return;
document.getElementById("btn-path")?.click();
}
// When implementing: unhide the button, set `ready = true`; on click seek to the first
// camPath keyframe, play the path, mux scene3d canvas `captureStream(30)` + the audio stream
// with MediaRecorder (video/webm;codecs=vp9,opus), stop at the last keyframe, download
// `festival4d-cut.webm`, then RESTORE all prior UI/transport state. The canvas is
// `scene3d.renderer.domElement`. MediaRecorder needs a *focused* tab to capture real frames
// (pitfall #2) — say so in your evidence if you couldn't.
export const exportVideo = {
ready: false,
exporting: false,
_btn: null,
_recorder: null,
_chunks: [],
_canvasStream: null,
_endT: 0,
_prior: null,
_finishing: false,
init() {
const btn = document.getElementById("btn-export");
if (!btn) return; // markup missing: stay hidden, degrade silently
this._btn = btn;
btn.addEventListener("click", () => {
if (this.exporting) this._finish("stopped early");
else this._start();
});
// Enabled only with a playable path (>= 2 keyframes). main.js assigned camPath.onChange
// before lane modules init, so chain it rather than replace it (camPath.js is frozen).
const chained = camPath.onChange;
camPath.onChange = (n) => {
chained?.(n);
if (!this.exporting) btn.disabled = n < 2;
};
btn.disabled = camPath.count() < 2;
btn.style.display = ""; // unhide: this lane has landed
this.ready = true;
},
_start() {
if (this.exporting || camPath.keyframes.length < 2) return;
const mime = pickMime();
if (!mime) {
console.error("[export] MediaRecorder/webm unsupported in this browser");
this._flash("⏺ unsupported");
return;
}
const kf = camPath.keyframes; // sorted by t_global (camPath invariant)
const startT = kf[0].t_global;
this._endT = kf[kf.length - 1].t_global;
// Snapshot everything we mutate, so _finish can restore it exactly.
this._prior = {
tGlobal: state.tGlobal,
playing: state.playing,
rate: state.rate,
pathPlaying: camPath.playing,
followCameraId: state.followCameraId,
};
// Park the world at the first keyframe, real-time rate, path engaged.
setPathPlaying(false);
if (state.playing) transport.pause();
transport.setRate(1);
transport.seek(startT);
// Canvas frames + the live WebAudio mix tap (foundation2 hook; silent when no track).
const canvas = scene3d.renderer.domElement;
this._canvasStream = canvas.captureStream(30);
const audioStream = transport.captureAudioStream();
const mixed = new MediaStream([
...this._canvasStream.getVideoTracks(),
...audioStream.getAudioTracks(),
]);
this._chunks = [];
this._recorder = new MediaRecorder(mixed, {
mimeType: mime,
videoBitsPerSecond: 8_000_000,
});
this._recorder.addEventListener("dataavailable", (e) => {
if (e.data && e.data.size > 0) this._chunks.push(e.data);
});
this._recorder.addEventListener("stop", () => this._deliver(mime));
this.exporting = true;
this._finishing = false;
this._btn.classList.add("active");
this._btn.textContent = "⏺ REC — click to stop";
// Roll: recorder + playback in the same tick so file duration ~= the path span.
setPathPlaying(true); // #btn-path flow: camPath.play() + transport starts the clock
this._recorder.start(250);
this._tick = this._tick.bind(this);
requestAnimationFrame(this._tick);
console.info(`[export] recording ${startT.toFixed(2)}s -> ${this._endT.toFixed(2)}s (${mime})`);
},
_tick() {
if (!this.exporting || this._finishing) return;
// Done when the playhead crosses the last keyframe — or when anything halts the ride
// (end of timeline pauses the transport; snap-to-camera exits path mode).
if (state.tGlobal >= this._endT - 1e-3 || !state.playing || !camPath.playing) {
this._finish("end of path");
return;
}
requestAnimationFrame(this._tick);
},
_finish(reason) {
if (!this.exporting || this._finishing) return;
this._finishing = true;
console.info(`[export] finishing (${reason})`);
try {
if (this._recorder && this._recorder.state !== "inactive") this._recorder.stop();
else this._deliver(null); // recorder never really started: still restore the UI
} catch (err) {
console.error("[export] recorder stop failed", err);
this._deliver(null);
}
},
/** MediaRecorder "stop" handler: build + download the file, then restore prior state. */
_deliver(mime) {
if (this._chunks.length > 0) {
const blob = new Blob(this._chunks, { type: mime ?? "video/webm" });
download(blob, "festival4d-cut.webm");
console.info(`[export] festival4d-cut.webm — ${(blob.size / 1e6).toFixed(2)} MB`);
} else {
console.warn("[export] no data recorded (hidden tab? — captureStream needs focus)");
}
this._chunks = [];
this._recorder = null;
// Release the shared hooks.
transport.releaseAudioStream();
this._canvasStream?.getTracks().forEach((t) => t.stop());
this._canvasStream = null;
// Restore ALL prior UI/transport state (spec M12).
const prior = this._prior ?? {};
this._prior = null;
setPathPlaying(false); // resets #btn-path label via main.js's own flow
if (state.playing) transport.pause();
transport.setRate(prior.rate ?? 1);
transport.seek(prior.tGlobal ?? 0);
if (prior.followCameraId != null) scene3d.snapTo(prior.followCameraId);
if (prior.pathPlaying) setPathPlaying(true);
if (prior.playing && !state.playing) transport.play();
this.exporting = false;
this._finishing = false;
this._btn.classList.remove("active");
this._btn.textContent = BTN_LABEL;
this._btn.disabled = camPath.count() < 2;
},
_flash(text, ms = 2200) {
const btn = this._btn;
btn.textContent = text;
setTimeout(() => {
if (!this.exporting) btn.textContent = BTN_LABEL;
}, ms);
},
init() {}, // ponytail: stub — lane E replaces the body
};

View File

@ -1,41 +0,0 @@
// Friend tracks (spec M21 ribbons + moving labels + panel, M22 follow-a-friend cam; lane J).
// STUB shipped by foundation3 — lane J fills the body.
//
// main.js imports this, calls friendTracks.init() in buildUI (alongside the other lane
// modules) and friendTracks.update(state.tGlobal) every animation frame — the fx.js pattern
// (after transport.tick(), next to camPath.update / fx.update, before scene3d.update()).
// While stubbed, both are safe no-ops and the hidden #btn-friends button stays hidden; a
// broken or unfilled module degrades to no UI, exactly like the phase-5 stubs.
//
// Contract lane J must honor (frozen by foundation3):
// - state.tracks is loaded at boot by main.js when manifest.has_tracks, in the
// GET /api/tracks shape (contract #2):
// [{ id, marker_key, label, color,
// points: [{ t_global_s, x, y, z, quality, views }] }] // points sorted by t_global_s
// - Scene objects go through scene3d.addObject / scene3d.removeObject ONLY, and every
// helper is registered with scene3d.registerHelper so photo mode (M14) hides ribbons +
// labels. (Read anchorPanel.js for the panel style/UX; do not edit it.)
// - Rename / recolor / delete round-trip through PATCH / DELETE /api/tracks/{id}; those
// controls carry the `write-ui` class so the capsule (M17) hides them while ribbons +
// follow still work.
// - Follow-a-friend (M22) shares scene3d pathMode with camPath — mirror camPath's "someone
// else took the camera" contention checks exactly (pitfall #4), or two drivers fight.
// - update(tGlobal) MUST NEVER THROW: it runs inside the master loop and a throw kills
// playback. Guard the body and disable the module on error (fx.js does this).
export const friendTracks = {
ready: false,
/** Called once by main.js (buildUI). Lane J (M21): unhide #btn-friends, build the friends
* panel in #friends-panel, wire the toggle that renders ribbons + moving labels. */
init() {
// Stub: #btn-friends stays hidden until lane J fills M21. No-op is the safe degrade.
},
/** Called by main.js every animation frame with the master clock. Lane J (M21/M22):
* interpolate each track's position at tGlobal, move ribbons/labels, drive the chase cam.
* MUST NEVER THROW. */
update(_tGlobal) {
// Stub: no tracks rendered yet (M21/M22).
},
};

View File

@ -23,10 +23,8 @@ import { anchorPanel } from "./anchorPanel.js";
import { photoMode } from "./photoMode.js";
import { fx } from "./fx.js";
import { pathsStore } from "./pathsStore.js";
// Phase-6 lane module (stub until lane J fills it — M21 ribbons/panel, M22 follow-a-friend).
import { friendTracks } from "./friendTracks.js";
const laneModules = { director, exportVideo, anchorPanel, photoMode, fx, pathsStore, friendTracks };
const laneModules = { director, exportVideo, anchorPanel, photoMode, fx, pathsStore };
async function getJSON(path) {
const resp = await fetch(API_BASE + path);
@ -60,7 +58,6 @@ async function boot() {
link.style.display = "";
}
state.hasSplat = !!manifest.has_splat;
state.hasTracks = !!manifest.has_tracks; // phase 6 (M18): any solved friend track
// Capsule (M17): a static baked bundle has no write API — hide everything .write-ui.
state.capsule = !!manifest.capsule;
document.body.classList.toggle("capsule", state.capsule);
@ -74,16 +71,6 @@ async function boot() {
state.anchors = anchors;
state.events = events;
// Friend tracks (M18): only fetched when the backend reports solved tracks, so a project
// without them pays nothing. Failure is non-fatal — tracks are an overlay, not core.
if (state.hasTracks) {
try {
state.tracks = await getJSON("/api/tracks");
} catch (err) {
console.warn("[festival4d] friend tracks failed to load", err);
}
}
if (state.hasPoses) {
const lists = await Promise.all(
state.videos.map((v) => getJSON(`/api/videos/${v.id}/poses`))
@ -280,7 +267,6 @@ function startLoop() {
transport.tick();
for (const cell of cells) drawOverlay(cell);
fx.update(state.tGlobal); // M15: playhead-crossing effects (no-op while stubbed)
friendTracks.update(state.tGlobal); // M21/M22: move ribbons/labels + chase cam (no-op while stubbed)
camPath.update(state.tGlobal); // M9: drive the viewer camera before the scene renders
scene3d.update();
timeline.update(state.tGlobal);

View File

@ -1,4 +1,4 @@
// Server-side camera paths UI (spec M16, lane G).
// Server-side camera paths UI (spec M16, lane G). STUB — lane G fills THIS FILE ONLY.
//
// 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,120 +6,13 @@
// GET /api/paths/{id} ({..., json: <camPath JSON>}), POST /api/paths {name, json:<text>}
// (422 on invalid), DELETE /api/paths/{id}.
//
// 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();
}
// 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.
export const pathsStore = {
ready: false,
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();
},
init() {}, // ponytail: stub — lane G replaces the body
};

View File

@ -26,8 +26,6 @@ export const state = {
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
anchors: [], // [{id, label, x, y, z, color}]
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
hasTracks: false, // manifest.has_tracks (M18): any solved friend track => load + render tracks
tracks: [], // [{id, marker_key, label, color, points:[{t_global_s,x,y,z,quality,views}]}] (M18)
// transport / playback
tGlobal: 0,

View File

@ -1,214 +0,0 @@
# Phase 6 — Friend Tracks (M18M24): markers → moving people in the 4D scene
Canonical spec for phase 6. **Everything frozen in `../OPUS_BUILD_INSTRUCTIONS.md` (M0M9)
and `20-phase5.md` (M10M17 contracts) still applies.** Standing protocol (status files,
directives loop, evidence discipline, per-agent worktrees) is unchanged from
[`README.md`](README.md); the newest round of [`DIRECTIVES.md`](DIRECTIVES.md) always wins.
## The idea
A friend tag today is a *static* anchor. Phase 6 makes friends **move**: wearable markers
(distinct-hue patches or blinking LED badges) are detected in every camera, associated across
synced views, triangulated per timestep, and become **tracks**`t_global → (x,y,z)` paths
rendered as ribbons in the 3D scene with moving labels, a follow-a-friend camera mode, and
capsule support. Same machinery as annotation resolution (M8), run automatically over time.
## Context — what exists that you build on (read, don't re-invent)
- `geometry.py` — COLMAP↔Three.js conversion, ray casting, triangulation primitives
(`resolve.py` shows exactly how a bbox becomes a 3D point; your solver is that, per-timestep).
- `frames.py` — frame sampling via ffmpeg (SfM uses it; detection re-uses it).
- `config.t_video_from_global` / `t_global_from_video` — the FROZEN timebase; detections are
found at `t_video`, tracks live at `t_global`.
- The synthetic fixture (`synthetic.py`) — every acceptance below is against it first.
- Suite floor is **189 passed**. It only goes up.
## Phase map
```
Phase 6a (serial, ONE agent) Phase 6b (parallel, one agent per lane) Phase 6c (serial)
┌───────────────────────┐ ┌ lane/h-tracker ─── M19 M20 ──────┐ ┌─────────────────┐
│ foundation3 │ ├ lane/j-ribbons ─── M21 M22 ──────┤ │ integration3 │
│ fixture ground truth +│──merge──┤ ├──merge─│ full pass + │
│ contracts + stubs │ └ lane/k-badges ──── M23 M24 ──────┘ │ tracked field │
│ branch: foundation3 │ (independent — merge in any order) │ test → v0.3.0 │
└───────────────────────┘ └────────────────────────────────── └─────────────────┘
```
Lanes depend **only** on foundation3, never on each other.
## Milestones
### M18 — foundation3: fixture ground truth, contracts, stubs (serial, ONE agent)
1. **Synthetic fixture upgrade** (`synthetic.py` — coordinator-sanctioned edit): render **two
moving markers** into every camera's video via the existing camera projection:
- marker A: a solid bright disc (frozen hue, e.g. pure magenta #ff00ff) following a known
smooth trajectory across the stage (e.g. a slow circle, radius ~2, y≈1.5);
- marker B: a **blinking** white/bright disc following a different known path, blinking the
frozen protocol below with ID=5.
Markers must be small (≤2 % of frame area) and rendered *after* the existing content so the
full existing suite stays green (offsets, events, poses, SfM tests untouched). Write ground
truth to `data/work/track_truth.json`: `{"markers": [{"marker_key": ..., "points":
[{"t_global_s", "x", "y", "z"}]}]}` (Three.js scene space).
2. **DB:** `tracks` + `track_points` tables (contract #2) + `db.py` helpers
(`add_track`, `get_tracks`, `set_track_points`, `patch_track`, `delete_track`).
3. **API:** `GET /api/tracks`, `PATCH /api/tracks/{id}` `{label?, color?}`,
`DELETE /api/tracks/{id}`, `POST /api/tracks/solve` (dispatches into the lane-H stub with
the house degradation: valid empty result + note while stubbed). Manifest gains
`"has_tracks": bool` — update `test_api.py`'s key-set lock consciously in the same commit.
4. **CLI:** `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
5. **Backend stubs:** `tracker_detect.py` (`run_detect()`), `tracker_solve.py`
(`run_solve()`) — `NotImplementedError` with the full contract in the docstring.
6. **Frontend:** stub `frontend/src/friendTracks.js` imported from `main.js`, `init()` +
per-frame `friendTracks.update(tGlobal)` wired into the master loop (fx.js pattern);
hidden **👣 Friends** button `#btn-friends` + empty `#friends-panel` container in
`index.html`; `state.tracks = []` loaded at boot when `manifest.has_tracks`.
7. **Capsule:** add `api/tracks` to `capsule.py`'s bake list (additive; update lane G's
bundle-structure test consciously in the same commit, CR-style note in the commit message).
8. Tests for every new route shape; suite ≥ 189 + new tests. Merge fast — three lanes wait.
### M19 — Marker detection (lane H, backend)
Fill `tracker_detect.py`. `run_detect()`: sample frames (~8 fps via `frames.py` machinery) from
every video; detect markers two ways: **color mode** (HSV blob of the frozen marker hues;
`marker_key = "hue:<bucket>"`) and **blink mode** (per-blob brightness over a sliding window,
decode the frozen OOK protocol; `marker_key = "code:<id>"`). Write
`data/work/detections.json` (contract #3). OpenCV (`opencv-python-headless`) is already a
dependency. No markers → valid-empty detections, never a crash.
**Accept:** on the synthetic fixture, ≥90 % of sampled frames where a marker is visible yield
a detection within 3 px (normalized ≤0.008) of the projected ground-truth center; blink ID 5
decoded; tests in `test_tracker_detect.py`.
### M20 — Track solving (lane H, backend)
Fill `tracker_solve.py`. `run_solve()`: load detections; group by `marker_key`; per time bucket
(≈0.25 s) collect same-key detections across cameras at matching `t_global`; **2+ views →
triangulate** through `geometry` primitives (the `resolve.py` pattern — never inline pose
math); **1 view → ray ∩ ground plane** (contract #5); smooth each track (moving average or
Catmull-Rom, your call — document it); write `tracks`/`track_points` through `db` helpers;
solid color for hue markers, palette color for code markers. `POST /api/tracks/solve` and the
CLI now run detect→solve end-to-end.
**Accept:** synthetic recovery — median 3D error of both tracks < 0.3 scene units against
`track_truth.json`, ≥80 % temporal coverage of each marker's visible span; single-view
fallback exercised by a test that hides all-but-one camera's detections; degradation (no
detections → zero tracks + note); tests in `test_tracker_solve.py`.
### M21 — Ribbons, moving labels, friends panel (lane J, frontend)
Fill `friendTracks.js`. On init (when `manifest.has_tracks`): unhide 👣; fetch
`/api/tracks` into `state.tracks`. Toggle renders per-track: a **ribbon** polyline through the
points (track color) + a **moving marker** (sphere + label sprite, position interpolated at
the current `t_global`, hidden outside the track's time span) — objects via
`scene3d.addObject/removeObject` only, registered with `scene3d.registerHelper` so photo mode
hides them. Panel in `#friends-panel` (anchorPanel.js is the style/UX reference — read it,
don't edit it): list tracks (dot, label, time span), rename/recolor via
`PATCH /api/tracks/{id}` (controls carry `write-ui`), delete, and a **Follow** button per row.
**Accept:** live-browser evidence — ribbons + moving labels track the synthetic markers in
play and scrub; rename/recolor/delete round-trip; photo mode hides ribbons; capsule mode
hides write controls but keeps ribbons/follow.
### M22 — Follow-a-friend camera (lane J, frontend)
Chase cam on any track: `scene3d.enterPathMode()` hands you the camera (the camPath.js
pattern — read it); every `update(tGlobal)` compute the track position, place the camera at a
smoothed offset (behind/above, ~4 units, low-pass the position so it glides), look at the
friend (`applyCameraPose`). Esc / Free-roam / snap-to-camera exits (pathMode already handles
contention — mirror camPath's "someone else took the camera" check).
**Accept:** live-browser evidence — camera glides after the moving marker in play and scrub;
clean handoff to/from free-roam, snap-to-cam, and camPath playback; no fighting.
### M23 — LED badge firmware + hardware kit (lane K, hardware/docs)
`hardware/badge/badge.ino` — ESP32/ATtiny-compatible Arduino sketch blinking the frozen OOK
protocol (contract #4): configurable 6-bit ID, bright single LED or WS2812; plus
`hardware/beacon/beacon.ino` — the sync/calibration beacon (distinct reserved ID 63,
double-brightness). `docs/hardware.md`: parts list (boards, LEDs, battery, diffuser), build
photos placeholders, wearing guidance (chest/hat height, diffusion, spacing), and the
camera-side truth: what ranges/framerates the protocol survives (≥24 fps, exposure caveats).
**Accept:** sketches compile (`arduino-cli compile` for esp32 + attiny if available — else
document exact compile commands and mark unverified); protocol constants byte-identical to
contract #4 (import/duplicate them in ONE header with a test-facing copy check documented).
### M24 — Bench self-test tool (lane K, backend script)
`scripts/badge_selftest.py`: given a short phone clip of a badge on a desk, run the SAME
decoder path as lane H (`tracker_detect` blink mode) and print the decoded ID + confidence —
John's hardware smoke test before a shoot. Works on a synthetic self-test too:
`--synthetic` renders a blinking clip via the fixture helpers and asserts round-trip.
**Accept:** `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID;
clear failure text for: no blob, ID mismatch, too-short clip. Small test in
`test_badge_selftest.py` (synthetic path only).
## Contracts (frozen at foundation3 merge)
1. **Marker hues (color mode):** magenta `#ff00ff` and cyan `#00ffff` are the two supported
fixture hues; real shoots may configure others via `FESTIVAL4D_MARKER_HUES` (comma-sep hex).
2. **Schema:**
`tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)`
`track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
quality FLOAT, views INTEGER)` — points ordered by `t_global_s`; `views` = cameras used
(1 = ground-plane fallback); coordinates in **Three.js scene space**.
`GET /api/tracks``[{id, marker_key, label, color, points: [{t_global_s,x,y,z,quality,views}]}]`.
3. **detections.json:** `{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
"cx": float, "cy": float, "area": float, "conf": float 0..1}]}, "generated_by":
"festival4d.tracker_detect"}` — `cx`/`cy` normalized 0..1 (bbox-annotation convention).
4. **Blink protocol (OOK, camera-decodable):** bit period **133 ms** (4 frames @ 30 fps);
word = preamble `11100` + 6-bit ID (MSB first) + even parity = 12 bits, repeated
continuously (~1.6 s/word). ID 63 reserved for the sync beacon. Decoder needs ≥24 fps
video and ≥1 full word of visibility.
5. **Ground plane:** the synthetic fixture's ground is `y = 0` (scene space). For real
projects the solver estimates it as the 5th-percentile-y horizontal plane of the point
cloud and records it in its result dict. Single-view fallback intersects the pixel ray
with this plane; such points get `views = 1` and `quality ≤ 0.5`.
6. **Frozen during 6b:** everything frozen in phases 15 **plus** all phase-5 lane modules
(`director.js`, `exportVideo.js`, `anchorPanel.js`, `photoMode.js`, `fx.js`,
`pathsStore.js`, `audio_features.py`, `director.py`, `capsule.py`) and foundation3's edits
(`synthetic.py`, `main.js`, `index.html`, api/cli/db). Lanes fill their own new files only.
Believe a frozen file must change → `plan/CHANGE_REQUESTS.md`, work around, integration3
adjudicates. Stub-era test assertions superseded by your milestone landing follow the
CR-1/-4/-5 pattern: minimal edit + CR entry.
## integration3 scope (Phase 6c)
Merge order irrelevant; resolve CRs; full synthetic pass (evidence per milestone); raise the
suite floor; **tracked field test** — real clips with at least one badge/marker in frame
(combine with the still-open phase-5 field test); failures → `plan/ISSUES.md`; propose
`v0.3.0` (with `v0.2.0` if the phase-5 field test passes in the same session).
## File ownership (Phase 6b)
| Owner | Files |
|---|---|
| Lane H | `backend/festival4d/tracker_detect.py`, `tracker_solve.py`, `backend/tests/test_tracker_detect.py`, `test_tracker_solve.py` |
| Lane J | `frontend/src/friendTracks.js` |
| Lane K | `hardware/**`, `docs/hardware.md`, `scripts/badge_selftest.py`, `backend/tests/test_badge_selftest.py` |
| Every agent | its own `plan/status/<lane>.md` |
| **Coordinator only** | `plan/DIRECTIVES.md` |
| **Frozen during 6b** | contract #6 above |
## Phase-6 pitfalls
1. **Time alignment is the whole game.** Detections happen at `t_video`; association happens
at `t_global`. One `t_video_from_global` inversion mistake silently shears every track.
Use the frozen helpers; test association against ground truth, not against itself.
2. **Stage lighting lies about color.** The color detector must gate on saturation+value, not
hue alone, and the acceptance is on the *synthetic* fixture — note in your status file that
real-world hue robustness is a known field-test risk (that's why blink mode exists).
3. **Blink decoding vs. video fps:** cameras at 2460 fps sample the 133 ms bit period
differently. Decode by integrating brightness per bit window using the video's actual fps
(`meta.fps`), never by counting frames.
4. **Don't fight the camera owners.** Follow-mode (M22) shares `pathMode` with camPath —
mirror camPath's contention checks exactly, or two drivers will interleave writes.
5. **rAF throttle (standing):** verify with the `__f4d` pump in hidden panes
(`localStorage f4dDebug=1`); the WebAudio timer exemption gives you ~50 Hz `setTimeout`.
6. **Fixture compatibility:** foundation3's synthetic edit runs UNDER the existing 189 tests —
markers must not move offsets, events, poses, or break SfM-adjacent tests.
## Kickoff prompts (copy-paste one per Opus 4.8 session, in run order)
**foundation3 (first, alone):**
> Clone ssh://git@100.71.119.27:222/monster/festifun.git. Read OPUS_BUILD_INSTRUCTIONS.md,
> plan/20-phase5.md, plan/30-phase6.md (M18 is yours), and the latest round of
> plan/DIRECTIVES.md. Execute foundation3 on branch `foundation3`; maintain
> plan/status/foundation3.md per plan/status/TEMPLATE.md; suite ≥ 189 + your tests; push the
> branch and report — the coordinator reviews and merges.
**lane H / lane J / lane K (parallel, after foundation3 merges):**
> Clone ssh://git@100.71.119.27:222/monster/festifun.git and create your own git worktree
> before any edit. Read OPUS_BUILD_INSTRUCTIONS.md, plan/30-phase6.md, plan/lane-<X>.md, and
> the latest round of plan/DIRECTIVES.md. Execute your lane on branch `lane/<x>-<name>`;
> maintain plan/status/lane-<X>.md; obey the ownership table and frozen-files contract; push
> your branch and report — the coordinator reviews and merges. Do not merge to main.

View File

@ -72,64 +72,3 @@ 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:** **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 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:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Same
supersession pattern as CR-1/CR-4; the shape-locked director test is stronger than the
stub assertion it replaces. Suite 169 → **189** on the merged tree.
### CR-6 — foundation3 — 2026-07-17 — Conscious test-lock updates for the two additive M18 contract fields
- **File:** `backend/tests/test_api.py::test_manifest_shape` (manifest exact-set lock) and
`backend/tests/test_capsule.py::test_bundle_structure` (baked-paths list).
- **Problem:** Not a supersession — these are the two *deliberate* lock edits M18 mandates
(spec `plan/30-phase6.md` steps 3 & 7). foundation3 adds an additive manifest field
`has_tracks` (drives the frontend loading friend tracks) and an additive capsule bake
`api/tracks` (so ribbons + follow work zero-backend). The manifest key-set lock is
intentionally exact ("has already caught two silent drifts"), and the capsule
bundle-structure test enumerates baked paths, so both must be updated **consciously in the
same commit** as the source change — the spec calls for exactly this + a CR note.
- **Proposed change:** (a) add `"has_tracks"` to the `test_manifest_shape` exact-set and assert
it is `False` on the live synthetic build (no solver has run — solving is lane H); (b) add
`"api/tracks"` to the `test_bundle_structure` baked-paths tuple so the test now asserts the
new read-only bake exists. No behavior removed; both are stronger post-edit.
- **Workaround in place:** N/A — these are foundation-sanctioned edits to test files (not to a
frozen *contract* file: `db.py`/`api.py`/`capsule.py` were extended additively per M18, and
`synthetic.py`'s marker code is a coordinator-sanctioned M18 edit). Applied directly in the
foundation3 commit per the house pattern; flagged here for integration3 to ratify.
- **Decision:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Both edits are
the spec-mandated conscious lock updates (30-phase6.md steps 3 & 7), additive and stronger
post-edit. Suite 189 → **209** on the merged tree.

View File

@ -4,76 +4,6 @@ Written **only** by the coordinator (the human's planning session). Agents: read
---
## Round 6 — 2026-07-17 — PHASE 6 planned: Friend Tracks (M18M24)
**Spec:** `plan/30-phase6.md` (canonical). Lane briefs: `plan/lane-H-tracker.md`,
`plan/lane-J-ribbons.md`, `plan/lane-K-badges.md`. Kickoff prompts are at the bottom of the
spec — the human launches each Opus session with one.
**Directives:**
1. **Now: `foundation3` only** (one agent, branch `foundation3`, M18). Contracts correctness
beats speed — three lanes hard-depend on your fixture ground truth, schema, detections
shape, and blink protocol. The synthetic-fixture edit must keep all 189 existing tests
green (spec pitfall #6). Maintain `plan/status/foundation3.md`. **Push your branch; do NOT
merge to main** — this phase the coordinator merges after review (see #4).
2. **After foundation3 merges — lanes in priority order** (all three concurrently if capacity
allows): **H** (tracker — longest, validates every contract), **J** (ribbons/follow — the
visible payoff), **K** (badges — independent, hardware-facing). Per-agent worktrees remain
MANDATORY.
3. **Merge policy (changed from Round 4):** agents do NOT merge to main and do NOT tag.
Push your lane branch + status file, report completion to the human; the human pings the
coordinator session, which reviews (independent suite re-run, ownership check, CR
adjudication) and merges in completion order. Everything else from Round 0 protocol
(status cadence, blocker rule, evidence discipline) unchanged.
4. **Review loop:** coordinator reads `plan/status/<lane>.md` + the branch diff on each ping;
findings come back as a new DIRECTIVES round or direct merge. Stub-era assertions
superseded by a landing milestone: CR entry + minimal edit (CR-1/-4/-5 house pattern).
5. **integration3** (after all three merge): full synthetic pass, floor raise, tracked field
test (fold in the still-open phase-5 field test), `v0.3.0` proposal. `v0.2.0` remains held
per Round 5.
**For the human:** (a) launch foundation3 first, alone, with its kickoff prompt; tell the
coordinator when its status file says ready; (b) then launch H, J, K in parallel; (c) the
field-test gate is unchanged — real clips in `data/raw/`, now ideally with a marker or badge
in frame; (d) if you order badge parts, `docs/hardware.md` (lane K) will have the list.
## Round 5 — 2026-07-17 — PHASE 5 COMPLETE on synthetic; floor 189; v0.2.0 waits on the field test
**Execution report (all same-day):** foundation2 → lanes E/G/F in parallel worktrees (per
directive 3) → integration2. Every lane merged after independent coordinator verification
(suite re-run in the lane's worktree, then on the merged tree, live-browser checks per
feature). Full evidence: plan/status/{foundation2,lane-E,lane-F,lane-G,integration2}.md.
**Accepted:** M10M17 all pass acceptance on the synthetic fixture. Highlights: beats recover
the 120 BPM ground truth (worst beat 30 ms); director output is beat-snapped and shape-locked
through frozen geometry helpers; export produced a 13.98 s vp9/opus webm (±5% ✓, audio track
live) driven headless via the `__f4d` pump; the capsule boots with ZERO off-origin requests,
seeks over Range, hides all write UI. Lane F self-verified live in isolated servers — model
evidence discipline, worth repeating.
**Ratified:** CR-4 (lane G) and CR-5 (lane E) — stub-era test assertions superseded by
milestones landing, CR-1 precedent. **The test floor is now 189.** Build stays clean.
**Standing decisions:**
1. **v0.2.0 tag is HELD** until the real-footage field test runs (it is integration2's actual
milestone; synthetic-only doesn't earn the tag). Failures go to plan/ISSUES.md, not fixes.
2. Stub-era assertions in foundation tests are now an acknowledged lifecycle: when a milestone
lands, retiring its stub assertion via a CR entry + pointer to the real coverage is the
house pattern (CR-1/-4/-5). Foundation agents: prefer writing stub tests that survive the
landing (assert shape, not stub-ness) so future lanes don't need CRs.
3. `.gitignore` gained `data/capsule/` and `.claude/worktrees/`.
**For the human (the entire remaining critical path):**
(a) **Drop 24 real overlapping clips into `data/raw/`** — the field test and v0.2.0 gate on
this and only this.
(b) Focused-window eyeball pass (10 min, headphones): sync panel stays green during playback;
🎧 spatial audio while flying around; 🎬 auto-path ride; ⏺ export and CHECK THE FRAMES
(headless verified duration/audio only — rendered frame quality needs human eyes);
📷 photo PNG; ✨ FX bursts at full rAF rate; anchor panel roundtrip.
(c) Optional but high-impact: train a splat for the real project
(`scripts/splat_via_modelbeast.sh`) before capsuling it — photo mode + capsule get
dramatically better.
## Round 4 — 2026-07-17 — Fresh-eyes upgrades landed; PHASE 5 begins (M10M17)
**Coordinator work landed directly on `main` (self-reviewed + live-verified):**

View File

@ -2,12 +2,9 @@
**Read [`../OPUS_BUILD_INSTRUCTIONS.md`](../OPUS_BUILD_INSTRUCTIONS.md) first — it is the canonical spec** (architecture, repo layout, data model, milestone details M0M9, pitfalls). This directory only organizes *who builds what, in what order, without colliding*. Lane briefs reference spec milestones by number; they do not restate them.
> **Current phase: 6** (M18M24, Friend Tracks) — spec and organization in
> [`30-phase6.md`](30-phase6.md), lane briefs `lane-H/J/K-*.md`, run order in DIRECTIVES
> Round 6 (note: this phase the **coordinator merges**, agents only push branches).
> Phases 15 below are complete (v0.1.0 shipped; phase 5 M10M17 accepted on synthetic,
> suite floor 189, v0.2.0 held for the field test); their protocol sections (git, status,
> directives loop, evidence discipline) remain the standing rules.
> **Current phase: 5** (M10M17) — spec and organization in [`20-phase5.md`](20-phase5.md), lane
> briefs `lane-E/F/G-*.md`. Phases 14 below are complete (v0.1.0 + polish); their protocol
> sections (git, status, directives loop, evidence discipline) remain the standing rules.
## Phase map

View File

@ -1,37 +0,0 @@
# Lane H — Tracker (M19 detection, M20 solving)
You turn wearable markers in the videos into 3D friend tracks. Backend only.
Spec: `30-phase6.md` M19/M20 + contracts #1#5. Branch `lane/h-tracker`, own worktree.
## What foundation3 gives you
- Stubs `tracker_detect.py` / `tracker_solve.py` with the contracts in their docstrings;
API (`POST /api/tracks/solve` etc.), CLI (`track`), DB helpers, and the synthetic fixture's
two moving markers + `data/work/track_truth.json` ground truth — all already wired.
- You fill the two stub bodies and write the two test files. Nothing else.
## M19 — detection, the parts that bite
- Sample frames with the `frames.py` machinery (~8 fps is plenty; document your rate).
- Color mode: convert to HSV, gate on **saturation AND value** before hue (pitfall #2);
connected components; centroid → normalized cx/cy. Hue buckets from contract #1 +
`FESTIVAL4D_MARKER_HUES`.
- Blink mode: track candidate bright blobs across the sampled window, integrate brightness
per 133 ms bit window **using the video's real fps** (pitfall #3), correlate against the
preamble, check parity, emit `code:<id>`.
- Empty/dark/markerless video → valid-empty detections. Never crash.
## M20 — solving, the parts that bite
- `t_video → t_global` through the frozen helpers ONLY (pitfall #1).
- Triangulation through `geometry` primitives exactly as `resolve.py` does — read it first.
- Bucket ≈0.25 s; 2+ cameras → triangulate (record `views`, `quality` from residual);
1 camera → ray ∩ ground plane per contract #5 (`views=1`, `quality ≤ 0.5`).
- Smooth per track; write through `db` helpers; re-solve replaces that marker's tracks
(idempotent — don't stack duplicates on every run).
## Acceptance (evidence in plan/status/lane-H.md)
- [ ] Detection: ≥90 % recall on visible synthetic markers, ≤0.008 normalized center error;
blink ID 5 decoded (`test_tracker_detect.py`)
- [ ] Solve: median 3D error < 0.3 vs `track_truth.json`, 80 % coverage; single-view
fallback test; idempotent re-solve; degradations (`test_tracker_solve.py`)
- [ ] CLI + `POST /api/tracks/solve` run detect→solve end-to-end on the fixture
- [ ] Suite ≥ foundation3's count + yours, green; stub-era assertions superseded by your
landing → CR entry (CR-1/-4/-5 pattern)

View File

@ -1,38 +0,0 @@
# Lane J — Ribbons & follow-a-friend (M21, M22)
You make tracks visible and rideable. Pure frontend — ONE file: `frontend/src/friendTracks.js`.
Spec: `30-phase6.md` M21/M22 + contract #2. Branch `lane/j-ribbons`, own worktree.
## What foundation3 gives you
- Your stub module imported from main.js with `init()` + per-frame `update(tGlobal)` already
called; hidden `#btn-friends` + `#friends-panel` in index.html; `state.tracks` loaded at
boot when `manifest.has_tracks`.
- Read-only building blocks you may CALL but never edit: `scene3d.addObject/removeObject`,
`registerHelper` (photo mode hides you for free), `enterPathMode`/`exitPathMode`/
`applyCameraPose` (the camPath.js pattern — read that file first), `focusOn`;
`PATCH/DELETE /api/tracks/{id}`. anchorPanel.js is your UX/style reference (inject your own
<style> tag; reuse the CSS vars). Mark mutating controls `write-ui`.
## M21 — ribbons + panel, the parts that bite
- Interpolate the moving marker between track points at the current tGlobal (linear is fine);
HIDE it outside the track's span — don't park it at the ends.
- Rebuild ribbons only on tracks-changed, not per frame; per-frame work = one position lerp
per visible track (fx.js shows the hot-path discipline).
- Empty tracks (solver not run) → panel says so, no errors; capsule mode: ribbons + follow
stay, write controls hidden.
## M22 — follow cam, the parts that bite
- Mirror camPath's contention handling EXACTLY: if `scene3d.pathMode` goes false under you
(snap-to-cam, Esc, free-roam), stop following without a fight (pitfall #4).
- Low-pass the camera position (e.g. lerp factor ~0.08/frame) so it glides; look at the
friend, offset ~4 units behind/above; scrubbing far must not whiplash (clamp the lerp on
big jumps: teleport if the target moved > ~5 units).
- Starting a camPath playback or clicking 🎬 must cleanly cancel follow mode (and vice versa).
## Acceptance (evidence in plan/status/lane-J.md — you likely can't drive a browser;
write defensively, list exactly what the coordinator must verify live)
- [ ] Ribbons + moving labels track the synthetic markers in play AND scrub
- [ ] Panel: rename/recolor (PATCH) + delete round-trip; `write-ui` on mutators
- [ ] Follow glides after a friend; clean handoffs with free-roam / snap / camPath / 🎬
- [ ] Photo mode hides ribbons (registerHelper); capsule keeps read-only features
- [ ] `npm run build` clean; backend suite unchanged and green

View File

@ -1,40 +0,0 @@
# Lane K — LED badges & bench kit (M23 firmware+docs, M24 self-test)
You build the wearable side: badge firmware speaking the frozen blink protocol, the hardware
guide, and the bench self-test John runs before a shoot. Spec: `30-phase6.md` M23/M24 +
contract #4. Branch `lane/k-badges`, own worktree.
## M23 — firmware + docs, the parts that bite
- Contract #4 is FROZEN: 133 ms bit, `11100` preamble + 6-bit ID + even parity, continuous
repeat, ID 63 = sync beacon. Keep the constants in ONE header (`hardware/badge/protocol.h`)
shared by badge.ino and beacon.ino; document the manual-sync rule with
`tracker_detect.py`'s decoder constants (can't import across languages — state both
locations in the header comment and in docs/hardware.md).
- badge.ino: plain Arduino APIs (millis()-paced, no delay()-drift), single bright LED on a
PWM pin OR a WS2812 (compile-time #define), ID set by #define (document per-badge flashing).
Hardware is never ideal on paper: expose a `BIT_TRIM_US` calibration knob for clock drift.
- beacon.ino: ID 63, double LED brightness/size guidance.
- docs/hardware.md: parts list with rough prices (ESP32 dev board / ATtiny85, LED + resistor
or WS2812, LiPo/coin cell, diffuser ping-pong ball), wiring, flashing (`arduino-cli`
commands), wearing guidance (chest/hat, diffusion, one badge per person, ≥24 fps cameras,
exposure caveat: blown-out video kills OOK contrast).
- Compile check: `arduino-cli compile --fqbn esp32:esp32:esp32` (and attiny if the core is
installable). If arduino-cli isn't available in your environment, document the exact
commands and mark the box unverified — do NOT claim compiled without output.
## M24 — bench self-test, the parts that bite
- `scripts/badge_selftest.py` must run lane H's REAL decoder (`tracker_detect` blink path) —
import it, don't re-implement; if lane H hasn't merged yet, code against the frozen
contract #3/#4 shapes and mark the integration box pending (integration3 re-runs it).
- `--synthetic` mode: render a small blinking clip using the fixture's marker-rendering
helpers (foundation3 exposes them in synthetic.py) and assert the ID round-trips.
- Clear, John-readable failure messages: no blob found / decoded wrong ID / clip too short
(< 1 word). Exit codes: 0 ok, 1 decode-fail, 2 usage.
## Acceptance (evidence in plan/status/lane-K.md)
- [ ] protocol.h constants byte-identical to contract #4; badge + beacon sketches compile
(output pasted) or compile commands documented + marked unverified
- [ ] docs/hardware.md complete enough that John can order parts from it
- [ ] `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID
(`test_badge_selftest.py` covers it); failure paths print actionable text
- [ ] Backend suite green; `npm` untouched

View File

@ -1,57 +0,0 @@
# Status — foundation3 (Phase 6a / M18)
## Round 6 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** round 6 of plan/DIRECTIVES.md. Directive #1: `foundation3` only,
branch `foundation3`, push — do NOT merge. Contracts correctness beats speed; keep 189 green.
**Acceptance checklist** (M18, plan/30-phase6.md §M18):
- [x] 1. Synthetic fixture: two moving markers (A magenta `hue:150`, B blink `code:5`)
composited through each camera's real pose projection; `data/work/track_truth.json`
built from the SAME 3D paths (`synthetic.build_track_truth`, Three.js scene space).
`synthetic.project_point` is the exact inverse of `geometry.ray_from_pixel`. Base
`testsrc2` is saturation/brightness-muted (`BASE_SATURATION`/`BASE_BRIGHTNESS`) so its
own magenta/cyan colour bars don't swamp lane H's colour detector — the discs are the
only high-sat magenta/cyan + only bright small blobs (verified by a real-render test).
- [x] 2. DB: `Track`/`TrackPoint` tables (contract #2) + `add_track`/`get_tracks`/`get_track`/
`get_track_points`/`set_track_points`/`patch_track`/`delete_track`/`has_tracks`.
- [x] 3. API: GET /api/tracks, PATCH/DELETE /api/tracks/{id}, POST /api/tracks/solve (degrades
to valid empty + note while lane H stubbed), manifest `has_tracks`. Key-set lock edit → CR-6.
- [x] 4. CLI: `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
- [x] 5. Backend stubs: `tracker_detect.py`/`tracker_solve.py` — `NotImplementedError` + full
contracts #1#5 in docstrings; foundation-owned frozen constants (blink OOK, hue keys)
live in `tracker_detect` as the single source of truth (`synthetic` imports them).
- [x] 6. Frontend: `friendTracks.js` stub (never-throws, fx.js pattern) wired into `main.js`
loop; hidden `#btn-friends` + `#friends-panel`; `state.tracks` loaded when `has_tracks`.
`npm run build` clean (27 modules, 851 ms).
- [x] 7. Capsule: `capsule.py` bakes `api/tracks` (additive). Bundle-structure test edit → CR-6.
- [x] 8. Tests: `test_tracks_foundation.py` (20) — DB helpers, routes, solve degradation,
manifest, frozen protocol/encoders, track_truth shape, the **projection round-trip**, and
a real-ffmpeg render test proving marker A is the dominant magenta blob after base-muting.
**Done this round:** all 8 steps land. Evidence:
- Full suite **209 passed, exit 0** (`uv run pytest -q`) — the 14 pre-existing files sum to
exactly 189 (floor held), + 20 new in `test_tracks_foundation.py` (all passed, none skipped).
- `test_projection_round_trip_recovers_ground_truth`: projects both markers' 3D truth through
every camera pose, back-projects via frozen `ray_from_pixel`, triangulates via
`triangulate_rays`**worst recovery error < 1e-3** across ≥8 samples in 2+ views. Lane H's
M20 target (median 3D error < 0.3) is reachable by construction (~300× margin).
- `encode_word(5) == [1,1,1,0,0,0,0,0,1,0,1,1]`; all 64 IDs preamble+parity+MSB-recover checked.
- Frontend production build clean; manifest `has_tracks` reflects the DB live.
- CR-6 filed (plan/CHANGE_REQUESTS.md): the two sanctioned M18 test-lock edits (manifest key-set,
capsule bundle paths) — additive, flagged for integration3 ratification.
**Recovery note (2026-07-17):** a prior foundation3 attempt left M18 half-done and **uncommitted**
in a stale locked worktree — steps 2/3/4/5/7 (DB/API/CLI/stubs/capsule) + the `synthetic.py`
marker code present, but step 1's ground-truth never validated, steps 6/8 (frontend/tests) not
started, suite never run. This session salvaged it: committed the WIP as checkpoint `6a703c3`,
then completed steps 1/6/8 and verified every inherited contract choice against the spec. The
one real bug found: the base `testsrc2` visual carried solid magenta+cyan bars (the marker hues)
that would have swamped lane H's colour detector — fixed by muting base saturation/brightness
and locked with a real-render regression test (marker A dominant-blob within 2.5 px, 24/24
frames). No work was redone. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).
**Blockers / questions for coordinator:** none. Ready for review + merge.
**Next:** coordinator reviews `git diff main..foundation3` + this file, merges to `main`.
After merge, lanes H / J / K launch in parallel worktrees (DIRECTIVES R6 #2).

View File

@ -1,49 +0,0 @@
# Status — integration2
## Round 1 — 2026-07-17 — STATUS: merged (synthetic pass complete; field test GATED on real clips)
**Directives acknowledged:** Round 4. Single-agent phase 5c, main working directory on
branch `integration2`. Coordinator and integration agent are the same session this round.
**Merge state:** foundation2, lane/g-capsule, lane/f-social, lane/e-director2 all merged to
`main` in completion order; every merge independently re-verified (suite re-run in the lane
worktree before merging, then on the merged tree). CR-4 and CR-5 ratified at merge (CR-1
precedent; decisions recorded in plan/CHANGE_REQUESTS.md). Suite: 127 → 169 → **189 passed**;
`npm run build` clean at every step.
**Full-system synthetic pass — evidence per milestone:**
- **M10 (beats):** `ingest → features` on the repo project: 29 beats, tempo **120.0 BPM**
(ground truth 120), beat grid at 0.5 s spacing (~+20 ms bias, well inside ±50 ms), 40
onsets → `data/work/beats.json` matches contract #1.
- **M11 (director):** CLI `direct --top-n 4` emits frozen-shape camPath JSON, keyframe times
ON the beat grid (3.02, 4.02, …). Live browser: 🎬 visible, click → **10 keyframes loaded,
all beat-snapped (≤11 ms), path auto-plays** (⏹ Path state).
- **M12 (export):** live browser via the `__f4d` pump at 50 Hz (hidden pane, rAF=0 — pitfall
#2 workaround; WebAudio grants the timer exemption): full-length export auto-stopped at the
last keyframe → `video/webm;codecs=vp9,opus`, **duration 13.98 s vs 14.0 expected (±5% ✓)**,
audio track present and decoding (webkitAudioDecodedByteCount 28 770 after 1.5 s), state
restored, ⏺ re-armed. NOT verifiable headless: rendered-frame quality (needs a focused
window — flagged for the human).
- **M13M15 (lane F):** lane self-verified live in isolated servers (see plan/status/lane-F.md
— full create→rename→recolor→jump→delete roundtrip, 3840×3093 helper-free PNG, both FX
types, 0.101 ms/frame worst case). Coordinator re-check on merged main: all modules
ready=true, buttons visible, anchor panel lists 4 anchors with tag/jump/edit/delete, FX
fires on a scrub crossing of bass_drop, **zero console errors**.
- **M16 (paths):** live: save (via module's own prompt flow) → dropdown refreshes → load
reproduces keyframes **exactly** (deep-equal on toJSON) → delete refreshes. 30 lane tests.
- **M17 (capsule):** baked from the real dist (19 files, 6.8 MB), served by bundle
`serve.py`: app boots in the browser with **zero off-origin requests**
(performance.getEntriesByType — nothing to :8000), `capsule: true`, all `.write-ui` hidden,
**video seek to t=10 exact** (Range/206 over serve.py), `/api/audio/1` → 200 (WebAudio
clock live), 4 anchors + 7 events + point cloud + timeline all present. Screenshot on file.
**Housekeeping:** `.gitignore` now covers `data/capsule/` and `.claude/worktrees/`
(lane G's note).
**Field test:** NOT RUN — gated on 24 real overlapping clips in `data/raw/` (the standing
human item since Round 2). Per the adjusted plan (DIRECTIVES Round 5) the **v0.2.0 tag waits**
for the field test; the synthetic integration pass is complete and accepted.
**Blockers:** none for code. Human items listed in DIRECTIVES Round 5.
**Next:** field test on real footage when clips land → ISSUES.md entries → v0.2.0.

View File

@ -1,66 +0,0 @@
# Status — lane-E (director & export, phase 5b — M10/M11/M12)
## Round 1 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** Round 4 of plan/DIRECTIVES.md (phase 5 begins; foundation2 merged;
suite floor 121 — was 127 at lane start after foundation2's tests).
**Branch:** `lane/e-director2` (own worktree per Round-4 rule). Commits: `0f8685b` M10,
`52e8912` M11, `ddb8dea` M12. Not merged, not pushed — coordinator merges.
**Acceptance checklist**
- [x] **M10** beats/onsets: synthetic 120 BPM pulse grid recovered — tempo **120.000** (±2 req),
worst beat **30 ms** off grid (±50 req) — `test_run_features_recovers_synthetic_pulse_grid`;
the 3 ground-truth bangs found as strong onsets — `test_onsets_catch_the_ground_truth_bangs`;
silence/short → valid-empty JSON, missing WAV → note + nothing written, beatless noise →
valid typed result, CLI end-to-end — 7 tests in `test_audio_features.py`. Key impl detail:
10 ms analysis hop (librosa's default 32 ms grid at 16 kHz misses the ±50 ms bound; measured
60 ms → 30 ms), tempo from median inter-beat interval (117.19 → 120.000).
- [x] **M11** auto-director: structure-locked against the frozen camPath shape (exact key sets,
unit quats, monotone times) — `test_output_matches_frozen_campath_shape`; top-3 grid ==
`[1,4,8,11,14]` incl. shared-keyframe dedupe; **quat order [x,y,z,w] locked** via
round-trip against frozen `geometry.colmap_to_threejs` + `quat_to_mat`
(`test_keyframe_pose_matches_frozen_conversion`); beat snap puts every key on the grid, no
beats.json / corrupt beats.json → unsnapped (no block); no events / no poses → valid empty
path + note; confidence ties → earlier; lead clamps at 0; unregistered poses excluded; API
route + CLI dispatch real output — 14 tests in `test_director.py`. `director.js` wires 🎬:
POST → `camPath.fromJSON` → auto-play via the existing #btn-path flow; empty path shows
"no events yet" on the button.
- [x] **M12** export (code-complete; needs live browser, below): canvas captureStream(30) +
`transport.captureAudioStream()` tap → MediaRecorder vp9/opus webm (vp8/webm fallbacks),
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 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-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
**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):
1. M11: `python -m festival4d synthetic && ingest && features && serve` + vite dev → 🎬 visible;
click → path loads ( Key counter jumps), camera flies the cut, #btn-path shows ⏹.
With `data/work/beats.json` deleted, 🎬 still works (unsnapped). With events cleared,
button flashes "🎬 no events yet", no console error.
2. M12: with a path loaded, ⏺ → records the ride, auto-stops at the last keyframe, downloads
`festival4d-cut.webm`; `ffprobe` it: duration within ±5% of the path span (top-8 synthetic:
1.0→20.0 s ⇒ 19 s ±0.95), has an opus audio track; plays in Chrome. Then confirm prior
state restored (playhead back, rate back, path button reset). Clicking ⏺ mid-ride stops
early and still yields a valid file. Note: hidden/backgrounded pane records frozen frames.
3. M12 + 🎧: enable spatial audio before ⏺ — exported track carries the spatial mix.
**Blockers / questions:** none beyond CR-4 adjudication.
**Next:** coordinator merge + live checks above; integration2 owns the real-footage pass.

View File

@ -1,101 +0,0 @@
# 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.