festifun/backend/festival4d/config.py
type-two 51650c728e foundation2 (phase 5a): contracts, stubs, hooks & UI wiring for lanes E/F/G
- DB: paths table (contract #3) + helpers; patch_anchor (label/color)
- API: GET /api/beats, POST /api/director (valid-empty degradation),
  /api/paths CRUD (validated POST -> 422), PATCH /api/anchors/{id};
  manifest gains capsule:false (test_api.py key-set lock updated consciously)
- CLI: features | direct | capsule subcommands -> lane stubs (exit-2 degradation)
- Backend stubs with frozen contracts in docstrings: audio_features, director, capsule
- Frontend: six lane stub modules imported from main.js; all phase-5 buttons
  pre-wired hidden in index.html; write-ui/body.capsule read-only mode;
  __F4D_API_BASE__ runtime override in state.js
- Hooks (contract #6): transport.captureAudioStream/releaseAudioStream,
  scene3d.focusOn, scene3d.setHelpersVisible (+registerHelper; camPath gizmos)
- Suite 121 -> 127 passed; npm build clean; live-browser verified (status file)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:31:08 +10:00

83 lines
3.8 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)
AUDIO_HQ_DIR = WORK_DIR / "audio_hq" # per-video listening-quality AAC (lazy, /api/audio/{id})
FRAMES_DIR = WORK_DIR / "frames" # sampled JPEGs for SfM
COLMAP_DIR = WORK_DIR / "colmap" # COLMAP workspace
DB_PATH = DATA_DIR / "project.db" # SQLite (gitignored)
BEATS_JSON = WORK_DIR / "beats.json" # beat/onset analysis (lane E / M10, /api/beats)
CAPSULE_DIR = DATA_DIR / "capsule" # default output of `python -m festival4d capsule` (M17)
POINTS_PLY = WORK_DIR / "points.ply" # reconstructed / synthetic point cloud
SPLAT_PLY = WORK_DIR / "splat.ply" # optional 3DGS splat (trained externally, e.g. via MODELBEAST — see docs/modelbeast-crossover.md); viewer prefers it over the point cloud
SYNC_JSON = WORK_DIR / "sync.json" # exported sync solution (lane A)
GROUND_TRUTH_JSON = WORK_DIR / "ground_truth.json" # synthetic fixture ground truth
# ---------------------------------------------------------------------------
# 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