What now works: - M0 scaffold: pyproject (all spec deps), uv/py3.12 env, `python -m festival4d` CLI registering synthetic|ingest|sync|reconstruct|events|serve. Vite hello page. - Synthetic fixture (synthetic.py): 3 shifted-audio videos (offsets 0/+1370/-842 ms), camera-arc poses, stage point cloud -> points.ply, seeded events + anchors, ground_truth.json. `python -m festival4d synthetic` populates data/ + DB. - DB schema exactly per spec §2 (db.py) + CRUD helpers all lanes use. - M3 API (api.py) full against synthetic data: manifest/poses/pointcloud/anchors/ events/detect/annotations; Range-capable video serving (206 verified); CORS for any localhost origin. - Frozen geometry contract: geometry.colmap_to_threejs (M5 math) + unit test (3 known vectors, random round-trip, scipy oracle); mirrored frontend/src/lib/pose.js with identical POSE_TEST_VECTORS. Lane-B stubs: slerp_pose, ray_from_pixel, triangulate_rays, nearest_point_on_ray. - Classifier contract (events_ai.py): MomentClassification model + MomentClassifier protocol + Gemini/Claude/Local provider stubs. - Lane-owned modules stubbed with final signatures (ingest, audio_sync, frames, sfm, events_ai); cli/api catch NotImplementedError and degrade gracefully. - plan/CHANGE_REQUESTS.md created; plan/status/foundation.md updated. Acceptance: pytest 24 passed; serve endpoints verified via curl + browser (video seek, manifest fetch cross-origin, pose.js self-test, 0 console errors). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
79 lines
3.4 KiB
Python
79 lines
3.4 KiB
Python
"""Paths, constants, and the timebase convention.
|
|
|
|
Timebase convention (FROZEN CONTRACT — do not change without a change request):
|
|
|
|
``t_global`` is the master timeline in seconds.
|
|
For a video *v* with ``offset_ms`` and ``drift_ppm``::
|
|
|
|
t_video = (t_global - offset_ms / 1000) * (1 + drift_ppm * 1e-6)
|
|
|
|
The reference video has ``offset_ms == 0`` (and ``drift_ppm == 0``). A positive
|
|
``offset_ms`` means the video started recording *later* than the master zero, so at a
|
|
given ``t_global`` its local playhead is earlier. Use :func:`t_video_from_global` and
|
|
:func:`t_global_from_video` everywhere — never re-derive the algebra inline. The
|
|
frontend mirrors :func:`t_video_from_global` in ``transport.js``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths. The data root can be overridden with FESTIVAL4D_DATA_DIR (used by tests
|
|
# to run against a temporary project without touching the repo's data/).
|
|
# ---------------------------------------------------------------------------
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _data_dir() -> Path:
|
|
env = os.environ.get("FESTIVAL4D_DATA_DIR")
|
|
return Path(env).resolve() if env else REPO_ROOT / "data"
|
|
|
|
|
|
DATA_DIR = _data_dir()
|
|
RAW_DIR = DATA_DIR / "raw" # user drops source videos here (gitignored)
|
|
WORK_DIR = DATA_DIR / "work" # extracted wavs, frames, colmap workspace (gitignored)
|
|
AUDIO_DIR = WORK_DIR / "audio" # per-video mono 16 kHz wavs (ingest)
|
|
FRAMES_DIR = WORK_DIR / "frames" # sampled JPEGs for SfM
|
|
COLMAP_DIR = WORK_DIR / "colmap" # COLMAP workspace
|
|
DB_PATH = DATA_DIR / "project.db" # SQLite (gitignored)
|
|
|
|
POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cloud
|
|
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
|
|
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
FRONTEND_ORIGINS = [
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
]
|
|
# Local-only, single-user, offline tool: allow any localhost origin so a Vite dev server on
|
|
# a fallback port (5174, …) still works. Kept alongside the explicit 5173 default.
|
|
FRONTEND_ORIGIN_REGEX = r"http://(localhost|127\.0\.0\.1)(:\d+)?"
|
|
API_HOST = "127.0.0.1"
|
|
API_PORT = 8000
|
|
|
|
AUDIO_SAMPLE_RATE = 16_000 # ingest extracts mono 16 kHz for sync (M1)
|
|
|
|
# Synthetic fixture parameters (M0). Ground-truth offsets are deliberately odd,
|
|
# non-round numbers so a naive integer-second guesser can't accidentally "pass".
|
|
SYNTH_VIDEO_W = 640
|
|
SYNTH_VIDEO_H = 360
|
|
SYNTH_FPS = 30
|
|
SYNTH_DURATION_S = 20.0
|
|
SYNTH_AUDIO_SR = 48_000
|
|
SYNTH_OFFSETS_MS = (0.0, 1370.0, -842.0) # cam0 is the reference (offset 0)
|
|
|
|
|
|
def t_video_from_global(t_global: float, offset_ms: float, drift_ppm: float = 0.0) -> float:
|
|
"""Map master-timeline seconds to a video's local playhead seconds (FROZEN)."""
|
|
return (t_global - offset_ms / 1000.0) * (1.0 + drift_ppm * 1e-6)
|
|
|
|
|
|
def t_global_from_video(t_video: float, offset_ms: float, drift_ppm: float = 0.0) -> float:
|
|
"""Inverse of :func:`t_video_from_global` (FROZEN)."""
|
|
return t_video / (1.0 + drift_ppm * 1e-6) + offset_ms / 1000.0
|