Compare commits

...

11 Commits

Author SHA1 Message Date
type-two
bdb931def9 Merge lane/f-social: M13 anchor manager + friend tags, M14 photo mode, M15 moment FX
Coordinator-verified: ownership clean (3 owned modules + status only);
lane self-verified live in isolated servers (evidence in plan/status/lane-F.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:55:10 +10:00
type-two
175ab0f772 M15: moment FX (lane F) + status file
 toggle: fx.update(tGlobal) (already called each frame by main.js) tracks
the previous tGlobal and fires on marker crossings in play AND scrub;
backward jumps and >2s forward jumps just reset the tracker. pyro/confetti ->
~1s additive particle burst at the event's resolved anchor (mapped by the
backend's label convention, stage-centroid fallback); bass_drop -> point
cloud/splat scale pulse for ~a beat (60/tempo via GET /api/beats, else 0.5s),
exact scale restored. Scene access only via scene3d.addObject/removeObject.
Hot path: 6 preallocated burst slots (160 particles each), zero per-frame
allocation, whole body behind a breaker so a throw can never kill the app
loop (it disables FX instead).

Live-verified: pyro fired at t=10.00 in play, confetti on scrub crossing 17.0,
no retro-fire on backward jump, pulse 1.16x -> exact 1.0 restore, worst-case
frame body 0.101 ms CPU (~330x inside the 30fps budget), clean toggle-off
(plan/status/lane-F.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:54:11 +10:00
type-two
1b97086145 M14: photo mode (lane F)
📷 / P: pause if playing, scene3d.setHelpersVisible(false) + one scene3d
update so the per-video rigs honor the flag, then render ONE frame at 3840px
wide (aspect preserved, pixelRatio 1) by temporarily resizing the live
renderer, snapshot via canvas.toBlob in the same task, download
festival4d-photo.png, and restore pixelRatio/size/camera.aspect/helpers/play
state (try/finally). Splat: DropInViewer is a scene child and renders with
the same call — no branch; live splat verification deferred to coordinator.

Live-verified: PNG 3840x3093 (aspect 1.2416 preserved), zero helpers in the
captured image, full restore incl. play state (plan/status/lane-F.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:54:11 +10:00
type-two
13b7424c83 M13: anchor manager & friend tags (lane F)
Collapsible #anchor-panel (toggle  #btn-anchors): every anchor gets a color
dot (recolor via inline color input), label (rename via prompt), one-click
jump-to (scene3d.focusOn), and delete — PATCH/DELETE /api/anchors/{id}, then
state.anchors update + emit("anchors-changed") so scene3d + overlays refresh.

+ Tag drives the FROZEN M8 annotator without an event (annotate.js is
null-event safe by construction; the backend keeps event-less annotations as
independent anchors): prompt a name -> startAnnotateMode -> user drags a bbox
on any video -> the resolved anchor is PATCHed to the name + a palette color.
Esc / + Tag again cancels. All mutating controls carry .write-ui (capsule
mode keeps the read-only list + jump). CSS injected from the module —
index.html is frozen.

Live-verified: create->rename->recolor->jump->delete roundtrip, labels in 3D
+ both video overlays, reload survival, capsule hiding (plan/status/lane-F.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:54:11 +10:00
type-two
4975e3e3a0 CR-4 ratified at merge (coordinator)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:53:57 +10:00
type-two
f29e0dca59 Merge lane/g-capsule: M16 paths UI + tests, M17 memory capsule (CR-4 ratified)
Coordinator-verified: 169 passed independently re-run in the lane worktree;
CR-4 (retire capsule stub assertion) ratified per CR-1 precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:53:25 +10:00
type-two
4bc2638c12 lane G: status — M16+M17 complete, 169 passed, capsule E2E evidence, CR-4 noted
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:33 +10:00
type-two
feb5588a1b lane G M17: memory capsule — zero-backend shareable bundle + Range-capable serve.py
build_capsule copies frontend/dist (clear error if missing), copies referenced media,
bakes every read-only API response extension-less at the live API's relative paths
(manifest with capsule:true + has_capture:false, events, anchors, poses, beats,
pointcloud, splat, audio via the same lazy extract_audio_hq cache, plus M16 saved
paths — listing baked as api/paths/index.html so per-id files can share the prefix),
injects window.__F4D_API_BASE__="" ahead of the app script, and ships a stdlib
serve.py (206 Range slicing incl. suffix ranges + 416, JSON content types for the
extension-less api/* files). Optional artifacts degrade by omission.

test_capsule.py: bundle structure, baked-JSON deep-equal vs the live route functions,
injection position, degrade/overwrite-guard/no-dist errors, CLI dispatch (CR-4), and
serve.py exercised over real HTTP in a subprocess (200/206/416/404, content types,
mp4+m4a magic bytes). Suite: 169 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:48:22 +10:00
type-two
b37f1fab0a lane G M16: deepen paths-route coverage (30 tests)
Roundtrip fidelity (float precision, 500-keyframe lists, extra keys ride along),
multiple coexisting paths + id-ordered listing, duplicate/unicode/whitespace names,
ISO created_at, 404s, and an 18-case 422 matrix over the frozen camPath contract.
Cleans up after itself so test_phase5_api still sees an empty table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:48:22 +10:00
type-two
1c0422a025 lane G M16: pathsStore — save/load/delete server-side camera paths UI
Unhides #btn-path-save / #path-load-select / #btn-path-del; save prompts a name and
POSTs camPath.toJSON(), load fetches + camPath.fromJSON, delete confirms; dropdown
refreshes after every mutation. Capsule mode: write buttons hide via write-ui, the
load dropdown keeps working against baked api/paths (hides itself if unavailable).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:42:19 +10:00
type-two
0314daec1a lane G: CR-4 — retire the obsolete capsule stub assertion (M17 landed; CR-1 precedent)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:42:19 +10:00
11 changed files with 1808 additions and 52 deletions

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,3 +72,20 @@ 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.

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

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

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

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