Merge lane/g-capsule: M16 paths UI + tests, M17 memory capsule (CR-4 ratified)
Coordinator-verified: 169 passed independently re-run in the lane worktree; CR-4 (retire capsule stub assertion) ratified per CR-1 precedent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
f29e0dca59
@ -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
|
Contract: :func:`build_capsule` writes a self-contained bundle to ``out_dir`` (default
|
||||||
``config.CAPSULE_DIR``):
|
``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.
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from festival4d import config, db
|
||||||
|
|
||||||
def build_capsule(out_dir: str | Path | None = None) -> dict:
|
log = logging.getLogger("festival4d.capsule")
|
||||||
"""Build the static shareable bundle (see module doc)."""
|
|
||||||
raise NotImplementedError("memory capsule (lane G / M17)")
|
# 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
|
||||||
|
|||||||
337
backend/tests/test_capsule.py
Normal file
337
backend/tests/test_capsule.py
Normal 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
198
backend/tests/test_paths.py
Normal 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
|
||||||
@ -99,9 +99,10 @@ def test_anchor_patch_label_and_color(client):
|
|||||||
|
|
||||||
|
|
||||||
def test_cli_dispatches_stubs_gracefully():
|
def test_cli_dispatches_stubs_gracefully():
|
||||||
# The three new subcommands exist and degrade per the house pattern: exit code 2,
|
# The new subcommands exist and degrade per the house pattern: exit code 2,
|
||||||
# no traceback, while their lane entrypoints are stubs.
|
# 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
|
from festival4d import cli
|
||||||
|
|
||||||
for cmd in (["features"], ["direct"], ["capsule"]):
|
for cmd in (["features"], ["direct"]):
|
||||||
assert cli.main(cmd) == 2
|
assert cli.main(cmd) == 2
|
||||||
|
|||||||
@ -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 (💾),
|
// 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
|
// #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>}
|
// GET /api/paths/{id} ({..., json: <camPath JSON>}), POST /api/paths {name, json:<text>}
|
||||||
// (422 on invalid), DELETE /api/paths/{id}.
|
// (422 on invalid), DELETE /api/paths/{id}.
|
||||||
//
|
//
|
||||||
// When implementing: unhide the controls, set `ready = true`. Save = prompt for a name,
|
// Save = prompt for a name, POST camPath.toJSON(); load = fetch the selected path and
|
||||||
// POST camPath.toJSON(); load = fetch the selected path and camPath.fromJSON(json); delete =
|
// camPath.fromJSON(json); delete = DELETE the selected id; the dropdown refreshes after
|
||||||
// DELETE the selected id. Refresh the dropdown after every mutation. #btn-path-save and
|
// every mutation. #btn-path-save / #btn-path-del carry `write-ui`, so capsule mode
|
||||||
// #btn-path-del already carry the `write-ui` class, so capsule mode (contract #5) hides
|
// (contract #5) hides them for free; the load dropdown stays — the capsule bakes
|
||||||
// them for free — the load dropdown may stay if you bake paths in, else hide it too.
|
// 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 = {
|
export const pathsStore = {
|
||||||
ready: false,
|
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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -72,3 +72,18 @@ Instead append an entry here and work around it locally; the integration agent a
|
|||||||
pre-existing route/shape/column and the timebase + pose contracts are unchanged; the annotation
|
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).
|
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.
|
- **Decision:** **APPLIED** (phase4, 2026-07-16). Fixes the sole Round-3 wart; strictly additive.
|
||||||
|
|
||||||
|
### CR-4 — lane G — 2026-07-17 — Drop obsolete `capsule` stub assertion in test_phase5_api.py
|
||||||
|
- **File:** `backend/tests/test_phase5_api.py::test_cli_dispatches_stubs_gracefully`
|
||||||
|
- **Problem:** The foundation2 test asserts `cli.main(["capsule"]) == 2`, i.e. the *stub*
|
||||||
|
contract (`build_capsule` raises `NotImplementedError`). With M17 landed the entrypoint is
|
||||||
|
real, so the CLI no longer exits 2 — the assertion fails by design, exactly like CR-1 when
|
||||||
|
lane D landed M7.
|
||||||
|
- **Proposed change:** remove only `["capsule"]` from the loop (features/direct stay — lane E
|
||||||
|
still stubbed) with a comment pointing at the landed coverage. The capsule CLI dispatch is
|
||||||
|
now covered for real in `backend/tests/test_capsule.py` (builds a bundle via
|
||||||
|
`cli.main(["capsule", "--out", ...])`, and asserts the clear no-dist error path).
|
||||||
|
- **Workaround in place:** none possible without perverting `build_capsule` semantics (the
|
||||||
|
spec requires a clear "run npm run build" error, not `NotImplementedError`); applied the
|
||||||
|
minimal edit directly per the CR-1 precedent, flagged here for integration2 to ratify.
|
||||||
|
- **Decision:** (integration2 fills this)
|
||||||
|
|||||||
101
plan/status/lane-G.md
Normal file
101
plan/status/lane-G.md
Normal 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.
|
||||||
Loading…
Reference in New Issue
Block a user