diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d38359b --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: replay-web-install replay-web-build replay-web-dev + +replay-web-install: + cd src/syncfield/replay/_web && yarn install + +replay-web-build: + cd src/syncfield/replay/_web && yarn build + +replay-web-dev: + cd src/syncfield/replay/_web && yarn dev diff --git a/README.md b/README.md index 039219e..7274df6 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,35 @@ resp = requests.post("http://localhost:8080/api/v1/sync/upload", files=files, da print(resp.json()) # {"job_id": "a1b2c3d4"} ``` +## Replay a synced session + +After you've recorded a session and run it through the SyncField sync +service, open the result in a local browser-based viewer: + +```python +import syncfield as sf + +sf.replay.launch("./data/session_2026-04-09T14-49") +``` + +This boots a small HTTP server on `127.0.0.1`, opens your default +browser, and shows: + +- Synchronized multi-stream video playback +- A **Before / After** toggle (`B`) to compare raw vs. synced alignment +- A per-stream sync report (offset, confidence, quality) +- Minimal SVG charts for each sensor stream + +Requires the `replay` extra: + +```bash +pip install 'syncfield[replay]' +``` + +The viewer ships a pre-built React bundle, so end users do not need +Node.js or yarn — those are only needed if you want to hack on the +frontend itself (`make replay-web-dev`). + ## Format Specification This section defines the output format for implementors in other languages. diff --git a/docs/superpowers/plans/2026-04-09-replay-viewer.md b/docs/superpowers/plans/2026-04-09-replay-viewer.md new file mode 100644 index 0000000..4f9d9ef --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-replay-viewer.md @@ -0,0 +1,2892 @@ +# Replay Viewer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `sf.replay.launch(session_dir)` — a local browser-based viewer that opens a saved SyncField session, plays its streams back together, and lets the user toggle between Before (raw) and After (synced) to verify sync quality. + +**Architecture:** Python `[replay]` extra (Starlette + uvicorn) serves a pre-built React SPA over localhost. The SPA is a port of egonaut/web's `DataReviewPage` with Supabase/auth/router stripped out and data fetched from local HTTP endpoints. Pre-built `static/` lives under `src/syncfield/replay/static/` and is committed to the repo so end users never need Node. + +**Tech Stack:** Python 3.9+, Starlette, uvicorn, hatchling. React 19, Vite 8, Tailwind 4, TypeScript, yarn. + +**Spec:** `docs/superpowers/specs/2026-04-09-replay-viewer-design.md` + +--- + +## File structure + +### Python (new) + +| File | Responsibility | +|---|---| +| `src/syncfield/replay/__init__.py` | Public `launch()`, dep-check on import | +| `src/syncfield/replay/loader.py` | `ReplayManifest`, `ReplayStream`, `load_session()` | +| `src/syncfield/replay/_handler.py` | `safe_resolve()` path-traversal guard, custom `RangedFileResponse` if needed | +| `src/syncfield/replay/server.py` | `ReplayServer` class, route definitions | +| `src/syncfield/replay/__main__.py` | `python -m syncfield.replay.server --dev` dev entry | +| `src/syncfield/replay/static/` | Built frontend (placeholder until web build) | + +### Web (new) — `src/syncfield/replay/_web/` + +| File | Responsibility | +|---|---| +| `package.json` | yarn deps and scripts | +| `vite.config.ts` | build outDir = `../static`, dev proxy | +| `tsconfig.json`, `tsconfig.node.json` | TS config (copied from egonaut) | +| `tailwind.config.ts` | Tailwind 4 with egonaut design tokens | +| `index.html` | SPA entry | +| `src/main.tsx` | React root | +| `src/App.tsx` | Replaces `DataReviewPage` shell | +| `src/types.ts` | `SessionManifest`, `SyncReport`, `ReplayStream` types | +| `src/hooks/useReplaySession.ts` | Fetches `/api/session` + `/api/sync-report` | +| `src/hooks/useBeforeAfter.ts` | Mode state + offset lookup | +| `src/components/VideoArea.tsx` | Ported from egonaut, offset-aware | +| `src/components/HeroVideo.tsx` | Ported as-is | +| `src/components/SecondaryVideo.tsx` | Ported as-is | +| `src/components/SyncReportPanel.tsx` | NEW — per-stream offset/quality cards | +| `src/components/BeforeAfterToggle.tsx` | NEW — segmented control + keyboard `B` | +| `src/components/SensorChartPanel.tsx` | NEW — generalized minimal sensor charts | +| `src/components/ContactTimeline.tsx` | Ported, conditional | +| `src/components/TactilePanel.tsx` | Ported, conditional | +| `src/lib/sensorParser.ts` | Ported as-is from egonaut | +| `src/index.css` | Ported tailwind directives + design tokens | + +### Modified + +| File | Change | +|---|---| +| `pyproject.toml` | Add `[replay]` extra, force-include `static/`, update `[all]` | +| `Makefile` | Add `replay-web-install`, `replay-web-build`, `replay-web-dev` | +| `README.md` | Add "Replay a synced session" section | + +### Tests (new) + +| File | Coverage | +|---|---| +| `tests/unit/replay/__init__.py` | Empty marker | +| `tests/unit/replay/conftest.py` | `synthetic_session` fixture | +| `tests/unit/replay/test_loader.py` | `load_session` happy path + edge cases | +| `tests/unit/replay/test_handler.py` | `safe_resolve()` path traversal cases | +| `tests/unit/replay/test_server.py` | Starlette `TestClient` against routes | +| `tests/unit/replay/test_launch_smoke.py` | Background-thread launch + requests | + +--- + +## Phase 1 — Python loader + +### Task 1: Create the `syncfield.replay` package skeleton + +**Files:** +- Create: `src/syncfield/replay/__init__.py` +- Create: `src/syncfield/replay/static/.gitkeep` +- Create: `tests/unit/replay/__init__.py` + +- [ ] **Step 1: Create the package directories and empty init** + +```bash +mkdir -p src/syncfield/replay/static +mkdir -p tests/unit/replay +touch src/syncfield/replay/static/.gitkeep +touch tests/unit/replay/__init__.py +``` + +Create `src/syncfield/replay/__init__.py`: + +```python +"""SyncField replay viewer — local browser-based session playback. + +Open a previously recorded session in your default browser to verify +sync quality (per-stream offsets, Before/After comparison, sync report). + +Usage:: + + import syncfield as sf + sf.replay.launch("./data/session_2026-04-09T14-49") + +Requires the ``replay`` extra:: + + pip install 'syncfield[replay]' +""" + +from __future__ import annotations + +try: + import starlette # noqa: F401 + import uvicorn # noqa: F401 +except ImportError as exc: # pragma: no cover + raise ImportError( + "syncfield.replay requires the 'replay' extra. " + "Install with `pip install 'syncfield[replay]'`." + ) from exc + +# Re-exported once server.py exists in Task 5. +__all__ = ["launch"] +``` + +- [ ] **Step 2: Verify the package imports** + +Run: `python -c "import syncfield.replay" 2>&1` + +Expected: ImportError about the `replay` extra (because starlette/uvicorn are not installed yet). That's fine — proves the dep guard fires. + +- [ ] **Step 3: Install the dev deps so the rest of the plan works** + +Run: +```bash +uv pip install 'starlette>=0.36' 'uvicorn>=0.27' 'httpx>=0.27' +``` + +(`httpx` is needed for Starlette's `TestClient` in later tests.) + +- [ ] **Step 4: Verify the import now succeeds** + +Run: `python -c "import syncfield.replay; print(syncfield.replay.__doc__.splitlines()[0])"` + +Expected: `SyncField replay viewer — local browser-based session playback.` + +- [ ] **Step 5: Commit** + +```bash +git add src/syncfield/replay tests/unit/replay +git commit -m "feat(replay): scaffold syncfield.replay package with dep guard" +``` + +--- + +### Task 2: `loader.py` — session-folder parsing + +**Files:** +- Create: `src/syncfield/replay/loader.py` +- Create: `tests/unit/replay/conftest.py` +- Create: `tests/unit/replay/test_loader.py` + +- [ ] **Step 1: Create the synthetic-session fixture** + +Write `tests/unit/replay/conftest.py`: + +```python +"""Fixtures for replay loader and server tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _write_json(path: Path, data: dict) -> None: + path.write_text(json.dumps(data, indent=2)) + + +@pytest.fixture +def synthetic_session(tmp_path: Path) -> Path: + """Build a minimal session folder on disk and return its path.""" + session = tmp_path / "session_test" + session.mkdir() + + _write_json( + session / "manifest.json", + { + "sdk_version": "0.2.0", + "host_id": "test_rig", + "streams": { + "cam_ego": { + "kind": "video", + "capabilities": { + "provides_audio_track": True, + "supports_precise_timestamps": True, + "is_removable": False, + "produces_file": True, + }, + "status": "completed", + "frame_count": 60, + }, + "wrist_imu": { + "kind": "sensor", + "capabilities": { + "provides_audio_track": False, + "supports_precise_timestamps": True, + "is_removable": False, + "produces_file": False, + }, + "status": "completed", + "frame_count": 600, + }, + }, + }, + ) + + _write_json( + session / "sync_point.json", + { + "sdk_version": "0.2.0", + "monotonic_ns": 100_000_000_000, + "wall_clock_ns": 1_775_000_000_000_000_000, + "host_id": "test_rig", + "timestamp_ms": 1_775_000_000_000, + "iso_datetime": "2026-04-09T00:00:00", + }, + ) + + # Fake "video" file — content is irrelevant to the loader, only the + # path matters. Use a few bytes so Range tests have something to slice. + (session / "cam_ego.mp4").write_bytes(b"\x00MP4FAKE\x00" * 64) + + # Sensor jsonl with two samples + (session / "wrist_imu.jsonl").write_text( + '{"t_ns":0,"channels":{"ax":0.1}}\n' + '{"t_ns":1000000,"channels":{"ax":0.2}}\n' + ) + + return session + + +@pytest.fixture +def synced_session(synthetic_session: Path) -> Path: + """A session that also has a synced/sync_report.json.""" + synced = synthetic_session / "synced" + synced.mkdir() + _write_json( + synced / "sync_report.json", + { + "streams": { + "cam_ego": { + "offset_seconds": 0.012, + "confidence": 0.97, + "quality": "excellent", + }, + "wrist_imu": { + "offset_seconds": -0.034, + "confidence": 0.81, + "quality": "good", + }, + }, + }, + ) + return synthetic_session +``` + +- [ ] **Step 2: Write the failing loader test** + +Write `tests/unit/replay/test_loader.py`: + +```python +"""Unit tests for syncfield.replay.loader.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from syncfield.replay.loader import ReplayManifest, load_session + + +def test_load_session_returns_manifest(synthetic_session: Path) -> None: + manifest = load_session(synthetic_session) + + assert isinstance(manifest, ReplayManifest) + assert manifest.session_dir == synthetic_session + assert manifest.host_id == "test_rig" + assert manifest.sync_point["host_id"] == "test_rig" + assert manifest.sync_report is None + assert manifest.has_frame_map is False + + +def test_load_session_finds_video_and_sensor_streams( + synthetic_session: Path, +) -> None: + manifest = load_session(synthetic_session) + by_id = {s.id: s for s in manifest.streams} + + assert set(by_id) == {"cam_ego", "wrist_imu"} + + cam = by_id["cam_ego"] + assert cam.kind == "video" + assert cam.media_url == "/media/cam_ego" + assert cam.media_path == synthetic_session / "cam_ego.mp4" + assert cam.frame_count == 60 + + imu = by_id["wrist_imu"] + assert imu.kind == "sensor" + assert imu.media_url is None + assert imu.data_url == "/data/wrist_imu.jsonl" + assert imu.frame_count == 600 + + +def test_load_session_with_sync_report(synced_session: Path) -> None: + manifest = load_session(synced_session) + + assert manifest.sync_report is not None + assert manifest.sync_report["streams"]["cam_ego"]["quality"] == "excellent" + + +def test_load_session_missing_manifest_raises(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + load_session(tmp_path / "does_not_exist") + + +def test_load_session_missing_sync_point_is_optional( + synthetic_session: Path, +) -> None: + (synthetic_session / "sync_point.json").unlink() + manifest = load_session(synthetic_session) + assert manifest.sync_point == {} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `pytest tests/unit/replay/test_loader.py -v` + +Expected: All five tests fail with `ModuleNotFoundError: No module named 'syncfield.replay.loader'`. + +- [ ] **Step 4: Implement `loader.py`** + +Write `src/syncfield/replay/loader.py`: + +```python +"""Session-folder loader for the replay viewer. + +Reads a directory written by ``syncfield.writer`` and produces a +:class:`ReplayManifest` that the HTTP server can serve as JSON. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Optional + +logger = logging.getLogger(__name__) + +StreamKind = Literal["video", "sensor", "custom"] + + +@dataclass(frozen=True) +class ReplayStream: + """One stream's metadata + on-disk locations. + + ``media_path`` is kept on the Python side for the file-serving + handler; it is intentionally excluded from the JSON view of the + manifest (see :meth:`ReplayManifest.to_json`). + """ + + id: str + kind: StreamKind + media_url: Optional[str] + media_path: Optional[Path] + data_url: Optional[str] + data_path: Optional[Path] + frame_count: int + + +@dataclass(frozen=True) +class ReplayManifest: + """Everything the SPA needs to render a session, in one struct.""" + + session_dir: Path + host_id: str + sync_point: dict + streams: list[ReplayStream] + sync_report: Optional[dict] + has_frame_map: bool + + def to_json(self) -> dict[str, Any]: + """Serializable view — strips Path fields the SPA does not need.""" + return { + "host_id": self.host_id, + "sync_point": self.sync_point, + "has_frame_map": self.has_frame_map, + "streams": [ + { + "id": s.id, + "kind": s.kind, + "media_url": s.media_url, + "data_url": s.data_url, + "frame_count": s.frame_count, + } + for s in self.streams + ], + } + + +def load_session(session_dir: Path) -> ReplayManifest: + """Read a session folder and return its :class:`ReplayManifest`. + + Raises: + FileNotFoundError: if ``session_dir/manifest.json`` does not exist. + """ + session_dir = Path(session_dir) + manifest_path = session_dir / "manifest.json" + if not manifest_path.is_file(): + raise FileNotFoundError( + f"manifest.json not found in {session_dir}" + ) + + raw = json.loads(manifest_path.read_text()) + host_id = raw.get("host_id", "") + streams_raw: dict = raw.get("streams", {}) + + streams: list[ReplayStream] = [] + for stream_id, info in streams_raw.items(): + kind: StreamKind = info.get("kind", "custom") + frame_count = int(info.get("frame_count", 0) or 0) + + media_path: Optional[Path] = None + media_url: Optional[str] = None + data_path: Optional[Path] = None + data_url: Optional[str] = None + + if kind == "video": + mp4 = session_dir / f"{stream_id}.mp4" + if mp4.is_file(): + media_path = mp4 + media_url = f"/media/{stream_id}" + + sensor_jsonl = session_dir / f"{stream_id}.jsonl" + if sensor_jsonl.is_file(): + data_path = sensor_jsonl + data_url = f"/data/{stream_id}.jsonl" + + streams.append( + ReplayStream( + id=stream_id, + kind=kind, + media_url=media_url, + media_path=media_path, + data_url=data_url, + data_path=data_path, + frame_count=frame_count, + ) + ) + + sync_point: dict = {} + sp_path = session_dir / "sync_point.json" + if sp_path.is_file(): + sync_point = json.loads(sp_path.read_text()) + else: + logger.warning("sync_point.json missing in %s", session_dir) + + sync_report: Optional[dict] = None + sr_path = session_dir / "synced" / "sync_report.json" + if sr_path.is_file(): + sync_report = json.loads(sr_path.read_text()) + + has_frame_map = (session_dir / "synced" / "frame_map.jsonl").is_file() + + return ReplayManifest( + session_dir=session_dir, + host_id=host_id, + sync_point=sync_point, + streams=streams, + sync_report=sync_report, + has_frame_map=has_frame_map, + ) +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pytest tests/unit/replay/test_loader.py -v` + +Expected: 5 passed. + +- [ ] **Step 6: Commit** + +```bash +git add src/syncfield/replay/loader.py tests/unit/replay/conftest.py tests/unit/replay/test_loader.py +git commit -m "feat(replay): session folder loader with manifest + sync_report parsing" +``` + +--- + +### Task 3: `_handler.py` — path-traversal guard + +**Files:** +- Create: `src/syncfield/replay/_handler.py` +- Create: `tests/unit/replay/test_handler.py` + +- [ ] **Step 1: Write the failing security test** + +Write `tests/unit/replay/test_handler.py`: + +```python +"""Path-traversal protection for the media/data routes.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from syncfield.replay._handler import UnsafePathError, safe_resolve + + +def test_safe_resolve_accepts_in_root(tmp_path: Path) -> None: + target = tmp_path / "ok.mp4" + target.write_bytes(b"x") + assert safe_resolve(tmp_path, "ok.mp4") == target.resolve() + + +def test_safe_resolve_rejects_parent_escape(tmp_path: Path) -> None: + with pytest.raises(UnsafePathError): + safe_resolve(tmp_path, "../etc/passwd") + + +def test_safe_resolve_rejects_absolute(tmp_path: Path) -> None: + with pytest.raises(UnsafePathError): + safe_resolve(tmp_path, "/etc/passwd") + + +def test_safe_resolve_rejects_symlink_escape(tmp_path: Path) -> None: + outside = tmp_path.parent / "outside_target" + outside.write_text("secret") + link = tmp_path / "link" + link.symlink_to(outside) + with pytest.raises(UnsafePathError): + safe_resolve(tmp_path, "link") + outside.unlink() + + +def test_safe_resolve_missing_file_returns_none(tmp_path: Path) -> None: + assert safe_resolve(tmp_path, "nope.mp4") is None +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/unit/replay/test_handler.py -v` + +Expected: All 5 tests fail with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement `_handler.py`** + +Write `src/syncfield/replay/_handler.py`: + +```python +"""Internal helpers for the replay HTTP server. + +Right now this is just :func:`safe_resolve` — the path-traversal guard +that every file-serving route must funnel through. Kept in its own +module so the security-sensitive surface is small and easy to audit. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional + + +class UnsafePathError(ValueError): + """Raised when a requested path resolves outside the session root.""" + + +def safe_resolve(root: Path, requested: str) -> Optional[Path]: + """Resolve ``requested`` against ``root`` or refuse. + + Returns the resolved absolute path if it exists and is contained + inside ``root`` (after following symlinks). Returns ``None`` if the + path simply does not exist. Raises :class:`UnsafePathError` if the + request tries to escape the root in any way — absolute paths, + parent traversals, and symlinks pointing outside all qualify. + """ + if requested.startswith("/") or requested.startswith("\\"): + raise UnsafePathError(f"absolute path rejected: {requested!r}") + + root_abs = root.resolve(strict=True) + candidate = (root_abs / requested).resolve(strict=False) + + try: + candidate.relative_to(root_abs) + except ValueError as exc: + raise UnsafePathError( + f"path escapes session root: {requested!r}" + ) from exc + + if not candidate.exists(): + return None + return candidate +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/unit/replay/test_handler.py -v` + +Expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/syncfield/replay/_handler.py tests/unit/replay/test_handler.py +git commit -m "feat(replay): path-traversal-safe file resolution helper" +``` + +--- + +## Phase 2 — Python server + +### Task 4: `server.py` — Starlette app + routes + +**Files:** +- Create: `src/syncfield/replay/server.py` +- Create: `tests/unit/replay/test_server.py` + +- [ ] **Step 1: Write the failing server tests** + +Write `tests/unit/replay/test_server.py`: + +```python +"""Tests for the Starlette replay server (no real network).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + +from syncfield.replay.loader import load_session +from syncfield.replay.server import build_app + + +@pytest.fixture +def client_with_synthetic(synthetic_session: Path) -> TestClient: + manifest = load_session(synthetic_session) + app = build_app(manifest) + return TestClient(app) + + +@pytest.fixture +def client_with_synced(synced_session: Path) -> TestClient: + manifest = load_session(synced_session) + app = build_app(manifest) + return TestClient(app) + + +def test_get_session_returns_manifest_json( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/api/session") + assert response.status_code == 200 + body = response.json() + assert body["host_id"] == "test_rig" + assert {s["id"] for s in body["streams"]} == {"cam_ego", "wrist_imu"} + + +def test_get_sync_report_404_when_missing( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/api/sync-report") + assert response.status_code == 404 + + +def test_get_sync_report_returns_json_when_present( + client_with_synced: TestClient, +) -> None: + response = client_with_synced.get("/api/sync-report") + assert response.status_code == 200 + assert response.json()["streams"]["cam_ego"]["quality"] == "excellent" + + +def test_get_media_serves_video_bytes( + client_with_synthetic: TestClient, synthetic_session: Path, +) -> None: + response = client_with_synthetic.get("/media/cam_ego") + assert response.status_code == 200 + expected = (synthetic_session / "cam_ego.mp4").read_bytes() + assert response.content == expected + + +def test_get_media_supports_range_request( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get( + "/media/cam_ego", headers={"Range": "bytes=0-15"}, + ) + assert response.status_code == 206 + assert "content-range" in {k.lower() for k in response.headers} + assert len(response.content) == 16 + + +def test_get_media_unknown_stream_returns_404( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/media/no_such_stream") + assert response.status_code == 404 + + +def test_get_data_serves_jsonl( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/data/wrist_imu.jsonl") + assert response.status_code == 200 + assert b"channels" in response.content + + +def test_get_data_path_traversal_rejected( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/data/..%2Fetc%2Fpasswd") + assert response.status_code in (400, 404) + + +def test_get_root_serves_index_html( + client_with_synthetic: TestClient, +) -> None: + response = client_with_synthetic.get("/") + assert response.status_code == 200 + assert b" src/syncfield/replay/static/index.html <<'HTML' + +