Compare commits

...

50 Commits

Author SHA1 Message Date
type-two
52674bbaf1 CR-6 ratified at merge (coordinator); launch.json: alt-port web config
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:38:16 +10:00
type-two
9bc1dae41d Merge foundation3 (phase 6a / M18): Friend-Tracks fixture ground truth, contracts, stubs (CR-6 ratified)
Coordinator-verified: 209 passed independently; build clean; live boot on the
marker fixture (btn-friends hidden, solve degrades, has_tracks false); blink
protocol constants match contract #4 bit-for-bit; CR-6 lock edits ratified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 21:38:06 +10:00
type-two
9306a3f362 foundation3 status: accurate recovery note + fix suite count (209)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:21:44 +10:00
type-two
8f2a488c51 foundation3 (M18): complete Friend-Tracks foundation — fixture ground truth, contracts, stubs
Completes M18 on top of the WIP checkpoint (6a703c3). Three lanes (H/J/K) depend only on
what this freezes.

- synthetic.py: base testsrc2 saturation/brightness-muted so its magenta/cyan colour bars
  don't swamp lane H's colour detector; the two moving markers stay the only detectable blobs.
- Frontend (step 6): friendTracks.js stub (never-throws, fx.js pattern) wired into main.js;
  hidden #btn-friends + #friends-panel; state.tracks loaded when manifest.has_tracks.
- Tests (step 8): test_tracks_foundation.py (20) — DB helpers, /api/tracks routes, solve
  degradation, manifest.has_tracks, frozen OOK protocol + encoders, track_truth shape, the
  projection round-trip (rendered pixels back-project to 3D truth, worst err <1e-3 → lane H's
  median<0.3 reachable by construction), and a real-ffmpeg render test proving marker A is the
  dominant magenta blob after base-muting.
- CR-6: the two sanctioned M18 test-lock edits (test_api manifest key-set +has_tracks;
  test_capsule bundle paths +api/tracks). Additive; flagged for integration3 ratification.

Suite: 14 pre-existing files sum to 189 (floor held) + 20 new = 209 passed, exit 0.
Frontend `npm run build` clean. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).

Recovered by the coordinator session after the foundation3 agent's isolation worktree was
removed mid-run, leaving the work uncommitted; every file independently reviewed, full suite +
build re-run green, then committed. No work redone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 21:18:53 +10:00
type-two
6a703c314d foundation3 WIP checkpoint (steps 2-5,7): DB/API/CLI/config/capsule + tracker stubs
Incomplete: step 1 (fixture markers + track_truth.json), step 6 (frontend wiring),
step 8 (tests) not done; suite not yet run. Salvaged from interrupted prior session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 20:55:29 +10:00
type-two
56e569fd7a Plan: Phase 6 spec + directives round 6 (M18-M24 Friend Tracks, lanes H/J/K)
Markers/badges -> detection -> cross-view triangulation -> track ribbons +
follow-a-friend + LED badge hardware kit. Coordinator-merges policy this phase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:28:21 +10:00
type-two
bf9e41be7f ideas: stereo/spatial-video ingest, depth seeding, microcontroller rig kit; mark phase-5 items shipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:20:19 +10:00
type-two
f7ba7f2095 Merge integration2: phase 5 synthetic integration complete (suite floor 189)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:11:56 +10:00
type-two
fea0d5cb9e integration2: full synthetic pass of M10-M17 accepted; DIRECTIVES round 5; floor 189
Evidence in plan/status/integration2.md. v0.2.0 held for the real-footage field test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:11:56 +10:00
type-two
bbcee47afd CR-5 ratified at merge (coordinator)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:01:54 +10:00
type-two
a791bb37d5 Merge lane/e-director2: M10 beats/onsets, M11 auto-director, M12 cinematic export (CR-5 ratified)
Coordinator-verified: 189 passed independently re-run in the lane worktree;
CR-5 (retire stub-era assertions, shape-lock director test) ratified per CR-1/CR-4 precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:01:01 +10:00
type-two
9fdc5d2317 Merge main (lane G M16/M17 + CR-4) into lane/e-director2; reconcile CRs
Conflicts resolved: (a) test_phase5_api.py — with lane G's capsule AND
lane E's features/direct all landed, the CLI stub loop is empty, so
test_cli_dispatches_stubs_gracefully is retired with a pointer comment to
the real dispatch coverage (test_capsule.py / test_audio_features.py /
test_director.py); (b) CHANGE_REQUESTS.md — lane G keeps the ratified
CR-4; lane E's entry renumbered to CR-5 and updated for the merged
reality. Merged tree: 189 passed (169 main + 21 lane E - 1 retired),
frontend build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:58:22 +10:00
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
369c559b1e lane E status: M10-M12 ready_to_merge (148 passed; CR-4 filed)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:54:41 +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
ddb8dea6d8 M12: cinematic export — camPath ride -> festival4d-cut.webm
frontend/src/exportVideo.js fills the foundation2 stub: the ⏺ button
records the scene3d canvas captureStream(30) muxed with the live WebAudio
mix via the transport.captureAudioStream() hook (spatial included, silent
tap when no track — degrade, don't block), MediaRecorder vp9/opus webm
(vp8/webm fallbacks), spanning first -> last camPath keyframe. Playback is
driven through the existing #btn-path flow at forced rate 1 (file duration
must equal the path span), stops at the last keyframe (or early on a
second click / anything that halts the ride), downloads
festival4d-cut.webm, then restores ALL prior transport/UI state (playhead,
rate, play state, path state, followed camera) and releases the audio tap
+ canvas tracks. Button disabled under 2 keyframes by chaining main.js's
camPath.onChange (frozen files untouched).

Requires live-browser verification (focused tab — captureStream delivers
no frames hidden): see plan/status/lane-E.md. Frontend build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:53:34 +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
52e891204d M11: auto-director — top-N events -> beat-snapped camPath (backend + UI)
generate_path() fills the foundation2 stub with the deterministic v1 rules:
top-N events by confidence (ties -> earlier, then shot chronologically);
per event, the registered camera whose pose track is nearest the event
time; keyframes at t_event - lead_s (clamped >= 0) and t_event + duration;
nearest-beat snapping when beats.json exists (missing/corrupt file -> no
snapping); FOV = 2*atan(h/(2*fy)) like scene3d.snapTo. Every pose goes
through the frozen geometry.colmap_to_threejs and the camPath quaternion
is emitted in Three.js [x,y,z,w] order (pitfall #1 — locked by test).
Degradation: no events / no registered poses -> valid empty camPath with a
note; the response always round-trips through camPath.fromJSON.

frontend/src/director.js wires the pre-wired hidden 🎬 button: POST
/api/director, camPath.fromJSON(response), auto-play via the existing
#btn-path flow; empty path or fetch failure surfaces on the button and
recovers — never a crash.

Acceptance (test_director.py, 14 tests): structure-locked against the
frozen camPath shape (exact key sets, unit quats, monotone times);
top-3 grid == [1,4,8,11,14] incl. shared-keyframe dedupe; conversion
matches colmap_to_threejs + mat_to_quat exactly; snapping puts every key
on the beat grid; unregistered poses excluded; API route + CLI dispatch
real output. Suite 148 passed; frontend build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:51:52 +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
0f8685b49e M10: audio features — beats & onsets from the reference ingest WAV
run_features() fills the foundation2 stub: librosa beat/tempo tracking +
onset-strength detection at a 10 ms hop (librosa's default 32 ms grid at
16 kHz quantizes past the +/-50 ms acceptance bound), tempo from the median
inter-beat interval, times mapped to t_global via the frozen timebase
helpers. Degradation per house style: missing videos/WAV -> summary with
note, nothing written; silent/short/beatless audio -> valid-empty
beats.json, never a crash.

Acceptance (test_audio_features.py, 7 tests): synthetic 120 BPM grid
recovered — tempo 120.0 (+/-2 required), worst beat 30 ms off grid
(+/-50 required); the three ground-truth bangs appear as strong onsets;
silence/short/missing all degrade cleanly; CLI dispatch end-to-end.

CR-4: minimal stub-era assertion updates in test_phase5_api.py (CLI stub
loop shrinks to capsule) per the approved CR-1 precedent — see
plan/CHANGE_REQUESTS.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:48:31 +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
type-two
547450f5ca Merge foundation2 (phase 5a): phase-5 contracts frozen, lanes E/F/G may start
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:31:20 +10:00
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
m3ultra
545f7a347d Plan: Phase 5 spec + directives round 4 (M10-M17, lanes E/F/G)
plan/20-phase5.md is the phase-5 canonical spec: M10 beats/onsets,
M11 auto-director, M12 cinematic export (lane E); M13 anchor manager +
friend tags, M14 photo mode, M15 moment FX (lane F); M16 server-side
camera paths, M17 static memory-capsule export (lane G). foundation2
freezes the new contracts (beats.json, camPath JSON, paths table,
PATCH anchors, __F4D_API_BASE__ / manifest.capsule, transport/scene3d
hooks) and pre-wires all UI so parallel lanes never touch shared files.

DIRECTIVES Round 4 records the fresh-eyes upgrades (FFT caching,
WebAudio master clock, spatial audio; suite floor now 121), ratifies
the additive contract amendments, and sets run order:
foundation2 -> E, G, F (own worktrees, mandatory) -> integration2
(real-footage field test, v0.2.0 proposal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:08:44 +10:00
m3ultra
db42c42932 Chore: lock python-multipart (declared but never locked), launch configs
uv.lock drifted from pyproject when live capture added python-multipart;
uv run resynced it. launch.json gains festifun-web-dist (uv + http.server
over frontend/dist) for sandboxed sessions where node/npm can't start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:48 +10:00
m3ultra
c24af8029e Audio is the master clock + spatial audio free-roam (fresh-eyes opts 1 & feature 1)
Master clock: the selected soundtrack (new GET /api/audio/{id} —
listening-quality stereo AAC, lazily ffmpeg-extracted and cached in
data/work/audio_hq/) plays through WebAudio, and t_global derives from
AudioContext.currentTime. Every <video> is muted picture-only, so hard
seeks and playbackRate corrections are inaudible (the unmuted video
used to warble on every nudge). Graceful fallback to the old
performance.now() clock + <video> audio when a track can't load, with
an in-place upgrade if the decode lands mid-play. Drift is compensated
in the track's playbackRate; _resync() now recomputes from the live
clock since the soundtrack keeps playing while rAF is throttled.

Spatial audio (🎧 3D audio, shown when poses exist): every enabled
camera's track plays through an HRTF PannerNode at its live
reconstructed pose, gains normalized 1/sqrt(N); the listener follows
the viewer camera each frame (free roam, follow-cam, and M9 paths).
Tracks that finish decoding mid-play join the mix in place; toggling,
seeking, rate and enable changes rebuild the graph at the current
t_global with no timeline jump.

The window.__f4d debug hook is now also available in production builds
via localStorage f4dDebug=1 (headless panes throttle rAF; the pump is
the only way to drive the loop there).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:48 +10:00
m3ultra
95b71788a3 Sync solver: cache per-signal FFTs across the pairwise GCC-PHAT stage
pairwise_offsets now computes each signal's rFFT once at a shared padded
length (2*max_len) and reuses it for every pair: O(K) forward FFTs +
cheap per-pair spectrum products instead of O(K^2) full FFTs. Each
pair's lag search stays capped at +/-(la+lb)/2 — the same window the
per-pair transform used — and gcc_phat() itself is unchanged for
callers (drift windows, tests). Measured 1.8x on 6 cams x 10 min;
the advantage grows with camera count.

New test locks the cached path against direct gcc_phat, including
unequal-length signals where the padded sizes genuinely differ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 17:02:27 +10:00
m3ultra
36bacf67f0 Live capture: USB webcams, laptop cams, and phones -> data/raw
Records rather than streams: each device records a clip WITH AUDIO and uploads
it; the normal offline pipeline takes over. Fits the design almost for free —
MediaRecorder's webm/mp4 are already ingestible, and audio-based sync means
devices need no clock sync and needn't start together.

- capture.py: POST /api/capture/upload (streamed, sanitized name, size cap,
  partial cleanup), heartbeat/devices registry, GET /capture page.
- static/capture.html: standalone page (no build step) — device picker, live
  preview, record->upload, 1fps snapshot heartbeat, 'who's shooting what'
  monitor, and secure-context detection that tells you to use
  (getUserMedia needs HTTPS — the #1 gotcha for phones).
- OPT-IN via FESTIVAL4D_CAPTURE=1; manifest reports has_capture and the viewer
  only then shows a Capture link. Public deploy leaves it off (open upload
  endpoint would be unsafe) — noted in DEPLOY.md.
- Adds python-multipart dep. Suite 103 -> 119.

Also fixes test_manifest_shape, which was already RED on main: f12b6e0 added
has_splat to the manifest without updating the exact-set lock. Updated to the
true set (now also has_capture); the lock is what caught both drifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 15:43:51 +10:00
m3ultra
f12b6e0847 Splat upgrade (ideas.md #1) + MODELBEAST crossover
- GET /api/splat serves data/work/splat.ply; manifest reports has_splat
- scene3d: lazy-load @mkkellogg/gaussian-splats-3d DropInViewer when a splat
  exists (rigs/anchors/paths overlay as before); point-cloud fallback intact
- scripts/splat_via_modelbeast.sh: train the splat on the MODELBEAST fleet
  (colmap_poses -> brush_train via the mb queue) and drop it in place
- docs/modelbeast-crossover.md: full crossover map (fleet compute, volumetric
  capture w/ CorridorKey + audio sync, per-moment splats)
Verified in-browser: 32MB room splat rendered via /api/splat with overlays live.
2026-07-16 22:32:01 +10:00
m3ultra
931cf81a6c Deploy-ready: path-prefix hosting (digalot.fyi/festifun) + deploy artifacts
Frontend: API_BASE env-driven (VITE_API_BASE, default localhost:8000 so dev is
unchanged); vite.config.js base from FESTIVAL4D_BASE. Prod build
'FESTIVAL4D_BASE=/festifun/ VITE_API_BASE=/festifun' serves same-origin under
the prefix. Validated end-to-end via a local proxy mirroring the nginx config:
app boots, media Range 206, 3D + overlays + timeline render under /festifun.

deploy/: nginx location blocks (prefix strip + Range), systemd unit
(uvicorn 127.0.0.1:8000, no keys on the public box), DEPLOY.md. Labels baked
into the shipped DB locally so detect can't spend credits from an anonymous
public endpoint. Backend suite 103 passed; default build unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:20:36 +10:00
m3ultra
7c13f519d2 Phase 4 polish: anchor supersede + delete, docs/ideas, field-test scaffolding
Fixes the sole Round-3 wart: annotating one object from two views now yields
ONE anchor (the single-view fallback is superseded in place by the
triangulation, same id) instead of a sibling pair. Adds DELETE /api/anchors/{id}
(unlinks annotations, 404 on unknown) + a deletable anchor list in the
correction panel. db gains get/update/delete_anchor + get_annotations(event_id).
CR-3 filed (additive). Suite 101 -> 103; frontend build clean; delete UI
verified live (state + DOM + 3D scene stay consistent).

Also: docs/ideas.md (future extensions), plan/ISSUES.md (seeded with the
gemini-2.5-flash retirement, FIXED), plan/status/phase4.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 12:15:35 +10:00
m3ultra
8dca2f5154 Coordinator hotfix: Gemini model env-overridable, default gemini-3.1-flash-lite
Google retired gemini-2.5-flash for new API keys (404). Verified
gemini-3.1-flash-lite live: inline video + structured output, 3/3 synthetic
candidates classified. FESTIVAL4D_GEMINI_MODEL overrides. Round 3 addendum
documents the local .env (gitignored) with Gemini + OpenRouter creds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:18:43 +10:00
m3ultra
b83e577870 DIRECTIVES round 3: v0.1.0 accepted; phase 4 = anchor polish + real-footage field test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:06:01 +10:00
m3ultra
d313c8dce8 DIRECTIVES round 2: integration complete, v0.1.0 shipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:00:47 +10:00
m3ultra
e5d6b2c47f Integration (M8 + M9): annotation→3D, camera paths, README, v0.1.0
M8: POST /api/annotations resolves a bbox to a 3D point (ray → two-view
triangulation / nearest-point / centroid-depth) via lane B's geometry
primitives; correction panel + bbox-drawing UI; resolved anchors render live
in overlays and the 3D scene. CR-2 (additive): annotations.event_id +
PATCH /api/events/{id} + db.update_event.

M9: keyframed god's-eye camera paths (Catmull-Rom position + slerp
orientation, export/import), gizmos, path-mode camera hooks.

Verified: 101 backend tests pass (two-view corner recovery err 0.00000);
frontend build clean; live in-browser M8 triangulation (gap 0.21) + M9 path
(camera on keyframe at t) + M4 sync ~16ms. Cleanup: worktrees removed, debris
stash dropped, pristine demo DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:59:51 +10:00
m3ultra
58c01559c1 DIRECTIVES round 1: all lanes merged & verified (96 tests), integration begins
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 09:04:29 +10:00
m3ultra
9d17b474ea Merge branch 'lane/c-viewer'
# Conflicts:
#	plan/status/lane-C.md
2026-07-16 09:02:13 +10:00
m3ultra
2cad2f7b02 Merge branch 'lane/b-recon' 2026-07-16 09:01:59 +10:00
m3ultra
e28dc78e06 Merge branch 'lane/a-media' 2026-07-16 09:01:59 +10:00
m3ultra
ec22cbdf1f Lane C (M4+M5+M6 + timeline): synchronized viewer frontend
Full frontend built against the synthetic API (no dependence on lanes A/B/D):

M4 — synchronized playback: master clock from performance.now() (never a <video>);
per-video correction via requestVideoFrameCallback (exact mediaTime) with a
currentTime+half-frame fallback — hard-seek >150ms / nudge playbackRate +/-5% 20-150ms /
lock <20ms; out-of-range videos pause+dim; single audio source; dev sync-error overlay.
Timebase mirrors config.py (lib/timebase.js). Verified: seek is exact to 0ms; continuous
inter-video desync 13ms mean / 46ms max.

M5 — 3D viewer: PLY point cloud, per-video camera paths + current-pose frusta, OrbitControls,
snap-to-camera (intrinsics->PerspectiveCamera fov) + free roam. All COLMAP->Three.js via the
frozen lib/pose.js; pose interpolation in lib/poseTrack.js (slerp+lerp of contract inputs).

M6 — anchor overlays: letterbox-correct per-video canvas; anchors projected via pose.js;
behind-camera cull. Projection agrees with an independent pinhole to 1.45e-13 px.

Timeline — colored event markers + legend, hover tooltip, click-to-jump; draggable scrubber.

Phase-3 seams (annotate.js M8, camPath.js M9) left as stubs. No frozen files edited; no new
deps; no change requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:45:51 +10:00
m3ultra
bb542960c2 Lane B (M2 + M8 geometry): COLMAP reconstruction, geometry math, graceful degradation
Implements spec M2 (frame sampling, COLMAP orchestration, TXT-model parsing, scene
normalization, pose interpolation, PLY + DB export) and the M8 geometry functions.

geometry.py (M8): slerp_pose (shortest-arc, double-cover), ray_from_pixel (COLMAP
+y-down back-projection), triangulate_rays (closest-point + parallel guard),
nearest_point_on_ray (in-front radius cylinder). Frozen colmap_to_threejs untouched.

frames.py (M2): variance-of-Laplacian sharpness + windowed sharpest-frame sampling.

sfm.py (M2): hand-written COLMAP images/cameras/points3D parsers; normalize_scene
(centroid->0, camera sphere r->10, up->+Y) as one similarity transform over points+poses;
interpolate_poses (slerp+lerp, no extrapolation); build-aware COLMAP CLI orchestration
(3.x/4.x option detection, CPU SIFT, single-camera-per-folder, undistort->TXT, largest
component by images.bin header count); run_reconstruct with full graceful degradation
(COLMAP absent / <60% / <2 videos / no frames / missing files -> poses left untouched).

Tests: 61 pass (24 foundation + 37 lane-B) with independent oracles (scipy Slerp,
projection-inverse, closed-form geometry) and every degradation/DB-safety branch covered.
Validated end-to-end against real COLMAP 4.1.0 (6 components, largest selected, weak ->
graceful degradation, exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:40:46 +10:00
m3ultra
6ec55c70e8 lane D: status -> merged (M7 done, 5c2d7c6; review findings fixed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:38:47 +10:00
m3ultra
5c2d7c6ea3 lane D (M7): audio moment detection + pluggable AI classification
Fills events_ai.py against the frozen M7 contract:
- detect_candidates: RMS x spectral-flux, local-max over a 10s neighborhood,
  >=P90, with an AC-RMS (std) silence/DC floor so flat audio yields no events.
  Matches the fixture ground-truth pulses [3,10,17] within ~0.02s (<0.5s).
- GeminiClassifier (native video), ClaudeClassifier (claude-opus-4-8,
  messages.parse, adaptive thinking, no temperature), LocalClassifier
  (OpenAI-compatible, 6 frames, 1 retry). SDKs imported lazily.
- get_classifier: FESTIVAL4D_CLASSIFIER selection; unconfigured -> None
  (candidates-only, logged).
- prepare_inputs: ffmpeg 3s clip (<=720p, audio kept) + 6 JPEGs (<=768px).
- run_events: reference-audio detect -> classify (insert-once in final state,
  since db.py has no update-event helper); per-candidate exception isolation;
  whole-track replaces machine events (keeps user edits), windowed is additive.

test_events_ai.py: candidate-vs-ground-truth (no ffmpeg/API), stub-classifier
orchestration, exception isolation, degradation, provider selection, silence.
Also updates the now-obsolete api stub test (test_detect_events_endpoint) to the
real M3 {result, events} shape; frozen api.py unchanged (see CR-1). pytest: 90 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:34:56 +10:00
m3ultra
4399bc90e2 lane C: session start — verified frozen contracts against live synthetic API
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:55:42 +10:00
89 changed files with 12625 additions and 293 deletions

61
.claude/launch.json Normal file
View File

@ -0,0 +1,61 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "festifun-api",
"runtimeExecutable": "uv",
"runtimeArgs": [
"run",
"python",
"-m",
"festival4d",
"serve"
],
"port": 8000,
"autoPort": false
},
{
"name": "festifun-web",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--prefix",
"frontend"
],
"port": 5173,
"autoPort": false
},
{
"name": "festifun-web-alt",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev",
"--prefix",
"frontend",
"--",
"--port",
"5177",
"--strictPort"
],
"port": 5177,
"autoPort": false
},
{
"name": "festifun-web-dist",
"runtimeExecutable": "uv",
"runtimeArgs": [
"run",
"python",
"-m",
"http.server",
"5174",
"--directory",
"/Users/m3ultra/Documents/festifun/frontend/dist"
],
"port": 5174,
"autoPort": false
}
]
}

4
.gitignore vendored
View File

@ -5,3 +5,7 @@ node_modules/
__pycache__/
.venv/
.DS_Store
frontend/dist/
.env
data/capsule/
.claude/worktrees/

234
README.md
View File

@ -3,55 +3,243 @@
Turn multiple fan-shot smartphone videos of the same concert into a synchronized,
explorable **4D experience**: time-aligned multi-video playback, 3D scene reconstruction
with camera poses, a free-roam "god's eye" viewer, AR-style overlays projected onto each
video, and AI-tagged moments on a shared timeline.
video, AI-tagged moments on a shared timeline, click-to-place 3D annotations, and keyframed
cinematic fly-throughs.
> **Status: foundation phase.** The scaffold, database, frozen contracts (API, pose math,
> DB schema), and a synthetic fixture generator are in place. Feature lanes (media sync,
> reconstruction, viewer, AI events) build on top. The full README with the real-footage
> workflow is written in the integration phase (M9). See
> [`OPUS_BUILD_INSTRUCTIONS.md`](OPUS_BUILD_INSTRUCTIONS.md) for the canonical spec and
> [`plan/`](plan/) for the execution plan.
Everything works **from pixels and audio alone** — no depth sensors, no IMU logs. It runs
fully offline and local; the only optional cloud piece is moment classification.
---
## What you can do
- **Scrub a shared timeline** and watch every camera stay locked to the same instant, aligned
by their audio (no manual frame-matching).
- **Fly through the reconstructed scene** in 3D — orbit freely, snap to any real camera, or
play a keyframed cinematic path.
- **See an "x-ray" HUD**: 3D anchor points (stage corners, tagged objects) projected onto every
video, tracking even when occluded by crowd or scaffolding.
- **Jump between AI-tagged moments** (bass drops, pyro, crowd waves…) on the timeline, and
correct any misclassification.
- **Click to locate things in 3D**: draw a box around an object in one or two videos and the
app triangulates its 3D position, dropping an anchor that appears everywhere at once.
- **Hear the scene in 3D** 🎧: toggle spatial audio and every camera becomes a positional
sound source at its reconstructed pose — fly toward a camera and you hear *that* spot in
the crowd get closer.
---
## Prerequisites
- **Python 3.11+** and [`uv`](https://docs.astral.sh/uv/) (or venv + pip)
- **ffmpeg** / **ffprobe** on your `PATH` (required)
- **COLMAP** (optional — reconstruction degrades gracefully without it)
- A classifier API key (optional — `GEMINI_API_KEY` by default; see `events_ai.py`)
- **Node 18+** for the frontend
| Tool | Required? | Notes |
|---|---|---|
| **Python 3.11+** + [`uv`](https://docs.astral.sh/uv/) (or venv+pip) | yes | backend |
| **ffmpeg** / **ffprobe** on `PATH` | yes | audio extraction, frame sampling, clip cutting |
| **Node 18+** | yes | frontend (Vite + Three.js) |
| **COLMAP** on `PATH` | optional | 3D reconstruction. Without it the app still runs — you get synced videos + timeline, just no 3D scene. `brew install colmap` on macOS. |
| A classifier API key | optional | AI moment labels. Default **Gemini flash** (`GEMINI_API_KEY`; model `gemini-3.1-flash-lite`, override with `FESTIVAL4D_GEMINI_MODEL`); also Claude or any OpenAI-compatible/local/OpenRouter endpoint via `FESTIVAL4D_CLASSIFIER`. Without a key you still get audio-detected candidate moments, just unlabeled. |
---
## Quickstart (synthetic demo — no footage needed)
The synthetic fixture generates three fake camera videos of the same fake stage, with known
audio offsets and known 3D geometry — so you can see the whole app working end-to-end before
you have any real footage.
```bash
# 1. backend env
uv venv --python 3.12
uv pip install -e ".[dev]"
# 2. generate the synthetic fixture project (fake videos + poses + point cloud + events)
# 2. generate the synthetic project (fake videos + poses + point cloud + seeded events)
uv run python -m festival4d synthetic
# 3. serve the API (http://127.0.0.1:8000)
uv run python -m festival4d serve
# 4. in another terminal, the frontend
# 4. in another terminal, run the frontend
cd frontend
npm install
npm run dev # http://localhost:5173
npm run dev # opens http://localhost:5173 (or the next free port)
```
## Backend CLI
Open the printed URL. You should see the 3D scene with three camera frusta around a stage box,
the three synced video players, stage-corner anchors projected onto each video, and colored
moment markers on the timeline.
---
## Capture: record straight from webcams & phones
Don't have footage yet? Record it with whatever cameras you have — USB webcams, the laptop cam,
and any phone with a browser. **No app install.** Cameras *record* clips that feed the normal
pipeline (this isn't live streaming — see `docs/ideas.md` for that).
This works because of two properties of the design: browser recordings (`.webm`, or `.mp4` on
iOS) are already ingestible formats, and **sync is audio-based** — so the devices need **no clock
sync and don't even have to start together**. They only have to record the same moment and hear
the same sound.
```bash
# capture is OPT-IN (it accepts uploads) — enable it explicitly:
FESTIVAL4D_CAPTURE=1 uv run python -m festival4d serve
```
Then open **`/capture`** on each device (a 📹 Capture link also appears in the viewer): name the
device, pick a camera + mic, hit record, hit stop — the clip uploads into `data/raw/`. The page
shows a live "who's shooting what" monitor of every connected camera. When you're done:
```bash
uv run python -m festival4d ingest && uv run python -m festival4d sync # then reconstruct / events
```
### Getting a phone connected (the HTTPS bit)
Browsers **only grant camera access on a secure origin**. A phone opening `http://<lan-ip>:8000`
gets *no camera*, silently. The easiest fix if you use Tailscale — it issues a real cert, so
there's nothing to trust manually:
```bash
tailscale serve --bg 8000 # prints https://<machine>.<tailnet>.ts.net
```
Join the phone to the tailnet and open `https://<machine>.<tailnet>.ts.net/capture`. (Without
Tailscale: any HTTPS reverse proxy works; `localhost` is also treated as secure, which is why the
laptop's own cameras work with no setup.)
### Capture tips
- **Every clip needs audio**, and all cameras must hear the same sound — that's the only thing
aligning them. Testing indoors? Play music.
- **USB bandwidth is the real limit** on multiple webcams: two 1080p cams on one controller often
fails. Drop to 720p or use separate ports/hubs.
- Follow the *Shooting tips* below for reconstruction-friendly angles.
- Capture routes stay **unmounted** unless `FESTIVAL4D_CAPTURE=1` — a public deployment must
never expose an open upload endpoint.
## Real-footage workflow
1. **Shoot / gather** 24 videos of the same performance from different positions (see
*Shooting tips* below) and drop the files into `data/raw/`.
2. **Run the pipeline:**
```bash
uv run python -m festival4d ingest # probe videos, extract 16 kHz mono audio
uv run python -m festival4d sync # GCC-PHAT audio alignment (offsets + drift)
uv run python -m festival4d reconstruct # COLMAP structure-from-motion → camera poses + point cloud
uv run python -m festival4d events # detect audio moments + AI-classify them
uv run python -m festival4d serve # serve it
```
Each step is independent and re-runnable. `sync` alone already gives you locked multi-video
playback; `reconstruct` adds the 3D scene and overlays; `events` adds the tagged timeline.
3. **Explore** in the browser (`cd frontend && npm run dev`).
If COLMAP can't reconstruct your footage (common with dark, motion-blurred, or low-overlap
clips), the pipeline says so and leaves you with the synced-video experience — it never
corrupts existing data.
To enable AI moment labels, set a key before `events`:
```bash
export GEMINI_API_KEY=... # default provider (Gemini flash, native video)
# or: export FESTIVAL4D_CLASSIFIER=claude ANTHROPIC_API_KEY=...
# or: export FESTIVAL4D_CLASSIFIER=local FESTIVAL4D_OPENAI_BASE_URL=... FESTIVAL4D_OPENAI_MODEL=...
```
---
## Using the viewer
| Action | How |
|---|---|
| Play / pause | `Space` or the ⏵ button |
| Nudge time ±1 s (±5 s) | `←` / `→` (hold `Shift`) |
| Scrub | drag the timeline |
| Pick the audio you hear | click a video (one soundtrack at a time) |
| Spatial audio | 🎧 **3D audio** (needs poses): every camera plays from its position in the scene; the mix follows the viewer camera as you fly |
| Enable/disable a video | ◉ on the video |
| Orbit the 3D scene | drag in the 3D pane |
| Snap the 3D view to a camera | click its frustum, its ⛶ button, or press `1``9` |
| Free roam again | `Esc` / `0` / **Free roam** |
| Correct a moment / annotate | click a timeline marker → panel |
| Place a 3D anchor | in the panel, **Annotate location**, then drag a box on a video (repeat on a 2nd video to triangulate) |
| Add a camera keyframe | ** Key** or `K` (captures the current free-roam view at the current time) |
| Play the keyframed path | **▶ Path** · export/import with ⤓ / ⤒ |
The top-right **sync error** panel shows each video's alignment error in ms while playing —
a dev aid; it should stay green (<50 ms) once footage is well-synced.
---
## Architecture
```
python -m festival4d synthetic # generate the synthetic fixture (M0)
python -m festival4d ingest # probe videos + extract audio (lane A / M1)
python -m festival4d sync # GCC-PHAT audio alignment (lane A / M1)
python -m festival4d reconstruct # COLMAP SfM + pose export (lane B / M2)
python -m festival4d events # audio candidates + AI classify (lane D / M7)
python -m festival4d serve # FastAPI app (M3)
data/raw/ ──ingest──▶ data/work/audio ──sync────▶ videos.offset_ms / drift_ppm ┐
──frames──▶ data/work/frames ──reconstruct──▶ camera_poses + points.ply
reference audio ──events──▶ events (audio candidates → AI labels)
SQLite (data/project.db) ◀─────────┘
FastAPI (backend/festival4d/api.py)
│ /api/manifest, /poses, /pointcloud,
│ /events, /anchors, /annotations
Vite + Three.js SPA (frontend/src)
transport (master clock) · videoGrid + overlays · scene3d · timeline
annotate (M8) · camPath (M9)
```
- **Backend** — Python: `ingest`/`audio_sync` (media + GCC-PHAT sync), `frames`/`sfm`/`geometry`
(COLMAP orchestration + pose math + triangulation), `events_ai` (moment detection + pluggable
classifiers), `resolve` (annotation→3D), `api` (FastAPI, Range-capable video serving), `db`
(SQLAlchemy/SQLite), `synthetic` (the test fixture).
- **Frontend** — vanilla JS + Three.js. A single master clock (`transport.js`) derives each
video's local time from the shared timeline and continuously corrects playback. When possible
the clock is **audio**: the selected soundtrack (`GET /api/audio/{id}`, extracted+cached
server-side) plays through WebAudio and `t_global` derives from `AudioContext.currentTime`
so every video is muted picture-only and can be seeked/rate-trimmed with zero audible
artifacts (falls back to a `performance.now()` clock + `<video>` audio if the track can't
load). `lib/pose.js` is the one COLMAP→Three.js pose conversion, mirrored from `geometry.py`
and locked by a shared test.
- **Timebase contract:** `t_video = (t_global offset_ms/1000) · (1 + drift_ppm·1e6)`; the
reference video has offset 0.
Full design + milestone history: [`OPUS_BUILD_INSTRUCTIONS.md`](OPUS_BUILD_INSTRUCTIONS.md);
the parallel build plan and per-lane status live in [`plan/`](plan/).
---
## Shooting tips (for good reconstructions)
Concert footage is genuinely hard for structure-from-motion. To give COLMAP a chance:
- **Spread the cameras out** but keep **overlapping views** — each pair of cameras should see
some of the same stage/structure. No overlap → they can't be related in 3D.
- **Keep some static structure in frame** (stage edges, truss, speaker stacks). A frame that's
all moving crowd and lights has nothing stable to triangulate.
- **Avoid pure zoom** — physically moving parallax reconstructs far better than zooming.
- **Brighter, sharper is better** — motion blur and near-dark frames are the main failure cause.
- **24 phones is the sweet spot** for a first reconstruction; more is fine but slower.
Audio sync is far more forgiving: any clips that share audible sound (the same music/claps)
will align, even across otherwise-unrelated angles.
---
## Tests
```bash
uv run pytest
uv run pytest # backend
cd frontend && npm run build # frontend typecheck/build
```
---
## Non-goals (this prototype)
No accounts/auth, no cloud storage, no realtime ingest, no NeRF/Gaussian splatting, no mobile
UI, no Docker. Single local user, one project at a time.

View File

@ -12,6 +12,28 @@ Serves the synthetic project fully so lane C can treat this API as finished:
POST /api/events/detect {t_global_s?, window_s?} -> runs M7 detection
POST /api/annotations {video_id, t_video_s, bbox:[x0,y0,x1,y1]} -> {anchor_id?, point?}
Phase 5 (foundation2; shapes frozen in plan/20-phase5.md contracts):
GET /api/beats -> beats.json body (contract #1); 404 when not yet analyzed
POST /api/director {top_n?, lead_s?} -> camPath JSON (contract #2); while lane E
is stubbed, degrades to a VALID empty path + "note"
GET /api/paths -> [{id, name, created_at}]
GET /api/paths/{id} -> {id, name, created_at, json: <parsed camPath JSON>}
POST /api/paths {name, json: <camPath JSON as text>} (validated -> 422)
DELETE /api/paths/{id}
PATCH /api/anchors/{id} {label?, color?} -> updated anchor dict
Phase 6 (foundation3 / M18; shapes frozen in plan/30-phase6.md contract #2):
GET /api/tracks -> [{id, marker_key, label, color, points:[{t_global_s,x,y,z,
quality,views}]}] (points ordered by t_global_s)
PATCH /api/tracks/{id} {label?, color?} -> updated track dict (404 on missing)
DELETE /api/tracks/{id} -> {deleted: id} (404 on missing)
POST /api/tracks/solve -> {result, tracks[, note]} runs lane H's detect->solve
(tracker_solve.run_solve); while lane H is stubbed it
degrades to a VALID empty result + "note" (house pattern).
manifest gains "has_tracks": bool.
Source videos in ``data/raw`` are mounted at ``/media`` via Starlette ``StaticFiles``,
which supports HTTP Range (required for ``<video>`` seeking; verify ``curl -H "Range:
bytes=0-100"`` -> 206). Stubbed lane entrypoints (events detection, M8 annotation
@ -52,6 +74,14 @@ config.RAW_DIR.mkdir(parents=True, exist_ok=True)
# HTTP Range-capable static serving of source videos (spec M3, pitfall #3).
app.mount("/media", StaticFiles(directory=config.RAW_DIR), name="media")
# Live capture (webcams / phones -> data/raw). OPT-IN: these routes accept file uploads, so they
# stay unmounted unless FESTIVAL4D_CAPTURE=1 — a public deployment must never expose them.
from festival4d import capture # noqa: E402 (import after app/config are ready)
if capture.capture_enabled():
app.include_router(capture.router)
log.info("capture routes ENABLED (/capture, /api/capture/*) — FESTIVAL4D_CAPTURE is set")
# ---------------------------------------------------------------------------
# Request models
@ -73,6 +103,35 @@ class AnnotationIn(BaseModel):
video_id: int
t_video_s: float
bbox: list[float] = Field(min_length=4, max_length=4) # [x0, y0, x1, y1] normalized
event_id: int | None = None # CR-2: link to the event this annotation locates (M8)
class EventPatch(BaseModel):
event_type: str | None = None
source: str | None = None
description: str | None = None
confidence: float | None = None
class AnchorPatch(BaseModel):
label: str | None = None
color: str | None = None
class DirectorIn(BaseModel):
top_n: int = 8
lead_s: float = 2.0
class PathIn(BaseModel):
# Wire key is "json" (contract #3); aliased because `json` shadows a BaseModel attr.
name: str
path_json: str = Field(alias="json")
class TrackPatch(BaseModel):
label: str | None = None
color: str | None = None
# ---------------------------------------------------------------------------
@ -119,6 +178,24 @@ def _event_dict(e) -> dict:
}
def _track_dict(t) -> dict:
"""A track with its points inlined (phase 6 contract #2), ordered by t_global_s."""
return {
"id": t.id,
"marker_key": t.marker_key,
"label": t.label,
"color": t.color,
"points": [
{
"t_global_s": p.t_global_s,
"x": p.x, "y": p.y, "z": p.z,
"quality": p.quality, "views": p.views,
}
for p in db.get_track_points(t.id)
],
}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@ -137,7 +214,14 @@ def manifest() -> dict:
return {
"videos": [_video_dict(v) for v in videos],
"t_global_max": t_global_max,
"has_capture": capture.capture_enabled(),
"has_poses": db.has_poses(),
"has_splat": config.SPLAT_PLY.exists(),
"has_tracks": db.has_tracks(), # phase 6 (M18): any solved friend track ⇒ frontend loads them
# Always False from the live server; the capsule (M17) bakes a manifest with true,
# which makes the frontend hide all write UI (phase-5 contract #5).
"capsule": False,
}
@ -159,6 +243,51 @@ def pointcloud() -> FileResponse:
)
@app.get("/api/splat")
def splat() -> FileResponse:
"""Optional 3DGS splat of the scene (see docs/modelbeast-crossover.md).
Trained externally from the same COLMAP reconstruction (e.g. MODELBEAST's
brush_train) and dropped at ``data/work/splat.ply``. The viewer prefers it
over the sparse point cloud when present.
"""
if not config.SPLAT_PLY.exists():
raise HTTPException(status_code=404, detail="no splat (train one — see docs/modelbeast-crossover.md)")
return FileResponse(
config.SPLAT_PLY,
media_type="application/octet-stream",
filename="splat.ply",
)
@app.get("/api/audio/{video_id}")
def video_audio(video_id: int) -> FileResponse:
"""Listening-quality audio track of a video (stereo AAC), for the WebAudio master clock.
Extracted lazily from the source video via ffmpeg on first request and cached in
``data/work/audio_hq/`` (re-extracted if the source file is newer). The frontend
decodes this whole file with ``decodeAudioData`` and derives ``t_global`` from
``AudioContext.currentTime`` see ``frontend/src/transport.js``.
"""
from festival4d import ingest
import subprocess
v = db.get_video(video_id)
if v is None:
raise HTTPException(status_code=404, detail=f"no video with id={video_id}")
src = config.RAW_DIR / v.filename
if not src.exists():
raise HTTPException(status_code=404, detail=f"source file missing: {v.filename}")
out = config.AUDIO_HQ_DIR / f"{video_id}.m4a"
if not out.exists() or out.stat().st_mtime < src.stat().st_mtime:
try:
ingest.extract_audio_hq(src, out)
except (subprocess.CalledProcessError, RuntimeError) as exc:
# No audio stream / no ffmpeg — the frontend falls back to <video> audio.
raise HTTPException(status_code=404, detail=f"no extractable audio: {exc}") from exc
return FileResponse(out, media_type="audio/mp4", filename=out.name)
@app.get("/api/anchors")
def get_anchors() -> list[dict]:
return [_anchor_dict(a) for a in db.get_anchors()]
@ -191,19 +320,233 @@ def detect_events(payload: DetectIn) -> dict:
return {"result": result, "events": [_event_dict(e) for e in db.get_events()]}
@app.patch("/api/events/{event_id}")
def patch_event(event_id: int, payload: EventPatch) -> dict:
"""Correct an event in place (M8). A user correction defaults ``source`` to ``'user'``."""
if db.get_event(event_id) is None:
raise HTTPException(status_code=404, detail=f"no event with id={event_id}")
source = payload.source
if source is None and (payload.event_type is not None or payload.description is not None):
source = "user" # any user-driven correction is attributed to the user
event = db.update_event(
event_id,
event_type=payload.event_type,
source=source,
description=payload.description,
confidence=payload.confidence,
)
return _event_dict(event)
@app.post("/api/annotations")
def create_annotation(payload: AnnotationIn) -> dict:
x0, y0, x1, y1 = payload.bbox
annotation = db.add_annotation(payload.video_id, payload.t_video_s, x0, y0, x1, y1)
from festival4d import resolve
x0, y0, x1, y1 = payload.bbox
if db.get_video(payload.video_id) is None:
raise HTTPException(status_code=404, detail=f"no video with id={payload.video_id}")
annotation = db.add_annotation(
payload.video_id, payload.t_video_s, x0, y0, x1, y1, event_id=payload.event_id
)
# M8: cast a ray through the bbox center; triangulate against another view of the same
# event, else fall back to the nearest point-cloud point / centroid depth.
result = resolve.resolve_annotation(
payload.video_id, payload.t_video_s, payload.bbox, event_id=payload.event_id
)
anchor = None
superseded = False
if result.point is not None:
px, py, pz = result.point
label = "annotation"
if payload.event_id is not None:
ev = db.get_event(payload.event_id)
if ev is not None:
label = ev.description or ev.event_type or label
# One anchor per event: the first annotation creates it (a single-view fallback); a later
# view that TRIANGULATES supersedes the anchor in place rather than dropping a sibling. A
# later fallback links to the existing anchor without moving it (don't regress a good
# triangulation). Annotations with no event stay independent (each makes its own anchor).
existing_id = _existing_event_anchor_id(payload.event_id) if payload.event_id else None
if existing_id is None:
anchor = db.add_anchor(label, px, py, pz, None)
elif result.method == "triangulated":
anchor = db.update_anchor(existing_id, px, py, pz, label=label)
superseded = True
else:
anchor = db.get_anchor(existing_id)
if anchor is not None:
db.set_annotation_anchor(annotation.id, anchor.id)
# M8 resolution (triangulate / nearest-point) is integration work; geometry stubs raise
# until lane B lands. Store the annotation now; resolve to an anchor later.
anchor_id = None
point = None
note = "annotation stored; 3D resolution is M8/integration work"
return {
"annotation_id": annotation.id,
"anchor_id": anchor_id,
"point": point,
"note": note,
"anchor_id": anchor.id if anchor else None,
"point": result.point,
"method": result.method,
"gap": result.gap,
"superseded": superseded,
"anchor": _anchor_dict(anchor) if anchor else None,
}
def _existing_event_anchor_id(event_id: int) -> int | None:
"""The anchor resolved from a prior annotation of this event (most recent), or None."""
for ann in reversed(db.get_annotations(event_id=event_id)):
if ann.resolved_anchor_id is not None:
return ann.resolved_anchor_id
return None
# ---------------------------------------------------------------------------
# Phase 5 (foundation2). Shapes are frozen in plan/20-phase5.md "Contracts".
# ---------------------------------------------------------------------------
@app.get("/api/beats")
def get_beats() -> dict:
"""The beats.json analysis (contract #1). 404 until `python -m festival4d features` runs."""
import json
if not config.BEATS_JSON.exists():
raise HTTPException(status_code=404, detail="no beat analysis (run `python -m festival4d features`)")
return json.loads(config.BEATS_JSON.read_text())
@app.post("/api/director")
def run_director(payload: DirectorIn) -> dict:
"""Auto-director (M11) -> camPath JSON. Degrades to a valid EMPTY path while stubbed,
so the response always round-trips through camPath.fromJSON (contract #2)."""
from festival4d import director
try:
return director.generate_path(top_n=payload.top_n, lead_s=payload.lead_s)
except NotImplementedError as exc:
log.info("auto-director not implemented yet: %s", exc)
return {"version": 1, "keyframes": [], "note": "auto-director not implemented yet (lane E / M11)"}
def _validate_campath(text: str) -> None:
"""422 unless ``text`` is the frozen camPath JSON (contract #2). Lane G owns hardening."""
import json
try:
data = json.loads(text)
except ValueError:
raise HTTPException(status_code=422, detail="path json is not valid JSON")
ok = (
isinstance(data, dict)
and data.get("version") == 1
and isinstance(data.get("keyframes"), list)
and all(
isinstance(k, dict)
and isinstance(k.get("t_global"), (int, float))
and isinstance(k.get("pos"), list) and len(k["pos"]) == 3
and isinstance(k.get("quat"), list) and len(k["quat"]) == 4
and isinstance(k.get("fov"), (int, float))
for k in data.get("keyframes", [])
)
)
if not ok:
raise HTTPException(status_code=422, detail="not a camPath JSON (see plan/20-phase5.md contract #2)")
def _path_dict(p, with_json: bool = False) -> dict:
import json
d = {"id": p.id, "name": p.name, "created_at": p.created_at}
if with_json:
d["json"] = json.loads(p.path_json)
return d
@app.get("/api/paths")
def get_paths() -> list[dict]:
return [_path_dict(p) for p in db.get_paths()]
@app.get("/api/paths/{path_id}")
def get_path(path_id: int) -> dict:
p = db.get_path(path_id)
if p is None:
raise HTTPException(status_code=404, detail=f"no path with id={path_id}")
return _path_dict(p, with_json=True)
@app.post("/api/paths")
def create_path(payload: PathIn) -> dict:
_validate_campath(payload.path_json)
return _path_dict(db.add_path(payload.name, payload.path_json), with_json=True)
@app.delete("/api/paths/{path_id}")
def delete_path(path_id: int) -> dict:
if not db.delete_path(path_id):
raise HTTPException(status_code=404, detail=f"no path with id={path_id}")
return {"deleted": path_id}
@app.patch("/api/anchors/{anchor_id}")
def patch_anchor(anchor_id: int, payload: AnchorPatch) -> dict:
"""Rename / recolor an anchor in place (M13, contract #4)."""
try:
anchor = db.patch_anchor(anchor_id, label=payload.label, color=payload.color)
except KeyError:
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
return _anchor_dict(anchor)
@app.delete("/api/anchors/{anchor_id}")
def delete_anchor(anchor_id: int) -> dict:
"""Remove an anchor (M8 / Phase 4). Unlinks any annotations that resolved to it."""
if not db.delete_anchor(anchor_id):
raise HTTPException(status_code=404, detail=f"no anchor with id={anchor_id}")
return {"deleted": anchor_id}
# ---------------------------------------------------------------------------
# Phase 6 (foundation3 / M18). Friend tracks. Shapes frozen in plan/30-phase6.md contract #2.
# ---------------------------------------------------------------------------
@app.get("/api/tracks")
def get_tracks() -> list[dict]:
"""All friend tracks with their points inlined (contract #2)."""
return [_track_dict(t) for t in db.get_tracks()]
@app.patch("/api/tracks/{track_id}")
def patch_track(track_id: int, payload: TrackPatch) -> dict:
"""Rename / recolor a track in place (M21 friends panel)."""
try:
track = db.patch_track(track_id, label=payload.label, color=payload.color)
except KeyError:
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
return _track_dict(track)
@app.delete("/api/tracks/{track_id}")
def delete_track(track_id: int) -> dict:
"""Delete a track and its points (M21)."""
if not db.delete_track(track_id):
raise HTTPException(status_code=404, detail=f"no track with id={track_id}")
return {"deleted": track_id}
@app.post("/api/tracks/solve")
def solve_tracks() -> dict:
"""Run marker detection + track solving (M19+M20) and return the resulting tracks.
Dispatches into lane H's ``tracker_solve.run_solve`` (which itself consumes
``tracker_detect``'s detections). While lane H is stubbed both raise
``NotImplementedError``; we catch it and degrade to a VALID empty result + a note, so the
frozen route never 500s the house pattern (mirrors ``POST /api/events/detect``)."""
from festival4d import tracker_solve
try:
result = tracker_solve.run_solve()
except NotImplementedError as exc:
log.info("track solving not implemented yet: %s", exc)
return {
"result": None,
"note": "friend-track solving not implemented yet (lane H / M19-M20)",
"tracks": [_track_dict(t) for t in db.get_tracks()],
}
return {"result": result, "tracks": [_track_dict(t) for t in db.get_tracks()]}

View File

@ -0,0 +1,187 @@
"""Audio features: beats & onsets (spec M10, lane E).
Contract (frozen at foundation2 merge phase-5 contract #1):
:func:`run_features` loads the **reference** video's ingest WAV (16 kHz mono from
``config.AUDIO_DIR``; the reference timeline *is* ``t_global``), runs beat/tempo tracking
and onset-strength detection (librosa is already a dependency), and writes
``config.BEATS_JSON``::
{"tempo_bpm": float | null,
"beats_s": [float, ...],
"onsets": [{"t_global_s": float, "strength": float 0..1}, ...],
"generated_by": "festival4d.audio_features"}
All times are ``t_global`` seconds. Quiet / short / beatless audio must produce a
*valid, empty* result (``tempo_bpm: null``, empty lists) never a crash. Returns a
small summary dict for the CLI log. ``GET /api/beats`` serves the written file.
Implementation notes
--------------------
- Analysis runs on the reference's local timeline and maps to ``t_global`` via the frozen
:func:`config.t_global_from_video` (identity for a true reference: offset 0, drift 0;
correct either way if we ever fall back to a non-zero-offset "reference").
- ``HOP_LENGTH = 160`` (10 ms at 16 kHz): librosa's default 512-sample hop is a 32 ms
grid at this rate, which quantizes beats past the ±50 ms acceptance bound. 10 ms frames
recover the synthetic 120 BPM pulse grid to ~30 ms / ±0.001 BPM (see test_audio_features).
- Reported ``tempo_bpm`` is the median inter-beat interval of the tracked beats (more
accurate than the tracker's coarse tempo prior) and ``null`` when < 2 beats are found.
- Missing prerequisites (no videos ingested / WAV absent) degrade to a summary dict with a
``note`` and write nothing mirroring ``events_ai.run_events`` house style; degenerate
*content* (silence, too short, beatless) writes the valid-empty JSON above.
"""
from __future__ import annotations
import json
import logging
import wave
from pathlib import Path
import numpy as np
from festival4d import config, db
log = logging.getLogger("festival4d.audio_features")
GENERATED_BY = "festival4d.audio_features"
HOP_LENGTH = 160 # analysis hop in samples: 10 ms at the 16 kHz ingest rate
MIN_DURATION_S = 1.0 # anything shorter is "no usable audio" -> valid-empty result
SILENCE_PEAK = 1e-4 # |peak| below this is treated as silence -> valid-empty result
def _empty_result() -> dict:
"""The valid-empty beats.json body (contract #1) for silent/short/beatless audio."""
return {
"tempo_bpm": None,
"beats_s": [],
"onsets": [],
"generated_by": GENERATED_BY,
}
def _select_reference(videos: list) -> "object | None":
"""The reference video (spec §2: offset 0). Fall back to the smallest |offset|, else first."""
if not videos:
return None
exact = [v for v in videos if v.offset_ms == 0]
if exact:
return exact[0]
with_offset = [v for v in videos if v.offset_ms is not None]
if with_offset:
return min(with_offset, key=lambda v: abs(v.offset_ms))
return videos[0]
def _read_wav_mono(path: Path) -> tuple[np.ndarray, int]:
"""Read a 16-bit PCM WAV (the ingest format) as float64 mono in [-1, 1]."""
with wave.open(str(path), "rb") as w:
sr = w.getframerate()
channels = w.getnchannels()
frames = w.readframes(w.getnframes())
pcm = np.frombuffer(frames, dtype="<i2").astype(np.float64) / 32767.0
if channels > 1:
pcm = pcm.reshape(-1, channels).mean(axis=1)
return pcm, sr
def analyze(y: np.ndarray, sr: int) -> dict:
"""Beat/tempo + onset-strength analysis of a mono signal on its *local* timeline.
Returns the contract #1 dict with times in local (signal) seconds — the caller maps
them to ``t_global``. Degenerate input (short/silent/beatless) returns the valid-empty
shape; this function never raises on quiet or short audio.
"""
import librosa # deferred: keeps `festival4d` import light for the API/CLI
y = np.asarray(y, dtype=np.float32).reshape(-1)
if len(y) < MIN_DURATION_S * sr or float(np.max(np.abs(y), initial=0.0)) < SILENCE_PEAK:
return _empty_result()
try:
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP_LENGTH)
_tempo, beat_frames = librosa.beat.beat_track(
onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH
)
beat_times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=HOP_LENGTH)
onset_frames = librosa.onset.onset_detect(
onset_envelope=onset_env, sr=sr, hop_length=HOP_LENGTH
)
onset_times = librosa.frames_to_time(onset_frames, sr=sr, hop_length=HOP_LENGTH)
except Exception as exc: # librosa internals on weird signals: degrade, never crash
log.warning("audio_features: analysis failed (%s) — returning empty result", exc)
return _empty_result()
beats = [float(t) for t in np.atleast_1d(beat_times)]
if len(beats) >= 2:
tempo_bpm: float | None = float(60.0 / np.median(np.diff(beats)))
elif beats:
tempo_bpm = float(np.atleast_1d(_tempo)[0]) or None
else:
tempo_bpm = None
env_max = float(onset_env.max()) if len(onset_env) else 0.0
onsets = []
if env_max > 0:
for frame, t in zip(np.atleast_1d(onset_frames), np.atleast_1d(onset_times)):
strength = float(onset_env[int(frame)]) / env_max
onsets.append({"t_global_s": float(t), "strength": min(1.0, max(0.0, strength))})
return {
"tempo_bpm": tempo_bpm,
"beats_s": beats,
"onsets": onsets,
"generated_by": GENERATED_BY,
}
def run_features() -> dict:
"""Analyze the reference soundtrack and write ``data/work/beats.json`` (see module doc)."""
videos = db.get_videos()
reference = _select_reference(videos)
if reference is None:
log.warning("audio_features: no videos in the project — run ingest first")
return {"status": "no_videos", "note": "no videos in the project (run ingest first)"}
wav_path = config.AUDIO_DIR / f"{reference.id}.wav" # ingest.audio_wav_path convention
if not wav_path.exists():
log.warning("audio_features: reference WAV missing at %s — run ingest first", wav_path)
return {
"status": "no_audio",
"note": f"reference audio not found ({wav_path.name}) — run `python -m festival4d ingest`",
}
y, sr = _read_wav_mono(wav_path)
result = analyze(y, sr)
# Map local (reference) times onto the master timeline via the frozen timebase helpers.
# For a true reference (offset 0, drift 0) this is the identity.
offset_ms = reference.offset_ms or 0.0
drift_ppm = reference.drift_ppm or 0.0
if offset_ms != 0.0 or drift_ppm != 0.0:
result["beats_s"] = [
float(config.t_global_from_video(t, offset_ms, drift_ppm)) for t in result["beats_s"]
]
for onset in result["onsets"]:
onset["t_global_s"] = float(
config.t_global_from_video(onset["t_global_s"], offset_ms, drift_ppm)
)
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
config.BEATS_JSON.write_text(json.dumps(result, indent=2))
log.info(
"audio_features: %s beats (tempo %s BPM), %s onsets -> %s",
len(result["beats_s"]),
f"{result['tempo_bpm']:.1f}" if result["tempo_bpm"] else "null",
len(result["onsets"]),
config.BEATS_JSON,
)
return {
"status": "ok",
"reference_video_id": reference.id,
"tempo_bpm": result["tempo_bpm"],
"beats": len(result["beats_s"]),
"onsets": len(result["onsets"]),
"beats_json": str(config.BEATS_JSON),
}

View File

@ -68,29 +68,15 @@ def _peak_ratio(env: np.ndarray, peak: int, sr: int, guard_s: float = 0.004) ->
return main / second
def gcc_phat(sig: np.ndarray, ref: np.ndarray, sr: int,
max_lag_s: float | None = None) -> tuple[float, float]:
"""GCC-PHAT time delay of ``sig`` relative to ``ref`` (spec M1, lane A).
Returns ``(offset_s, confidence)`` where ``offset_s`` is positive when ``sig`` *lags*
``ref`` (i.e. ``sig[n] ref[n offset_s·sr]``), and confidence is the
peak-to-second-peak ratio.
"""
sig = np.asarray(sig, dtype=np.float64)
ref = np.asarray(ref, dtype=np.float64)
# Remove DC so the whitened correlation keys on structure, not a bias term.
sig = sig - sig.mean()
ref = ref - ref.mean()
n = len(sig) + len(ref) # linear (zero-padded) correlation → no circular wrap
SIG = np.fft.rfft(sig, n)
REF = np.fft.rfft(ref, n)
def _whitened_cc(SIG: np.ndarray, REF: np.ndarray, n: int) -> np.ndarray:
"""PHAT-whitened cross-correlation from precomputed rFFTs (lag 0 at index 0)."""
R = SIG * np.conj(REF)
R /= np.abs(R) + _EPS # PHAT weighting: flatten magnitude, keep phase
cc = np.fft.irfft(R, n) # lag 0 at index 0; negative lags wrap to the tail
return np.fft.irfft(R, n) # lag 0 at index 0; negative lags wrap to the tail
max_lag = n // 2 if max_lag_s is None else int(round(max_lag_s * sr))
max_lag = max(1, min(max_lag, n // 2))
def _peak_offset(cc: np.ndarray, sr: int, max_lag: int) -> tuple[float, float]:
"""Peak of a whitened correlation → ``(offset_s, confidence)`` over ±``max_lag`` samples."""
# Re-center to contiguous lags [max_lag … +max_lag].
cc = np.concatenate((cc[-max_lag:], cc[:max_lag + 1]))
env = np.abs(cc)
@ -107,6 +93,27 @@ def gcc_phat(sig: np.ndarray, ref: np.ndarray, sr: int,
return offset_s, _peak_ratio(env, peak, sr)
def gcc_phat(sig: np.ndarray, ref: np.ndarray, sr: int,
max_lag_s: float | None = None) -> tuple[float, float]:
"""GCC-PHAT time delay of ``sig`` relative to ``ref`` (spec M1, lane A).
Returns ``(offset_s, confidence)`` where ``offset_s`` is positive when ``sig`` *lags*
``ref`` (i.e. ``sig[n] ref[n offset_s·sr]``), and confidence is the
peak-to-second-peak ratio.
"""
sig = np.asarray(sig, dtype=np.float64)
ref = np.asarray(ref, dtype=np.float64)
# Remove DC so the whitened correlation keys on structure, not a bias term.
sig = sig - sig.mean()
ref = ref - ref.mean()
n = len(sig) + len(ref) # linear (zero-padded) correlation → no circular wrap
cc = _whitened_cc(np.fft.rfft(sig, n), np.fft.rfft(ref, n), n)
max_lag = n // 2 if max_lag_s is None else int(round(max_lag_s * sr))
max_lag = max(1, min(max_lag, n // 2))
return _peak_offset(cc, sr, max_lag)
# ---------------------------------------------------------------------------
# Pairwise graph
# ---------------------------------------------------------------------------
@ -116,13 +123,30 @@ def pairwise_offsets(signals: dict[int, np.ndarray], sr: int) -> list[dict]:
``signals`` maps ``video_id -> mono samples``. Returns a list of
``{a, b, offset_s, confidence}`` edges, where ``offset_s`` is the *relative start
offset* ``offset_a offset_b`` in seconds (see the module's sign conventions).
Each signal's rFFT is computed **once** at a shared padded length and reused across
every pair O(K) FFTs + O() cheap spectrum products, instead of the O() full
FFTs a per-pair :func:`gcc_phat` would cost. The shared length (2·max_len any
pair's la+lb) keeps the correlation linear (no circular wrap), and each pair's lag
search stays capped at ±(la+lb)/2 the same window the per-pair transform used.
"""
ids = sorted(signals)
if len(ids) < 2:
return []
lengths = {i: len(signals[i]) for i in ids}
n = 2 * max(lengths.values())
spectra: dict[int, np.ndarray] = {}
for i in ids:
s = np.asarray(signals[i], dtype=np.float64)
s = s - s.mean() # same DC removal as gcc_phat
spectra[i] = np.fft.rfft(s, n)
edges: list[dict] = []
for i in range(len(ids)):
for j in range(i + 1, len(ids)):
a, b = ids[i], ids[j]
delay_s, conf = gcc_phat(signals[a], signals[b], sr)
cc = _whitened_cc(spectra[a], spectra[b], n)
max_lag = max(1, min(n // 2, (lengths[a] + lengths[b]) // 2))
delay_s, conf = _peak_offset(cc, sr, max_lag)
edges.append({"a": a, "b": b, "offset_s": -delay_s, "confidence": conf})
return edges

View File

@ -0,0 +1,317 @@
"""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``):
- copy ``frontend/dist`` (error clearly if missing tell the user to ``npm run build``);
- copy media from ``config.RAW_DIR``;
- bake every read-only API response to files at the *same relative paths*
(``api/manifest``, ``api/events``, ``api/anchors``, ``api/tracks``, ``api/beats``,
``api/videos/{id}/poses``, ``api/pointcloud``, ``api/splat``, ``api/audio/{id}``
extracting audio via ``ingest.extract_audio_hq`` if not yet cached);
- inject ``<script>window.__F4D_API_BASE__=""</script>`` into the copied ``index.html``;
- set ``"capsule": true`` in the baked manifest (the frontend hides all write UI);
- ship a stdlib ``serve.py`` in the bundle root that serves with HTTP Range support
(video seeking needs 206s; plain ``http.server`` can't — pitfall #4).
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
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())
# Friend tracks (M18/M20) — read-only in a capsule so ribbons + follow still work zero-backend.
_bake_json(api_dir / "tracks", api.get_tracks())
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,166 @@
"""Live capture — USB webcams, laptop cams, and phones -> ``data/raw`` (Phase 5).
**Records, it doesn't stream.** Each device records a clip *with audio* and uploads it; the normal
offline pipeline (``ingest -> sync -> reconstruct -> events``) takes it from there. This fits the
app's design almost for free:
- Browser ``MediaRecorder`` emits WebM (Safari/iOS: MP4) both are already in ingest's
``_VIDEO_EXTS``, so no format work is needed.
- Sync is **audio-based** (GCC-PHAT), so capture devices need **no clock sync and no
synchronized start** they only have to record the same moment and hear the same sound.
Every recording MUST carry an audio track or ``sync`` has nothing to align.
A lightweight device registry powers the "who's shooting what" monitor: each device posts a
heartbeat with its status and a small JPEG snapshot (~1 fps), so the capture page can show every
camera without a WebRTC stack.
**Disabled by default.** These routes accept file uploads; they must never be exposed on a public
deployment. Set ``FESTIVAL4D_CAPTURE=1`` to mount them (see :func:`capture_enabled`).
"""
from __future__ import annotations
import logging
import os
import re
import time
from datetime import datetime
from pathlib import Path
from fastapi import APIRouter, Form, HTTPException, UploadFile
from fastapi.responses import HTMLResponse
from pydantic import BaseModel, Field
from festival4d import config
log = logging.getLogger("festival4d.capture")
router = APIRouter()
STATIC_DIR = Path(__file__).resolve().parent / "static"
# Containers a browser MediaRecorder can produce, intersected with what ingest accepts.
ALLOWED_EXTS = {".webm", ".mp4", ".mov", ".mkv"}
MAX_UPLOAD_BYTES = int(os.environ.get("FESTIVAL4D_MAX_UPLOAD_MB", "2048")) * 1024 * 1024
MAX_SNAPSHOT_CHARS = 300_000 # data-URL cap for a heartbeat thumbnail
DEVICE_STALE_S = 15.0 # drop devices that stop heartbeating
def capture_enabled() -> bool:
"""Capture routes are opt-in — they accept uploads, so never on by default."""
return os.environ.get("FESTIVAL4D_CAPTURE", "").strip().lower() in {"1", "true", "yes", "on"}
# ---------------------------------------------------------------------------
# Device registry (in-memory, ephemeral — it only powers the live monitor)
# ---------------------------------------------------------------------------
_devices: dict[str, dict] = {}
class Heartbeat(BaseModel):
device_id: str = Field(min_length=1, max_length=64)
name: str = Field(default="camera", max_length=64)
status: str = Field(default="idle", max_length=16) # idle | recording
elapsed_s: float = 0.0
snapshot: str | None = None # data:image/jpeg;base64,...
def _prune() -> None:
now = time.monotonic()
for did in [d for d, v in _devices.items() if now - v["_seen"] > DEVICE_STALE_S]:
_devices.pop(did, None)
def _slug(name: str) -> str:
"""Filesystem-safe slug from a user-supplied device name."""
s = re.sub(r"[^a-zA-Z0-9]+", "-", name).strip("-").lower()
return (s or "cam")[:40]
def _safe_raw_path(device_name: str, filename: str) -> Path:
"""Build a sanitized destination inside RAW_DIR. Never trusts the client filename."""
ext = Path(filename or "").suffix.lower()
if ext not in ALLOWED_EXTS:
raise HTTPException(
status_code=400,
detail=f"unsupported container '{ext or '?'}' (allowed: {sorted(ALLOWED_EXTS)})",
)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
dest = (config.RAW_DIR / f"{_slug(device_name)}-{stamp}{ext}").resolve()
# Defence in depth: the slug can't traverse, but verify containment anyway.
if not str(dest).startswith(str(config.RAW_DIR.resolve()) + os.sep):
raise HTTPException(status_code=400, detail="invalid destination path")
return dest
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("/capture", response_class=HTMLResponse)
def capture_page() -> str:
"""The standalone capture page (open this on a phone over HTTPS — see README)."""
page = STATIC_DIR / "capture.html"
if not page.is_file():
raise HTTPException(status_code=500, detail="capture.html missing from the install")
return page.read_text(encoding="utf-8")
@router.post("/api/capture/upload")
async def upload(file: UploadFile, device_name: str = Form("camera")) -> dict:
"""Stream an uploaded clip into ``data/raw`` under a sanitized name."""
dest = _safe_raw_path(device_name, file.filename or "")
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
written = 0
try:
with open(dest, "wb") as out:
while chunk := await file.read(1 << 20): # 1 MiB
written += len(chunk)
if written > MAX_UPLOAD_BYTES:
raise HTTPException(
status_code=413,
detail=f"upload exceeds {MAX_UPLOAD_BYTES // (1024*1024)} MB",
)
out.write(chunk)
except Exception:
dest.unlink(missing_ok=True) # never leave a partial file for ingest to trip on
raise
if written == 0:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="empty upload")
log.info("capture: saved %s (%.1f MB) from %r", dest.name, written / 1e6, device_name)
return {"filename": dest.name, "bytes": written, "next": "run `python -m festival4d ingest`"}
@router.post("/api/capture/heartbeat")
def heartbeat(hb: Heartbeat) -> dict:
"""Register/refresh a capture device and its latest thumbnail (powers the monitor)."""
snap = hb.snapshot
if snap and len(snap) > MAX_SNAPSHOT_CHARS:
snap = None # oversized thumbnail: keep the device, drop the image
entry = _devices.setdefault(hb.device_id, {})
entry.update(
{
"device_id": hb.device_id,
"name": hb.name,
"status": hb.status,
"elapsed_s": hb.elapsed_s,
"_seen": time.monotonic(),
}
)
if snap:
entry["snapshot"] = snap
_prune()
return {"ok": True, "devices": len(_devices)}
@router.get("/api/capture/devices")
def devices() -> list[dict]:
"""Live capture devices (stale ones drop off after ~15 s)."""
_prune()
return [
{k: v for k, v in d.items() if not k.startswith("_")}
for d in sorted(_devices.values(), key=lambda d: d["name"])
]

View File

@ -4,7 +4,8 @@ FROZEN after foundation. Lanes fill in the bodies of the functions this dispatch
to (in ``ingest.py``, ``audio_sync.py``, ``sfm.py``, ``events_ai.py``); they never
edit this file or ``api.py``.
Subcommands: ``synthetic | ingest | sync | reconstruct | events | serve``.
Subcommands: ``synthetic | ingest | sync | reconstruct | events | features | direct |
track | capsule | serve``.
Every subcommand dispatches to a single lane entrypoint. While a lane is still a stub,
its entrypoint raises :class:`NotImplementedError`; we catch that here and print a clear
@ -57,6 +58,49 @@ def _cmd_events(args: argparse.Namespace) -> int:
return 0
def _cmd_features(args: argparse.Namespace) -> int:
from festival4d import audio_features
result = audio_features.run_features()
log.info("features: %s", result)
return 0
def _cmd_direct(args: argparse.Namespace) -> int:
import json
from festival4d import director
path = director.generate_path(top_n=args.top_n, lead_s=args.lead_s)
print(json.dumps(path, indent=2))
return 0
def _cmd_track(args: argparse.Namespace) -> int:
"""Detect wearable markers and solve them into 3D friend tracks (lane H / M19-M20).
Runs detect -> solve by default; ``--detect-only`` / ``--solve-only`` run one stage.
Both stages dispatch into lane-H stubs, so while unimplemented this prints the
"not implemented yet" message and exits 2 (same as every other stubbed subcommand)."""
from festival4d import tracker_detect, tracker_solve
if not args.solve_only:
result = tracker_detect.run_detect()
log.info("track detect: %s", result)
if not args.detect_only:
result = tracker_solve.run_solve()
log.info("track solve: %s", result)
return 0
def _cmd_capsule(args: argparse.Namespace) -> int:
from festival4d import capsule
result = capsule.build_capsule(out_dir=args.out)
log.info("capsule: %s", result)
return 0
def _cmd_serve(args: argparse.Namespace) -> int:
import uvicorn
@ -97,6 +141,28 @@ def build_parser() -> argparse.ArgumentParser:
help="window width in seconds when --t-global-s is given")
p_evt.set_defaults(func=_cmd_events)
p_feat = sub.add_parser("features", help="beat/onset analysis of the reference audio (lane E / M10)")
p_feat.set_defaults(func=_cmd_features)
p_dir = sub.add_parser("direct", help="auto-director: events -> camPath JSON on stdout (lane E / M11)")
p_dir.add_argument("--top-n", dest="top_n", type=int, default=8,
help="number of top-confidence events to cover (default 8)")
p_dir.add_argument("--lead-s", dest="lead_s", type=float, default=2.0,
help="seconds of lead-in before each event (default 2.0)")
p_dir.set_defaults(func=_cmd_direct)
p_trk = sub.add_parser("track", help="detect markers + solve friend tracks (lane H / M19-M20)")
trk_mode = p_trk.add_mutually_exclusive_group()
trk_mode.add_argument("--detect-only", dest="detect_only", action="store_true",
help="run marker detection only (write detections.json)")
trk_mode.add_argument("--solve-only", dest="solve_only", action="store_true",
help="run track solving only (from an existing detections.json)")
p_trk.set_defaults(func=_cmd_track, detect_only=False, solve_only=False)
p_cap = sub.add_parser("capsule", help="bake a zero-backend shareable bundle (lane G / M17)")
p_cap.add_argument("--out", default=None, help="output dir (default data/capsule/)")
p_cap.set_defaults(func=_cmd_capsule)
p_srv = sub.add_parser("serve", help="run the FastAPI app (M3)")
p_srv.add_argument("--host", default=config.API_HOST)
p_srv.add_argument("--port", type=int, default=config.API_PORT)

View File

@ -35,13 +35,19 @@ 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
DETECTIONS_JSON = WORK_DIR / "detections.json" # marker detections (phase 6 / M19, contract #3)
TRACK_TRUTH_JSON = WORK_DIR / "track_truth.json" # synthetic marker ground truth (phase 6 / M18)
# ---------------------------------------------------------------------------
# Constants

View File

@ -12,6 +12,9 @@ Schema (spec §2)::
anchors(id, label, x,y,z, color)
events(id, t_global_s, duration_s, event_type, confidence, description, source)
annotations(id, video_id FK, t_video_s, x0,y0,x1,y1, resolved_anchor_id FK NULL)
paths(id, name, created_at, path_json) # phase 5 (M16): saved camPath JSON, as text
tracks(id, marker_key, label, color, created_at) # phase 6 (M18/M20): friend tracks
track_points(id, track_id FK, t_global_s, x,y,z, quality, views) # ordered by t_global_s
Engine management: :func:`init_engine` (re)binds the module to a SQLite file. It defaults
to ``config.DB_PATH`` but tests point it at a temp file. Helpers open and commit their own
@ -35,6 +38,7 @@ from sqlalchemy import (
create_engine,
delete,
select,
update,
)
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column, sessionmaker
@ -120,11 +124,64 @@ class Annotation(Base):
y0: Mapped[float] = mapped_column(Float, nullable=False)
x1: Mapped[float] = mapped_column(Float, nullable=False)
y1: Mapped[float] = mapped_column(Float, nullable=False)
# Nullable link to the event this annotation locates (CR-2, M8). Two annotations sharing an
# event_id from different videos triangulate to a 3D anchor. Nullable => additive/back-compat.
event_id: Mapped[int | None] = mapped_column(ForeignKey("events.id"), nullable=True)
resolved_anchor_id: Mapped[int | None] = mapped_column(
ForeignKey("anchors.id"), nullable=True
)
class SavedPath(Base):
"""A saved keyframed camera path (phase 5 contract #3). ``path_json`` is the frozen
camPath JSON (``{"version": 1, "keyframes": [...]}``) stored verbatim as text
the DB never interprets it beyond POST-time validation in the API layer."""
__tablename__ = "paths"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
created_at: Mapped[str] = mapped_column(String, nullable=False)
path_json: Mapped[str] = mapped_column(String, nullable=False)
class Track(Base):
"""A friend track (phase 6 contract #2): a wearable marker's identity + metadata.
``marker_key`` is the detector-emitted identity ``"hue:<opencv_hue>"`` for a color
marker or ``"code:<id>"`` for a blink badge (see ``tracker_detect``). The per-timestep
3D path lives in :class:`TrackPoint`. ``label`` / ``color`` are user-editable.
"""
__tablename__ = "tracks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
marker_key: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str | None] = mapped_column(String, nullable=True)
color: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[str] = mapped_column(String, nullable=False)
class TrackPoint(Base):
"""One solved position of a track at a master-timeline instant (phase 6 contract #2).
Coordinates are **Three.js scene space**. ``views`` = number of cameras used to solve the
point (1 single-view ground-plane fallback, contract #5); ``quality`` is 0..1.
Points are always read ordered by ``t_global_s``.
"""
__tablename__ = "track_points"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
track_id: Mapped[int] = mapped_column(ForeignKey("tracks.id"), nullable=False, index=True)
t_global_s: Mapped[float] = mapped_column(Float, nullable=False)
x: Mapped[float] = mapped_column(Float, nullable=False)
y: Mapped[float] = mapped_column(Float, nullable=False)
z: Mapped[float] = mapped_column(Float, nullable=False)
quality: Mapped[float | None] = mapped_column(Float, nullable=True)
views: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ---------------------------------------------------------------------------
# Engine / session management
# ---------------------------------------------------------------------------
@ -315,6 +372,75 @@ def get_anchors() -> list[Anchor]:
return list(s.scalars(select(Anchor).order_by(Anchor.id)))
def get_anchor(anchor_id: int) -> Anchor | None:
with session_scope() as s:
return s.get(Anchor, anchor_id)
def update_anchor(
anchor_id: int,
x: float,
y: float,
z: float,
label: str | None = None,
color: str | None = None,
) -> Anchor:
"""Move an anchor in place (M8 supersede). Position always updates; label/color only if given.
Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
raise KeyError(f"no anchor with id={anchor_id}")
anchor.x, anchor.y, anchor.z = x, y, z
if label is not None:
anchor.label = label
if color is not None:
anchor.color = color
s.flush()
s.refresh(anchor)
return anchor
def patch_anchor(anchor_id: int, label: str | None = None, color: str | None = None) -> Anchor:
"""Rename / recolor an anchor without moving it (phase 5 contract #4, M13).
Only provided fields change. Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
raise KeyError(f"no anchor with id={anchor_id}")
if label is not None:
anchor.label = label
if color is not None:
anchor.color = color
s.flush()
s.refresh(anchor)
return anchor
def delete_anchor(anchor_id: int) -> bool:
"""Delete an anchor, first unlinking any annotations that resolved to it.
Returns ``True`` if a row was deleted, ``False`` if the id was unknown. SQLite doesn't
enforce the FK by default, so we NULL ``annotations.resolved_anchor_id`` explicitly to keep
the link consistent.
"""
with session_scope() as s:
anchor = s.get(Anchor, anchor_id)
if anchor is None:
return False
s.execute(
update(Annotation)
.where(Annotation.resolved_anchor_id == anchor_id)
.values(resolved_anchor_id=None)
)
s.delete(anchor)
return True
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@ -356,6 +482,172 @@ def clear_events(source: str | None = None) -> int:
return int(result.rowcount or 0)
def get_event(event_id: int) -> Event | None:
with session_scope() as s:
return s.get(Event, event_id)
def update_event(
event_id: int,
event_type: str | None = None,
source: str | None = None,
description: str | None = None,
confidence: float | None = None,
) -> Event:
"""Patch an existing event in place (M8 correction). Only provided fields change.
Returns the updated row; raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
event = s.get(Event, event_id)
if event is None:
raise KeyError(f"no event with id={event_id}")
if event_type is not None:
event.event_type = event_type
if source is not None:
event.source = source
if description is not None:
event.description = description
if confidence is not None:
event.confidence = confidence
s.flush()
s.refresh(event)
return event
# ---------------------------------------------------------------------------
# Saved camera paths (phase 5 / M16)
# ---------------------------------------------------------------------------
def add_path(name: str, path_json: str) -> SavedPath:
with session_scope() as s:
path = SavedPath(name=name, path_json=path_json, created_at=_now_iso())
s.add(path)
s.flush()
s.refresh(path)
return path
def get_paths() -> list[SavedPath]:
with session_scope() as s:
return list(s.scalars(select(SavedPath).order_by(SavedPath.id)))
def get_path(path_id: int) -> SavedPath | None:
with session_scope() as s:
return s.get(SavedPath, path_id)
def delete_path(path_id: int) -> bool:
"""Delete a saved path. Returns ``True`` if a row was deleted, ``False`` if unknown."""
with session_scope() as s:
path = s.get(SavedPath, path_id)
if path is None:
return False
s.delete(path)
return True
# ---------------------------------------------------------------------------
# Friend tracks (phase 6 / M18 contract #2). Written by the solver (lane H / M20),
# read by the API + frontend. TrackPoints are always returned ordered by t_global_s.
# ---------------------------------------------------------------------------
def add_track(marker_key: str, label: str | None = None, color: str | None = None) -> Track:
"""Insert a track row (no points yet) and return it."""
with session_scope() as s:
track = Track(marker_key=marker_key, label=label, color=color, created_at=_now_iso())
s.add(track)
s.flush()
s.refresh(track)
return track
def get_tracks() -> list[Track]:
"""All tracks, ordered by id."""
with session_scope() as s:
return list(s.scalars(select(Track).order_by(Track.id)))
def get_track(track_id: int) -> Track | None:
with session_scope() as s:
return s.get(Track, track_id)
def get_track_points(track_id: int) -> list[TrackPoint]:
"""A track's points, ordered by ``t_global_s`` (contract #2)."""
with session_scope() as s:
return list(
s.scalars(
select(TrackPoint)
.where(TrackPoint.track_id == track_id)
.order_by(TrackPoint.t_global_s)
)
)
def set_track_points(track_id: int, points: Iterable[dict]) -> int:
"""Replace all points for a track with ``points`` (solver output, M20). Returns the count.
Each dict needs ``t_global_s, x, y, z`` and optionally ``quality`` (default None) and
``views`` (default None). Atomic: the delete + inserts share one transaction, so a caller
that raises mid-way never leaves a half-written track. Raises ``KeyError`` if the track
is unknown.
"""
rows = list(points)
with session_scope() as s:
if s.get(Track, track_id) is None:
raise KeyError(f"no track with id={track_id}")
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
for p in rows:
s.add(
TrackPoint(
track_id=track_id,
t_global_s=float(p["t_global_s"]),
x=float(p["x"]), y=float(p["y"]), z=float(p["z"]),
quality=None if p.get("quality") is None else float(p["quality"]),
views=None if p.get("views") is None else int(p["views"]),
)
)
return len(rows)
def patch_track(track_id: int, label: str | None = None, color: str | None = None) -> Track:
"""Rename / recolor a track (M21 panel). Only provided fields change.
Raises ``KeyError`` if the id is unknown.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
raise KeyError(f"no track with id={track_id}")
if label is not None:
track.label = label
if color is not None:
track.color = color
s.flush()
s.refresh(track)
return track
def delete_track(track_id: int) -> bool:
"""Delete a track and its points. Returns ``True`` if a row was deleted, else ``False``.
SQLite doesn't cascade by default, so the points are removed explicitly.
"""
with session_scope() as s:
track = s.get(Track, track_id)
if track is None:
return False
s.execute(delete(TrackPoint).where(TrackPoint.track_id == track_id))
s.delete(track)
return True
def has_tracks() -> bool:
"""Whether any track exists (drives ``manifest.has_tracks``)."""
with session_scope() as s:
return s.scalar(select(Track.id).limit(1)) is not None
# ---------------------------------------------------------------------------
# Annotations
# ---------------------------------------------------------------------------
@ -366,6 +658,7 @@ def add_annotation(
y0: float,
x1: float,
y1: float,
event_id: int | None = None,
resolved_anchor_id: int | None = None,
) -> Annotation:
with session_scope() as s:
@ -373,6 +666,7 @@ def add_annotation(
video_id=video_id,
t_video_s=t_video_s,
x0=x0, y0=y0, x1=x1, y1=y1,
event_id=event_id,
resolved_anchor_id=resolved_anchor_id,
)
s.add(annotation)
@ -381,11 +675,15 @@ def add_annotation(
return annotation
def get_annotations(video_id: int | None = None) -> list[Annotation]:
def get_annotations(
video_id: int | None = None, event_id: int | None = None
) -> list[Annotation]:
with session_scope() as s:
stmt = select(Annotation).order_by(Annotation.id)
if video_id is not None:
stmt = stmt.where(Annotation.video_id == video_id)
if event_id is not None:
stmt = stmt.where(Annotation.event_id == event_id)
return list(s.scalars(stmt))

View File

@ -0,0 +1,152 @@
"""Auto-director: events -> keyframed camera path (spec M11, lane E).
Contract (frozen at foundation2 merge phase-5 contract #2): :func:`generate_path`
returns a **camPath-compatible** dict::
{"version": 1,
"keyframes": [{"t_global": float, "pos": [x, y, z], "quat": [x, y, z, w], "fov": float}]}
Poses are **Three.js scene space**, quaternion order **[x, y, z, w]** convert from the
COLMAP rows via :func:`festival4d.geometry.colmap_to_threejs`, never inline (pitfall #1).
Deterministic v1 rules no ML: take the top-N events by confidence (ties -> earlier);
for each, choose the registered camera with a pose nearest the event time; keyframes at
``t_event - lead_s`` and ``t_event + duration``; when ``config.BEATS_JSON`` exists, snap
keyframe times to the nearest beat (no beats file -> no snapping degrade, don't block);
FOV from that camera's intrinsics (``2 * atan(height / (2 * fy))`` in degrees, the same
formula as ``scene3d.snapTo``).
Degradation (house style): no events -> valid empty path; no registered poses -> valid
empty path with a clear ``note`` (the response always round-trips through
``camPath.fromJSON``); no beats.json -> same path, just unsnapped. Never a crash.
"""
from __future__ import annotations
import json
import logging
import math
from festival4d import config, db
from festival4d.geometry import colmap_to_threejs, mat_to_quat
log = logging.getLogger("festival4d.director")
DEFAULT_EVENT_DURATION_S = 1.0 # used when an event has no duration_s
def _empty(note: str | None = None) -> dict:
path: dict = {"version": 1, "keyframes": []}
if note:
path["note"] = note
log.info("director: %s -> empty path", note)
return path
def _load_beats() -> list[float]:
"""The beat grid from beats.json, if valid; otherwise [] (no snapping — pitfall #5)."""
if not config.BEATS_JSON.exists():
return []
try:
beats = json.loads(config.BEATS_JSON.read_text()).get("beats_s", [])
return sorted(float(b) for b in beats)
except (ValueError, TypeError, AttributeError) as exc:
log.warning("director: unreadable beats.json (%s) — skipping beat snap", exc)
return []
def _snap_to_beat(t: float, beats: list[float]) -> float:
"""Nearest beat to t (beats sorted ascending); t unchanged when the grid is empty."""
if not beats:
return t
return min(beats, key=lambda b: abs(b - t))
def _nearest_pose(poses: list, t_video: float):
"""The registered pose nearest a local time, and its |dt|. Poses are t_video_s-sorted."""
best = min(poses, key=lambda p: abs(p.t_video_s - t_video))
return best, abs(best.t_video_s - t_video)
def _keyframe(pose, video, t_global: float) -> dict:
"""Build one frozen-shape keyframe from a COLMAP pose row (contract #2).
The COLMAP [w,x,y,z] world->camera pose goes through the frozen
``geometry.colmap_to_threejs`` (never inline pitfall #1); the resulting Three.js
world rotation matrix becomes the camPath quaternion, reordered to Three.js [x,y,z,w].
"""
position, rotation = colmap_to_threejs(
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
)
qw, qx, qy, qz = mat_to_quat(rotation) # [w,x,y,z] of the *Three.js* world rotation
fov_deg = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy))) # scene3d.snapTo
return {
"t_global": float(t_global),
"pos": [float(v) for v in position],
"quat": [float(qx), float(qy), float(qz), float(qw)], # Three.js order [x,y,z,w]
"fov": float(fov_deg),
}
def generate_path(top_n: int = 8, lead_s: float = 2.0) -> dict:
"""Build a camPath JSON dict from the detected events (see module doc)."""
events = db.get_events()
if not events:
return _empty("no events to direct (run `python -m festival4d events`)")
videos = db.get_videos()
# Registered poses per video (interpolated rows are excluded: the director only cuts
# to cameras that were actually solved at that moment).
tracks: dict[int, list] = {}
for video in videos:
registered = [p for p in db.get_poses(video.id) if p.registered]
if registered:
tracks[video.id] = registered
if not tracks:
return _empty("no registered camera poses (run `python -m festival4d reconstruct`)")
video_by_id = {v.id: v for v in videos}
# Top-N events by confidence, ties -> earlier; then shoot them in chronological order.
ranked = sorted(events, key=lambda e: (-(e.confidence or 0.0), e.t_global_s, e.id))
chosen = sorted(ranked[: max(0, int(top_n))], key=lambda e: (e.t_global_s, e.id))
beats = _load_beats()
keyframes: list[dict] = []
for event in chosen:
# The registered camera whose pose track comes closest to the event time.
best_id, best_dt = None, None
for vid, poses in sorted(tracks.items()): # sorted -> deterministic tie-break
video = video_by_id[vid]
tv_event = config.t_video_from_global(
event.t_global_s, video.offset_ms or 0.0, video.drift_ppm or 0.0
)
_, dt = _nearest_pose(poses, tv_event)
if best_dt is None or dt < best_dt:
best_id, best_dt = vid, dt
video = video_by_id[best_id]
poses = tracks[best_id]
duration = event.duration_s if event.duration_s is not None else DEFAULT_EVENT_DURATION_S
for t_key in (event.t_global_s - lead_s, event.t_global_s + duration):
t_key = _snap_to_beat(max(0.0, t_key), beats)
tv_key = config.t_video_from_global(
t_key, video.offset_ms or 0.0, video.drift_ppm or 0.0
)
pose, _ = _nearest_pose(poses, tv_key)
keyframes.append(_keyframe(pose, video, t_key))
# Chronological, and collapse keyframes that landed on the same instant (adjacent events
# snapping to a shared beat) — camPath needs a monotone timeline.
keyframes.sort(key=lambda k: k["t_global"])
deduped: list[dict] = []
for kf in keyframes:
if deduped and abs(kf["t_global"] - deduped[-1]["t_global"]) < 1e-6:
continue
deduped.append(kf)
log.info(
"director: %d event(s) -> %d keyframe(s)%s",
len(chosen), len(deduped), " (beat-snapped)" if beats else "",
)
return {"version": 1, "keyframes": deduped}

View File

@ -4,9 +4,9 @@ Two stages: cheap audio candidates first, then a vision LLM on candidates only.
This module defines the **FROZEN classifier contract** :class:`MomentClassification`
(Pydantic model) and :class:`MomentClassifier` (Protocol) exactly as spec M7. Lanes and
integration depend on these shapes. Everything else here is a STUB that lane D fills:
candidate detection, the provider classes, provider selection, and the ``run_events``
entrypoint (called by both ``cli.py`` and ``api.py``'s ``POST /api/events/detect``).
integration depend on these shapes. Lane D fills the rest: candidate detection, the provider
classes, provider selection, and the ``run_events`` entrypoint (called by both ``cli.py`` and
``api.py``'s ``POST /api/events/detect``).
Provider selection (lane D): ``FESTIVAL4D_CLASSIFIER=gemini|claude|local`` (default
``gemini``). Inputs prepared once per candidate: a ~3 s MP4 clip around ``t_global`` from
@ -15,19 +15,31 @@ behavior: catch exceptions per candidate (one failure never aborts the batch); i
selected provider is unconfigured, run candidate detection only (``event_type='candidate'``)
and log that classification was skipped.
The module imports with only ``pydantic`` present provider SDKs (``google-genai``,
``anthropic``, ``openai``) are imported lazily inside the provider bodies, never at load.
The module imports with only ``pydantic`` present heavy numeric libs (``numpy``,
``librosa``, ``scipy``, ``soundfile``) and provider SDKs (``google-genai``, ``anthropic``,
``openai``) are imported lazily inside the functions that need them, never at load.
DB note (lane D): ``db.py`` (frozen) exposes ``add_event`` and ``clear_events`` but *no*
update-event helper, so instead of "insert audio_auto candidate, later update to ai" this
module inserts each event **once in its final state** ``source='ai'`` with the classified
type when a provider succeeds, else ``source='audio_auto'`` with ``event_type='candidate'``.
The end state in the DB is identical to the spec's two-step description.
"""
from __future__ import annotations
import base64
import logging
import os
import shutil
import subprocess
from pathlib import Path
from typing import Literal, Protocol, runtime_checkable
from pydantic import BaseModel, Field
from festival4d import config, db
log = logging.getLogger("festival4d.events_ai")
# The closed set of moment classes (spec M7). Lane C colors timeline markers by these.
@ -40,6 +52,25 @@ EVENT_TYPES: tuple[str, ...] = (
"artist_moment", "light_show", "quiet_moment", "other",
)
# ---------------------------------------------------------------------------
# Tuning constants (lane D). Detection is verified against the synthetic fixture's
# ground-truth pulse times (spec M0/M7); see backend/tests/test_events_ai.py.
# ---------------------------------------------------------------------------
STFT_HOP = 512 # librosa frame hop (samples)
STFT_FRAME = 2048 # RMS window length (samples)
NEIGHBORHOOD_S = 10.0 # a candidate must be the max over this centered window
PEAK_PERCENTILE = 90.0 # ...and above this percentile of the detection function
MERGE_S = 1.0 # merge peaks closer than this, keeping the strongest
MIN_SIGNAL_STD = 1e-3 # AC-RMS floor (~60 dBFS): silence AND constant/DC have ~0 std
CANDIDATE_DURATION_S = 1.0 # stored event duration (a moment, not an interval)
DEFAULT_WINDOW_S = 6.0 # window width when run_events is given only t_global_s
CLIP_SECONDS = 3.0 # length of the classifier clip (spec M7: ~3 s)
NUM_FRAMES = 6 # sampled JPEGs for providers without video input
FRAME_LONG_EDGE = 768 # max long-edge px for sampled frames (spec M7: ≤768)
CLIP_MAX_HEIGHT = 720 # clip capped at ≤720p (spec M7)
AUDIO_EXTRACT_SR = 48_000 # sample rate when extracting reference audio via ffmpeg
class MomentClassification(BaseModel):
"""Structured classifier output (FROZEN CONTRACT, spec M7)."""
@ -58,87 +89,462 @@ class MomentClassifier(Protocol):
...
_CLASSIFY_PROMPT = (
"This is a ~3-second clip from a concert, captured around a detected audio spike. "
"Classify the single most salient moment in it. Choose event_type from: "
"bass_drop, pyro, confetti, crowd_wave, artist_moment, light_show, quiet_moment, other. "
"Give a confidence in [0, 1] and one short human-readable sentence describing what happens."
)
# ---------------------------------------------------------------------------
# Provider stubs (lane D fills bodies; classifiers import their SDK lazily).
# Provider classes (lane D). Each imports its SDK lazily so the module loads with
# only pydantic present; construction reads the key/endpoint from the environment.
# ---------------------------------------------------------------------------
class GeminiClassifier:
"""Default provider — Gemini 2.5 Flash, native video input (``GEMINI_API_KEY``)."""
"""Default provider — Gemini flash tier, native video input (``GEMINI_API_KEY``).
model = "gemini-2.5-flash"
Model is overridable via ``FESTIVAL4D_GEMINI_MODEL``. Default was ``gemini-2.5-flash``
until Google retired it for new API keys (404 "no longer available to new users",
field-found 2026-07-16); ``gemini-3.1-flash-lite`` is the cheapest current model verified
to accept inline video + JSON-schema structured output.
"""
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement GeminiClassifier")
from google import genai
self.model = os.environ.get("FESTIVAL4D_GEMINI_MODEL", "gemini-3.1-flash-lite")
self._genai = genai
self._client = genai.Client() # reads GEMINI_API_KEY from the environment
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement GeminiClassifier.classify")
genai = self._genai
clip_bytes = Path(clip_path).read_bytes()
resp = self._client.models.generate_content(
model=self.model,
contents=[
genai.types.Part.from_bytes(data=clip_bytes, mime_type="video/mp4"),
_CLASSIFY_PROMPT,
],
config={
"response_mime_type": "application/json",
"response_schema": MomentClassification,
},
)
return MomentClassification.model_validate_json(resp.text)
class ClaudeClassifier:
"""Optional provider — Claude Opus 4.8, 6 frames as images (``ANTHROPIC_API_KEY``).
Uses ``client.messages.parse(..., thinking={'type': 'adaptive'},
output_format=MomentClassification)``; do **not** pass ``temperature`` (rejected on
Opus 4.8). Model id ``claude-opus-4-8`` (never date-suffixed).
output_format=MomentClassification)`` -> ``resp.parsed_output``; does **not** pass
``temperature`` (rejected on Opus 4.8). Model id ``claude-opus-4-8`` (never date-suffixed).
"""
model = "claude-opus-4-8"
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement ClaudeClassifier")
import anthropic
self._client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY from the environment
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement ClaudeClassifier.classify")
content: list[dict] = []
for fp in frames[:NUM_FRAMES]:
data = base64.standard_b64encode(Path(fp).read_bytes()).decode("ascii")
content.append({
"type": "image",
"source": {"type": "base64", "media_type": "image/jpeg", "data": data},
})
content.append({"type": "text", "text": _CLASSIFY_PROMPT})
resp = self._client.messages.parse(
model=self.model,
max_tokens=1024,
thinking={"type": "adaptive"},
output_format=MomentClassification,
messages=[{"role": "user", "content": content}],
)
return resp.parsed_output
class LocalClassifier:
"""Offline/free provider — any OpenAI-compatible vision endpoint (Ollama/LM Studio/
OpenRouter). Config: ``FESTIVAL4D_OPENAI_BASE_URL``, ``FESTIVAL4D_OPENAI_KEY``,
``FESTIVAL4D_OPENAI_MODEL``. Sends the 6 frames as image parts; validates JSON with one
retry on failure.
retry on failure (local models are sloppier about JSON).
"""
def __init__(self) -> None:
raise NotImplementedError("lane D (M7): implement LocalClassifier")
from openai import OpenAI
base_url = os.environ["FESTIVAL4D_OPENAI_BASE_URL"]
# Local servers (Ollama/LM Studio) accept any non-empty key; OpenRouter needs a real one.
api_key = os.environ.get("FESTIVAL4D_OPENAI_KEY") or "not-needed"
self._model = os.environ["FESTIVAL4D_OPENAI_MODEL"]
self._client = OpenAI(base_url=base_url, api_key=api_key)
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
raise NotImplementedError("lane D (M7): implement LocalClassifier.classify")
from pydantic import ValidationError
schema = MomentClassification.model_json_schema()
content: list[dict] = [{
"type": "text",
"text": (
_CLASSIFY_PROMPT
+ " Respond with ONLY a JSON object matching this schema: "
+ repr(schema)
),
}]
for fp in frames[:NUM_FRAMES]:
b64 = base64.standard_b64encode(Path(fp).read_bytes()).decode("ascii")
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
})
messages = [{"role": "user", "content": content}]
last_exc: Exception | None = None
for attempt in range(2): # one retry on validation failure
resp = self._client.chat.completions.create(
model=self._model,
messages=messages,
response_format={"type": "json_object"},
)
text = resp.choices[0].message.content or ""
try:
return MomentClassification.model_validate_json(text)
except ValidationError as exc:
last_exc = exc
log.warning("events: local classifier returned invalid JSON (attempt %d)",
attempt + 1)
assert last_exc is not None
raise last_exc
_PROVIDERS: dict[str, tuple[type, tuple[str, ...]]] = {
# name -> (class, required environment variables)
"gemini": (GeminiClassifier, ("GEMINI_API_KEY",)),
"claude": (ClaudeClassifier, ("ANTHROPIC_API_KEY",)),
"local": (LocalClassifier, ("FESTIVAL4D_OPENAI_BASE_URL", "FESTIVAL4D_OPENAI_MODEL")),
}
def get_classifier(name: str | None = None) -> MomentClassifier | None:
"""Select a provider by name or ``FESTIVAL4D_CLASSIFIER`` (default ``gemini``).
Returns a ready classifier, or ``None`` if the selected provider is unconfigured (no
key/endpoint) callers then run candidates-only. STUB lane D implements selection.
key/endpoint) or fails to initialize callers then run candidates-only.
"""
_ = name or os.environ.get("FESTIVAL4D_CLASSIFIER", "gemini")
raise NotImplementedError("lane D (M7): implement get_classifier")
name = (name or os.environ.get("FESTIVAL4D_CLASSIFIER") or "gemini").lower()
entry = _PROVIDERS.get(name)
if entry is None:
log.warning("events: unknown classifier %r; running candidates-only", name)
return None
cls, required = entry
missing = [var for var in required if not os.environ.get(var)]
if missing:
log.info("events: provider %r unconfigured (missing %s); classification skipped, "
"storing candidates only", name, ", ".join(missing))
return None
try:
return cls()
except Exception: # SDK import/construction failure -> degrade, don't crash the batch
log.exception("events: failed to initialize %r classifier; running candidates-only", name)
return None
# ---------------------------------------------------------------------------
# Candidate detection + orchestration (lane D fills bodies).
# Candidate detection (spec M7). Verified against the fixture ground-truth pulses.
# ---------------------------------------------------------------------------
def detect_candidates(audio: "object", sr: int) -> list[float]:
"""Audio candidate moments on the reference track (spec M7, lane D).
Compute RMS energy + spectral-flux onset strength (librosa); return the times
(reference-local seconds) of peaks that are local maxima over a 10 s neighborhood and
above the 90th percentile. Verified against the fixture's ground-truth pulse times
(±0.5 s).
Compute RMS energy and spectral-flux onset strength (librosa), combine into a single
detection function (product bangs dominate in both, so the product isolates them
cleanly from the regular beat floor), and return the times (reference-local seconds) of
peaks that are local maxima over a ~10 s neighborhood and above the 90th percentile.
Verified against the fixture's ground-truth pulse times (±0.5 s).
"""
raise NotImplementedError("lane D (M7): implement detect_candidates")
import librosa
import numpy as np
from scipy.ndimage import maximum_filter1d
y = np.asarray(audio, dtype=np.float32).reshape(-1)
if y.size < STFT_FRAME:
return []
# A reference track with no real moments (silence, constant/DC, or a bare noise floor)
# has ~0 AC energy. The percentile threshold below is purely *relative*, so on such a
# flat signal it would flag ~1 spurious peak per second; gate on AC-RMS (std) first —
# this catches silence and DC alike (peak amplitude does not: a DC offset has a large
# peak but no transients, only STFT-boundary artifacts).
if float(np.std(y)) < MIN_SIGNAL_STD:
return []
rms = librosa.feature.rms(y=y, hop_length=STFT_HOP, frame_length=STFT_FRAME)[0]
flux = librosa.onset.onset_strength(y=y, sr=sr, hop_length=STFT_HOP)
n = min(len(rms), len(flux))
if n == 0:
return []
rms, flux = rms[:n], flux[:n]
eps = 1e-12
strength = (rms / (rms.max() + eps)) * (flux / (flux.max() + eps))
# No transient structure at all (e.g. constant/DC audio -> flat detection function).
if float(strength.max()) <= eps:
return []
times = librosa.frames_to_time(np.arange(n), sr=sr, hop_length=STFT_HOP)
win = max(1, int(round(NEIGHBORHOOD_S * sr / STFT_HOP)))
if win % 2 == 0:
win += 1
# A frame is a local maximum if it equals the max over its centered ~10 s window. Because
# the fixture's bangs are >5 s apart, each is the unique max in its own window — a
# min-distance peak picker would instead merge the 7 s-spaced bangs and drop one.
is_local_max = strength >= (maximum_filter1d(strength, size=win, mode="nearest") - eps)
threshold = float(np.percentile(strength, PEAK_PERCENTILE))
idx = np.where(is_local_max & (strength >= threshold))[0]
# Greedy merge of near-duplicate peaks (plateaus), keeping the strongest.
peaks: list[float] = []
for i in sorted(idx.tolist(), key=lambda j: -strength[j]):
t = float(times[i])
if all(abs(t - p) >= MERGE_S for p in peaks):
peaks.append(t)
return sorted(peaks)
# ---------------------------------------------------------------------------
# Reference-audio loading + classifier-input preparation (ffmpeg).
# ---------------------------------------------------------------------------
def _select_reference(videos: list) -> "object | None":
"""The reference video (spec §2: offset 0). Fall back to the smallest |offset|."""
if not videos:
return None
exact = [v for v in videos if v.offset_ms == 0]
if exact:
return exact[0]
with_offset = [v for v in videos if v.offset_ms is not None]
if with_offset:
return min(with_offset, key=lambda v: abs(v.offset_ms))
return videos[0]
def _load_reference_audio(video) -> "tuple[object, int] | None":
"""Load the reference video's audio as mono float samples.
Independent of lane A: extract the audio straight from the raw video with ffmpeg (a
reused ingest WAV would couple us to lane A's naming). Returns ``(samples, sr)`` or
``None`` if the raw video or ffmpeg is unavailable.
"""
import tempfile
import numpy as np
import soundfile as sf
raw = config.RAW_DIR / video.filename
if not raw.exists():
log.warning("events: raw video %s not found under %s", video.filename, config.RAW_DIR)
return None
if shutil.which("ffmpeg") is None:
log.warning("events: ffmpeg not on PATH; cannot extract reference audio")
return None
with tempfile.TemporaryDirectory() as tmp:
wav = Path(tmp) / "ref.wav"
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", str(raw), "-vn", "-ac", "1", "-ar", str(AUDIO_EXTRACT_SR),
"-f", "wav", str(wav),
]
try:
subprocess.run(cmd, check=True, capture_output=True)
except subprocess.CalledProcessError as exc:
log.warning("events: ffmpeg audio extraction failed: %s", exc.stderr.decode("utf-8", "ignore"))
return None
samples, sr = sf.read(str(wav), dtype="float32", always_2d=False)
if getattr(samples, "ndim", 1) > 1:
samples = samples.mean(axis=1)
return np.asarray(samples, dtype=np.float32), int(sr)
def _select_best_video(videos: list, t_global_s: float) -> "tuple[object, float] | None":
"""The video whose 3 s window around ``t_global`` is best-covered (most centered)."""
best = None
best_score = None
best_t = 0.0
for v in videos:
t_video = config.t_video_from_global(t_global_s, v.offset_ms or 0.0, v.drift_ppm or 0.0)
margin = min(t_video, (v.duration_s or 0.0) - t_video) # ≥ CLIP_SECONDS/2 => fully covers
if best_score is None or margin > best_score:
best_score, best, best_t = margin, v, t_video
if best is None:
return None
return best, best_t
def _events_workdir() -> Path:
d = config.WORK_DIR / "events"
d.mkdir(parents=True, exist_ok=True)
return d
def prepare_inputs(t_global_s: float) -> tuple[Path, list[Path]]:
"""Prepare classifier inputs for a candidate: a ~3 s MP4 clip + 6 sampled JPEGs (M7)."""
raise NotImplementedError("lane D (M7): implement prepare_inputs")
"""Prepare classifier inputs for a candidate: a ~3 s MP4 clip + up to 6 sampled JPEGs (M7).
Picks the best-covering video, then uses ffmpeg to cut a 720p clip (audio kept, for
Gemini's native-video path) and sample 6 frames (≤768 px long edge). Raises if ffmpeg
or a usable source video is missing ``run_events`` isolates that per candidate.
"""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required to prepare classifier inputs")
videos = db.get_videos()
picked = _select_best_video(videos, t_global_s)
if picked is None:
raise RuntimeError("no videos available to prepare classifier inputs")
video, t_video = picked
raw = config.RAW_DIR / video.filename
if not raw.exists():
raise RuntimeError(f"source video {raw} not found")
duration = video.duration_s or (t_video + CLIP_SECONDS)
start = max(0.0, min(t_video - CLIP_SECONDS / 2.0, max(0.0, duration - CLIP_SECONDS)))
workdir = _events_workdir()
tag = f"cand_{t_global_s:0.3f}".replace("-", "m")
clip_path = workdir / f"{tag}.mp4"
frames_dir = workdir / tag
if frames_dir.exists():
for old in frames_dir.glob("frame_*.jpg"):
old.unlink()
frames_dir.mkdir(parents=True, exist_ok=True)
# 3 s clip, downscaled only if taller than 720p, audio preserved, faststart for inline bytes.
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{start:.3f}", "-i", str(raw), "-t", f"{CLIP_SECONDS:.3f}",
"-vf", f"scale=-2:'min({CLIP_MAX_HEIGHT},ih)'",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "28", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-b:a", "96k", "-movflags", "+faststart", str(clip_path)],
check=True, capture_output=True,
)
# 6 frames evenly across the 3 s window, long edge ≤ 768 px.
scale = (f"scale='if(gt(iw,ih),min({FRAME_LONG_EDGE},iw),-2)':"
f"'if(gt(iw,ih),-2,min({FRAME_LONG_EDGE},ih))'")
fps = NUM_FRAMES / CLIP_SECONDS
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-ss", f"{start:.3f}", "-i", str(raw), "-t", f"{CLIP_SECONDS:.3f}",
"-vf", f"fps={fps},{scale}", "-frames:v", str(NUM_FRAMES),
str(frames_dir / "frame_%02d.jpg")],
check=True, capture_output=True,
)
frames = sorted(frames_dir.glob("frame_*.jpg"))
return clip_path, frames
# ---------------------------------------------------------------------------
# Orchestration (spec M7). Entrypoint for the CLI + POST /api/events/detect.
# ---------------------------------------------------------------------------
def _classify_one(t_global_s: float, classifier: "MomentClassifier | None") -> tuple[str, str, float | None, str | None]:
"""Resolve one candidate to (event_type, source, confidence, description).
No classifier -> a bare ``audio_auto`` candidate. Otherwise classify, isolating any
failure (SDK error, bad clip) by falling back to a candidate one failure never aborts
the batch (spec M7).
"""
if classifier is None:
return "candidate", "audio_auto", None, None
try:
clip_path, frames = prepare_inputs(t_global_s)
result = classifier.classify(clip_path, frames)
return result.event_type, "ai", result.confidence, result.description
except Exception:
log.exception("events: classification failed at t_global=%.3f s; storing as candidate",
t_global_s)
return "candidate", "audio_auto", None, None
def run_events(t_global_s: float | None = None, window_s: float | None = None) -> dict:
"""Detect audio candidates and (if a provider is configured) classify them.
Entrypoint for ``python -m festival4d events`` and ``POST /api/events/detect``. Inserts
events via ``db`` (``source='audio_auto'``; classified ones updated to ``source='ai'``).
Optionally restrict to a single window around ``t_global_s`` of width ``window_s``.
Returns a summary dict. Per-candidate exceptions are isolated (spec M7).
Entrypoint for ``python -m festival4d events`` and ``POST /api/events/detect``. Detects
on the reference track, maps candidate times to ``t_global``, then inserts events via
``db`` (``source='audio_auto'`` for bare candidates, ``source='ai'`` for classified ones;
see the module docstring on why this is a single insert per event). Per-candidate
exceptions are isolated. Returns a summary dict.
Whole-track detection (``t_global_s is None``) is authoritative: it first clears prior
machine-generated events (``audio_auto`` + ``ai``) while preserving user edits. A windowed
call (``t_global_s`` given, optional ``window_s``) only *adds* candidates inside the
window and clears nothing, so it never disturbs the rest of the timeline.
"""
raise NotImplementedError("lane D (M7): implement run_events")
db.init_engine()
db.init_db()
selected = (os.environ.get("FESTIVAL4D_CLASSIFIER") or "gemini").lower()
videos = db.get_videos()
reference = _select_reference(videos)
if reference is None:
log.warning("events: no videos registered; run `synthetic` or `ingest` first")
return _summary(selected, False, 0, 0, 0, None, note="no videos registered")
loaded = _load_reference_audio(reference)
if loaded is None:
return _summary(selected, False, 0, 0, 0, None,
note="no reference audio (missing raw video or ffmpeg)")
audio, sr = loaded
local_times = detect_candidates(audio, sr)
off = reference.offset_ms or 0.0
drift = reference.drift_ppm or 0.0
cand_globals = [config.t_global_from_video(t, off, drift) for t in local_times]
window = None
if t_global_s is not None:
w = window_s if window_s is not None else DEFAULT_WINDOW_S
lo, hi = t_global_s - w / 2.0, t_global_s + w / 2.0
cand_globals = [t for t in cand_globals if lo <= t <= hi]
window = {"t_global_s": t_global_s, "window_s": w}
else:
# Whole-track detection replaces the machine timeline; user events are kept.
db.clear_events("audio_auto")
db.clear_events("ai")
classifier = get_classifier(selected)
n_ai = n_auto = 0
for t_global in cand_globals:
event_type, source, confidence, description = _classify_one(t_global, classifier)
db.add_event(
t_global_s=t_global,
event_type=event_type,
source=source,
duration_s=CANDIDATE_DURATION_S,
confidence=confidence,
description=description,
)
if source == "ai":
n_ai += 1
else:
n_auto += 1
summary = _summary(selected, classifier is not None, len(cand_globals), n_ai, n_auto, window)
log.info("events: reference=%s candidates=%d classified=%d candidates_only=%d provider=%s",
reference.filename, len(cand_globals), n_ai, n_auto,
selected if classifier is not None else f"{selected} (unconfigured)")
return summary
def _summary(provider: str, configured: bool, candidates: int, classified: int,
candidates_only: int, window: dict | None, note: str | None = None) -> dict:
out = {
"provider": provider,
"classifier_configured": configured,
"candidates": candidates,
"classified": classified,
"candidates_only": candidates_only,
"window": window,
}
if note is not None:
out["note"] = note
return out

View File

@ -11,18 +11,78 @@ from __future__ import annotations
import logging
from pathlib import Path
import numpy as np
log = logging.getLogger("festival4d.frames")
def sharpness(image) -> float:
"""Variance of the Laplacian of a grayscale image (OpenCV) — higher is sharper."""
raise NotImplementedError("lane B (M2): implement sharpness")
"""Variance of the Laplacian of an image (OpenCV) — higher is sharper.
Accepts a BGR or grayscale ``ndarray`` (as returned by ``cv2.VideoCapture.read``).
The variance of the Laplacian is the standard focus/blur measure: a sharp frame has
strong high-frequency edges (high variance), a blurred one is smooth (low variance).
"""
import cv2
img = np.asarray(image)
if img.ndim == 3:
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(img, cv2.CV_64F).var())
def sample_frames(video_path: Path, video_id: int, out_dir: Path,
target_fps: float = 2.0, window_s: float = 0.5) -> list[Path]:
"""Sample the sharpest frame per ``window_s`` window from ``video_path`` (spec M2).
Returns the written JPEG paths (named ``{video_id}_{frame_idx}.jpg``).
Decodes every frame, buckets them into non-overlapping ``window_s``-second windows, and
keeps the single sharpest frame (variance of Laplacian) in each window. This yields
~``1 / window_s`` frames per second ( ``target_fps`` at the defaults), biased toward the
in-focus frames COLMAP needs. Writes JPEGs named ``{video_id}_{frame_idx}.jpg`` (the
original decoded ``frame_idx``, so ``t_video = frame_idx / fps``) into ``out_dir``.
Returns the written JPEG paths, ordered by frame index.
"""
raise NotImplementedError("lane B (M2): implement sample_frames")
import cv2
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise RuntimeError(f"could not open video for frame sampling: {video_path}")
try:
fps = float(cap.get(cv2.CAP_PROP_FPS))
if not np.isfinite(fps) or fps <= 0.0:
log.warning("frames: %s reported fps=%s; falling back to 30.0", video_path, fps)
fps = 30.0
bucket_s = float(window_s) if window_s and window_s > 0 else 0.5
# window_idx -> (sharpness, frame_idx, frame_bgr). Only the current best per window
# is retained, so memory stays ~O(number of windows), not O(frames).
best: dict[int, tuple[float, int, np.ndarray]] = {}
frame_idx = 0
while True:
ok, frame = cap.read()
if not ok:
break
t = frame_idx / fps
win = int(t / bucket_s)
s = sharpness(frame)
cur = best.get(win)
if cur is None or s > cur[0]:
best[win] = (s, frame_idx, frame)
frame_idx += 1
finally:
cap.release()
written: list[Path] = []
for win in sorted(best):
_, fidx, frame = best[win]
path = out_dir / f"{video_id}_{fidx}.jpg"
if not cv2.imwrite(str(path), frame):
raise RuntimeError(f"failed to write frame JPEG: {path}")
written.append(path)
log.info("frames: %s -> %d frames sampled into %s", Path(video_path).name,
len(written), out_dir)
return written

View File

@ -121,7 +121,8 @@ def colmap_to_threejs(q: ArrayLike, t: ArrayLike) -> tuple[NDArray[np.float64],
# ---------------------------------------------------------------------------
# Lane B stubs (spec M2 + M8). Signatures FROZEN; bodies raise NotImplementedError.
# Lane B (spec M2 + M8): pose interpolation, pixel ray casting, two-view triangulation,
# and the single-view nearest-point fallback. Signatures FROZEN; bodies implemented below.
# ---------------------------------------------------------------------------
def slerp_pose(
q0: ArrayLike,
@ -149,7 +150,39 @@ def slerp_pose(
-------
(q, t) : the interpolated quaternion ``[w, x, y, z]`` and translation ``[x, y, z]``.
"""
raise NotImplementedError("lane B (M2): implement slerp_pose")
a = float(alpha)
q0 = np.asarray(q0, dtype=np.float64).reshape(4)
q1 = np.asarray(q1, dtype=np.float64).reshape(4)
n0 = np.linalg.norm(q0)
n1 = np.linalg.norm(q1)
if n0 < 1e-12 or n1 < 1e-12:
raise ValueError("slerp endpoint quaternion has near-zero norm")
q0 = q0 / n0
q1 = q1 / n1
# Double cover: pick the sign of q1 that lies on the same hemisphere as q0, so slerp
# takes the shorter arc (a rotation and its negation are the same orientation).
dot = float(np.dot(q0, q1))
if dot < 0.0:
q1 = -q1
dot = -dot
dot = min(1.0, max(-1.0, dot))
if dot > 0.9995:
# Endpoints almost coincide: nlerp is numerically safe and visually identical.
q_interp = q0 + a * (q1 - q0)
else:
theta_0 = np.arccos(dot)
sin_0 = np.sin(theta_0)
s0 = np.sin((1.0 - a) * theta_0) / sin_0
s1 = np.sin(a * theta_0) / sin_0
q_interp = s0 * q0 + s1 * q1
q_interp = q_interp / np.linalg.norm(q_interp)
t0 = np.asarray(t0, dtype=np.float64).reshape(3)
t1 = np.asarray(t1, dtype=np.float64).reshape(3)
t_interp = (1.0 - a) * t0 + a * t1
return q_interp, t_interp
def ray_from_pixel(
@ -174,7 +207,20 @@ def ray_from_pixel(
direction : ndarray, shape (3,)
Unit ray direction in world coords, pointing into the scene.
"""
raise NotImplementedError("lane B (M8): implement ray_from_pixel")
R = quat_to_mat(q) # world -> cam
t_vec = np.asarray(t, dtype=np.float64).reshape(3)
origin = -R.T @ t_vec # camera center in world coords
# Pinhole back-projection. A world point X projects with x_cam = R X + t and
# px = fx * x_cam.x / x_cam.z + cx, py = fy * x_cam.y / x_cam.z + cy
# (COLMAP camera axes: +x right, +y down, +z forward). So the camera-space direction
# through pixel (px, py) is [(px-cx)/fx, (py-cy)/fy, 1], pointing forward into the scene.
d_cam = np.array([(px - cx) / fx, (py - cy) / fy, 1.0], dtype=np.float64)
d_world = R.T @ d_cam # rotate direction cam -> world
norm = np.linalg.norm(d_world)
if norm < 1e-12:
raise ValueError("degenerate ray direction")
return origin, d_world / norm
def triangulate_rays(
@ -194,7 +240,37 @@ def triangulate_rays(
reject the triangulation when the rays are near-parallel or ``gap`` exceeds the
spec threshold (0.5 scene units).
"""
raise NotImplementedError("lane B (M8): implement triangulate_rays")
oa = np.asarray(origin_a, dtype=np.float64).reshape(3)
ob = np.asarray(origin_b, dtype=np.float64).reshape(3)
da = np.asarray(dir_a, dtype=np.float64).reshape(3)
db = np.asarray(dir_b, dtype=np.float64).reshape(3)
na, nb = np.linalg.norm(da), np.linalg.norm(db)
if na < 1e-12 or nb < 1e-12:
raise ValueError("triangulate_rays: zero-length direction")
da = da / na
db = db / nb
# Shortest segment between two lines P(s)=oa+s*da, Q(u)=ob+u*db. Minimize |P-Q|^2.
# With unit directions: b = da.db, denom = 1 - b^2 (0 when parallel).
w0 = oa - ob
b = float(np.dot(da, db))
d = float(np.dot(da, w0))
e = float(np.dot(db, w0))
denom = 1.0 - b * b
if denom < 1e-9:
# Near-parallel: no unique closest pair. Anchor on oa, take the closest point on
# line b to it; gap is the line-to-line perpendicular distance. Callers reject
# near-parallel rays up front, so this branch just stays numerically safe.
s = 0.0
u = e
else:
s = (b * e - d) / denom
u = (e - b * d) / denom
pa = oa + s * da
pb = ob + u * db
point = 0.5 * (pa + pb)
gap = float(np.linalg.norm(pa - pb))
return point, gap
def nearest_point_on_ray(
@ -214,4 +290,26 @@ def nearest_point_on_ray(
point : ndarray shape (3,) or None
The selected point-cloud point, or ``None`` if none lie within ``radius``.
"""
raise NotImplementedError("lane B (M8): implement nearest_point_on_ray")
o = np.asarray(origin, dtype=np.float64).reshape(3)
d = np.asarray(direction, dtype=np.float64).reshape(3)
nd = np.linalg.norm(d)
if nd < 1e-12:
raise ValueError("nearest_point_on_ray: zero-length direction")
d = d / nd
pts = np.asarray(points, dtype=np.float64).reshape(-1, 3)
if len(pts) == 0:
return None
v = pts - o # origin -> each point
proj = v @ d # signed distance along the ray
perp = v - np.outer(proj, d) # component perpendicular to the ray
perp_dist = np.linalg.norm(perp, axis=1)
# In front of the origin and inside the cylinder of the given radius.
mask = (proj > 0.0) & (perp_dist <= radius)
if not np.any(mask):
return None
idx_in = np.where(mask)[0]
best = idx_in[np.argmin(perp_dist[idx_in])]
return pts[best].copy()

View File

@ -95,6 +95,31 @@ def extract_audio(path: Path, out_wav: Path, sample_rate: int = 16_000) -> Path:
return out_wav
def extract_audio_hq(path: Path, out_m4a: Path) -> Path:
"""Extract a listening-quality stereo AAC track (48 kHz, 192 kbps) from ``path``.
Feeds the frontend's WebAudio master clock (``GET /api/audio/{id}``). AAC-in-m4a is
re-encoded (not stream-copied) so any source codec including webm/Opus from browser
capture decodes in every browser's ``decodeAudioData``. Written atomically (tmp +
rename) so a request racing the first extraction never reads a half-written file.
"""
_require("ffmpeg")
out_m4a.parent.mkdir(parents=True, exist_ok=True)
tmp = out_m4a.with_suffix(".tmp.m4a")
subprocess.run(
["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-i", str(path),
"-vn",
"-ac", "2",
"-ar", "48000",
"-c:a", "aac", "-b:a", "192k",
str(tmp)],
check=True,
)
tmp.replace(out_m4a)
return out_m4a
def run_ingest() -> list[dict]:
"""Ingest every video in ``config.RAW_DIR``: probe, register in DB, extract audio.

View File

@ -0,0 +1,152 @@
"""Annotation -> 3D point resolution (spec M8, integration phase).
Given a 2D bounding box drawn on one video at a local video time, recover a 3D scene point:
1. Cast a ray from that camera through the bbox center, using the pose interpolated at
``t_video_s`` and the stored pinhole intrinsics.
2. If another annotation for the **same event** exists from a **different video**,
triangulate the two rays (reject near-parallel rays or a mutual gap > 0.5 scene units).
3. Else take the nearest point-cloud point to the ray (within a 0.3-unit cylinder).
4. Else return the ray at depth = distance from the camera to the point-cloud centroid.
This module is pure orchestration over lane B's frozen geometry primitives
(:func:`geometry.ray_from_pixel`, :func:`geometry.triangulate_rays`,
:func:`geometry.nearest_point_on_ray`, :func:`geometry.slerp_pose`) it never reimplements
the pose math. It reads the point cloud via :func:`synthetic.read_ply`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
from festival4d import config, db, geometry
from festival4d.synthetic import read_ply
log = logging.getLogger("festival4d.resolve")
TRIANGULATION_MAX_GAP = 0.5 # scene units — reject looser two-view intersections (spec M8)
NEAREST_RADIUS = 0.3 # scene units — cylinder for the point-cloud fallback (spec M8)
PARALLEL_DOT = 0.9995 # |cos(angle)| above this => rays too parallel to triangulate
@dataclass
class Resolution:
"""Outcome of resolving one annotation to a 3D point."""
point: list[float] | None
method: str # 'triangulated' | 'nearest_point' | 'ray_depth' | 'no_geometry'
gap: float | None = None # mutual-approach distance when method == 'triangulated'
def _pose_tuple(p) -> tuple[list[float], list[float], tuple[float, float, float, float]]:
return (
[p.qw, p.qx, p.qy, p.qz],
[p.tx, p.ty, p.tz],
(p.fx, p.fy, p.cx, p.cy),
)
def _pose_at(poses, t_video_s: float):
"""Interpolate a COLMAP pose ``(q, t, (fx,fy,cx,cy))`` at ``t_video_s``.
``poses`` is ascending by ``t_video_s`` (as returned by :func:`db.get_poses`). Clamps to
the endpoints no extrapolation, matching the frontend ``poseAt`` and spec M2.
"""
if not poses:
return None
if t_video_s <= poses[0].t_video_s:
return _pose_tuple(poses[0])
if t_video_s >= poses[-1].t_video_s:
return _pose_tuple(poses[-1])
lo, hi = 0, len(poses) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if poses[mid].t_video_s <= t_video_s:
lo = mid
else:
hi = mid
a, b = poses[lo], poses[hi]
span = b.t_video_s - a.t_video_s
alpha = (t_video_s - a.t_video_s) / span if span > 1e-9 else 0.0
q, t = geometry.slerp_pose(
[a.qw, a.qx, a.qy, a.qz], [a.tx, a.ty, a.tz],
[b.qw, b.qx, b.qy, b.qz], [b.tx, b.ty, b.tz],
alpha,
)
# Intrinsics don't interpolate meaningfully across a fixed camera; use the earlier pose's.
return list(q), list(t), (a.fx, a.fy, a.cx, a.cy)
def _ray_for_annotation(video_id: int, t_video_s: float, bbox):
"""World-space ray (origin, unit direction) through the bbox center, or ``None``.
Returns ``None`` when the video has no poses (reconstruction absent for that camera).
"""
video = db.get_video(video_id)
if video is None:
raise KeyError(f"no video with id={video_id}")
pose = _pose_at(db.get_poses(video_id), t_video_s)
if pose is None:
return None
q, t, (fx, fy, cx, cy) = pose
x0, y0, x1, y1 = bbox
px = (x0 + x1) / 2.0 * video.width
py = (y0 + y1) / 2.0 * video.height
return geometry.ray_from_pixel(q, t, fx, fy, cx, cy, px, py)
def _load_points() -> np.ndarray | None:
if not config.POINTS_PLY.exists():
return None
try:
pts, _colors = read_ply(config.POINTS_PLY)
except Exception as exc: # pragma: no cover - defensive
log.warning("resolve: failed to read point cloud: %s", exc)
return None
return np.asarray(pts, dtype=np.float64).reshape(-1, 3)
def resolve_annotation(video_id: int, t_video_s: float, bbox, event_id: int | None = None) -> Resolution:
"""Resolve one annotation to a 3D point (see module docstring for the cascade)."""
ray = _ray_for_annotation(video_id, t_video_s, bbox)
if ray is None:
return Resolution(point=None, method="no_geometry")
origin, direction = ray
# 1. Two-view triangulation against another annotation of the same event.
if event_id is not None:
for other in reversed(db.get_annotations()): # most recent first
if other.event_id != event_id or other.video_id == video_id:
continue
other_ray = _ray_for_annotation(
other.video_id, other.t_video_s, [other.x0, other.y0, other.x1, other.y1]
)
if other_ray is None:
continue
o2, d2 = other_ray
if abs(float(np.dot(direction, d2))) >= PARALLEL_DOT:
continue # near-parallel: no reliable intersection
point, gap = geometry.triangulate_rays(origin, direction, o2, d2)
if gap <= TRIANGULATION_MAX_GAP:
return Resolution(
point=[float(v) for v in point], method="triangulated", gap=float(gap)
)
# 2/3. Point-cloud fallbacks.
pts = _load_points()
if pts is not None and len(pts):
near = geometry.nearest_point_on_ray(origin, direction, pts, NEAREST_RADIUS)
if near is not None:
return Resolution(point=[float(v) for v in near], method="nearest_point")
centroid = pts.mean(axis=0)
depth = float(np.linalg.norm(centroid - origin))
pt = origin + direction * depth
return Resolution(point=[float(v) for v in pt], method="ray_depth")
# 4. No point cloud at all: park at unit depth so a usable anchor still comes back.
pt = origin + direction * 1.0
return Resolution(point=[float(v) for v in pt], method="ray_depth")

View File

@ -1,33 +1,61 @@
"""COLMAP orchestration, model parsing, scene normalization, pose export (spec M2, lane B).
STUB lane B fills the bodies; the public signatures below are frozen (``cli.py`` calls
``run_reconstruct``).
Pipeline (spec M2, for lane B's reference):
Pipeline (spec M2):
- Sample frames (``frames.sample_frames``), then drive the COLMAP CLI via subprocess:
``feature_extractor`` (OPENCV model, single camera per folder) -> ``sequential_matcher``
within each video + ``exhaustive_matcher`` across -> ``mapper`` -> ``model_converter`` to
a TXT model. Parse ``images.txt`` / ``cameras.txt`` / ``points3D.txt`` by hand (no
pycolmap).
``feature_extractor`` (OPENCV model, single camera per folder) -> ``exhaustive_matcher``
(small frame counts; exhaustive covers within- and cross-video pairs) -> ``mapper`` ->
``image_undistorter`` + ``model_converter`` to a PINHOLE TXT model. Parse ``images.txt`` /
``cameras.txt`` / ``points3D.txt`` by hand (no pycolmap).
- **Normalize** the scene: centroid -> origin, camera bounding sphere radius -> 10, world-up
-> average camera up. Apply the same similarity transform to poses and points.
-> average camera up. The same similarity transform is applied to poses and points.
- **Interpolate** poses for unregistered frames (slerp rotation, lerp translation, mark
``registered=False``); never extrapolate past the first/last registered frame.
- Export ``config.POINTS_PLY`` (binary little-endian, matching ``synthetic.write_ply``'s
format) and fill ``camera_poses`` via ``db.set_poses``.
- **Failure handling** (spec M2): COLMAP optional at runtime (``shutil.which("colmap")``);
if it registers < 60% of frames or < 2 videos into one model, print a clear diagnostic and
leave existing poses untouched never corrupt the DB. The app degrades to "synced videos,
no 3D".
``registered=False``); never extrapolate past the first/last registered frame of a video.
- Export ``config.POINTS_PLY`` (binary little-endian, matching ``synthetic.write_ply``) and
fill ``camera_poses`` via ``db.set_poses``.
- **Failure handling** (spec M2): COLMAP optional at runtime (``shutil.which("colmap")``); if
it registers < 60% of sampled frames or < 2 videos into one model, print a clear diagnostic
and leave existing poses untouched never corrupt the DB. The app degrades to "synced
videos, no 3D".
"""
from __future__ import annotations
import logging
import functools
import struct
import subprocess
from pathlib import Path
import numpy as np
from festival4d import config, db, frames
from festival4d.geometry import mat_to_quat, quat_to_mat, slerp_pose
log = logging.getLogger("festival4d.sfm")
# Minimum share of sampled frames COLMAP must register, and minimum videos in one model,
# for the reconstruction to be trusted (spec M2 failure handling).
MIN_REGISTERED_FRACTION = 0.60
MIN_VIDEOS_IN_MODEL = 2
NORMALIZED_SPHERE_RADIUS = 10.0
# How COLMAP camera models lay out PARAMS -> (fx, fy, cx, cy). Distortion terms are ignored:
# the stored intrinsics are the pinhole part (exact after image_undistorter's PINHOLE output,
# approximate if a distorted model is parsed directly). Covers the models COLMAP emits here.
_INTRINSICS_FROM_PARAMS = {
"SIMPLE_PINHOLE": lambda p: (p[0], p[0], p[1], p[2]),
"PINHOLE": lambda p: (p[0], p[1], p[2], p[3]),
"SIMPLE_RADIAL": lambda p: (p[0], p[0], p[1], p[2]),
"RADIAL": lambda p: (p[0], p[0], p[1], p[2]),
"SIMPLE_RADIAL_FISHEYE": lambda p: (p[0], p[0], p[1], p[2]),
"RADIAL_FISHEYE": lambda p: (p[0], p[0], p[1], p[2]),
"OPENCV": lambda p: (p[0], p[1], p[2], p[3]),
"OPENCV_FISHEYE": lambda p: (p[0], p[1], p[2], p[3]),
"FULL_OPENCV": lambda p: (p[0], p[1], p[2], p[3]),
"FOV": lambda p: (p[0], p[1], p[2], p[3]),
"THIN_PRISM_FISHEYE": lambda p: (p[0], p[1], p[2], p[3]),
}
def colmap_available() -> bool:
"""Whether the COLMAP binary is on PATH (spec M2 optional-at-runtime rule)."""
@ -36,29 +64,416 @@ def colmap_available() -> bool:
return shutil.which("colmap") is not None
# ---------------------------------------------------------------------------
# COLMAP TXT-model parsers (hand-written; no pycolmap dependency)
# ---------------------------------------------------------------------------
def _iter_data_lines(path: Path):
"""Yield non-empty, non-comment lines from a COLMAP TXT file."""
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
yield line
def parse_images_txt(path: Path) -> list[dict]:
"""Parse a COLMAP ``images.txt`` into per-image pose records (spec M2, lane B)."""
raise NotImplementedError("lane B (M2): implement parse_images_txt")
"""Parse a COLMAP ``images.txt`` into per-image pose records (spec M2, lane B).
Format: two lines per image. The first is
``IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME``; the second is the 2D keypoint list
(ignored here). Returns one dict per image with the world->camera quaternion
``[w,x,y,z]``, translation, camera id, and image name.
"""
records: list[dict] = []
take_pose_line = True # image data alternates: pose line, then points2D line
for line in _iter_data_lines(path):
if take_pose_line:
parts = line.split()
if len(parts) < 10:
raise ValueError(f"malformed images.txt pose line: {line!r}")
records.append({
"image_id": int(parts[0]),
"qw": float(parts[1]), "qx": float(parts[2]),
"qy": float(parts[3]), "qz": float(parts[4]),
"tx": float(parts[5]), "ty": float(parts[6]), "tz": float(parts[7]),
"camera_id": int(parts[8]),
"name": " ".join(parts[9:]), # NAME may itself contain spaces
})
take_pose_line = not take_pose_line
return records
def parse_cameras_txt(path: Path) -> dict[int, dict]:
"""Parse a COLMAP ``cameras.txt`` into ``camera_id -> intrinsics`` (spec M2, lane B)."""
raise NotImplementedError("lane B (M2): implement parse_cameras_txt")
"""Parse a COLMAP ``cameras.txt`` into ``camera_id -> intrinsics`` (spec M2, lane B).
Format per line: ``CAMERA_ID MODEL WIDTH HEIGHT PARAMS[]``. Returns, per camera id, a
dict with ``model``, ``width``, ``height``, pinhole ``fx, fy, cx, cy`` and the raw
``params`` list.
"""
cameras: dict[int, dict] = {}
for line in _iter_data_lines(path):
parts = line.split()
if len(parts) < 4:
raise ValueError(f"malformed cameras.txt line: {line!r}")
cam_id = int(parts[0])
model = parts[1]
width, height = int(parts[2]), int(parts[3])
params = [float(x) for x in parts[4:]]
if model not in _INTRINSICS_FROM_PARAMS:
raise ValueError(f"unsupported COLMAP camera model {model!r}")
try:
fx, fy, cx, cy = _INTRINSICS_FROM_PARAMS[model](params)
except IndexError:
raise ValueError(
f"cameras.txt: model {model} has too few PARAMS: {line!r}"
) from None
cameras[cam_id] = {
"model": model, "width": width, "height": height,
"fx": float(fx), "fy": float(fy), "cx": float(cx), "cy": float(cy),
"params": params,
}
return cameras
def parse_points3d_txt(path: Path):
"""Parse a COLMAP ``points3D.txt`` into ``(points Nx3, colors Nx3)`` (spec M2, lane B)."""
raise NotImplementedError("lane B (M2): implement parse_points3d_txt")
def parse_points3d_txt(path: Path) -> tuple[np.ndarray, np.ndarray]:
"""Parse a COLMAP ``points3D.txt`` into ``(points Nx3 float32, colors Nx3 uint8)``.
Format per line: ``POINT3D_ID X Y Z R G B ERROR TRACK[]`` (track ignored).
"""
xyz: list[tuple[float, float, float]] = []
rgb: list[tuple[int, int, int]] = []
for line in _iter_data_lines(path):
parts = line.split()
if len(parts) < 7:
raise ValueError(f"malformed points3D.txt line: {line!r}")
xyz.append((float(parts[1]), float(parts[2]), float(parts[3])))
rgb.append((int(parts[4]), int(parts[5]), int(parts[6])))
if not xyz:
return (np.zeros((0, 3), dtype=np.float32), np.zeros((0, 3), dtype=np.uint8))
return (np.asarray(xyz, dtype=np.float32), np.asarray(rgb, dtype=np.uint8))
# ---------------------------------------------------------------------------
# Scene normalization
# ---------------------------------------------------------------------------
def _rotation_aligning(a: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Shortest-arc rotation matrix that maps unit vector ``a`` onto unit vector ``b``."""
a = a / np.linalg.norm(a)
b = b / np.linalg.norm(b)
v = np.cross(a, b)
c = float(np.dot(a, b))
s = float(np.linalg.norm(v))
if s < 1e-12:
if c > 0: # already aligned
return np.eye(3)
# antiparallel: rotate 180 deg about any axis perpendicular to a
perp = np.array([1.0, 0.0, 0.0])
if abs(a[0]) > 0.9:
perp = np.array([0.0, 1.0, 0.0])
axis = np.cross(a, perp)
axis /= np.linalg.norm(axis)
return 2.0 * np.outer(axis, axis) - np.eye(3)
vx = np.array([[0.0, -v[2], v[1]], [v[2], 0.0, -v[0]], [-v[1], v[0], 0.0]])
return np.eye(3) + vx + vx @ vx * ((1.0 - c) / (s * s))
def _camera_center(pose: dict) -> np.ndarray:
"""World-space camera center ``C = -R^T t`` for a world->camera pose dict."""
R = quat_to_mat([pose["qw"], pose["qx"], pose["qy"], pose["qz"]])
t = np.array([pose["tx"], pose["ty"], pose["tz"]], dtype=np.float64)
return -R.T @ t
def _camera_up(pose: dict) -> np.ndarray:
"""World-space camera up vector. COLMAP camera +y is *down*, so up = R^T @ (0,-1,0)."""
R = quat_to_mat([pose["qw"], pose["qx"], pose["qy"], pose["qz"]])
return R.T @ np.array([0.0, -1.0, 0.0])
def normalize_scene(points, poses):
"""Similarity transform: centroid->origin, camera sphere radius->10, up->+Y (spec M2)."""
raise NotImplementedError("lane B (M2): implement normalize_scene")
"""Similarity transform: centroid->origin, camera sphere radius->10, up->+Y (spec M2).
``points`` is an ``(N,3)`` array; ``poses`` a list of world->camera pose dicts (keys
``qw,qx,qy,qz,tx,ty,tz`` plus any extras, which are preserved). The single similarity
transform ``X' = s * Rn @ (X - c)`` (translate to the point centroid, rotate the average
camera-up onto +Y, scale so the camera bounding sphere has radius 10) is applied to both
the points and the poses.
Returns ``(points_new float32 (N,3), poses_new list[dict])``.
"""
pts = np.asarray(points, dtype=np.float64).reshape(-1, 3)
poses = list(poses)
# 1. Translate to the point-cloud centroid.
centroid = pts.mean(axis=0) if len(pts) else np.zeros(3)
# 2. Rotate so the average camera-up aligns with +Y (phones held roughly upright).
if poses:
ups = np.array([_camera_up(p) for p in poses])
up_avg = ups.mean(axis=0)
if np.linalg.norm(up_avg) > 1e-9:
Rn = _rotation_aligning(up_avg, np.array([0.0, 1.0, 0.0]))
else:
Rn = np.eye(3)
else:
Rn = np.eye(3)
# 3. Scale so the farthest camera center from the centroid sits at radius 10.
if poses:
centers = np.array([_camera_center(p) for p in poses])
max_dist = float(np.max(np.linalg.norm(centers - centroid, axis=1)))
scale = NORMALIZED_SPHERE_RADIUS / max_dist if max_dist > 1e-9 else 1.0
else:
scale = 1.0
# Apply X' = s * Rn @ (X - c) to points.
points_new = (scale * (pts - centroid) @ Rn.T).astype(np.float32)
# Apply the matching transform to each world->camera pose:
# R' = R @ Rn^T , t' = s * (t + R @ c) (derived so projections are unchanged).
poses_new: list[dict] = []
for p in poses:
R = quat_to_mat([p["qw"], p["qx"], p["qy"], p["qz"]])
t = np.array([p["tx"], p["ty"], p["tz"]], dtype=np.float64)
R_new = R @ Rn.T
t_new = scale * (t + R @ centroid)
q_new = mat_to_quat(R_new)
out = dict(p)
out["qw"], out["qx"], out["qy"], out["qz"] = (
float(q_new[0]), float(q_new[1]), float(q_new[2]), float(q_new[3]))
out["tx"], out["ty"], out["tz"] = float(t_new[0]), float(t_new[1]), float(t_new[2])
poses_new.append(out)
return points_new, poses_new
def run_colmap(frames_dir: Path, workspace: Path) -> Path:
"""Drive the COLMAP CLI and return the exported TXT-model directory (spec M2, lane B)."""
raise NotImplementedError("lane B (M2): implement run_colmap")
# ---------------------------------------------------------------------------
# Pose interpolation
# ---------------------------------------------------------------------------
def interpolate_poses(registered: list[dict], sampled: list[dict]) -> list[dict]:
"""Fill unregistered sampled frames of one video between registered ones (spec M2).
``registered`` are solved poses (with ``registered=True``); ``sampled`` are the frames
that were fed to COLMAP as ``{frame_idx, t_video_s, ...}``. For each sampled frame that
lies strictly between the first and last registered frame but wasn't solved, slerp the
rotation and lerp the translation of the two bracketing registered poses (intrinsics
inherited from the earlier neighbor), marking it ``registered=False``. Frames outside the
registered span are dropped (no extrapolation). Returns all poses sorted by ``t_video_s``.
"""
reg = sorted(registered, key=lambda p: p["t_video_s"])
if not reg:
return []
reg_frames = {int(p["frame_idx"]) for p in reg}
t_first, t_last = reg[0]["t_video_s"], reg[-1]["t_video_s"]
out: list[dict] = list(reg)
for sf in sampled:
fidx = int(sf["frame_idx"])
tv = float(sf["t_video_s"])
if fidx in reg_frames:
continue
if tv <= t_first or tv >= t_last:
continue # no extrapolation beyond the registered span
# bracketing registered neighbors
lo = max((p for p in reg if p["t_video_s"] <= tv), key=lambda p: p["t_video_s"])
hi = min((p for p in reg if p["t_video_s"] >= tv), key=lambda p: p["t_video_s"])
span = hi["t_video_s"] - lo["t_video_s"]
alpha = 0.0 if span <= 1e-12 else (tv - lo["t_video_s"]) / span
q, t = slerp_pose(
[lo["qw"], lo["qx"], lo["qy"], lo["qz"]], [lo["tx"], lo["ty"], lo["tz"]],
[hi["qw"], hi["qx"], hi["qy"], hi["qz"]], [hi["tx"], hi["ty"], hi["tz"]],
alpha,
)
out.append({
"frame_idx": fidx, "t_video_s": tv,
"qw": float(q[0]), "qx": float(q[1]), "qy": float(q[2]), "qz": float(q[3]),
"tx": float(t[0]), "ty": float(t[1]), "tz": float(t[2]),
"fx": lo["fx"], "fy": lo["fy"], "cx": lo["cx"], "cy": lo["cy"],
"registered": False,
})
out.sort(key=lambda p: p["t_video_s"])
return out
# ---------------------------------------------------------------------------
# COLMAP CLI orchestration
# ---------------------------------------------------------------------------
@functools.lru_cache(maxsize=None)
def _colmap_help(command: str) -> str:
"""Cached ``colmap <command> --help`` text, for build-aware option detection.
COLMAP prints its option list to stderr, so both streams are captured.
"""
try:
proc = subprocess.run(
["colmap", command, "--help"], capture_output=True, text=True
)
return (proc.stdout or "") + "\n" + (proc.stderr or "")
except Exception:
return ""
def _cpu_flag(command: str) -> list[str]:
"""Force CPU SIFT for ``command`` if this COLMAP build exposes a ``use_gpu`` option.
The option prefix differs by version: COLMAP 3.x uses ``SiftExtraction`` /
``SiftMatching``, 4.x uses ``FeatureExtraction`` / ``FeatureMatching``. We read the option
name from ``--help`` so the pipeline runs headless on either (and on CPU-only builds that
omit the option entirely, we pass nothing). Returns e.g. ``["--FeatureExtraction.use_gpu",
"0"]`` or ``[]``.
"""
for line in _colmap_help(command).splitlines():
line = line.strip()
if line.startswith("--") and ".use_gpu" in line:
return [line.split()[0], "0"]
return []
def _run_colmap_step(args: list[str]) -> bool:
"""Run one ``colmap <args>`` step; return True on success, False (logged) on failure."""
cmd = ["colmap", *args]
log.info("colmap: %s", " ".join(args[:2]))
try:
proc = subprocess.run(cmd, capture_output=True, text=True)
except FileNotFoundError:
log.error("colmap binary vanished from PATH")
return False
if proc.returncode != 0:
log.error("colmap %s failed (rc=%d): %s", args[0], proc.returncode,
(proc.stderr or proc.stdout or "").strip()[-500:])
return False
return True
def _registered_image_count(model_dir: Path) -> int | None:
"""Number of registered images in a COLMAP model dir, or None if it has no image list.
The mapper writes binary models by default; ``images.bin`` begins with a little-endian
``uint64`` giving the registered-image count, which is the exact metric we want (file
size would instead track total keypoint observations and mis-rank multi-component runs).
Falls back to counting the TXT model's two-lines-per-image list.
"""
images_txt = model_dir / "images.txt"
images_bin = model_dir / "images.bin"
if images_txt.exists():
return sum(1 for _ in _iter_data_lines(images_txt)) // 2
if images_bin.exists():
with open(images_bin, "rb") as f:
header = f.read(8)
return struct.unpack("<Q", header)[0] if len(header) == 8 else 0
return None
def _largest_model_dir(sparse_dir: Path) -> Path | None:
"""Return the reconstruction subdir (``0``, ``1``, ...) with the most registered images."""
candidates = [d for d in sparse_dir.iterdir() if d.is_dir()] if sparse_dir.exists() else []
best, best_n = None, -1
for d in candidates:
n = _registered_image_count(d)
if n is None:
continue
if n > best_n:
best, best_n = d, n
return best
def run_colmap(frames_dir: Path, workspace: Path) -> Path | None:
"""Drive the COLMAP CLI and return the exported TXT-model directory (spec M2, lane B).
``frames_dir`` holds one subfolder of JPEGs per video (single camera per folder). Runs
feature extraction (CPU SIFT, OPENCV model), exhaustive matching, incremental mapping,
then undistortion + TXT conversion. Returns the directory containing ``images.txt`` /
``cameras.txt`` / ``points3D.txt``, or ``None`` if COLMAP produced no usable model
(caller degrades gracefully).
"""
workspace = Path(workspace)
# Start each run from a clean workspace: a stale database.db makes feature_extractor
# fail ("images already exist"), and a leftover sparse/ model would poison the pick.
if workspace.exists():
import shutil
shutil.rmtree(workspace)
workspace.mkdir(parents=True, exist_ok=True)
database = workspace / "database.db"
sparse = workspace / "sparse"
sparse.mkdir(exist_ok=True)
ok = _run_colmap_step([
"feature_extractor",
"--database_path", str(database),
"--image_path", str(frames_dir),
"--ImageReader.single_camera_per_folder", "1",
"--ImageReader.camera_model", "OPENCV",
*_cpu_flag("feature_extractor"),
])
if not ok:
return None
# Exhaustive matching covers within- and cross-video pairs; frame counts are small.
if not _run_colmap_step([
"exhaustive_matcher",
"--database_path", str(database),
*_cpu_flag("exhaustive_matcher"),
]):
return None
if not _run_colmap_step([
"mapper",
"--database_path", str(database),
"--image_path", str(frames_dir),
"--output_path", str(sparse),
]):
return None
model = _largest_model_dir(sparse)
if model is None:
log.error("colmap mapper produced no reconstruction")
return None
# Undistort -> PINHOLE model, then convert to TXT. Fall back to converting the raw
# (possibly distorted) sparse model directly if undistortion fails.
txt_dir = workspace / "model_txt"
txt_dir.mkdir(exist_ok=True)
dense = workspace / "dense"
undistorted = _run_colmap_step([
"image_undistorter",
"--image_path", str(frames_dir),
"--input_path", str(model),
"--output_path", str(dense),
"--output_type", "COLMAP",
])
convert_input = (dense / "sparse") if undistorted and (dense / "sparse").exists() else model
if not _run_colmap_step([
"model_converter",
"--input_path", str(convert_input),
"--output_path", str(txt_dir),
"--output_type", "TXT",
]):
return None
if not (txt_dir / "images.txt").exists():
log.error("colmap model_converter produced no images.txt")
return None
return txt_dir
# ---------------------------------------------------------------------------
# Full reconstruction entrypoint
# ---------------------------------------------------------------------------
def _video_id_and_frame(name: str) -> tuple[int, int] | None:
"""Recover ``(video_id, frame_idx)`` from a ``{video_id}_{frame_idx}.jpg`` image name."""
stem = Path(name).stem
if "_" not in stem:
return None
vid_s, frame_s = stem.rsplit("_", 1)
try:
return int(vid_s), int(frame_s)
except ValueError:
return None
def _diagnostic(msg: str) -> None:
"""Emit a clear operator-facing diagnostic (spec M2 graceful degradation)."""
log.warning("reconstruct: %s", msg)
print(f"[reconstruct] {msg}")
def run_reconstruct() -> dict:
@ -66,6 +481,128 @@ def run_reconstruct() -> dict:
Entrypoint for ``python -m festival4d reconstruct``. Degrades gracefully when COLMAP is
absent or the reconstruction is too weak (spec M2 failure handling): existing poses are
left untouched. Returns a summary dict.
left untouched and the DB is never corrupted. Returns a summary dict with a ``status``.
"""
raise NotImplementedError("lane B (M2): implement run_reconstruct")
db.init_engine()
db.init_db()
videos = db.get_videos()
if not videos:
_diagnostic("no videos in the project — run `synthetic` or `ingest` first.")
return {"status": "no_videos"}
if not colmap_available():
_diagnostic(
"COLMAP not found on PATH — skipping 3D reconstruction. Install it "
"(`brew install colmap` on macOS) to enable pose/point-cloud solving. "
"Existing (synthetic/previous) poses are left untouched; the app still runs as "
"synced videos without 3D."
)
return {"status": "skipped_no_colmap", "videos": len(videos)}
# 1. Sample sharp frames per video into one subfolder each (single camera per folder).
frames_root = config.FRAMES_DIR
per_video_sampled: dict[int, list[dict]] = {}
for v in videos:
video_path = config.RAW_DIR / v.filename
if not video_path.exists():
_diagnostic(f"video file missing, skipping: {video_path}")
continue
out_dir = frames_root / str(v.id)
paths = frames.sample_frames(video_path, v.id, out_dir)
fps = float(v.fps) if v.fps and v.fps > 0 else 30.0
sampled = []
for p in paths:
parsed = _video_id_and_frame(p.name)
if parsed is None:
continue
_, fidx = parsed
sampled.append({"frame_idx": fidx, "t_video_s": fidx / fps})
per_video_sampled[v.id] = sorted(sampled, key=lambda s: s["frame_idx"])
total_sampled = sum(len(s) for s in per_video_sampled.values())
if total_sampled == 0:
_diagnostic("no frames could be sampled from any video — nothing to reconstruct.")
return {"status": "no_frames"}
# 2. Run COLMAP.
model_dir = run_colmap(frames_root, config.COLMAP_DIR)
if model_dir is None:
_diagnostic(
"COLMAP produced no usable reconstruction. Likely causes: too-dark footage, "
"motion blur, or insufficient view overlap between cameras. Existing poses left "
"untouched — the app degrades to synced videos, no 3D."
)
return {"status": "failed_no_model", "sampled": total_sampled}
# 3. Parse the model.
images = parse_images_txt(model_dir / "images.txt")
cameras = parse_cameras_txt(model_dir / "cameras.txt")
points, colors = parse_points3d_txt(model_dir / "points3D.txt")
# 4. Attach each registered image to its video + intrinsics.
registered_by_video: dict[int, list[dict]] = {}
for img in images:
parsed = _video_id_and_frame(img["name"])
if parsed is None:
continue
video_id, frame_idx = parsed
cam = cameras.get(img["camera_id"])
if cam is None:
continue
fps = next((float(v.fps) for v in videos if v.id == video_id), 30.0) or 30.0
registered_by_video.setdefault(video_id, []).append({
"frame_idx": frame_idx, "t_video_s": frame_idx / fps,
"qw": img["qw"], "qx": img["qx"], "qy": img["qy"], "qz": img["qz"],
"tx": img["tx"], "ty": img["ty"], "tz": img["tz"],
"fx": cam["fx"], "fy": cam["fy"], "cx": cam["cx"], "cy": cam["cy"],
"registered": True, "video_id": video_id,
})
registered_count = sum(len(v) for v in registered_by_video.values())
videos_in_model = len(registered_by_video)
frac = registered_count / total_sampled if total_sampled else 0.0
if frac < MIN_REGISTERED_FRACTION or videos_in_model < MIN_VIDEOS_IN_MODEL:
_diagnostic(
f"weak reconstruction: {registered_count}/{total_sampled} frames "
f"({frac:.0%}) registered across {videos_in_model} video(s); need "
f">= {MIN_REGISTERED_FRACTION:.0%} of frames and >= {MIN_VIDEOS_IN_MODEL} "
"videos. Likely causes: too-dark footage, motion blur, or too little view "
"overlap. Existing poses left untouched — degrading to synced videos, no 3D."
)
return {
"status": "failed_weak", "sampled": total_sampled,
"registered": registered_count, "videos_in_model": videos_in_model,
}
# 5. Normalize the whole scene (points + all registered poses) with one transform.
all_registered = [p for v in registered_by_video.values() for p in v]
norm_points, norm_registered = normalize_scene(points, all_registered)
norm_by_video: dict[int, list[dict]] = {}
for p in norm_registered:
norm_by_video.setdefault(p["video_id"], []).append(p)
# 6. Interpolate unregistered sampled frames, then write poses per video (atomic replace).
interpolated_total = 0
for video_id, reg in norm_by_video.items():
sampled = per_video_sampled.get(video_id, [])
final = interpolate_poses(reg, sampled)
interpolated_total += sum(1 for p in final if not p.get("registered", True))
db.set_poses(video_id, final)
# 7. Export the normalized point cloud in the frozen PLY format.
from festival4d.synthetic import write_ply
write_ply(config.POINTS_PLY, norm_points, colors)
summary = {
"status": "ok",
"videos_in_model": videos_in_model,
"registered": registered_count,
"interpolated": interpolated_total,
"points": int(len(norm_points)),
"points_ply": str(config.POINTS_PLY),
}
log.info("reconstruct: done — %s", summary)
print(f"[reconstruct] reconstructed {videos_in_model} videos, "
f"{registered_count} registered + {interpolated_total} interpolated poses, "
f"{len(norm_points)} points -> {config.POINTS_PLY}")
return summary

View File

@ -0,0 +1,276 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Festival 4D — Capture</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0d12; --panel: #141824; --panel-2: #1c2233; --border: #232838;
--text: #e6e8ee; --dim: #8a92a6; --ok: #59d499; --bad: #ff6b6b; --rec: #ff3b6b;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); min-height: 100vh;
font: 15px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: env(safe-area-inset-top) 0.8rem calc(env(safe-area-inset-bottom) + 0.8rem);
}
h1 { font-size: 1.1rem; margin: 0.8rem 0 0.2rem; }
.sub { color: var(--dim); font-size: 0.8rem; margin-bottom: 0.8rem; }
.card {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 0.7rem; margin-bottom: 0.7rem;
}
label { display: block; color: var(--dim); font-size: 0.76rem; margin-bottom: 0.2rem; }
select, input[type="text"] {
width: 100%; background: var(--panel-2); color: var(--text); font-size: 1rem;
border: 1px solid var(--border); border-radius: 7px; padding: 0.5rem;
margin-bottom: 0.55rem;
}
#preview {
width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px;
object-fit: contain; display: block;
}
.row { display: flex; gap: 0.5rem; align-items: center; }
button {
flex: 1 1 auto; font-size: 1rem; font-weight: 600; padding: 0.75rem 0.6rem;
border-radius: 9px; border: 1px solid var(--border); background: var(--panel-2);
color: var(--text); cursor: pointer;
}
button:disabled { opacity: 0.45; cursor: default; }
#rec.recording { background: var(--rec); border-color: var(--rec); color: #fff; }
.pill {
display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.75rem;
color: var(--dim);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); }
.dot.on { background: var(--rec); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.25; } }
.msg { font-size: 0.82rem; margin-top: 0.5rem; line-height: 1.4; }
.err { color: var(--bad); }
.ok { color: var(--ok); }
code {
background: var(--panel-2); padding: 0.1rem 0.3rem; border-radius: 4px;
font-size: 0.85em; word-break: break-all;
}
/* monitor */
#mon { display: grid; grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 0.5rem; }
.mcell { background: var(--panel-2); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
.mcell img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; }
.mcell .cap { padding: 0.25rem 0.35rem; font-size: 0.68rem; display: flex; align-items: center; gap: 0.25rem; }
.mcell .nm { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mcell.rec { border-color: var(--rec); }
#gate { display: none; }
</style>
</head>
<body>
<h1>Festival 4D — Capture</h1>
<div class="sub">Record this camera. Clips upload to the project and sync by audio.</div>
<!-- Shown when getUserMedia is unavailable (insecure origin) -->
<div class="card" id="gate">
<div class="msg err"><b>Camera blocked — this page isn't a secure context.</b></div>
<div class="msg">
Browsers only grant camera access over <b>HTTPS</b> (or localhost). On the machine running
the backend:
<div style="margin:0.4rem 0"><code>tailscale serve --bg 8000</code></div>
then open the printed <code>https://&lt;machine&gt;.&lt;tailnet&gt;.ts.net/capture</code>
URL on this device (join the tailnet first).
</div>
</div>
<div id="app">
<div class="card">
<label for="name">Device name (used in the filename)</label>
<input type="text" id="name" placeholder="e.g. phone-left / logitech-c920" />
<label for="cam">Camera</label>
<select id="cam"></select>
<label for="mic">Microphone <span style="color:var(--bad)">— required for sync</span></label>
<select id="mic"></select>
<video id="preview" playsinline muted autoplay></video>
<div class="row" style="margin-top:0.55rem">
<span class="pill"><span class="dot" id="recdot"></span><span id="status">idle</span></span>
</div>
</div>
<div class="row">
<button id="rec">● Record</button>
</div>
<div class="msg" id="msg"></div>
<div class="card" style="margin-top:0.8rem">
<label style="margin-bottom:0.4rem">Who's shooting what — live monitor</label>
<div id="mon"></div>
<div class="msg" id="monempty" style="color:var(--dim)">no other cameras yet</div>
</div>
<div class="msg" style="color:var(--dim)">
All cameras must hear the <b>same sound</b> — that's how clips get aligned. No clock sync
needed; you don't even have to start together. When done, run
<code>python -m festival4d ingest &amp;&amp; python -m festival4d sync</code>.
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const deviceId = (localStorage.f4dDevId ||= "dev-" + Math.random().toString(36).slice(2, 9));
let stream = null, recorder = null, chunks = [], startedAt = 0, timer = null;
const secure = window.isSecureContext && navigator.mediaDevices?.getUserMedia;
if (!secure) {
$("gate").style.display = "block";
$("app").style.display = "none";
}
function msg(html, cls = "") { $("msg").className = "msg " + cls; $("msg").innerHTML = html; }
async function listDevices() {
const devs = await navigator.mediaDevices.enumerateDevices();
const fill = (sel, kind, fallback) => {
const cur = sel.value;
sel.innerHTML = devs
.filter((d) => d.kind === kind)
.map((d, i) => `<option value="${d.deviceId}">${d.label || `${fallback} ${i + 1}`}</option>`)
.join("");
if (cur) sel.value = cur;
};
fill($("cam"), "videoinput", "Camera");
fill($("mic"), "audioinput", "Microphone");
}
async function start(camId, micId) {
stream?.getTracks().forEach((t) => t.stop());
// Audio is NOT optional: the pipeline aligns clips by sound.
stream = await navigator.mediaDevices.getUserMedia({
video: camId ? { deviceId: { exact: camId } } : true,
audio: micId ? { deviceId: { exact: micId } } : true,
});
$("preview").srcObject = stream;
await listDevices(); // labels only populate after permission is granted
}
function pickMime() {
const want = [
"video/webm;codecs=vp9,opus",
"video/webm;codecs=vp8,opus",
"video/webm",
"video/mp4", // Safari / iOS
];
return want.find((m) => MediaRecorder.isTypeSupported?.(m)) || "";
}
$("rec").addEventListener("click", () => (recorder ? stop() : record()));
function record() {
if (!stream) return msg("no camera stream yet", "err");
if (stream.getAudioTracks().length === 0)
return msg("no audio track — clips without sound can't be synced.", "err");
const mime = pickMime();
chunks = [];
recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);
recorder.onstop = upload;
recorder.start(1000);
startedAt = Date.now();
$("rec").textContent = "■ Stop";
$("rec").classList.add("recording");
$("recdot").classList.add("on");
timer = setInterval(tick, 250);
msg("recording — point at the stage and keep some static structure in frame.");
}
function stop() {
clearInterval(timer);
recorder?.stop();
recorder = null;
$("rec").textContent = "● Record";
$("rec").classList.remove("recording");
$("recdot").classList.remove("on");
tick();
}
function elapsed() { return startedAt ? (Date.now() - startedAt) / 1000 : 0; }
function tick() {
$("status").textContent = recorder ? `recording ${elapsed().toFixed(0)}s` : "idle";
}
async function upload() {
const type = chunks[0]?.type || "video/webm";
const blob = new Blob(chunks, { type });
const ext = type.includes("mp4") ? "mp4" : "webm";
const name = ($("name").value || "camera").trim();
const fd = new FormData();
fd.append("device_name", name);
fd.append("file", blob, `clip.${ext}`);
msg(`uploading ${(blob.size / 1e6).toFixed(1)} MB…`);
try {
const r = await fetch("api/capture/upload", { method: "POST", body: fd });
const j = await r.json();
if (!r.ok) throw new Error(j.detail || `HTTP ${r.status}`);
msg(`saved <code>${j.filename}</code> (${(j.bytes / 1e6).toFixed(1)} MB). Record again or run the pipeline.`, "ok");
} catch (e) {
msg(`upload failed: ${e.message}`, "err");
}
startedAt = 0;
tick();
}
// --- heartbeat + monitor -------------------------------------------------------------
const snapCanvas = document.createElement("canvas");
function snapshot() {
const v = $("preview");
if (!v.videoWidth) return null;
snapCanvas.width = 160;
snapCanvas.height = Math.round((160 * v.videoHeight) / v.videoWidth);
snapCanvas.getContext("2d").drawImage(v, 0, 0, snapCanvas.width, snapCanvas.height);
return snapCanvas.toDataURL("image/jpeg", 0.4);
}
async function beat() {
try {
await fetch("api/capture/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_id: deviceId,
name: ($("name").value || "camera").trim(),
status: recorder ? "recording" : "idle",
elapsed_s: elapsed(),
snapshot: snapshot(),
}),
});
const devs = await (await fetch("api/capture/devices")).json();
renderMonitor(devs.filter((d) => d.device_id !== deviceId));
} catch { /* offline: monitor just goes stale */ }
}
function renderMonitor(devs) {
$("monempty").style.display = devs.length ? "none" : "block";
$("mon").innerHTML = devs
.map(
(d) => `<div class="mcell ${d.status === "recording" ? "rec" : ""}">
${d.snapshot ? `<img src="${d.snapshot}" alt="">` : `<img alt="">`}
<div class="cap"><span class="dot ${d.status === "recording" ? "on" : ""}"></span>
<span class="nm">${d.name}</span>
<span>${d.status === "recording" ? d.elapsed_s.toFixed(0) + "s" : ""}</span></div>
</div>`
)
.join("");
}
// --- boot ----------------------------------------------------------------------------
if (secure) {
$("name").value = localStorage.f4dName || "";
$("name").addEventListener("change", () => (localStorage.f4dName = $("name").value));
$("cam").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
$("mic").addEventListener("change", () => start($("cam").value, $("mic").value).catch((e) => msg(e.message, "err")));
start().then(() => msg("ready — name this device, then record.")).catch((e) =>
msg(`camera error: ${e.message}`, "err")
);
setInterval(beat, 2000);
navigator.mediaDevices.addEventListener?.("devicechange", listDevices);
}
</script>
</body>
</html>

View File

@ -11,11 +11,17 @@ testable without real footage:
- **Point cloud**: random points on the stage box + ground plane, written to
``points.ply`` in the exact binary-little-endian format the real COLMAP path produces.
- **Seeded events + anchors** so the timeline and overlays have data immediately.
- **Two moving markers** (phase 6 / M18) composited into every camera's video *through the
same camera projection the poses use*, plus ``track_truth.json`` the 3D ground truth lane
H recovers. Marker A is a solid magenta disc (color mode, ``hue:150``); marker B is a
blinking bright disc speaking the frozen OOK protocol with ID 5 (``code:5``). See
:func:`marker_position`, :func:`render_marker_overlay`, :func:`build_track_truth`.
- **``ground_truth.json``**: offsets, drift, audio pulse (bang) times, events, and stage
corners part of the frozen contract (lanes A and D assert against it).
FROZEN CONTRACT after foundation. The module is factored so the numeric pieces
(audio shifts, poses, PLY round-trip) are unit-testable without invoking ffmpeg.
(audio shifts, poses, PLY round-trip, marker geometry) are unit-testable without invoking
ffmpeg.
"""
from __future__ import annotations
@ -31,8 +37,8 @@ from pathlib import Path
import numpy as np
from festival4d import config, db
from festival4d.geometry import mat_to_quat
from festival4d import config, db, tracker_detect
from festival4d.geometry import mat_to_quat, quat_to_mat, slerp_pose
log = logging.getLogger("festival4d.synthetic")
@ -363,6 +369,219 @@ def read_ply(path: Path) -> tuple[np.ndarray, np.ndarray]:
return points, colors
# ---------------------------------------------------------------------------
# Moving markers (phase 6 / M18). Two wearable markers travel known 3D paths across the
# stage; each camera's frame shows them at the pixel its OWN pose projects the 3D point to,
# so the rendered pixels and ``track_truth.json`` are consistent BY CONSTRUCTION — the marker
# pixel is derived from the ground-truth 3D point through the same projection lane H inverts.
#
# Marker A — solid magenta (#ff00ff) disc, color mode, marker_key "hue:150". Slow circle
# (radius 2, y≈1.5) centered on the stage.
# Marker B — blinking bright disc, blink mode, ID 5, marker_key "code:5". A different path;
# the LED follows the frozen OOK protocol (tracker_detect) in GLOBAL time, so
# every camera sees the same on/off state at the same t_global.
# Discs are small (radius ≈2.5% of frame height ⇒ ≪2% frame area) and composited AFTER all
# existing content, so the 189-test floor (offsets/events/poses/SfM) is untouched (pitfall #6).
# ---------------------------------------------------------------------------
MARKER_A_KEY = tracker_detect.color_marker_key(tracker_detect.MAGENTA_OPENCV_HUE) # "hue:150"
MARKER_A_HEX = "#ff00ff"
MARKER_A_BGR = (255, 0, 255) # OpenCV BGR for magenta (drawn AFTER the hue filter)
MARKER_B_ID = 5
MARKER_B_KEY = tracker_detect.code_marker_key(MARKER_B_ID) # "code:5"
MARKER_B_ON_BGR = (255, 255, 255) # bright white while the LED is ON
# testsrc2's base pattern contains solid full-saturation magenta AND cyan colour bars — the
# EXACT two marker hues (contract #1) — plus other bright bars. Left as-is they swamp lane H's
# colour detection (the magenta bar is a far bigger "magenta blob" than marker A's disc) and
# starve marker B's white-blink luminance contrast. So the base is MUTED before the markers are
# composited on top: saturation crushed (no high-saturation magenta/cyan survives) and dimmed
# (the bright-white blink disc pops in luminance). The fully-saturated discs are then the only
# high-S magenta/cyan and the only bright small blobs in frame — the fixture lane H is graded on
# is actually solvable by construction. Tests assert geometry/audio/PLY, never base pixels.
BASE_SATURATION = 0.28 # hue-filter saturation multiplier applied to the base
BASE_BRIGHTNESS = -0.28 # eq brightness offset applied to the base (dim it)
MARKER_DISC_HEIGHT_FRAC = 0.025 # disc radius / frame height (≈9 px @ 360 -> 0.11% area)
MARKER_A_PERIOD_S = 20.0 # one slow loop across the fixture span
MARKER_B_PERIOD_S = 16.0 # different path + rate so the two never lock together
MARKER_TRUTH_DT_S = 0.05 # track_truth.json sampling step (t_global seconds)
def marker_position(marker_key: str, t_global: float) -> tuple[float, float, float]:
"""Ground-truth 3D position (Three.js scene space) of a marker at ``t_global`` seconds.
Pure and total over all real ``t_global`` (the markers exist for the whole span). This is
THE ground truth: it is what ``track_truth.json`` records and what a correct solver
recovers. FROZEN lane H's acceptance is measured against these paths.
"""
if marker_key == MARKER_A_KEY:
theta = 2.0 * np.pi * t_global / MARKER_A_PERIOD_S
return (2.0 * float(np.cos(theta)), 1.5, 2.0 * float(np.sin(theta)))
if marker_key == MARKER_B_KEY:
phi = 2.0 * np.pi * t_global / MARKER_B_PERIOD_S
return (
2.4 * float(np.sin(phi)),
1.0 + 0.4 * float(np.sin(2.0 * phi)),
-0.8 + 1.2 * float(np.cos(phi)),
)
raise KeyError(f"unknown marker_key {marker_key!r}")
MARKER_KEYS = (MARKER_A_KEY, MARKER_B_KEY)
def _interp_pose(poses: list[dict], t_video: float):
"""Interpolate a COLMAP pose ``(q, t, (fx,fy,cx,cy))`` at ``t_video`` from ``camera_track``
output (ascending by ``t_video_s``). Mirrors ``resolve._pose_at`` / the frontend ``poseAt``
exactly slerp the rotation, lerp the translation via the FROZEN ``geometry.slerp_pose``
so the marker pixels a solver back-projects land on the same 3D point. Clamps (no extrap.).
"""
def tup(p):
return ([p["qw"], p["qx"], p["qy"], p["qz"]],
[p["tx"], p["ty"], p["tz"]],
(p["fx"], p["fy"], p["cx"], p["cy"]))
if t_video <= poses[0]["t_video_s"]:
return tup(poses[0])
if t_video >= poses[-1]["t_video_s"]:
return tup(poses[-1])
lo, hi = 0, len(poses) - 1
while hi - lo > 1:
mid = (lo + hi) // 2
if poses[mid]["t_video_s"] <= t_video:
lo = mid
else:
hi = mid
a, b = poses[lo], poses[hi]
span = b["t_video_s"] - a["t_video_s"]
alpha = (t_video - a["t_video_s"]) / span if span > 1e-9 else 0.0
q, t = slerp_pose(
[a["qw"], a["qx"], a["qy"], a["qz"]], [a["tx"], a["ty"], a["tz"]],
[b["qw"], b["qx"], b["qy"], b["qz"]], [b["tx"], b["ty"], b["tz"]],
alpha,
)
return list(q), list(t), (a["fx"], a["fy"], a["cx"], a["cy"])
def project_point(q, t, intr, X) -> tuple[float, float, float] | None:
"""Project world point ``X`` through a COLMAP world->camera pose (the inverse of
``geometry.ray_from_pixel``). Returns ``(px, py, z_cam)`` in pixels, or ``None`` if the
point is behind the camera. COLMAP camera axes: +x right, +y down, +z forward.
"""
R = quat_to_mat(q) # world -> cam
Xc = R @ np.asarray(X, dtype=np.float64).reshape(3) + np.asarray(t, dtype=np.float64).reshape(3)
z = float(Xc[2])
if z <= 1e-6:
return None
fx, fy, cx, cy = intr
return fx * float(Xc[0]) / z + cx, fy * float(Xc[1]) / z + cy, z
def _disc_radius_px(height: int) -> int:
return max(6, int(round(MARKER_DISC_HEIGHT_FRAC * height)))
def render_marker_overlay(out_dir: Path, poses: list[dict], offset_ms: float,
width: int, height: int, fps: float, duration_s: float) -> int:
"""Write a transparent BGRA PNG per frame with the two markers drawn at their projected
pixels (phase 6 / M18). Composited over one camera's clip by :func:`render_video`.
For frame ``k`` at local ``t_video = k/fps``: convert to ``t_global`` with the FROZEN
``config.t_global_from_video``, look up each marker's 3D truth at ``t_global``, and project
it through this camera's pose interpolated at ``t_video``. Marker A is always drawn; marker
B is drawn only when its LED is ON per the OOK schedule (``tracker_detect.led_on``, in
global time). Off-frame / behind-camera projections are simply not drawn. Returns frame count.
"""
import cv2
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
n = int(round(duration_s * fps))
for k in range(n):
t_video = k / fps
t_global = config.t_global_from_video(t_video, offset_ms, 0.0)
q, t, intr = _interp_pose(poses, t_video)
img = np.zeros((height, width, 4), dtype=np.uint8) # transparent BGRA
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_A_KEY, t_global),
MARKER_A_BGR, radius)
if tracker_detect.led_on(t_global, MARKER_B_ID):
_draw_marker_disc(img, q, t, intr, marker_position(MARKER_B_KEY, t_global),
MARKER_B_ON_BGR, radius)
if not cv2.imwrite(str(out_dir / f"{k:05d}.png"), img):
raise RuntimeError(f"failed to write marker overlay frame: {out_dir}/{k:05d}.png")
return n
def _draw_marker_disc(img, q, t, intr, X, bgr, radius) -> None:
import cv2
proj = project_point(q, t, intr, X)
if proj is None:
return
px, py, _ = proj
h, w = img.shape[:2]
if not (0.0 <= px < w and 0.0 <= py < h):
return # off this camera's frame ⇒ not visible here
cv2.circle(img, (int(round(px)), int(round(py))), radius,
(int(bgr[0]), int(bgr[1]), int(bgr[2]), 255), thickness=-1, lineType=cv2.LINE_8)
def build_track_truth(g_lo: float, g_hi: float, dt: float = MARKER_TRUTH_DT_S) -> dict:
"""The ``track_truth.json`` body: each marker's 3D path sampled over ``[g_lo, g_hi]``.
Shape (spec M18): ``{"markers": [{"marker_key", "points": [{"t_global_s","x","y","z"}]}]}``
in Three.js scene space. ``generated_by`` is added for provenance (extra keys are fine).
"""
n = max(1, int(round((g_hi - g_lo) / dt)) + 1)
markers = []
for key in MARKER_KEYS:
points = []
for i in range(n):
tg = g_lo + i * dt
x, y, z = marker_position(key, tg)
points.append({"t_global_s": round(tg, 6), "x": x, "y": y, "z": z})
markers.append({"marker_key": key, "points": points})
return {"markers": markers, "generated_by": "festival4d.synthetic"}
def render_blink_clip(out_path: Path, marker_id: int = MARKER_B_ID, fps: float = 30.0,
duration_s: float = 3.0, width: int = 320, height: int = 240) -> Path:
"""Render a small clip of a single blinking badge at frame center (phase 6 / M24 helper).
A fixed-position bright disc blinking ``marker_id`` in the frozen OOK protocol on a black
background no camera geometry, so lane K's ``badge_selftest --synthetic`` can round-trip
the ID through ``tracker_detect``'s blink decoder without a full fixture. No audio track.
"""
import cv2
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required to render a blink clip")
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
radius = _disc_radius_px(height)
cx, cy = width // 2, height // 2
n = int(round(duration_s * fps))
with tempfile.TemporaryDirectory() as tmp:
frames_dir = Path(tmp)
for k in range(n):
t = k / fps
img = np.zeros((height, width, 3), dtype=np.uint8) # black background
if tracker_detect.led_on(t, marker_id):
cv2.circle(img, (cx, cy), radius, (255, 255, 255), thickness=-1,
lineType=cv2.LINE_8)
cv2.imwrite(str(frames_dir / f"{k:05d}.png"), img)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-framerate", str(fps), "-start_number", "0",
"-i", str(frames_dir / "%05d.png"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "20",
"-movflags", "+faststart",
str(out_path),
]
subprocess.run(cmd, check=True)
return out_path
# ---------------------------------------------------------------------------
# Video rendering (ffmpeg)
# ---------------------------------------------------------------------------
@ -396,25 +615,30 @@ def _has_drawtext() -> bool:
def render_video(out_path: Path, wav_path: Path, cam_index: int,
width: int, height: int, fps: float, duration_s: float) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``."""
width: int, height: int, fps: float, duration_s: float,
poses: list[dict] | None = None, offset_ms: float = 0.0) -> None:
"""Render one synthetic clip: a distinct ``testsrc2`` visual muxed with ``wav_path``.
When ``poses`` is given (this camera's ``camera_track``), the two moving markers (M18) are
composited on top via a per-frame RGBA overlay produced by :func:`render_marker_overlay`
(drawn AFTER the base visual + label, so existing behavior/tests are untouched). ``offset_ms``
aligns the marker schedule to global time for this camera.
"""
if shutil.which("ffmpeg") is None:
raise RuntimeError("ffmpeg not found on PATH — required for the synthetic fixture")
out_path.parent.mkdir(parents=True, exist_ok=True)
# Per-camera hue shift for distinctness, then mute saturation + brightness so the base
# never collides with the fully-saturated markers composited on top (see BASE_* above).
hue = cam_index * 47
vf = f"hue=h={hue}"
vf = f"hue=h={hue}:s={BASE_SATURATION},eq=brightness={BASE_BRIGHTNESS}"
font = _find_font()
if font and _has_drawtext():
vf += (f",drawtext=fontfile='{font}':text='CAM {cam_index}':"
"x=24:y=24:fontsize=44:fontcolor=white:box=1:boxcolor=black@0.5")
src = f"testsrc2=size={width}x{height}:rate={fps}:duration={duration_s}"
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
common_out = [
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", "veryfast", "-crf", "28",
"-profile:v", "baseline", "-level", "3.1",
"-movflags", "+faststart",
@ -422,7 +646,33 @@ def render_video(out_path: Path, wav_path: Path, cam_index: int,
"-shortest",
str(out_path),
]
subprocess.run(cmd, check=True)
if poses is None:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src,
"-i", str(wav_path),
"-vf", vf,
*common_out,
]
subprocess.run(cmd, check=True)
return
# Markers: pre-render the RGBA overlay frames, then composite them over the base visual.
with tempfile.TemporaryDirectory() as ov:
ov_dir = Path(ov)
render_marker_overlay(ov_dir, poses, offset_ms, width, height, fps, duration_s)
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", src, # 0: base video
"-i", str(wav_path), # 1: audio
"-framerate", str(fps), "-start_number", "0",
"-i", str(ov_dir / "%05d.png"), # 2: marker overlay
"-filter_complex", f"[0:v]{vf}[bg];[bg][2:v]overlay=0:0:eof_action=pass[vo]",
"-map", "[vo]", "-map", "1:a",
*common_out,
]
subprocess.run(cmd, check=True)
def ffprobe_video(path: Path) -> dict:
@ -481,11 +731,15 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
filename = f"cam{i}.mp4"
out_path = raw / filename
clip = slice_for_offset(master, sr, offset_ms, duration)
poses = camera_track(i, duration, fps, w, h)
if run_ffmpeg:
wav_path = tmp / f"cam{i}.wav"
write_wav(wav_path, clip, sr)
render_video(out_path, wav_path, i, w, h, fps, duration)
# Pass this camera's poses + offset so the M18 markers render through the same
# projection lane H inverts (the marker pixel derives from its 3D truth).
render_video(out_path, wav_path, i, w, h, fps, duration,
poses=poses, offset_ms=float(offset_ms))
probed = ffprobe_video(out_path)
vw, vh, vfps, vdur = (probed["width"], probed["height"],
probed["fps"], probed["duration_s"])
@ -496,7 +750,7 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
filename=filename, duration_s=vdur, fps=vfps, width=vw, height=vh,
offset_ms=float(offset_ms), drift_ppm=0.0, sync_confidence=1.0,
)
db.set_poses(video.id, camera_track(i, duration, fps, w, h))
db.set_poses(video.id, poses)
videos_gt.append({
"video_id": video.id, "filename": filename,
"offset_ms": float(offset_ms), "drift_ppm": 0.0,
@ -537,17 +791,28 @@ def build(base_dir: Path | str | None = None, duration_s: float | None = None,
gt_path = work / "ground_truth.json"
gt_path.write_text(json.dumps(ground_truth, indent=2))
# Friend-track ground truth (M18): the two markers' 3D paths over the union of the
# cameras' global coverage. Written for both ffmpeg and no-ffmpeg builds — it is pure
# geometry (the same paths the markers are rendered from), so tests can check it cheaply.
offsets_s = [o / 1000.0 for o in config.SYNTH_OFFSETS_MS]
track_truth = build_track_truth(min(offsets_s), max(offsets_s) + duration)
tt_path = work / "track_truth.json"
tt_path.write_text(json.dumps(track_truth, indent=2))
summary = {
"base_dir": str(base),
"videos": [v["filename"] for v in videos_gt],
"points_ply": str(work / "points.ply"),
"point_count": int(len(points)),
"ground_truth": str(gt_path),
"track_truth": str(tt_path),
"markers": [m["marker_key"] for m in track_truth["markers"]],
"events": len(SEED_EVENTS),
"anchors": len(STAGE_CORNERS),
}
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS))
log.info("synthetic: done — %d videos, %d points, %d events, %d anchors, %d markers",
len(videos_gt), len(points), len(SEED_EVENTS), len(STAGE_CORNERS),
len(track_truth["markers"]))
return summary

View File

@ -0,0 +1,171 @@
"""Marker detection (spec M19, lane H) — STUB with the frozen contracts baked in.
foundation3 ships this file with:
- the **concrete, foundation-owned protocol constants + pure encoders** every side of the
system must agree on (the blink OOK protocol #4, the color-mode marker-key convention #1).
``synthetic.py`` renders the fixture markers with these, ``scripts/badge_selftest.py``
(lane K) imports this decoder path, and ``hardware/badge/protocol.h`` (lane K) duplicates
the constants with a documented copy-check. **One source of truth lives here.**
- a **stub** :func:`run_detect` that raises ``NotImplementedError``. Lane H fills the body
(and adds ``backend/tests/test_tracker_detect.py``); it must not change the constants or
the emitted ``marker_key`` conventions, only implement the detection logic.
Nothing in this module raises at import time ``synthetic.py`` imports the constants and the
``encode_word`` / ``led_on`` helpers, so the import must stay side-effect-free and light
(no OpenCV at module scope; import ``cv2`` inside ``run_detect``).
================================================================================
FROZEN CONTRACTS (plan/30-phase6.md copy, do not reinterpret)
================================================================================
Contract #1 — Marker hues (color mode)
--------------------------------------
Magenta ``#ff00ff`` and cyan ``#00ffff`` are the two supported fixture hues; real shoots may
configure others via ``FESTIVAL4D_MARKER_HUES`` (comma-separated hex). Detection converts to
HSV and gates on **saturation AND value before hue** (pitfall #2 — stage lighting lies about
color). A color blob's ``marker_key`` is ``"hue:<H>"`` where ``<H>`` is the **OpenCV hue**
(0..179) of the configured marker color, i.e. ``round(matplotlib_hue_degrees / 2)``:
magenta ``hue:150``, cyan ``hue:90``. Use :func:`color_marker_key` never format the
string by hand.
Contract #2 — Schema (what the solver, not the detector, writes; here for context)
----------------------------------------------------------------------------------
``tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)``
``track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
quality FLOAT, views INTEGER)`` points ordered by ``t_global_s``;
``views`` = cameras used (1 ground-plane fallback); coordinates in **Three.js scene space**.
Contract #3 — detections.json (this module's OUTPUT — write via `db`? No: a JSON file)
--------------------------------------------------------------------------------------
Write ``config.DETECTIONS_JSON`` (``data/work/detections.json``) with EXACTLY this shape::
{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
"cx": float, "cy": float, "area": float,
"conf": float 0..1}]},
"generated_by": "festival4d.tracker_detect"}
``cx`` / ``cy`` are **normalized 0..1** (the bbox-annotation convention: ``cx = px / width``,
``cy = py / height``). ``area`` is the blob area in pixels. Detections at ``t_video`` (the
frame's local time) — the solver converts to ``t_global`` with the FROZEN
``config.t_global_from_video`` helper (pitfall #1). No markers / dark / markerless video →
a valid-empty ``{"videos": {}, "generated_by": ...}``, never a crash.
Contract #4 — Blink protocol (OOK, camera-decodable)
----------------------------------------------------
Bit period **133 ms** (4 frames @ 30 fps). One **word** = preamble ``11100`` + 6-bit ID
(MSB first) + even parity = **12 bits**, repeated continuously (~1.6 s/word). ID **63** is
reserved for the sync beacon. Decoding needs **24 fps** video and **1 full word** of
visibility. Decode by **integrating brightness per bit window using the video's real fps**
(``meta.fps``) never by counting frames (pitfall #3). Emit ``marker_key = "code:<id>"``
via :func:`code_marker_key`. The constants below are the byte-identical source for
``hardware/badge/protocol.h`` (lane K).
Contract #5 — Ground plane (solver context)
-------------------------------------------
Synthetic ground is ``y = 0`` (scene space). Real projects estimate it as the
5th-percentile-y horizontal plane of the point cloud. Single-view fallback intersects the
pixel ray with this plane; such points get ``views = 1`` and ``quality 0.5``.
"""
from __future__ import annotations
import logging
import os
log = logging.getLogger("festival4d.tracker_detect")
# ---------------------------------------------------------------------------
# Contract #4 — blink OOK protocol. FROZEN. Byte-identical to hardware/badge/protocol.h.
# ---------------------------------------------------------------------------
BLINK_BIT_PERIOD_S: float = 0.133 # 133 ms bit period (≈4 frames @ 30 fps)
BLINK_PREAMBLE: tuple[int, ...] = (1, 1, 1, 0, 0) # start-of-word marker
BLINK_ID_BITS: int = 6 # payload width (0..63)
BLINK_WORD_BITS: int = len(BLINK_PREAMBLE) + BLINK_ID_BITS + 1 # +1 parity = 12
BLINK_BEACON_ID: int = 63 # reserved: the sync/calibration beacon
BLINK_MIN_FPS: float = 24.0 # decoder needs at least this frame rate
# ---------------------------------------------------------------------------
# Contract #1 — color mode marker hues. OpenCV hue is 0..179 (degrees / 2).
# ---------------------------------------------------------------------------
MAGENTA_OPENCV_HUE: int = 150 # #ff00ff
CYAN_OPENCV_HUE: int = 90 # #00ffff
DEFAULT_MARKER_HUES_HEX: tuple[str, ...] = ("#ff00ff", "#00ffff")
def encode_word(marker_id: int) -> list[int]:
"""The 12-bit OOK word for ``marker_id`` (contract #4). Pure; no I/O.
``preamble(11100) + 6-bit ID MSB-first + even-parity bit``. The parity bit is chosen so
the **whole word has an even number of 1-bits** (parity over preamble+ID). This is the
exact bit sequence a badge emits, ``synthetic.py`` renders, and lane H's decoder inverts.
>>> encode_word(5)
[1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1]
"""
mid = int(marker_id)
if not 0 <= mid < (1 << BLINK_ID_BITS):
raise ValueError(f"marker_id {marker_id} out of range 0..{(1 << BLINK_ID_BITS) - 1}")
id_bits = [(mid >> (BLINK_ID_BITS - 1 - i)) & 1 for i in range(BLINK_ID_BITS)]
body = list(BLINK_PREAMBLE) + id_bits
parity = sum(body) % 2 # even parity: word weight (incl. parity) is even
return body + [parity]
def led_on(t_seconds: float, marker_id: int, epoch: float = 0.0) -> bool:
"""Whether the badge LED for ``marker_id`` is lit at wall-clock time ``t_seconds``.
The word repeats continuously from ``epoch``; a single physical badge blinks in real
(global) time, so every camera that captures a frame at the same ``t_global`` sees the
same LED state. Used by the synthetic fixture to render marker B faithfully.
"""
bit_index = int((t_seconds - epoch) // BLINK_BIT_PERIOD_S)
word = encode_word(marker_id)
return bool(word[bit_index % BLINK_WORD_BITS])
def color_marker_key(opencv_hue: int) -> str:
"""The ``marker_key`` for a color blob of the given OpenCV hue (contract #1)."""
return f"hue:{int(opencv_hue)}"
def code_marker_key(marker_id: int) -> str:
"""The ``marker_key`` for a decoded blink ID (contract #4)."""
return f"code:{int(marker_id)}"
def marker_hues_hex() -> list[str]:
"""Configured color-mode hues as hex strings (``FESTIVAL4D_MARKER_HUES`` or the default)."""
env = os.environ.get("FESTIVAL4D_MARKER_HUES")
if env:
hues = [h.strip() for h in env.split(",") if h.strip()]
if hues:
return hues
return list(DEFAULT_MARKER_HUES_HEX)
# ---------------------------------------------------------------------------
# Lane H fills these. Signatures FROZEN.
# ---------------------------------------------------------------------------
def run_detect(sample_fps: float = 8.0) -> dict:
"""Detect markers in every video and write ``detections.json`` (spec M19, contract #3).
Lane H implements:
1. Sample frames per video with the ``frames.py`` machinery at ~``sample_fps`` fps
(document the exact rate; ~8 fps comfortably oversamples the 133 ms bit period).
2. **Color mode:** BGRHSV; gate on saturation AND value *before* hue (pitfall #2);
connected components per configured hue (:func:`marker_hues_hex`); blob centroid
normalized ``cx``/``cy``; ``marker_key = color_marker_key(<opencv_hue>)``.
3. **Blink mode:** track bright blobs across the sampled window; integrate brightness
per :data:`BLINK_BIT_PERIOD_S` window using the video's **real fps** (``meta.fps``,
pitfall #3); correlate against :data:`BLINK_PREAMBLE`; check even parity; emit
``marker_key = code_marker_key(<id>)``.
4. Write ``config.DETECTIONS_JSON`` in the contract-#3 shape. No markers / dark /
markerless valid-empty ``{"videos": {}, "generated_by": "festival4d.tracker_detect"}``.
Returns a summary dict (per-video detection counts, decoded blink IDs). Never crash on a
markerless or unreadable video log and continue.
"""
raise NotImplementedError(
"marker detection not implemented yet (lane H / M19) — fill tracker_detect.run_detect"
)

View File

@ -0,0 +1,72 @@
"""Track solving (spec M20, lane H) — STUB with the frozen contract in the docstring.
foundation3 ships the stub; lane H fills :func:`run_solve` (and adds
``backend/tests/test_tracker_solve.py``). This is pure orchestration over the FROZEN
geometry primitives it must NEVER inline pose math (read ``resolve.py`` first; it is the
exact per-timestep pattern), and it writes through the ``db`` track helpers only.
================================================================================
FROZEN CONTRACTS (plan/30-phase6.md copy, do not reinterpret)
================================================================================
Input ``config.DETECTIONS_JSON`` (contract #3, written by ``tracker_detect.run_detect``)::
{"videos": {"<video_id>": [{"t_video_s", "marker_key", "cx", "cy", "area", "conf"}]},
"generated_by": "festival4d.tracker_detect"}
``cx``/``cy`` are normalized 0..1; multiply by the video's width/height for pixel coords.
Output the ``tracks`` / ``track_points`` tables (contract #2), via ``db`` helpers::
tracks(id, marker_key, label, color, created_at)
track_points(id, track_id, t_global_s, x, y, z, quality, views) # ordered by t_global_s
# coordinates in Three.js scene space; views = cameras used (1 ⇒ ground-plane fallback)
Algorithm (spec M20 the parts that bite):
1. Load detections; group by ``marker_key``.
2. **Time alignment is the whole game** (pitfall #1): a detection is found at ``t_video``;
association happens at ``t_global``. Convert with the FROZEN
``config.t_global_from_video(t_video, offset_ms, drift_ppm)`` ONLY never re-derive it.
3. Per time bucket (**0.25 s** of ``t_global``), collect same-``marker_key`` detections
across cameras at matching ``t_global``:
- **2+ views triangulate** through ``geometry`` primitives exactly as ``resolve.py``
does (``geometry.ray_from_pixel`` per view + ``geometry.triangulate_rays``, or a
multi-ray least squares built on them). Record ``views`` and a ``quality`` derived
from the residual/gap.
- **1 view ray ground plane** (contract #5): the synthetic ground is ``y = 0``;
real projects estimate the 5th-percentile-y horizontal plane of the point cloud and
record it in the result dict. Such points get ``views = 1`` and ``quality 0.5``.
4. Smooth each track (moving average or Catmull-Rom your call; **document which**).
5. Solid color for hue markers, palette color for code markers.
6. Write via ``db.add_track`` + ``db.set_track_points``. **Re-solve replaces that marker's
track** (idempotent never stack duplicates on repeated runs).
7. Degradation: no detections zero tracks + a note; never crash.
``POST /api/tracks/solve`` and ``python -m festival4d track`` both run detectsolve
end-to-end once this and ``tracker_detect`` are filled.
"""
from __future__ import annotations
import logging
log = logging.getLogger("festival4d.tracker_solve")
# Poses are keyed every ~0.5 s; bucket association at 0.25 s (spec M20). Kept here so lane H
# and any test reference one number.
BUCKET_S: float = 0.25
GROUND_PLANE_Y: float = 0.0 # synthetic fixture ground (contract #5, scene space)
SINGLE_VIEW_MAX_QUALITY: float = 0.5 # ground-plane fallback quality cap (contract #5)
def run_solve() -> dict:
"""Solve detections into 3D friend tracks (spec M20, see module docstring).
Lane H implements the full detect-output ``tracks``/``track_points`` pipeline described
above. Returns a summary dict (tracks written, per-track point counts + coverage, the
ground-plane used, and any degradation note). Idempotent: re-running replaces each
marker's track rather than stacking duplicates.
"""
raise NotImplementedError(
"track solving not implemented yet (lane H / M20) — fill tracker_solve.run_solve"
)

View File

@ -32,8 +32,15 @@ def client():
def test_manifest_shape(client):
data = client.get("/api/manifest").json()
assert set(data) == {"videos", "t_global_max", "has_poses"}
# Exact-set lock: additive manifest fields are fine, but must be added here consciously
# (this assertion has already caught two silent drifts — has_splat and has_capture).
# phase 6 (foundation3 / M18): has_tracks added — see plan/CHANGE_REQUESTS.md CR-6.
assert set(data) == {"videos", "t_global_max", "has_poses", "has_splat", "has_capture",
"capsule", "has_tracks"}
assert data["has_tracks"] is False # synthetic build seeds no solved tracks (solver = lane H)
assert data["has_poses"] is True
assert data["capsule"] is False # live server is never a capsule (M17 bakes true)
assert data["has_capture"] is False # capture routes are opt-in (FESTIVAL4D_CAPTURE)
assert len(data["videos"]) == 3
v = data["videos"][0]
assert set(v) == {
@ -78,6 +85,19 @@ def test_pointcloud_is_ply(client):
assert resp.content[:3] == b"ply"
def test_audio_track_extracted_and_cached(client):
"""GET /api/audio/{id}: lazily-extracted AAC for the WebAudio master clock."""
resp = client.get("/api/audio/1")
assert resp.status_code == 200
assert resp.headers["content-type"] == "audio/mp4"
assert len(resp.content) > 1000
# ftyp box near the start marks an MP4/M4A container.
assert b"ftyp" in resp.content[:64]
# Second request serves the cached file byte-identical (no re-extraction drift).
assert client.get("/api/audio/1").content == resp.content
assert client.get("/api/audio/999").status_code == 404
def test_video_range_returns_206(client):
"""Spec pitfall #3: <video> seeking needs HTTP Range -> 206."""
resp = client.get("/media/cam0.mp4", headers={"Range": "bytes=0-100"})
@ -91,16 +111,38 @@ def test_cors_allows_vite_origin(client):
assert resp.headers.get("access-control-allow-origin") == "http://localhost:5173"
def test_detect_degrades_gracefully(client):
data = client.post("/api/events/detect", json={}).json()
assert "note" in data and "events" in data # stubbed lane D -> 200 with note
assert client.post("/api/events/detect", json={}).status_code == 200
def test_detect_events_endpoint(client):
# Lane D (M7) has landed: POST /api/events/detect runs real detection and returns
# {result, events} (no more stub "note"). result is the run_events summary.
resp = client.post("/api/events/detect", json={})
assert resp.status_code == 200
data = resp.json()
assert "result" in data and "events" in data
assert {"candidates", "classified", "candidates_only", "classifier_configured"} <= set(data["result"])
assert isinstance(data["events"], list)
assert client.post("/api/events/detect", json={}).status_code == 200 # idempotent
def test_annotation_stored(client):
def test_annotation_resolves_to_anchor(client):
# M8 (integration): a stored annotation now resolves to a 3D anchor. Single-view (no
# event_id) falls back to the nearest point-cloud point / centroid-depth ray, so an
# anchor + point come back. (Foundation asserted the stub contract anchor_id is None;
# superseded by M8 landing — same situation as CR-1.)
data = client.post(
"/api/annotations",
json={"video_id": 1, "t_video_s": 0.5, "bbox": [0.4, 0.4, 0.6, 0.6]},
).json()
assert data["annotation_id"] > 0
assert data["anchor_id"] is None # M8 resolution is integration work
assert data["anchor_id"] is not None
assert data["point"] is not None and len(data["point"]) == 3
assert data["method"] in {"nearest_point", "ray_depth", "triangulated"}
assert data["anchor"]["id"] == data["anchor_id"]
def test_event_patch_sets_user_source(client):
events = client.get("/api/events").json()
eid = events[0]["id"]
patched = client.patch(f"/api/events/{eid}", json={"event_type": "pyro"}).json()
assert patched["event_type"] == "pyro"
assert patched["source"] == "user" # user correction attribution
assert client.patch("/api/events/99999", json={"event_type": "x"}).status_code == 404

View File

@ -0,0 +1,171 @@
"""M10 acceptance tests (lane E): beat/onset analysis of the reference ingest WAV.
Built on the synthetic fixture + a real ingest run (per the phase-5 test pattern): the
fixture's master audio carries a known 120 BPM click grid (``synthetic.BEAT_INTERVAL_S``
= 0.5 s), so acceptance is objective beats within ±50 ms of the grid, tempo within
±2 BPM. Silence / short / missing audio must degrade per the house style, never crash.
"""
from __future__ import annotations
import json
import shutil
import numpy as np
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required to build the fixture + run ingest",
)
BEAT_GRID_S = 0.5 # synthetic.BEAT_INTERVAL_S — the fixture's known pulse grid
TRUE_TEMPO_BPM = 60.0 / BEAT_GRID_S # 120
@pytest.fixture(scope="module")
def project():
"""Full synthetic project + ingest (produces the 16 kHz WAVs in config.AUDIO_DIR)."""
from festival4d import config, ingest, synthetic
synthetic.build(run_ffmpeg=True) # default 20 s — enough signal for tempo tracking
ingest.run_ingest()
config.BEATS_JSON.unlink(missing_ok=True)
yield config
config.BEATS_JSON.unlink(missing_ok=True) # don't leak state into later test modules
def _grid_error_s(t: float) -> float:
"""Distance from t to the nearest 0.5 s grid line."""
return abs(t - round(t / BEAT_GRID_S) * BEAT_GRID_S)
# ---------------------------------------------------------------------------
# The real thing: known pulse grid recovered from the ingest WAV.
# ---------------------------------------------------------------------------
def test_run_features_recovers_synthetic_pulse_grid(project):
from festival4d import audio_features
summary = audio_features.run_features()
assert summary["status"] == "ok"
assert project.BEATS_JSON.exists()
data = json.loads(project.BEATS_JSON.read_text())
# Contract #1 structure lock: exactly these keys.
assert set(data) == {"tempo_bpm", "beats_s", "onsets", "generated_by"}
assert data["generated_by"] == "festival4d.audio_features"
# Tempo within ±2 BPM of the known 120 BPM grid.
assert data["tempo_bpm"] == pytest.approx(TRUE_TEMPO_BPM, abs=2.0)
# Beats: a healthy number over 20 s @ 120 BPM, each within ±50 ms of the grid,
# strictly increasing, inside the reference window (t_global == reference local time).
beats = data["beats_s"]
assert len(beats) >= 20
assert beats == sorted(beats)
# AAC encode/decode pads the clip tail slightly, so allow a small margin past 20 s.
assert all(0.0 <= b <= 20.5 for b in beats)
worst = max(_grid_error_s(b) for b in beats)
assert worst <= 0.050, f"worst beat off grid by {worst * 1000:.1f} ms"
# Onsets: present, valid strengths, times on the master timeline.
onsets = data["onsets"]
assert len(onsets) > 0
assert all(set(o) == {"t_global_s", "strength"} for o in onsets)
assert all(0.0 <= o["strength"] <= 1.0 for o in onsets)
assert any(o["strength"] >= 0.9 for o in onsets) # normalized: the peak onset is ~1.0
assert all(0.0 <= o["t_global_s"] <= 20.5 for o in onsets)
# The summary mirrors the file.
assert summary["beats"] == len(beats)
assert summary["onsets"] == len(onsets)
assert summary["reference_video_id"] == 1 # cam0 (offset 0) is the reference
def test_onsets_catch_the_ground_truth_bangs(project):
"""The three loud synthetic bangs (3, 10, 17 s) must appear as strong onsets."""
from festival4d import audio_features, synthetic
audio_features.run_features()
data = json.loads(project.BEATS_JSON.read_text())
onset_times = [o["t_global_s"] for o in data["onsets"]]
for bang in synthetic.BANG_TIMES_S:
assert any(abs(t - bang) <= 0.075 for t in onset_times), f"bang at {bang}s not detected"
# ---------------------------------------------------------------------------
# Degradation: silence / short / missing input — valid output or clear note, never a crash.
# ---------------------------------------------------------------------------
def test_silence_produces_valid_empty_json(project):
from festival4d import audio_features, synthetic
wav = project.AUDIO_DIR / "1.wav"
original = wav.read_bytes()
try:
synthetic.write_wav(wav, np.zeros(16_000 * 5, dtype=np.float32), 16_000)
summary = audio_features.run_features()
assert summary["status"] == "ok"
data = json.loads(project.BEATS_JSON.read_text())
assert data == {
"tempo_bpm": None,
"beats_s": [],
"onsets": [],
"generated_by": "festival4d.audio_features",
}
finally:
wav.write_bytes(original)
project.BEATS_JSON.unlink(missing_ok=True)
def test_too_short_audio_produces_valid_empty_json(project):
from festival4d import audio_features, synthetic
wav = project.AUDIO_DIR / "1.wav"
original = wav.read_bytes()
try:
rng = np.random.default_rng(3)
synthetic.write_wav(wav, rng.standard_normal(4_000).astype(np.float32) * 0.5, 16_000)
audio_features.run_features()
data = json.loads(project.BEATS_JSON.read_text())
assert data["tempo_bpm"] is None and data["beats_s"] == [] and data["onsets"] == []
finally:
wav.write_bytes(original)
project.BEATS_JSON.unlink(missing_ok=True)
def test_missing_wav_degrades_with_note(project):
from festival4d import audio_features
wav = project.AUDIO_DIR / "1.wav"
moved = wav.with_suffix(".wav.bak")
wav.rename(moved)
try:
summary = audio_features.run_features() # must not raise
assert summary["status"] == "no_audio"
assert "ingest" in summary["note"]
assert not project.BEATS_JSON.exists() # nothing stale written
finally:
moved.rename(wav)
def test_analyze_is_pure_and_beatless_noise_is_empty():
"""Unit-level: analyze() on beatless noise returns the valid-empty shape."""
from festival4d import audio_features
rng = np.random.default_rng(11)
y = (rng.standard_normal(16_000 * 6) * 0.2).astype(np.float32) # 6 s of plain noise
data = audio_features.analyze(y, 16_000)
assert set(data) == {"tempo_bpm", "beats_s", "onsets", "generated_by"}
# librosa may or may not hallucinate a weak pulse in noise; the hard requirement is a
# valid, well-typed result — not a crash.
assert isinstance(data["beats_s"], list)
assert all(isinstance(b, float) for b in data["beats_s"])
def test_cli_features_runs_end_to_end(project):
"""`python -m festival4d features` dispatches into the real implementation now."""
from festival4d import cli
assert cli.main(["features"]) == 0
assert project.BEATS_JSON.exists()
project.BEATS_JSON.unlink()

View File

@ -88,6 +88,28 @@ def test_pairwise_and_solve_recover_synthetic_offsets():
assert abs(solved[i + 1] - off) <= 10.0, (off, solved[i + 1])
def test_pairwise_matches_direct_gcc_phat_with_unequal_lengths():
"""The cached-FFT pairwise path agrees with a direct per-pair gcc_phat call.
Signals get different lengths on purpose: the shared padded size (2·max_len) then
differs from the per-pair size (la+lb), which is exactly where the two paths could
diverge if the lag windowing were wrong.
"""
sr = 16_000
master = _broadband(sr * 8, seed=7)
signals = {
1: master[: sr * 6],
2: _delay_samples(master, 900)[: sr * 4],
3: _delay_samples(master, -1500)[: sr * 5],
}
edges = audio_sync.pairwise_offsets(signals, sr)
assert len(edges) == 3
for e in edges:
direct_delay, direct_conf = audio_sync.gcc_phat(signals[e["a"]], signals[e["b"]], sr)
assert abs(e["offset_s"] - -direct_delay) < 1.0 / sr, e
assert e["confidence"] > 5.0 and direct_conf > 5.0
def test_pairwise_edge_sign_is_relative_start_offset():
"""Edge ``offset_s`` = offset_a offset_b (seconds)."""
sr = config.AUDIO_SAMPLE_RATE

View File

@ -0,0 +1,339 @@
"""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",
# phase 6 (foundation3 / M18): friend tracks baked read-only — CR-6.
"api/tracks",
"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

View File

@ -0,0 +1,137 @@
"""Live capture tests (Phase 5) — upload safety, the env gate, and the device monitor.
The router is exercised directly on a throwaway app: ``capture_enabled()`` is read at
``api`` import time, so mounting it here is both simpler and keeps the gate test honest
(the real app must NOT expose these routes unless FESTIVAL4D_CAPTURE is set).
"""
from __future__ import annotations
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from festival4d import capture, config
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(capture.router)
config.RAW_DIR.mkdir(parents=True, exist_ok=True)
with TestClient(app) as c:
yield c
def _upload(client, name: str, filename: str, data: bytes = b"fake-video-bytes"):
return client.post(
"/api/capture/upload",
data={"device_name": name},
files={"file": (filename, data, "video/webm")},
)
# --- the gate ---------------------------------------------------------------------------------
def test_capture_disabled_by_default():
"""FESTIVAL4D_CAPTURE is unset in tests => the real app exposes no upload surface."""
assert capture.capture_enabled() is False
from festival4d import api
paths = {r.path for r in api.app.routes}
assert "/capture" not in paths
assert not any(p.startswith("/api/capture") for p in paths)
@pytest.mark.parametrize("val,expected", [("1", True), ("true", True), ("on", True),
("0", False), ("", False), ("no", False)])
def test_capture_enabled_parsing(monkeypatch, val, expected):
monkeypatch.setenv("FESTIVAL4D_CAPTURE", val)
assert capture.capture_enabled() is expected
# --- upload -----------------------------------------------------------------------------------
def test_upload_saves_sanitized_file(client):
r = _upload(client, "Phone Left!", "clip.webm")
assert r.status_code == 200, r.text
body = r.json()
saved = config.RAW_DIR / body["filename"]
assert saved.is_file() and saved.read_bytes() == b"fake-video-bytes"
assert body["filename"].startswith("phone-left-") # slugified, timestamped
assert body["filename"].endswith(".webm")
assert body["bytes"] == len(b"fake-video-bytes")
saved.unlink()
def test_upload_name_cannot_traverse(client):
r = _upload(client, "../../etc/passwd", "clip.webm")
assert r.status_code == 200
saved = config.RAW_DIR / r.json()["filename"]
# The slug strips separators entirely; the file lands inside RAW_DIR, nowhere else.
assert saved.resolve().parent == config.RAW_DIR.resolve()
assert ".." not in r.json()["filename"]
saved.unlink()
def test_upload_rejects_bad_container(client):
r = _upload(client, "cam", "payload.exe")
assert r.status_code == 400
assert "unsupported container" in r.json()["detail"]
def test_upload_rejects_empty(client):
r = _upload(client, "cam", "clip.webm", data=b"")
assert r.status_code == 400
assert "empty" in r.json()["detail"]
def test_upload_rejects_oversize(client, monkeypatch):
monkeypatch.setattr(capture, "MAX_UPLOAD_BYTES", 8)
r = _upload(client, "cam", "clip.webm", data=b"x" * 64)
assert r.status_code == 413
# The partial file must not be left behind for ingest to trip on.
assert not any(p.name.startswith("cam-") for p in config.RAW_DIR.glob("cam-*.webm"))
# --- monitor ----------------------------------------------------------------------------------
def test_heartbeat_and_devices_monitor(client):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={
"device_id": "d1", "name": "phone-left", "status": "recording",
"elapsed_s": 3.5, "snapshot": "data:image/jpeg;base64,AAAA",
})
client.post("/api/capture/heartbeat", json={"device_id": "d2", "name": "laptop"})
devs = client.get("/api/capture/devices").json()
assert [d["name"] for d in devs] == ["laptop", "phone-left"] # sorted by name
d1 = next(d for d in devs if d["device_id"] == "d1")
assert d1["status"] == "recording" and d1["snapshot"].startswith("data:image/jpeg")
assert "_seen" not in d1 # internals not leaked
capture._devices.clear()
def test_oversized_snapshot_dropped_but_device_kept(client):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={
"device_id": "big", "name": "cam", "snapshot": "data:image/jpeg;base64," + "A" * 400_000,
})
devs = client.get("/api/capture/devices").json()
assert len(devs) == 1 and "snapshot" not in devs[0]
capture._devices.clear()
def test_stale_devices_pruned(client, monkeypatch):
capture._devices.clear()
client.post("/api/capture/heartbeat", json={"device_id": "old", "name": "cam"})
assert len(client.get("/api/capture/devices").json()) == 1
monkeypatch.setattr(capture, "DEVICE_STALE_S", -1.0) # everything is instantly stale
assert client.get("/api/capture/devices").json() == []
capture._devices.clear()
def test_capture_page_served(client):
r = client.get("/capture")
assert r.status_code == 200
assert "Festival 4D — Capture" in r.text
assert "getUserMedia" in r.text # the page really is the capture app

View File

@ -0,0 +1,242 @@
"""M11 acceptance tests (lane E): deterministic auto-director -> frozen camPath JSON.
Runs on the synthetic fixture WITHOUT ffmpeg (poses + events + DB only): generate_path
never touches media files. The synthetic seed events have known times/confidences, so
event selection, keyframe placement, beat snapping, and the pose conversion are all
checked against ground truth. The quaternion test locks the [x,y,z,w] Three.js order via
the frozen geometry.colmap_to_threejs this repo's #1 historical bug source.
"""
from __future__ import annotations
import json
import math
import numpy as np
import pytest
# Synthetic seed events (synthetic.SEED_EVENTS): (t_global, type, confidence)
# 3.0 bass_drop .94 | 6.0 crowd_wave .72 | 8.0 quiet .61 | 10.0 pyro .88
# 13.0 light_show .90 | 17.0 confetti .81 | 19.0 artist .77
TOP3_TIMES = [3.0, 10.0, 13.0] # confidences .94, .88, .90 -> top 3
KEYFRAME_KEYS = {"t_global", "pos", "quat", "fov"}
@pytest.fixture()
def project():
"""Fresh synthetic project (no ffmpeg) per test — several tests mutate the DB."""
from festival4d import config, synthetic
synthetic.build(run_ffmpeg=False)
config.BEATS_JSON.unlink(missing_ok=True) # unsnapped by default; tests opt in
yield config
config.BEATS_JSON.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
# Structure lock: the frozen camPath shape (contract #2).
# ---------------------------------------------------------------------------
def test_output_matches_frozen_campath_shape(project):
from festival4d import director
path = director.generate_path()
assert set(path) == {"version", "keyframes"} # exact-set lock (no stray fields on success)
assert path["version"] == 1
assert len(path["keyframes"]) > 0
for kf in path["keyframes"]:
assert set(kf) == KEYFRAME_KEYS # exact-set lock per keyframe
assert isinstance(kf["t_global"], float)
assert len(kf["pos"]) == 3 and all(isinstance(v, float) for v in kf["pos"])
assert len(kf["quat"]) == 4 and all(isinstance(v, float) for v in kf["quat"])
assert abs(math.hypot(*kf["quat"][:3], kf["quat"][3]) - 1.0) < 1e-6 # unit quaternion
assert 10.0 < kf["fov"] < 120.0
times = [kf["t_global"] for kf in path["keyframes"]]
assert times == sorted(times)
assert len(set(times)) == len(times) # strictly monotone (dedup after snapping)
# JSON-serializable end to end (the API returns this dict verbatim).
json.dumps(path)
def test_covers_the_top_n_events_with_lead_and_duration(project):
from festival4d import director
path = director.generate_path(top_n=3, lead_s=2.0)
times = [kf["t_global"] for kf in path["keyframes"]]
# Each top-3 event contributes keys at t-2.0 and t+duration (1.0 in the fixture):
# 3.0 -> {1.0, 4.0}; 10.0 -> {8.0, 11.0}; 13.0 -> {11.0, 14.0}. The shared 11.0
# collapses to one keyframe -> exactly 5, and every event is bracketed.
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
for t_event in TOP3_TIMES:
assert any(t <= t_event for t in times) and any(t >= t_event for t in times)
def test_full_top_n_covers_every_event(project):
from festival4d import director
path = director.generate_path(top_n=8, lead_s=2.0) # fixture has 7 events
times = [kf["t_global"] for kf in path["keyframes"]]
for t_event in [3.0, 6.0, 8.0, 10.0, 13.0, 17.0, 19.0]:
assert min(abs(t - (t_event - 2.0)) for t in times) < 1e-9
assert min(abs(t - (t_event + 1.0)) for t in times) < 1e-9
def test_confidence_ties_break_to_the_earlier_event(project):
from festival4d import db, director
db.clear_events()
db.add_event(t_global_s=15.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
db.add_event(t_global_s=5.0, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
path = director.generate_path(top_n=1, lead_s=2.0)
assert [kf["t_global"] for kf in path["keyframes"]] == [3.0, 6.0] # the t=5 event won
def test_lead_time_clamps_at_zero(project):
from festival4d import db, director
db.clear_events()
db.add_event(t_global_s=0.5, event_type="pyro", source="ai", duration_s=1.0, confidence=0.9)
path = director.generate_path(top_n=1, lead_s=2.0)
assert [kf["t_global"] for kf in path["keyframes"]] == [0.0, 1.5]
# ---------------------------------------------------------------------------
# Pose conversion: through the frozen geometry.colmap_to_threejs, quat order [x,y,z,w].
# ---------------------------------------------------------------------------
def test_keyframe_pose_matches_frozen_conversion(project):
from festival4d import db, director
from festival4d.geometry import colmap_to_threejs, mat_to_quat, quat_to_mat
path = director.generate_path(top_n=1, lead_s=2.0) # top event: bass_drop @ 3.0
kf = path["keyframes"][0] # t_global = 1.0
# Every synthetic event time is an exact cam0 pose time (offset 0), so the chosen
# camera is video 1 and the keyframe pose is cam0's registered row at t_video_s=1.0.
pose = next(p for p in db.get_poses(1) if abs(p.t_video_s - 1.0) < 1e-9)
expected_pos, expected_rot = colmap_to_threejs(
[pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz]
)
assert np.allclose(kf["pos"], expected_pos, atol=1e-9)
# Quaternion order lock: reordering [x,y,z,w] -> [w,x,y,z] must reproduce the Three.js
# world rotation matrix (up to the quaternion double cover).
x, y, z, w = kf["quat"]
assert np.allclose(quat_to_mat([w, x, y, z]), expected_rot, atol=1e-9)
expected_q = mat_to_quat(expected_rot) # [w,x,y,z], canonical w >= 0
q_out = np.array([w, x, y, z])
if np.dot(q_out, expected_q) < 0:
q_out = -q_out
assert np.allclose(q_out, expected_q, atol=1e-9)
# FOV from the chosen camera's intrinsics: 2*atan(height / (2*fy)) in degrees.
video = db.get_video(1)
expected_fov = math.degrees(2.0 * math.atan(video.height / (2.0 * pose.fy)))
assert kf["fov"] == pytest.approx(expected_fov, abs=1e-9)
def test_unregistered_poses_are_excluded(project):
from festival4d import db, director
# Mark every cam0 pose unregistered: the director must cut to cam1/cam2 instead.
poses = db.get_poses(1)
db.set_poses(1, [
{"frame_idx": p.frame_idx, "t_video_s": p.t_video_s,
"qw": p.qw, "qx": p.qx, "qy": p.qy, "qz": p.qz,
"tx": p.tx, "ty": p.ty, "tz": p.tz,
"fx": p.fx, "fy": p.fy, "cx": p.cx, "cy": p.cy, "registered": False}
for p in poses
])
path = director.generate_path(top_n=1, lead_s=2.0)
assert len(path["keyframes"]) == 2
cam0_centers = {
tuple(np.round(np.asarray(_center(p)), 6)) for p in poses
}
for kf in path["keyframes"]:
assert tuple(np.round(kf["pos"], 6)) not in cam0_centers
def _center(pose):
from festival4d.geometry import colmap_to_threejs
pos, _ = colmap_to_threejs([pose.qw, pose.qx, pose.qy, pose.qz], [pose.tx, pose.ty, pose.tz])
return pos
# ---------------------------------------------------------------------------
# Beat snapping (M10 -> M11), and degradation without it.
# ---------------------------------------------------------------------------
def test_keyframes_snap_to_beats_when_beats_exist(project):
from festival4d import director
grid = [round(k * 0.75, 4) for k in range(0, 28)] # 0.75 s grid != the keyframe times
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
project.BEATS_JSON.write_text(json.dumps({
"tempo_bpm": 80.0, "beats_s": grid, "onsets": [],
"generated_by": "festival4d.audio_features",
}))
path = director.generate_path(top_n=3, lead_s=2.0)
times = [kf["t_global"] for kf in path["keyframes"]]
assert len(times) > 0
assert all(any(abs(t - b) < 1e-9 for b in grid) for t in times) # every key ON a beat
assert times != [1.0, 4.0, 8.0, 11.0, 14.0] # actually moved vs the unsnapped grid
def test_no_beats_file_means_no_snapping(project):
from festival4d import director
assert not project.BEATS_JSON.exists()
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
def test_corrupt_beats_file_degrades_to_unsnapped(project):
from festival4d import director
project.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
project.BEATS_JSON.write_text("{ not json")
times = [kf["t_global"] for kf in director.generate_path(top_n=3, lead_s=2.0)["keyframes"]]
assert times == [1.0, 4.0, 8.0, 11.0, 14.0]
# ---------------------------------------------------------------------------
# Degradation: empty inputs -> valid empty path (still camPath.fromJSON-safe).
# ---------------------------------------------------------------------------
def test_no_events_yields_valid_empty_path(project):
from festival4d import db, director
db.clear_events()
path = director.generate_path()
assert path["version"] == 1 and path["keyframes"] == []
assert "events" in path["note"]
def test_no_registered_poses_yields_valid_empty_path_with_note(project):
from festival4d import db, director
for video in db.get_videos():
db.set_poses(video.id, [])
path = director.generate_path()
assert path["version"] == 1 and path["keyframes"] == []
assert "poses" in path["note"]
# ---------------------------------------------------------------------------
# Dispatch: the API route and CLI now run the real implementation.
# ---------------------------------------------------------------------------
def test_api_director_route_returns_real_path(project):
from fastapi.testclient import TestClient
from festival4d import api
with TestClient(api.app) as client:
data = client.post("/api/director", json={"top_n": 3, "lead_s": 2.0}).json()
assert data["version"] == 1
assert [kf["t_global"] for kf in data["keyframes"]] == [1.0, 4.0, 8.0, 11.0, 14.0]
assert all(set(kf) == KEYFRAME_KEYS for kf in data["keyframes"])
def test_cli_direct_prints_campath_json(project, capsys):
from festival4d import cli
assert cli.main(["direct", "--top-n", "2", "--lead-s", "1.0"]) == 0
printed = json.loads(capsys.readouterr().out)
assert printed["version"] == 1 and len(printed["keyframes"]) > 0

View File

@ -0,0 +1,288 @@
"""Tests for events_ai.py (lane D / spec M7).
No test hits a real API or ffmpeg: candidate detection runs on the synthetic fixture's
in-memory audio, and the classifier orchestration is exercised with stub classifiers and a
monkeypatched input-preparer. This mirrors spec M7's acceptance:
- candidates land within ±0.5 s of the ground-truth pulse times, and
- the two-stage loop (detect -> classify) works independent of any provider.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import ValidationError
from festival4d import config, db, events_ai, synthetic
from festival4d.events_ai import (
ClaudeClassifier,
GeminiClassifier,
LocalClassifier,
MomentClassification,
MomentClassifier,
detect_candidates,
get_classifier,
run_events,
)
SR = config.SYNTH_AUDIO_SR
# ---------------------------------------------------------------------------
# Helpers / stub classifiers (no network)
# ---------------------------------------------------------------------------
def _reference_audio():
"""cam0's audio: the master signal sliced at offset 0 (local == global time)."""
master, gt = synthetic.synth_master_audio(SR)
audio = synthetic.slice_for_offset(master, SR, 0.0, config.SYNTH_DURATION_S)
return audio, gt["pulse_times_global_s"]
class StubClassifier:
"""Always returns the same valid classification; counts calls."""
def __init__(self, event_type: str = "bass_drop") -> None:
self.calls = 0
self.event_type = event_type
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
self.calls += 1
return MomentClassification(
event_type=self.event_type, confidence=0.9, description="stub classification"
)
class FlakyClassifier:
"""Raises on a chosen call to exercise per-candidate exception isolation."""
def __init__(self, fail_on_call: int = 2) -> None:
self.calls = 0
self.fail_on_call = fail_on_call
def classify(self, clip_path: Path, frames: list[Path]) -> MomentClassification:
self.calls += 1
if self.calls == self.fail_on_call:
raise RuntimeError("simulated provider failure")
return MomentClassification(event_type="pyro", confidence=0.6, description="ok")
def _fresh_reference_db(offset_ms: float = 0.0) -> None:
"""A clean DB with a single reference video (matching cam0)."""
db.init_engine()
db.reset_db()
db.add_video(
filename="cam0.mp4", duration_s=config.SYNTH_DURATION_S, fps=config.SYNTH_FPS,
width=config.SYNTH_VIDEO_W, height=config.SYNTH_VIDEO_H,
offset_ms=offset_ms, drift_ppm=0.0, sync_confidence=1.0,
)
def _stub_run(monkeypatch, classifier):
"""Wire run_events to run real detection on fixture audio + a stub classifier."""
audio, _ = _reference_audio()
monkeypatch.setattr(events_ai, "_load_reference_audio", lambda video: (audio, SR))
monkeypatch.setattr(events_ai, "get_classifier", lambda name=None: classifier)
monkeypatch.setattr(
events_ai, "prepare_inputs",
lambda t: (Path("/nonexistent/clip.mp4"), [Path("/nonexistent/frame_00.jpg")]),
)
# ---------------------------------------------------------------------------
# Candidate detection (the acceptance-critical numeric test)
# ---------------------------------------------------------------------------
def test_detect_candidates_matches_ground_truth():
audio, pulses = _reference_audio()
cands = detect_candidates(audio, SR)
# Recall: every ground-truth pulse has a candidate within ±0.5 s.
for pulse in pulses:
nearest = min(abs(c - pulse) for c in cands)
assert nearest < 0.5, f"pulse {pulse}s unmatched; nearest candidate {nearest:.3f}s away"
# Precision: no spurious candidates — each maps to a distinct ground-truth pulse.
for c in cands:
nearest = min(abs(c - pulse) for pulse in pulses)
assert nearest < 0.5, f"spurious candidate at {c:.3f}s ({nearest:.3f}s from any pulse)"
assert len(cands) == len(pulses), (cands, pulses)
def test_detect_candidates_short_audio_is_empty():
import numpy as np
assert detect_candidates(np.zeros(16, dtype=np.float32), SR) == []
def test_detect_candidates_silent_or_flat_audio_is_empty():
"""Silence / DC / a bare noise floor must yield NO candidates, never fabricate events."""
import numpy as np
n = SR * 6
assert detect_candidates(np.zeros(n, dtype=np.float32), SR) == [] # digital silence
assert detect_candidates(np.full(n, 0.5, dtype=np.float32), SR) == [] # constant / DC
rng = np.random.default_rng(0)
assert detect_candidates((1e-4 * rng.standard_normal(n)).astype(np.float32), SR) == [] # noise floor
# ---------------------------------------------------------------------------
# Frozen contract sanity (MomentClassification / MomentClassifier)
# ---------------------------------------------------------------------------
def test_moment_classification_validates_bounds_and_enum():
ok = MomentClassification(event_type="pyro", confidence=0.5, description="x")
assert ok.event_type == "pyro"
with pytest.raises(ValidationError):
MomentClassification(event_type="pyro", confidence=1.5, description="x") # >1
with pytest.raises(ValidationError):
MomentClassification(event_type="not_a_type", confidence=0.5, description="x")
def test_stub_satisfies_classifier_protocol():
assert isinstance(StubClassifier(), MomentClassifier)
# ---------------------------------------------------------------------------
# Provider selection + degradation (no network — constructors don't call out)
# ---------------------------------------------------------------------------
def test_get_classifier_unconfigured_returns_none(monkeypatch):
for var in ("GEMINI_API_KEY", "ANTHROPIC_API_KEY",
"FESTIVAL4D_OPENAI_BASE_URL", "FESTIVAL4D_OPENAI_MODEL", "FESTIVAL4D_OPENAI_KEY"):
monkeypatch.delenv(var, raising=False)
assert get_classifier("gemini") is None
assert get_classifier("claude") is None
assert get_classifier("local") is None
def test_get_classifier_unknown_provider_returns_none():
assert get_classifier("does-not-exist") is None
def test_get_classifier_defaults_to_gemini(monkeypatch):
monkeypatch.delenv("FESTIVAL4D_CLASSIFIER", raising=False)
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
assert isinstance(get_classifier(), GeminiClassifier)
def test_get_classifier_local_when_configured(monkeypatch):
monkeypatch.setenv("FESTIVAL4D_OPENAI_BASE_URL", "http://localhost:11434/v1")
monkeypatch.setenv("FESTIVAL4D_OPENAI_MODEL", "qwen2.5-vl")
monkeypatch.delenv("FESTIVAL4D_OPENAI_KEY", raising=False)
clf = get_classifier("local")
assert isinstance(clf, LocalClassifier)
assert isinstance(clf, MomentClassifier)
def test_get_classifier_claude_when_configured(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
assert isinstance(get_classifier("claude"), ClaudeClassifier)
# ---------------------------------------------------------------------------
# Orchestration loop (stub classifier — no API, no ffmpeg)
# ---------------------------------------------------------------------------
def test_run_events_classifies_all_candidates(monkeypatch):
_fresh_reference_db()
stub = StubClassifier(event_type="bass_drop")
_stub_run(monkeypatch, stub)
result = run_events()
assert result["candidates"] == 3
assert result["classified"] == 3
assert result["candidates_only"] == 0
assert result["classifier_configured"] is True
assert stub.calls == 3
events = db.get_events()
assert len(events) == 3
assert all(e.source == "ai" for e in events)
assert all(e.event_type == "bass_drop" for e in events)
# Candidate times map to t_global (reference offset 0) near the fixture pulses.
times = sorted(e.t_global_s for e in events)
for got, pulse in zip(times, (3.0, 10.0, 17.0)):
assert abs(got - pulse) < 0.5
def test_run_events_unconfigured_stores_candidates_only(monkeypatch):
_fresh_reference_db()
audio, _ = _reference_audio()
monkeypatch.setattr(events_ai, "_load_reference_audio", lambda video: (audio, SR))
monkeypatch.setattr(events_ai, "get_classifier", lambda name=None: None)
# Degraded path must never prepare inputs. A *raised* tripwire would be swallowed by
# _classify_one's `except Exception`; record calls with a side effect instead.
prepared: list = []
monkeypatch.setattr(events_ai, "prepare_inputs", lambda t: prepared.append(t))
result = run_events()
assert prepared == [] # prepare_inputs was never called when unconfigured
assert result["candidates"] == 3
assert result["classified"] == 0
assert result["candidates_only"] == 3
assert result["classifier_configured"] is False
events = db.get_events()
assert len(events) == 3
assert all(e.source == "audio_auto" for e in events)
assert all(e.event_type == "candidate" for e in events)
def test_run_events_isolates_per_candidate_failure(monkeypatch):
_fresh_reference_db()
flaky = FlakyClassifier(fail_on_call=2) # 2nd candidate fails
_stub_run(monkeypatch, flaky)
result = run_events()
assert result["candidates"] == 3
assert result["classified"] == 2 # two succeeded
assert result["candidates_only"] == 1 # the failed one fell back to a candidate
assert flaky.calls == 3 # the batch was not aborted
events = db.get_events()
assert sum(e.source == "ai" for e in events) == 2
assert sum(e.source == "audio_auto" for e in events) == 1
def test_run_events_whole_track_replaces_machine_events_keeps_user(monkeypatch):
_fresh_reference_db()
# Pre-existing events: a stale AI one and a user correction.
db.add_event(t_global_s=99.0, event_type="other", source="ai", description="stale")
db.add_event(t_global_s=5.0, event_type="crowd_wave", source="user", description="mine")
_stub_run(monkeypatch, StubClassifier())
run_events()
events = db.get_events()
user = [e for e in events if e.source == "user"]
ai = [e for e in events if e.source == "ai"]
assert len(user) == 1 and user[0].description == "mine" # user edit preserved
assert len(ai) == 3 # fresh detection, stale AI gone
assert not any(e.description == "stale" for e in events)
def test_run_events_windowed_adds_in_window_and_clears_nothing(monkeypatch):
_fresh_reference_db()
# A machine event OUTSIDE the window must survive — windowed detection is purely additive.
db.add_event(t_global_s=3.0, event_type="bass_drop", source="ai", description="pre-existing")
_stub_run(monkeypatch, StubClassifier())
result = run_events(t_global_s=10.0, window_s=4.0) # window [8, 12] -> only the ~10 s pulse
assert result["candidates"] == 1
assert result["window"] == {"t_global_s": 10.0, "window_s": 4.0}
events = db.get_events()
assert any(e.description == "pre-existing" for e in events) # out-of-window event preserved
assert len(events) == 2
new = [e for e in events if e.description != "pre-existing"]
assert len(new) == 1 and abs(new[0].t_global_s - 10.0) < 0.5
def test_run_events_no_videos_degrades(monkeypatch):
db.init_engine()
db.reset_db()
result = run_events()
assert result["candidates"] == 0
assert "note" in result
assert db.get_events() == []

View File

@ -108,3 +108,226 @@ def test_quat_to_mat_matches_scipy_oracle():
np.testing.assert_allclose(
quat_to_mat(q_wxyz), Rotation.from_quat(q_xyzw).as_matrix(), atol=1e-9
)
# ===========================================================================
# Lane B (spec M2 + M8) tests — below the frozen block. These exercise the
# stubs foundation left: slerp_pose, ray_from_pixel, triangulate_rays,
# nearest_point_on_ray. They do NOT touch the frozen colmap_to_threejs cases above.
# ===========================================================================
from scipy.spatial.transform import Slerp # noqa: E402
from festival4d.geometry import ( # noqa: E402
colmap_to_threejs,
nearest_point_on_ray,
ray_from_pixel,
slerp_pose,
triangulate_rays,
)
def _wxyz_to_xyzw(q):
q = np.asarray(q, dtype=float)
return np.array([q[1], q[2], q[3], q[0]])
def _rand_unit_quat(rng):
q = rng.normal(size=4)
return q / np.linalg.norm(q)
# --- slerp_pose ------------------------------------------------------------
def test_slerp_pose_endpoints():
q0 = _rand_unit_quat(np.random.default_rng(1))
q1 = _rand_unit_quat(np.random.default_rng(2))
t0 = np.array([1.0, -2.0, 3.0])
t1 = np.array([-4.0, 5.0, 6.0])
q_a, t_a = slerp_pose(q0, t0, q1, t1, 0.0)
q_b, t_b = slerp_pose(q0, t0, q1, t1, 1.0)
# endpoints recover the endpoint *rotations* (quaternion up to sign) and translations
np.testing.assert_allclose(quat_to_mat(q_a), quat_to_mat(q0), atol=1e-12)
np.testing.assert_allclose(quat_to_mat(q_b), quat_to_mat(q1), atol=1e-12)
np.testing.assert_allclose(t_a, t0, atol=1e-12)
np.testing.assert_allclose(t_b, t1, atol=1e-12)
def test_slerp_pose_matches_scipy_oracle():
rng = np.random.default_rng(20260716)
for _ in range(30):
q0 = _rand_unit_quat(rng)
q1 = _rand_unit_quat(rng)
oracle = Slerp([0.0, 1.0], Rotation.from_quat(
[_wxyz_to_xyzw(q0), _wxyz_to_xyzw(q1)]))
for alpha in (0.1, 0.25, 0.5, 0.73, 0.9):
q, _ = slerp_pose(q0, [0, 0, 0], q1, [0, 0, 0], alpha)
np.testing.assert_allclose(
quat_to_mat(q), oracle(alpha).as_matrix(), atol=1e-9)
def test_slerp_pose_translation_is_linear():
q = np.array([1.0, 0.0, 0.0, 0.0])
t0 = np.array([0.0, 0.0, 0.0])
t1 = np.array([10.0, -4.0, 2.0])
for alpha in (0.0, 0.3, 0.5, 1.0):
_, t = slerp_pose(q, t0, q, t1, alpha)
np.testing.assert_allclose(t, (1 - alpha) * t0 + alpha * t1, atol=1e-12)
def test_slerp_pose_double_cover_takes_short_arc():
"""q1 and -q1 are the same rotation; slerp must yield the same (short-arc) result."""
rng = np.random.default_rng(99)
q0 = _rand_unit_quat(rng)
q1 = _rand_unit_quat(rng)
for alpha in (0.2, 0.5, 0.8):
qa, _ = slerp_pose(q0, [0, 0, 0], q1, [0, 0, 0], alpha)
qb, _ = slerp_pose(q0, [0, 0, 0], -q1, [0, 0, 0], alpha)
np.testing.assert_allclose(quat_to_mat(qa), quat_to_mat(qb), atol=1e-12)
def test_slerp_pose_midpoint_is_half_angle():
"""A 180-degree-ish pair: the midpoint rotation angle is half the endpoint angle."""
q0 = np.array([1.0, 0.0, 0.0, 0.0]) # identity
ang = np.radians(100.0)
q1 = np.array([np.cos(ang / 2), 0.0, np.sin(ang / 2), 0.0]) # yaw 100 deg about Y
qm, _ = slerp_pose(q0, [0, 0, 0], q1, [0, 0, 0], 0.5)
# rotation angle of qm relative to identity should be ~50 deg
angle = 2.0 * np.arccos(min(1.0, abs(qm[0])))
assert abs(np.degrees(angle) - 50.0) < 1e-6
# --- ray_from_pixel --------------------------------------------------------
def test_ray_from_pixel_origin_is_camera_center():
"""The ray origin equals the Three.js camera center from the frozen contract."""
rng = np.random.default_rng(5)
for _ in range(20):
q = _rand_unit_quat(rng)
t = rng.uniform(-5, 5, size=3)
position, _ = colmap_to_threejs(q, t)
origin, _ = ray_from_pixel(q, t, 600, 600, 320, 180, 320, 180)
np.testing.assert_allclose(origin, position, atol=1e-9)
def test_ray_from_pixel_center_is_forward_axis():
"""The center pixel unprojects along the camera forward (+z) axis, in world coords."""
q = np.array([1.0, 0.0, 0.0, 0.0]) # identity world->cam
t = np.array([0.0, 0.0, -10.0]) # camera center at (0,0,10)
origin, direction = ray_from_pixel(q, t, 500, 500, 320, 180, 320, 180)
np.testing.assert_allclose(origin, [0.0, 0.0, 10.0], atol=1e-9)
np.testing.assert_allclose(direction, [0.0, 0.0, 1.0], atol=1e-9)
def _project_colmap(q, t, fx, fy, cx, cy, X):
"""Forward COLMAP pinhole projection of a world point X -> pixel (px, py)."""
R = quat_to_mat(q)
xc = R @ np.asarray(X, float) + np.asarray(t, float)
return fx * xc[0] / xc[2] + cx, fy * xc[1] / xc[2] + cy, xc[2]
def test_ray_from_pixel_is_projection_inverse():
"""A world point in front of the camera lies exactly on the ray through its pixel."""
rng = np.random.default_rng(7)
for _ in range(50):
q = _rand_unit_quat(rng)
t = rng.uniform(-3, 3, size=3)
fx = fy = rng.uniform(400, 800)
cx, cy = 320.0, 180.0
C = -quat_to_mat(q).T @ t # camera center
forward = quat_to_mat(q).T @ np.array([0, 0, 1.0])
X = C + rng.uniform(2, 8) * forward + rng.uniform(-1, 1, size=3) # in front
px, py, zc = _project_colmap(q, t, fx, fy, cx, cy, X)
if zc <= 0.1:
continue
origin, direction = ray_from_pixel(q, t, fx, fy, cx, cy, px, py)
to_X = X - origin
# X - origin must be parallel to direction and in front (positive projection)
cross = np.cross(to_X, direction)
assert np.linalg.norm(cross) < 1e-6 * (1 + np.linalg.norm(to_X))
assert np.dot(to_X, direction) > 0
# --- triangulate_rays ------------------------------------------------------
def test_triangulate_rays_intersecting():
P = np.array([1.0, 2.0, 3.0])
oa = np.array([0.0, 0.0, 0.0])
ob = np.array([4.0, 0.0, 0.0])
point, gap = triangulate_rays(oa, P - oa, ob, P - ob)
np.testing.assert_allclose(point, P, atol=1e-9)
assert gap < 1e-9
def test_triangulate_rays_skew_known_geometry():
"""Ray A along +x at z=0; ray B along +y at z=1. Closest points (0,0,0),(0,0,1)."""
oa = np.array([0.0, 0.0, 0.0]); da = np.array([1.0, 0.0, 0.0])
ob = np.array([0.0, 0.0, 1.0]); db = np.array([0.0, 1.0, 0.0])
point, gap = triangulate_rays(oa, da, ob, db)
np.testing.assert_allclose(point, [0.0, 0.0, 0.5], atol=1e-9)
assert abs(gap - 1.0) < 1e-9
def test_triangulate_rays_parallel_is_safe():
"""Parallel rays must not divide-by-zero; gap ~ their separation."""
oa = np.array([0.0, 0.0, 0.0]); da = np.array([1.0, 0.0, 0.0])
ob = np.array([0.0, 2.0, 0.0]); db = np.array([1.0, 0.0, 0.0])
point, gap = triangulate_rays(oa, da, ob, db)
assert np.all(np.isfinite(point))
assert abs(gap - 2.0) < 1e-6
def test_triangulate_rays_from_two_cameras_recovers_point():
"""Two cameras looking at a world point; triangulating their pixel rays recovers it."""
rng = np.random.default_rng(123)
P = np.array([0.5, 1.0, -0.5])
fx = fy = 600.0; cx, cy = 320.0, 180.0
rays = []
for center in ([6.0, 2.0, 6.0], [-6.0, 2.5, 6.0]):
C = np.array(center, float)
z = P - C; z /= np.linalg.norm(z)
up = np.array([0.0, 1.0, 0.0])
up = up - np.dot(up, z) * z; up /= np.linalg.norm(up)
x = np.cross(z, up); y = -up
R_c2w = np.column_stack([x, y, z]); R = R_c2w.T
t = -R @ C
q = mat_to_quat(R)
px, py, _ = _project_colmap(q, t, fx, fy, cx, cy, P)
rays.append(ray_from_pixel(q, t, fx, fy, cx, cy, px, py))
point, gap = triangulate_rays(rays[0][0], rays[0][1], rays[1][0], rays[1][1])
assert np.linalg.norm(point - P) < 0.2 # spec M8 acceptance tolerance
assert gap < 1e-6
# --- nearest_point_on_ray --------------------------------------------------
def test_nearest_point_on_ray_hits_within_radius():
o = np.array([0.0, 0.0, 0.0]); d = np.array([0.0, 0.0, 1.0])
points = np.array([
[0.1, 0.0, 5.0], # perp 0.1, in front -> candidate
[0.05, 0.0, 3.0], # perp 0.05, in front -> closest
[2.0, 0.0, 5.0], # perp 2.0 -> outside radius
])
got = nearest_point_on_ray(o, d, points, radius=0.3)
np.testing.assert_allclose(got, [0.05, 0.0, 3.0], atol=1e-12)
def test_nearest_point_on_ray_excludes_outside_and_behind():
o = np.array([0.0, 0.0, 0.0]); d = np.array([0.0, 0.0, 1.0])
behind = np.array([[0.05, 0.0, -3.0]]) # in cylinder but behind origin
outside = np.array([[1.0, 0.0, 3.0]]) # in front but outside radius
assert nearest_point_on_ray(o, d, behind, radius=0.3) is None
assert nearest_point_on_ray(o, d, outside, radius=0.3) is None
assert nearest_point_on_ray(o, d, np.zeros((0, 3)), radius=0.3) is None
def test_nearest_point_on_ray_synthetic_stage_corner():
"""M8 single-view fallback: a ray toward a stage corner selects that corner point."""
from festival4d import synthetic
corner = np.array(synthetic.STAGE_CORNERS["Stage FL"], float)
points, _ = synthetic.generate_point_cloud()
# a camera looking straight at the corner; ray through the image center hits it
q, t = synthetic.look_at_colmap((5.0, 3.0, 9.0), corner)
intr = synthetic.intrinsics(640, 360)
origin, direction = ray_from_pixel(
q, t, intr["fx"], intr["fy"], intr["cx"], intr["cy"], intr["cx"], intr["cy"])
got = nearest_point_on_ray(origin, direction, points.astype(float), radius=0.3)
assert got is not None
np.testing.assert_allclose(got, corner, atol=1e-4)

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

@ -0,0 +1,106 @@
"""Phase-5 foundation2 contract tests: the new route shapes lanes E/F/G depend on
(plan/20-phase5.md "Contracts"). Lane G deepens paths coverage in test_paths.py;
lane E owns the real beats/director behavior tests.
"""
from __future__ import annotations
import json
import shutil
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None,
reason="ffmpeg/ffprobe required to build the API test fixture",
)
CAMPATH = {
"version": 1,
"keyframes": [
{"t_global": 1.0, "pos": [0, 1, 5], "quat": [0, 0, 0, 1], "fov": 50},
{"t_global": 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 synthetic
synthetic.build(duration_s=1.0, run_ffmpeg=True) # into the temp data dir (conftest)
from festival4d import api
with TestClient(api.app) as c:
yield c
def test_beats_404_then_serves_file(client):
from festival4d import config
config.BEATS_JSON.unlink(missing_ok=True)
assert client.get("/api/beats").status_code == 404
body = {"tempo_bpm": 120.0, "beats_s": [0.5, 1.0], "onsets": [], "generated_by": "festival4d.audio_features"}
config.BEATS_JSON.parent.mkdir(parents=True, exist_ok=True)
config.BEATS_JSON.write_text(json.dumps(body))
try:
assert client.get("/api/beats").json() == body
finally:
config.BEATS_JSON.unlink()
def test_director_returns_valid_campath(client):
# CR-5: lane E's M11 landed, so on the synthetic fixture (events + poses present) the
# response is a REAL path — assert the frozen camPath shape (holds stubbed or implemented).
# The empty+note degradation (no events / no poses) is covered in test_director.py.
data = client.post("/api/director", json={}).json()
assert data["version"] == 1
assert isinstance(data["keyframes"], list)
for kf in data["keyframes"]:
assert set(kf) == {"t_global", "pos", "quat", "fov"}
assert client.post("/api/director", json={"top_n": 3, "lead_s": 1.0}).status_code == 200
def test_paths_crud_roundtrip(client):
assert client.get("/api/paths").json() == []
created = client.post(
"/api/paths", json={"name": "sweep", "json": json.dumps(CAMPATH)}
).json()
assert set(created) == {"id", "name", "created_at", "json"}
assert created["json"] == CAMPATH # deep-equal roundtrip
pid = created["id"]
listing = client.get("/api/paths").json()
assert [set(p) for p in listing] == [{"id", "name", "created_at"}]
assert client.get(f"/api/paths/{pid}").json()["json"] == CAMPATH
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
def test_paths_post_validates_campath(client):
post = lambda body: client.post("/api/paths", json={"name": "bad", "json": body}).status_code
assert post("not json at all {") == 422
assert post(json.dumps({"version": 2, "keyframes": []})) == 422
assert post(json.dumps({"version": 1})) == 422
assert post(json.dumps({"version": 1, "keyframes": [{"t_global": 0}]})) == 422
def test_anchor_patch_label_and_color(client):
a = client.post("/api/anchors", json={"label": "F", "x": 0, "y": 0, "z": 0}).json()
patched = client.patch(f"/api/anchors/{a['id']}", json={"label": "Fred", "color": "#4dd0ff"}).json()
assert patched["label"] == "Fred" and patched["color"] == "#4dd0ff"
assert (patched["x"], patched["y"], patched["z"]) == (0, 0, 0) # position untouched
# partial patch: color-only leaves the label alone
assert client.patch(f"/api/anchors/{a['id']}", json={"color": "#fff"}).json()["label"] == "Fred"
assert client.patch("/api/anchors/99999", json={"label": "x"}).status_code == 404
# foundation2's test_cli_dispatches_stubs_gracefully asserted `cli.main(<cmd>) == 2` while the
# phase-5 subcommands were stubs. Every one has now landed, so the test retired (CR-4 lane G for
# `capsule`, CR-5 lane E for `features`/`direct`): the real CLI dispatches are covered in
# test_capsule.py, test_audio_features.py, and test_director.py respectively.

View File

@ -0,0 +1,186 @@
"""M8 annotation -> 3D resolution tests (integration phase).
The headline acceptance (spec M8): annotating the *same* stage corner from two videos
produces an anchor within 0.2 scene units of the true corner. We build the synthetic fixture
without ffmpeg (``run_ffmpeg=False`` populates the DB + point cloud only), forward-project a
known corner into each camera to get faithful bbox centers, then resolve and check the error.
Also covers: the single-view nearest-point fallback, the near-parallel/gap rejection path,
and the ``update_event`` correction helper.
"""
from __future__ import annotations
import numpy as np
import pytest
from festival4d import config, db, resolve, synthetic
from festival4d.geometry import quat_to_mat
@pytest.fixture(scope="module")
def fixture():
# No ffmpeg: this writes videos/poses/anchors/events into the DB and points.ply to WORK_DIR.
synthetic.build(run_ffmpeg=False)
return synthetic.STAGE_CORNERS
def _project(video, pose, world_pt):
"""Forward-project a world point into a video, returning (px, py, z_cam).
Mirrors the COLMAP pinhole the fixture uses: x_cam = R@X + t; px = fx*x/z + cx (y-down).
"""
q = [pose.qw, pose.qx, pose.qy, pose.qz]
t = np.array([pose.tx, pose.ty, pose.tz])
R = quat_to_mat(q)
x_cam = R @ np.asarray(world_pt, dtype=float) + t
z = x_cam[2]
px = pose.fx * x_cam[0] / z + pose.cx
py = pose.fy * x_cam[1] / z + pose.cy
return px, py, z
def _bbox_center(px, py, W, H, half=0.02):
"""A small normalized bbox centered on the projected pixel (clamped to [0,1])."""
cx, cy = px / W, py / H
return [
max(0.0, cx - half), max(0.0, cy - half),
min(1.0, cx + half), min(1.0, cy + half),
]
def _views_seeing(corner):
"""Videos whose first pose projects `corner` in front of the camera and inside the frame."""
seen = []
for v in db.get_videos():
poses = db.get_poses(v.id)
if not poses:
continue
p0 = poses[0]
px, py, z = _project(v, p0, corner)
if z > 0 and 0 <= px < v.width and 0 <= py < v.height:
seen.append((v, p0, px, py))
return seen
def test_two_view_triangulation_recovers_corner(fixture):
corners = fixture
# Pick a corner visible in at least two cameras.
chosen = None
for label, world in corners.items():
views = _views_seeing(world)
if len(views) >= 2:
chosen = (label, np.asarray(world, dtype=float), views)
break
assert chosen is not None, "no stage corner is visible in >=2 synthetic cameras"
label, world, views = chosen
event = db.add_event(t_global_s=5.0, event_type="artist_moment", source="ai",
description=f"located {label}")
# Annotate the corner from the first two cameras that see it, then resolve the second one
# (which triangulates against the first).
(v_a, pose_a, pxa, pya), (v_b, pose_b, pxb, pyb) = views[0], views[1]
bbox_a = _bbox_center(pxa, pya, v_a.width, v_a.height)
bbox_b = _bbox_center(pxb, pyb, v_b.width, v_b.height)
db.add_annotation(v_a.id, pose_a.t_video_s, *bbox_a, event_id=event.id)
res = resolve.resolve_annotation(v_b.id, pose_b.t_video_s, bbox_b, event_id=event.id)
assert res.method == "triangulated", f"expected triangulation, got {res.method}"
err = float(np.linalg.norm(np.asarray(res.point) - world))
assert err < 0.2, f"triangulated {label} off by {err:.3f} units (>0.2)"
# Sanity: the mutual-approach gap is tight for exact projections.
assert res.gap is not None and res.gap < 0.2
def test_single_view_falls_back_to_point_cloud(fixture):
corners = fixture
world = np.asarray(corners["Stage FL"], dtype=float)
views = _views_seeing(world)
assert views, "Stage FL should be visible in at least one camera"
v, pose, px, py = views[0]
bbox = _bbox_center(px, py, v.width, v.height)
# No event_id => no other view to triangulate => nearest point-cloud point (corner is in
# the cloud) or centroid-depth ray. Either way a point comes back.
res = resolve.resolve_annotation(v.id, pose.t_video_s, bbox, event_id=None)
assert res.method in {"nearest_point", "ray_depth"}
assert res.point is not None and len(res.point) == 3
if res.method == "nearest_point":
# The exact corner is seeded into the cloud, so the nearest hit should be very close.
assert float(np.linalg.norm(np.asarray(res.point) - world)) < resolve.NEAREST_RADIUS
def test_update_event_sets_user_source(fixture):
ev = db.add_event(t_global_s=9.0, event_type="candidate", source="audio_auto",
description="unlabeled")
updated = db.update_event(ev.id, event_type="pyro", source="user")
assert updated.event_type == "pyro"
assert updated.source == "user"
# Unspecified fields are preserved.
assert updated.description == "unlabeled"
def test_missing_video_raises(fixture):
with pytest.raises(KeyError):
resolve.resolve_annotation(9999, 0.0, [0.4, 0.4, 0.6, 0.6], event_id=None)
# --- Phase 4: anchor supersede + delete (API-level, no ffmpeg) --------------------------------
def _views_and_bbox(corner):
views = _views_seeing(corner)
out = []
for (v, p, px, py) in views:
out.append((v, p, _bbox_center(px, py, v.width, v.height)))
return out
def test_two_view_annotation_supersedes_to_single_anchor(fixture):
"""Two annotations of the same event from two views yield ONE anchor (the fallback is
superseded in place by the triangulation), not a sibling pair (the Round-3 wart)."""
from festival4d.api import AnnotationIn, create_annotation
corner = np.asarray(fixture["Stage FR"], dtype=float)
vb = _views_and_bbox(corner)
assert len(vb) >= 2, "Stage FR should be visible in >=2 cameras"
event = db.add_event(t_global_s=5.0, event_type="pyro", source="ai", description="p4 pyro")
n0 = len(db.get_anchors())
(va, pa, bba), (vb2, pb, bbb) = vb[0], vb[1]
r1 = create_annotation(AnnotationIn(video_id=va.id, t_video_s=pa.t_video_s, bbox=bba, event_id=event.id))
r2 = create_annotation(AnnotationIn(video_id=vb2.id, t_video_s=pb.t_video_s, bbox=bbb, event_id=event.id))
assert r2["method"] == "triangulated" and r2["superseded"] is True
assert r1["anchor_id"] == r2["anchor_id"] # same anchor, updated in place
assert len(db.get_anchors()) - n0 == 1 # ONE anchor, not two
# Both annotations link to that single anchor.
links = {a.resolved_anchor_id for a in db.get_annotations(event_id=event.id)}
assert links == {r2["anchor_id"]}
# Superseded position is the accurate triangulation.
anchor = db.get_anchor(r2["anchor_id"])
assert float(np.linalg.norm([anchor.x - corner[0], anchor.y - corner[1], anchor.z - corner[2]])) < 0.2
def test_delete_anchor_unlinks_annotations_and_404(fixture):
from fastapi import HTTPException
from festival4d.api import AnnotationIn, create_annotation, delete_anchor
corner = np.asarray(fixture["Stage BL"], dtype=float)
vb = _views_and_bbox(corner)
assert vb, "Stage BL should be visible in >=1 camera"
event = db.add_event(t_global_s=12.0, event_type="confetti", source="ai", description="p4 del")
v, p, bb = vb[0]
r = create_annotation(AnnotationIn(video_id=v.id, t_video_s=p.t_video_s, bbox=bb, event_id=event.id))
aid = r["anchor_id"]
assert aid is not None
assert delete_anchor(aid) == {"deleted": aid}
assert db.get_anchor(aid) is None
# The annotation that resolved to it is unlinked, not orphaned with a dangling FK.
assert all(a.resolved_anchor_id != aid for a in db.get_annotations(event_id=event.id))
with pytest.raises(HTTPException) as exc:
delete_anchor(aid)
assert exc.value.status_code == 404

494
backend/tests/test_sfm.py Normal file
View File

@ -0,0 +1,494 @@
"""Tests for lane B: frames.py (sampling) + sfm.py (parsers, normalization,
interpolation, and the graceful-degradation reconstruct pipeline).
Everything here is verifiable without COLMAP: parsers run on hand-written TXT snippets, the
export path runs on a hand-built (synthetic-pose) COLMAP model injected in place of a real
COLMAP run, and the failure paths are exercised by monkeypatching. Spec M2 acceptance:
"on the synthetic fixture (which skips COLMAP and injects known poses) the whole export path
runs; if COLMAP is installed, the pipeline runs end-to-end without crashing."
"""
from __future__ import annotations
import struct
from pathlib import Path
import numpy as np
import pytest
from festival4d import config, db, frames, sfm, synthetic
from festival4d.geometry import quat_to_mat
# ===========================================================================
# COLMAP TXT parsers (hand-written snippets)
# ===========================================================================
IMAGES_TXT = """\
# Image list with two lines of data per image:
# IMAGE_ID, QW, QX, QY, QZ, TX, TY, TZ, CAMERA_ID, NAME
# POINTS2D[] as (X, Y, POINT3D_ID)
# Number of images: 2, mean observations per image: 2
1 0.9998 0.0 0.02 0.0 -1.5 0.3 8.0 1 1/1_0.jpg
480.0 270.0 5 500.1 260.2 -1
2 0.7071 0.0 0.7071 0.0 2.0 0.1 7.5 2 2/2_30.jpg
100.0 120.0 -1 300.0 200.0 5
"""
CAMERAS_TXT = """\
# Camera list with one line of data per camera:
# CAMERA_ID, MODEL, WIDTH, HEIGHT, PARAMS[]
# Number of cameras: 2
1 PINHOLE 640 360 500.0 500.0 320.0 180.0
2 OPENCV 640 360 510.0 511.0 321.0 181.0 0.01 -0.02 0.0 0.0
"""
POINTS3D_TXT = """\
# 3D point list with one line of data per point:
# POINT3D_ID, X, Y, Z, R, G, B, ERROR, TRACK[] as (IMAGE_ID, POINT2D_IDX)
# Number of points: 2, mean track length: 2
5 1.0 2.0 3.0 200 100 50 0.5 1 0 2 1
9 -1.0 0.0 4.0 10 20 30 0.8 1 1
"""
def test_parse_images_txt(tmp_path):
p = tmp_path / "images.txt"
p.write_text(IMAGES_TXT)
recs = sfm.parse_images_txt(p)
assert len(recs) == 2
assert recs[0]["image_id"] == 1
assert recs[0]["camera_id"] == 1
assert recs[0]["name"] == "1/1_0.jpg"
np.testing.assert_allclose(
[recs[0]["qw"], recs[0]["qx"], recs[0]["qy"], recs[0]["qz"]],
[0.9998, 0.0, 0.02, 0.0])
np.testing.assert_allclose(
[recs[1]["tx"], recs[1]["ty"], recs[1]["tz"]], [2.0, 0.1, 7.5])
def test_parse_cameras_txt(tmp_path):
p = tmp_path / "cameras.txt"
p.write_text(CAMERAS_TXT)
cams = sfm.parse_cameras_txt(p)
assert set(cams) == {1, 2}
assert cams[1]["model"] == "PINHOLE"
assert (cams[1]["fx"], cams[1]["fy"], cams[1]["cx"], cams[1]["cy"]) == \
(500.0, 500.0, 320.0, 180.0)
# OPENCV: first 4 params are fx, fy, cx, cy; distortion ignored
assert (cams[2]["fx"], cams[2]["fy"], cams[2]["cx"], cams[2]["cy"]) == \
(510.0, 511.0, 321.0, 181.0)
def test_parse_points3d_txt(tmp_path):
p = tmp_path / "points3D.txt"
p.write_text(POINTS3D_TXT)
points, colors = sfm.parse_points3d_txt(p)
assert points.shape == (2, 3) and colors.shape == (2, 3)
np.testing.assert_allclose(points[0], [1.0, 2.0, 3.0])
assert tuple(colors[0]) == (200, 100, 50)
assert points.dtype == np.float32 and colors.dtype == np.uint8
def test_parse_points3d_empty(tmp_path):
p = tmp_path / "points3D.txt"
p.write_text("# header only\n")
points, colors = sfm.parse_points3d_txt(p)
assert points.shape == (0, 3) and colors.shape == (0, 3)
# ===========================================================================
# Scene normalization
# ===========================================================================
def _all_synth_poses():
poses = []
for i in range(3):
poses.extend(synthetic.camera_track(i, 20.0, 30, 640, 360))
return poses
def test_normalize_scene_invariants():
points, _ = synthetic.generate_point_cloud()
poses = _all_synth_poses()
npoints, nposes = sfm.normalize_scene(points.astype(float), poses)
# centroid at origin
assert np.linalg.norm(npoints.mean(axis=0)) < 1e-3
# camera bounding sphere (from origin = point centroid) has radius 10
centers = np.array([sfm._camera_center(p) for p in nposes])
assert abs(float(np.max(np.linalg.norm(centers, axis=1))) - 10.0) < 1e-6
# average camera-up aligns with +Y
ups = np.array([sfm._camera_up(p) for p in nposes])
up_avg = ups.mean(axis=0)
up_avg /= np.linalg.norm(up_avg)
np.testing.assert_allclose(up_avg, [0.0, 1.0, 0.0], atol=1e-6)
def test_normalize_scene_preserves_projection():
"""A world point projects to the same pixel before and after normalization
(a similarity transform of the world must not change any image)."""
points, _ = synthetic.generate_point_cloud()
poses = _all_synth_poses()
intr = synthetic.intrinsics(640, 360)
for p in poses:
p.update(intr)
X = np.array([0.7, 0.9, 0.3]) # arbitrary world point
def project(pose, Xw):
R = quat_to_mat([pose["qw"], pose["qx"], pose["qy"], pose["qz"]])
t = np.array([pose["tx"], pose["ty"], pose["tz"]])
xc = R @ Xw + t
return np.array([pose["fx"] * xc[0] / xc[2] + pose["cx"],
pose["fy"] * xc[1] / xc[2] + pose["cy"]])
npoints, nposes = sfm.normalize_scene(points.astype(float), poses)
# transform the same world point by X' = s*Rn@(X-c): recover s,Rn,c from the point map.
# Easier: the invariant is projection equality, so map X through the same transform used
# on the cloud by fitting it — instead, just check pixel equality using each pose pair.
# Reconstruct transform from three non-collinear cloud points is overkill; use the fact
# that projection is invariant, so compare original point's pixel to the transformed
# point's pixel where the transform is inferred from the point cloud centroid+scale.
c = points.astype(float).mean(axis=0)
centers = np.array([sfm._camera_center(p) for p in poses])
scale = 10.0 / float(np.max(np.linalg.norm(centers - c, axis=1)))
ups = np.array([sfm._camera_up(p) for p in poses])
up_avg = ups.mean(axis=0)
Rn = sfm._rotation_aligning(up_avg, np.array([0.0, 1.0, 0.0]))
Xn = scale * Rn @ (X - c)
for p_old, p_new in zip(poses, nposes):
np.testing.assert_allclose(project(p_old, X), project(p_new, Xn), atol=1e-6)
# ===========================================================================
# Pose interpolation
# ===========================================================================
def _pose(frame_idx, t, q, t3, registered=True):
return {"frame_idx": frame_idx, "t_video_s": t,
"qw": q[0], "qx": q[1], "qy": q[2], "qz": q[3],
"tx": t3[0], "ty": t3[1], "tz": t3[2],
"fx": 500.0, "fy": 500.0, "cx": 320.0, "cy": 180.0,
"registered": registered, "video_id": 1}
def test_interpolate_poses_fills_gaps_no_extrapolation():
q0 = [1.0, 0.0, 0.0, 0.0]
ang = np.radians(60.0)
q1 = [np.cos(ang / 2), 0.0, np.sin(ang / 2), 0.0] # 60 deg yaw about Y
registered = [_pose(0, 0.0, q0, [0, 0, 0]), _pose(60, 2.0, q1, [6, 0, 0])]
sampled = [{"frame_idx": f, "t_video_s": f / 30.0} for f in (0, 30, 60, 90)]
out = sfm.interpolate_poses(registered, sampled)
by_frame = {p["frame_idx"]: p for p in out}
assert set(by_frame) == {0, 30, 60} # frame 90 dropped (beyond last registered)
assert by_frame[30]["registered"] is False # interpolated
assert by_frame[0]["registered"] is True and by_frame[60]["registered"] is True
# midpoint translation is the lerp; rotation is ~30 deg
np.testing.assert_allclose(
[by_frame[30]["tx"], by_frame[30]["ty"], by_frame[30]["tz"]], [3, 0, 0], atol=1e-9)
mid_angle = 2.0 * np.degrees(np.arccos(min(1.0, abs(by_frame[30]["qw"]))))
assert abs(mid_angle - 30.0) < 1e-6
def test_interpolate_poses_empty_registered():
assert sfm.interpolate_poses([], [{"frame_idx": 0, "t_video_s": 0.0}]) == []
# ===========================================================================
# frames.py — sharpness + windowed sampling
# ===========================================================================
def test_sharpness_sharp_beats_blurred():
import cv2
rng = np.random.default_rng(0)
noise = (rng.integers(0, 256, size=(120, 120), dtype=np.uint8)) # high-frequency
flat = np.full((120, 120), 128, dtype=np.uint8) # no edges
blurred = cv2.GaussianBlur(noise, (0, 0), sigmaX=5)
assert frames.sharpness(noise) > frames.sharpness(blurred) > frames.sharpness(flat)
# accepts BGR too
bgr = cv2.cvtColor(noise, cv2.COLOR_GRAY2BGR)
assert frames.sharpness(bgr) > 0
def test_sample_frames_picks_sharpest_per_window(tmp_path):
import cv2
w = h = 128
fps = 30.0
n_frames = 30 # 1.0 s -> two 0.5 s windows
vid_path = tmp_path / "clip.mp4"
writer = cv2.VideoWriter(str(vid_path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
if not writer.isOpened():
pytest.skip("no usable VideoWriter codec in this environment")
checker = np.indices((h, w)).sum(axis=0) % 2 # fine checkerboard -> sharp
sharp = (checker * 255).astype(np.uint8)
flat = np.full((h, w), 128, dtype=np.uint8)
sharp_frames = {7, 22}
for i in range(n_frames):
g = sharp if i in sharp_frames else flat
writer.write(cv2.cvtColor(g, cv2.COLOR_GRAY2BGR))
writer.release()
out = frames.sample_frames(vid_path, video_id=5, out_dir=tmp_path / "frames")
got_frames = sorted(int(Path(p).stem.split("_")[1]) for p in out)
# ground-truth: decode the written stream and take the per-window argmax ourselves
cap = cv2.VideoCapture(str(vid_path))
sh, idx = [], 0
while True:
ok, fr = cap.read()
if not ok:
break
sh.append(frames.sharpness(fr)); idx += 1
cap.release()
bucket = {}
for i, s in enumerate(sh):
win = int((i / fps) / 0.5)
if win not in bucket or s > bucket[win][0]:
bucket[win] = (s, i)
expected = sorted(v[1] for v in bucket.values())
assert got_frames == expected
assert all(Path(p).name.startswith("5_") for p in out)
# ===========================================================================
# run_reconstruct — graceful degradation + full export path (no real COLMAP)
# ===========================================================================
def _snapshot_poses():
return {
v.id: [(p.frame_idx, round(p.qw, 9), round(p.tx, 9), p.registered)
for p in db.get_poses(v.id)]
for v in db.get_videos()
}
def _write_colmap_model(model_dir: Path, records: list[dict], cameras: dict,
points, colors) -> None:
"""Write a COLMAP TXT model (images/cameras/points3D) from pose records."""
model_dir.mkdir(parents=True, exist_ok=True)
with open(model_dir / "images.txt", "w") as f:
f.write("# image list\n")
for i, r in enumerate(records, start=1):
f.write(f"{i} {r['qw']} {r['qx']} {r['qy']} {r['qz']} "
f"{r['tx']} {r['ty']} {r['tz']} {r['camera_id']} {r['name']}\n")
f.write("100.0 100.0 -1\n") # dummy points2D line
with open(model_dir / "cameras.txt", "w") as f:
f.write("# camera list\n")
for cid, cam in cameras.items():
f.write(f"{cid} PINHOLE {cam['width']} {cam['height']} "
f"{cam['fx']} {cam['fy']} {cam['cx']} {cam['cy']}\n")
with open(model_dir / "points3D.txt", "w") as f:
f.write("# point list\n")
for j, (p, c) in enumerate(zip(points, colors), start=1):
f.write(f"{j} {p[0]} {p[1]} {p[2]} {int(c[0])} {int(c[1])} {int(c[2])} 0.5 1 0\n")
def test_reconstruct_no_videos():
db.init_engine()
db.reset_db()
result = sfm.run_reconstruct()
assert result["status"] == "no_videos"
def test_reconstruct_skips_without_colmap(monkeypatch):
synthetic.build(run_ffmpeg=False)
monkeypatch.setattr(sfm, "colmap_available", lambda: False)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "skipped_no_colmap"
assert _snapshot_poses() == before # poses untouched
def _fake_sampling(monkeypatch, frames_per_video):
"""Make sampling hermetic: placeholder raw files + fake sample_frames output."""
for v in db.get_videos():
(config.RAW_DIR / v.filename).parent.mkdir(parents=True, exist_ok=True)
(config.RAW_DIR / v.filename).write_bytes(b"stub")
def fake_sample(video_path, video_id, out_dir, **kw):
return [Path(f"{video_id}_{f}.jpg") for f in frames_per_video]
monkeypatch.setattr(sfm.frames, "sample_frames", fake_sample)
monkeypatch.setattr(sfm, "colmap_available", lambda: True)
def test_reconstruct_failed_no_model(monkeypatch):
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, [0, 30, 60])
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: None)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "failed_no_model"
assert _snapshot_poses() == before # poses untouched
def test_reconstruct_failed_weak(monkeypatch, tmp_path):
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, [0, 30, 60]) # 3 videos x 3 = 9 sampled
# model registers only one image of one video -> ~11%, < 60% and < 2 videos
track = synthetic.camera_track(0, 20.0, 30, 640, 360)
rec = dict(track[0]); rec["camera_id"] = 1; rec["name"] = "1_0.jpg"
cams = {1: {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}}
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "weak"
_write_colmap_model(model_dir, [rec], cams, pts[:50], cols[:50])
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "failed_weak"
assert _snapshot_poses() == before # poses untouched
def test_reconstruct_full_export_path(monkeypatch, tmp_path):
"""The whole export path runs on injected known poses (spec M2 acceptance)."""
synthetic.build(run_ffmpeg=False)
videos = db.get_videos()
id_by_cam = {i: videos[i].id for i in range(len(videos))} # cam index -> db id
sampled_frames = [0, 15, 30, 45, 60]
_fake_sampling(monkeypatch, sampled_frames)
# Build a model: videos for cam0/cam1 fully registered; cam2 registers a subset so
# interpolation must fill its gaps. All frame indices come from the synthetic tracks.
records, cameras = [], {}
reg_plan = {0: sampled_frames, 1: sampled_frames, 2: [0, 30, 60]}
for cam_idx, frame_list in reg_plan.items():
vid = id_by_cam[cam_idx]
cameras[vid] = {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}
track = {p["frame_idx"]: p for p in synthetic.camera_track(cam_idx, 20.0, 30, 640, 360)}
for f in frame_list:
p = track[f]
records.append({**p, "camera_id": vid, "name": f"{vid}_{f}.jpg"})
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "good"
_write_colmap_model(model_dir, records, cameras, pts, cols)
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
result = sfm.run_reconstruct()
assert result["status"] == "ok"
assert result["registered"] == 13 # 5 + 5 + 3
assert result["videos_in_model"] == 3
assert result["interpolated"] == 2 # cam2 frames 15 and 45
# cam2 poses: 5 total (3 registered + 2 interpolated), sorted, with interp flags
cam2_poses = db.get_poses(id_by_cam[2])
assert len(cam2_poses) == 5
assert sum(1 for p in cam2_poses if not p.registered) == 2
assert [p.frame_idx for p in cam2_poses] == [0, 15, 30, 45, 60]
# normalized point cloud written in the frozen PLY format; camera sphere radius ~ 10
assert config.POINTS_PLY.exists()
npoints, _ = synthetic.read_ply(config.POINTS_PLY)
assert len(npoints) == len(pts)
all_centers = []
for v in videos:
for p in db.get_poses(v.id):
R = quat_to_mat([p.qw, p.qx, p.qy, p.qz])
all_centers.append(-R.T @ np.array([p.tx, p.ty, p.tz]))
max_r = float(np.max(np.linalg.norm(np.array(all_centers), axis=1)))
assert abs(max_r - 10.0) < 0.5 # registered cams normalized to radius 10
def test_colmap_model_parser_roundtrip(tmp_path):
"""Writing then parsing a model recovers the poses/intrinsics/points."""
track = synthetic.camera_track(0, 20.0, 30, 640, 360)[:3]
records = [{**p, "camera_id": 1, "name": f"1_{p['frame_idx']}.jpg"} for p in track]
cams = {1: {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}}
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "rt"
_write_colmap_model(model_dir, records, cams, pts[:20], cols[:20])
images = sfm.parse_images_txt(model_dir / "images.txt")
cameras = sfm.parse_cameras_txt(model_dir / "cameras.txt")
ppoints, pcolors = sfm.parse_points3d_txt(model_dir / "points3D.txt")
assert len(images) == 3
np.testing.assert_allclose(images[0]["qw"], track[0]["qw"], atol=1e-6)
assert cameras[1]["fx"] == pytest.approx(synthetic.intrinsics(640, 360)["fx"])
assert len(ppoints) == 20
def test_parse_cameras_txt_truncated_params(tmp_path):
"""A camera line with too few PARAMS gives a clean ValueError, not a raw IndexError."""
p = tmp_path / "cameras.txt"
p.write_text("# header\n7 PINHOLE 640 360 500.0 500.0 320.0\n") # PINHOLE needs 4 params
with pytest.raises(ValueError, match="too few PARAMS"):
sfm.parse_cameras_txt(p)
def test_largest_model_dir_picks_by_registered_count(tmp_path):
"""Binary models are ranked by the images.bin header count, not file size."""
sparse = tmp_path / "sparse"
# model 0: 10 registered images but a LARGE images.bin (many keypoint observations)
(sparse / "0").mkdir(parents=True)
(sparse / "0" / "images.bin").write_bytes(struct.pack("<Q", 10) + b"\x00" * 5000)
# model 1: 15 registered images but a SMALL images.bin (few keypoints)
(sparse / "1").mkdir(parents=True)
(sparse / "1" / "images.bin").write_bytes(struct.pack("<Q", 15) + b"\x00" * 100)
assert sfm._registered_image_count(sparse / "1") == 15
assert sfm._largest_model_dir(sparse).name == "1" # more images wins despite smaller file
def test_interpolate_poses_drops_before_first_registered():
"""Lower no-extrapolation guard: a sampled frame before the first registered one is dropped."""
q = [1.0, 0.0, 0.0, 0.0]
registered = [_pose(30, 1.0, q, [3, 0, 0]), _pose(60, 2.0, q, [6, 0, 0])]
sampled = [{"frame_idx": f, "t_video_s": f / 30.0} for f in (0, 30, 45, 60)]
out = sfm.interpolate_poses(registered, sampled)
frames_out = {p["frame_idx"] for p in out}
assert 0 not in frames_out # tv=0 < t_first=1.0 -> dropped, no extrapolation
assert frames_out == {30, 45, 60}
by = {p["frame_idx"]: p for p in out}
assert by[45]["registered"] is False # interior gap still interpolated
def test_reconstruct_subset_leaves_others_untouched(monkeypatch, tmp_path):
"""When only a subset of videos registers, the rest keep their existing poses (DB safety)."""
synthetic.build(run_ffmpeg=False)
videos = db.get_videos()
id_by_cam = {i: videos[i].id for i in range(len(videos))}
sampled_frames = [0, 15, 30, 45, 60]
_fake_sampling(monkeypatch, sampled_frames)
# Register cam0 + cam1 fully (10/15 = 67% >= 60%, 2 videos -> ok); cam2 NOT in the model.
records, cameras = [], {}
for cam_idx in (0, 1):
vid = id_by_cam[cam_idx]
cameras[vid] = {"width": 640, "height": 360, **synthetic.intrinsics(640, 360)}
track = {p["frame_idx"]: p for p in synthetic.camera_track(cam_idx, 20.0, 30, 640, 360)}
for f in sampled_frames:
records.append({**track[f], "camera_id": vid, "name": f"{vid}_{f}.jpg"})
pts, cols = synthetic.generate_point_cloud()
model_dir = tmp_path / "subset"
_write_colmap_model(model_dir, records, cameras, pts, cols)
monkeypatch.setattr(sfm, "run_colmap", lambda frames_dir, workspace: model_dir)
cam2_before = [(p.frame_idx, p.qw, p.tx, p.registered) for p in db.get_poses(id_by_cam[2])]
result = sfm.run_reconstruct()
assert result["status"] == "ok"
assert result["videos_in_model"] == 2
# cam2 absent from the model: its rows must be byte-identical afterward
cam2_after = [(p.frame_idx, p.qw, p.tx, p.registered) for p in db.get_poses(id_by_cam[2])]
assert cam2_after == cam2_before
assert len(cam2_before) == 41 # original synthetic poses intact
assert len(db.get_poses(id_by_cam[0])) == 5 # cam0 replaced with sampled frames
def test_reconstruct_no_frames_when_videos_missing(monkeypatch):
"""All raw files absent -> videos skipped -> no_frames, poses untouched."""
synthetic.build(run_ffmpeg=False)
for f in config.RAW_DIR.glob("*.mp4"): # clear stubs left by other tests
f.unlink()
monkeypatch.setattr(sfm, "colmap_available", lambda: True)
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "no_frames"
assert _snapshot_poses() == before
def test_reconstruct_no_frames_empty_sampling(monkeypatch):
"""Raw files exist but sampling yields nothing -> no_frames, poses untouched."""
synthetic.build(run_ffmpeg=False)
_fake_sampling(monkeypatch, []) # sample_frames returns []
before = _snapshot_poses()
result = sfm.run_reconstruct()
assert result["status"] == "no_frames"
assert _snapshot_poses() == before

View File

@ -0,0 +1,351 @@
"""foundation3 (phase 6 / M18) contract tests — the ground three lanes stand on.
Covers what foundation3 owns and freezes, NOT the lane implementations (detection = lane H's
test_tracker_detect.py; solving = test_tracker_solve.py; ribbons = lane J's live-browser
evidence):
A. tracks / track_points DB helpers (contract #2) — round-trips, replace + delete semantics.
B. /api/tracks routes (contract #2) + POST /api/tracks/solve degradation + manifest.has_tracks.
C. The frozen OOK blink protocol + color marker_key convention (contracts #1, #4) — pure,
no OpenCV. These constants are byte-identical to hardware/badge/protocol.h (lane K).
D. The synthetic fixture (M18 step 1): track_truth.json shape, marker paths, and the one
that matters most a projection ROUND-TRIP proving the rendered marker pixels
back-project to the ground-truth 3D point through the frozen geometry primitives. If this
passes, lane H's M20 recovery (median 3D error < 0.3) is achievable by construction.
Hermetic: no ffmpeg (geometry only), temp data dir from conftest. DB-touching tests clean up
after themselves so later modules see the tables as they found them.
"""
from __future__ import annotations
import math
import shutil
import numpy as np
import pytest
from festival4d import config, db, synthetic, tracker_detect
_HAS_FFMPEG = shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
# ===========================================================================
# A. DB helpers (contract #2)
# ===========================================================================
def _use_temp_db(tmp_path):
db.init_engine(tmp_path / "t.db")
db.reset_db()
def test_add_and_get_tracks_ordered_by_id(tmp_path):
_use_temp_db(tmp_path)
a = db.add_track("hue:150", label="Alice", color="#ff00ff")
b = db.add_track("code:5")
assert db.has_tracks() is True
tracks = db.get_tracks()
assert [t.id for t in tracks] == sorted(t.id for t in tracks) == [a.id, b.id]
assert tracks[0].marker_key == "hue:150" and tracks[0].label == "Alice"
assert tracks[1].label is None and tracks[1].color is None # optional fields default None
assert db.get_track(a.id).marker_key == "hue:150"
assert db.get_track(999999) is None
def test_has_tracks_false_on_empty(tmp_path):
_use_temp_db(tmp_path)
assert db.has_tracks() is False
assert db.get_tracks() == []
def test_set_track_points_replaces_and_orders(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("code:5")
# Insert out of order; helper must return them sorted by t_global_s (contract #2).
n = db.set_track_points(t.id, [
{"t_global_s": 2.0, "x": 1, "y": 2, "z": 3, "quality": 0.9, "views": 2},
{"t_global_s": 1.0, "x": 0, "y": 0, "z": 0, "quality": 0.4, "views": 1},
])
assert n == 2
pts = db.get_track_points(t.id)
assert [p.t_global_s for p in pts] == [1.0, 2.0]
assert pts[0].views == 1 and pts[0].quality == 0.4
assert pts[1].views == 2
# Re-solve replaces (idempotent — never stacks duplicates, per M20 contract).
db.set_track_points(t.id, [{"t_global_s": 5.0, "x": 9, "y": 9, "z": 9}])
pts = db.get_track_points(t.id)
assert len(pts) == 1 and pts[0].t_global_s == 5.0
assert pts[0].quality is None and pts[0].views is None # omitted optionals -> None
def test_set_track_points_unknown_track_raises(tmp_path):
_use_temp_db(tmp_path)
with pytest.raises(KeyError):
db.set_track_points(424242, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
def test_patch_track_partial_and_missing(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("hue:150", label="Old", color="#111111")
db.patch_track(t.id, label="New") # color untouched
got = db.get_track(t.id)
assert got.label == "New" and got.color == "#111111"
db.patch_track(t.id, color="#00ffff")
assert db.get_track(t.id).color == "#00ffff"
with pytest.raises(KeyError):
db.patch_track(424242, label="x")
def test_delete_track_removes_points(tmp_path):
_use_temp_db(tmp_path)
t = db.add_track("code:5")
db.set_track_points(t.id, [{"t_global_s": 0.0, "x": 0, "y": 0, "z": 0}])
assert db.delete_track(t.id) is True
assert db.get_track(t.id) is None
assert db.get_track_points(t.id) == [] # points cascade (SQLite doesn't by default)
assert db.delete_track(t.id) is False # already gone
# ===========================================================================
# B. /api/tracks routes (contract #2)
# ===========================================================================
@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()
def _api_db(client):
"""Point the API at a clean per-test db and restore the shared engine afterwards."""
import tempfile
from pathlib import Path
d = Path(tempfile.mkdtemp(prefix="tracks-api-"))
db.init_engine(d / "api.db")
db.reset_db()
yield
db.init_engine(config.DB_PATH)
def test_get_tracks_shape(client, _api_db):
t = db.add_track("hue:150", label="Alice", color="#ff00ff")
db.set_track_points(t.id, [
{"t_global_s": 1.0, "x": 0.1, "y": 1.5, "z": 2.0, "quality": 0.9, "views": 2},
{"t_global_s": 1.25, "x": 0.2, "y": 1.5, "z": 1.9, "quality": 0.5, "views": 1},
])
listing = client.get("/api/tracks").json()
assert len(listing) == 1
row = listing[0]
assert set(row) == {"id", "marker_key", "label", "color", "points"}
assert row["marker_key"] == "hue:150" and row["label"] == "Alice"
assert [p["t_global_s"] for p in row["points"]] == [1.0, 1.25] # ordered
assert set(row["points"][0]) == {"t_global_s", "x", "y", "z", "quality", "views"}
assert row["points"][1]["views"] == 1
def test_patch_and_delete_routes(client, _api_db):
t = db.add_track("code:5")
patched = client.patch(f"/api/tracks/{t.id}", json={"label": "Bob", "color": "#00ffff"}).json()
assert patched["label"] == "Bob" and patched["color"] == "#00ffff"
# partial patch leaves the other field
assert client.patch(f"/api/tracks/{t.id}", json={"color": "#fff"}).json()["label"] == "Bob"
assert client.patch("/api/tracks/999999", json={"label": "x"}).status_code == 404
assert client.delete(f"/api/tracks/{t.id}").json() == {"deleted": t.id}
assert client.get("/api/tracks").json() == []
assert client.delete(f"/api/tracks/{t.id}").status_code == 404
def test_solve_degrades_while_stubbed(client, _api_db):
# Lane H is a stub (NotImplementedError). The frozen route must NOT 500 — it returns a
# valid empty result + a note (the house pattern, mirrors POST /api/events/detect).
resp = client.post("/api/tracks/solve")
assert resp.status_code == 200
body = resp.json()
assert body["result"] is None
assert "not implemented" in body["note"].lower()
assert body["tracks"] == [] # no tracks solved yet
def test_manifest_has_tracks_reflects_db(client, _api_db):
assert client.get("/api/manifest").json()["has_tracks"] is False
db.add_track("hue:150")
assert client.get("/api/manifest").json()["has_tracks"] is True
# ===========================================================================
# C. Frozen blink OOK protocol + color marker_key (contracts #1, #4)
# ===========================================================================
def test_protocol_constants_match_spec():
assert tracker_detect.BLINK_BIT_PERIOD_S == 0.133
assert tracker_detect.BLINK_PREAMBLE == (1, 1, 1, 0, 0)
assert tracker_detect.BLINK_ID_BITS == 6
assert tracker_detect.BLINK_WORD_BITS == 12 # 5 preamble + 6 id + 1 parity
assert tracker_detect.BLINK_BEACON_ID == 63
assert tracker_detect.BLINK_MIN_FPS == 24.0
assert tracker_detect.MAGENTA_OPENCV_HUE == 150 # #ff00ff, hue 300°/2
assert tracker_detect.CYAN_OPENCV_HUE == 90 # #00ffff, hue 180°/2
def test_encode_word_id5_exact_and_even_parity():
word = tracker_detect.encode_word(5)
assert word == [1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 1] # preamble + 000101 + parity
assert len(word) == 12
for mid in range(0, 64):
w = tracker_detect.encode_word(mid)
assert tuple(w[:5]) == tracker_detect.BLINK_PREAMBLE
assert sum(w) % 2 == 0 # even parity over the whole word
# 6-bit ID MSB-first recovers the value
recovered = sum(bit << (5 - i) for i, bit in enumerate(w[5:11]))
assert recovered == mid
def test_encode_word_rejects_out_of_range():
for bad in (-1, 64, 100):
with pytest.raises(ValueError):
tracker_detect.encode_word(bad)
def test_led_on_matches_encoded_word_windows():
# The LED state at bit-window k equals bit k of the repeating word (global time).
word = tracker_detect.encode_word(5)
period = tracker_detect.BLINK_BIT_PERIOD_S
for k in range(len(word) * 2): # two full words
t_mid = (k + 0.5) * period # sample mid-window
assert tracker_detect.led_on(t_mid, 5) == bool(word[k % len(word)])
def test_marker_key_helpers_and_hue_env(monkeypatch):
assert tracker_detect.color_marker_key(150) == "hue:150"
assert tracker_detect.code_marker_key(5) == "code:5"
monkeypatch.delenv("FESTIVAL4D_MARKER_HUES", raising=False)
assert tracker_detect.marker_hues_hex() == ["#ff00ff", "#00ffff"]
monkeypatch.setenv("FESTIVAL4D_MARKER_HUES", "#ff0000, #00ff00")
assert tracker_detect.marker_hues_hex() == ["#ff0000", "#00ff00"]
# ===========================================================================
# D. Synthetic fixture: track_truth shape, marker paths, projection round-trip
# ===========================================================================
def test_track_truth_shape_and_both_markers():
tt = synthetic.build_track_truth(0.0, 2.0, dt=0.05)
assert set(tt) >= {"markers"}
keys = {m["marker_key"] for m in tt["markers"]}
assert keys == {"hue:150", "code:5"} # marker A (color) + marker B (blink)
for m in tt["markers"]:
ts = [p["t_global_s"] for p in m["points"]]
assert ts == sorted(ts) # monotonically increasing
assert len(ts) == len(set(ts)) # no dup timesteps
assert set(m["points"][0]) == {"t_global_s", "x", "y", "z"} # Three.js scene space
def test_marker_paths_and_unknown_key():
# Marker A: a circle radius 2 at y=1.5 (contract, spec M18).
for tg in (0.0, 3.7, 11.2):
x, y, z = synthetic.marker_position("hue:150", tg)
assert y == pytest.approx(1.5)
assert math.hypot(x, z) == pytest.approx(2.0, abs=1e-9)
# Marker B follows a different path (never identical to A) so the two never lock.
assert synthetic.marker_position("code:5", 3.7) != synthetic.marker_position("hue:150", 3.7)
with pytest.raises(KeyError):
synthetic.marker_position("code:99", 0.0)
def test_marker_discs_are_small():
# Both discs together must be well under 2% of frame area (spec M18: ≤2%, pitfall #6).
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
r = synthetic._disc_radius_px(h)
two_discs = 2 * math.pi * r * r
assert two_discs / (w * h) < 0.02
def test_projection_round_trip_recovers_ground_truth():
"""THE foundation contract: rendered marker pixels back-project to the 3D truth.
For each marker at several t_global: project its ground-truth 3D point through every
camera's interpolated pose (exactly as render_marker_overlay draws the pixel), then treat
the in-frame pixels as a perfect detector's output and triangulate them back through the
FROZEN geometry primitives. Recovery must be ~exact on the noise-free fixture this is
what makes lane H's M20 (median 3D error < 0.3) reachable by construction.
"""
from festival4d import geometry
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
fps, dur = float(config.SYNTH_FPS), config.SYNTH_DURATION_S
# Poses per camera, generated the same way build() does (no DB, no ffmpeg needed).
cam_poses = [synthetic.camera_track(i, dur, fps, w, h)
for i in range(len(config.SYNTH_OFFSETS_MS))]
worst = 0.0
checked = 0
for key in synthetic.MARKER_KEYS:
for t_global in (3.0, 6.5, 10.0, 14.25, 17.5): # inside every camera's valid span
truth = np.array(synthetic.marker_position(key, t_global))
rays = []
for i, offset_ms in enumerate(config.SYNTH_OFFSETS_MS):
t_video = config.t_video_from_global(t_global, offset_ms, 0.0)
q, t, intr = synthetic._interp_pose(cam_poses[i], t_video)
proj = synthetic.project_point(q, t, intr, truth)
if proj is None:
continue
px, py, _ = proj
if not (0.0 <= px < w and 0.0 <= py < h):
continue # off this camera's frame
fx, fy, cx, cy = intr
rays.append(geometry.ray_from_pixel(q, t, fx, fy, cx, cy, px, py))
assert len(rays) >= 2, f"{key} @ {t_global}s: need 2+ views, got {len(rays)}"
point, gap = geometry.triangulate_rays(rays[0][0], rays[0][1], rays[1][0], rays[1][1])
err = float(np.linalg.norm(point - truth))
worst = max(worst, err)
checked += 1
assert checked >= 8
# Noise-free synthetic geometry: recovery is essentially exact, far under the 0.3 M20 bar.
assert worst < 1e-3, f"worst round-trip error {worst:.2e} (>1e-3)"
@pytest.mark.skipif(not _HAS_FFMPEG, reason="ffmpeg/ffprobe required to render the fixture video")
def test_rendered_marker_a_is_the_dominant_magenta_blob(tmp_path):
"""The base-muting contract (BASE_SATURATION / BASE_BRIGHTNESS) actually renders a solvable
fixture: testsrc2's own solid magenta/cyan colour bars must NOT out-mass marker A's disc, or
lane H's colour detector locks onto a bar and M19/M20 fail. This emulates that detector —
largest saturated-magenta blob in a real rendered frame and asserts it lands on the disc.
Guards against a future un-muting of the base swamping detection.
"""
import cv2
dur = 2.0
synthetic.build(base_dir=tmp_path, duration_s=dur, run_ffmpeg=True)
try:
w, h = config.SYNTH_VIDEO_W, config.SYNTH_VIDEO_H
poses = synthetic.camera_track(0, dur, float(config.SYNTH_FPS), w, h)
t_video = 1.0
q, t, intr = synthetic._interp_pose(poses, t_video)
t_global = config.t_global_from_video(t_video, config.SYNTH_OFFSETS_MS[0], 0.0)
proj = synthetic.project_point(q, t, intr,
synthetic.marker_position(synthetic.MARKER_A_KEY, t_global))
assert proj and 0 <= proj[0] < w and 0 <= proj[1] < h, "marker A not in cam0 frame at t=1s"
cap = cv2.VideoCapture(str(tmp_path / "raw" / "cam0.mp4"))
cap.set(cv2.CAP_PROP_POS_MSEC, t_video * 1000.0)
ok, img = cap.read()
cap.release()
assert ok, "could not read cam0 frame"
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
hh, ss, vv = hsv[:, :, 0].astype(int), hsv[:, :, 1], hsv[:, :, 2]
# OpenCV-hue magenta ≈150, gated on high saturation AND value (pitfall #2).
mask = (((hh >= 145) & (hh <= 155)) & (ss > 170) & (vv > 170)).astype(np.uint8)
n, _lab, stats, cent = cv2.connectedComponentsWithStats(mask, 8)
assert n > 1, "no saturated-magenta blob at all — marker A not rendered?"
biggest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
cx, cy = cent[biggest]
dist = math.hypot(cx - proj[0], cy - proj[1])
assert dist < 10.0, (
f"dominant magenta blob at ({cx:.0f},{cy:.0f}) is {dist:.0f}px from marker A's "
f"projected centre ({proj[0]:.0f},{proj[1]:.0f}) — base bars are swamping the disc"
)
finally:
db.init_engine(config.DB_PATH) # build() rebound the engine; restore the shared one

118
deploy/DEPLOY.md Normal file
View File

@ -0,0 +1,118 @@
# Deploying Festival 4D to digalot.fyi/festifun
Target: `dealgod@100.94.195.115` (tailscale), served under `https://digalot.fyi/festifun` by the
existing web server that already terminates TLS for the domain.
The app runs as two pieces behind that web server:
- **Static SPA** — the built frontend (`frontend/dist`), served under `/festifun/`.
- **API backend** — uvicorn on `127.0.0.1:8000` (localhost-only), reached via `/festifun/api`
and `/festifun/media`.
This topology was validated locally end-to-end against a proxy that mirrors the nginx config in
this directory (static + prefix-stripping API/media proxy with Range) — the app loads, plays
video, and renders the 3D scene correctly under the `/festifun` prefix.
---
## Deploy mode: fully open
Per the owner's decision, **all features are public and unauthenticated** — playback, 3D, and the
mutating endpoints (annotate, correct event type, add/delete anchors). Be aware this means anyone
who finds the URL can edit or delete the project's annotations/anchors/events. If that becomes a
problem, add HTTP basic-auth on the `/festifun/` location, or run the backend read-only.
### AI classification & API keys — deliberately OFF on the public box
`POST /api/events/detect` runs ffmpeg and, if a classifier key is present, a paid AI call. To
avoid an unauthenticated public endpoint that spends real credits, **do not put `GEMINI_API_KEY`
or the OpenRouter creds on the server.** Instead bake the moment labels in *before* deploy:
```bash
# locally, with keys loaded (set -a; . ./.env; set +a):
python -m festival4d events # writes AI labels into data/project.db
```
Then ship that DB. On the server, `detect` still works but degrades to candidate-only (no spend),
exactly as designed. If you *want* live classification on the public site, set the key in the
systemd unit — but add rate-limiting first (nginx `limit_req`), or it's a bill-run-up vector.
---
## One-time server setup
```bash
# on dealgod@100.94.195.115
sudo mkdir -p /var/www/festifun
sudo chown dealgod:dealgod /var/www/festifun
# Python venv for the backend
python3 -m venv /var/www/festifun/.venv
```
## Build + push (run locally)
```bash
# 1. Build the frontend for the /festifun prefix + same-origin API
cd frontend
FESTIVAL4D_BASE=/festifun/ VITE_API_BASE=/festifun npm run build
# 2. Generate the project to ship (synthetic demo, or your real footage pipeline first),
# with labels baked in if you loaded keys:
cd ..
python -m festival4d synthetic # or: ingest -> sync -> reconstruct -> events
# 3. Push build + backend + data to the server
rsync -az --delete frontend/dist/ dealgod@100.94.195.115:/var/www/festifun/dist/
rsync -az --delete backend/ dealgod@100.94.195.115:/var/www/festifun/backend/
rsync -az pyproject.toml dealgod@100.94.195.115:/var/www/festifun/
rsync -az --delete data/ dealgod@100.94.195.115:/var/www/festifun/data/
```
## Install backend deps + service (on the server)
```bash
cd /var/www/festifun
.venv/bin/pip install -e ".[dev]" # or a runtime-only extra if defined
sudo cp backend/../deploy/festifun-api.service /etc/systemd/system/ # adjust path to the repo copy
sudo systemctl daemon-reload
sudo systemctl enable --now festifun-api
curl -s localhost:8000/api/health # -> {"status":"ok"}
```
## Wire up the web server
Append the blocks from `deploy/festifun.nginx.conf` into the existing `server { }` for
digalot.fyi, adjusting `alias` paths to `/var/www/festifun/dist/`. Then:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
Visit `https://digalot.fyi/festifun/`.
> Using Caddy instead of nginx? The equivalent is a `handle_path /festifun/*` block: `file_server`
> for the SPA, `reverse_proxy 127.0.0.1:8000` for `/festifun/api/*` and `/festifun/media/*`
> (Caddy strips the matched prefix with `handle_path`). Ask and I'll write the Caddyfile once I
> can see which server is actually running there.
---
## Updating later
Re-run the build + rsync steps, then `sudo systemctl restart festifun-api` (only needed if the
backend or data changed; a frontend-only change just needs the `dist` rsync).
## Notes / caveats
- **CORS** is irrelevant in this topology — everything is same-origin under `/festifun`. The
dev-only wide CORS in `config.py` stays as-is; it doesn't affect the hosted site.
- **Single project.** The app assumes one project at a time (SQLite at `data/project.db`).
- The backend binds `127.0.0.1` only; nginx is the sole public entry point.
---
## Live capture on the public box: leave it OFF
`FESTIVAL4D_CAPTURE` is **not** set in `deploy/festifun-api.service`, so `/capture` and
`/api/capture/*` are never mounted publicly — an open upload endpoint on a public host would let
anyone write files to the server. Capture is for your local/tailnet machine (see the README's
Capture section: `FESTIVAL4D_CAPTURE=1` + `tailscale serve`). Record locally, run the pipeline,
then deploy the resulting project.

View File

@ -0,0 +1,28 @@
# Festival 4D backend — systemd unit (uvicorn on 127.0.0.1:8000, localhost-only)
#
# Install: sudo cp deploy/festifun-api.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl enable --now festifun-api
# Logs: journalctl -u festifun-api -f
#
# The service binds 127.0.0.1 only — nginx is the sole public entry point. Adjust User,
# paths, and the venv location to match the server.
[Unit]
Description=Festival 4D API (uvicorn)
After=network.target
[Service]
Type=simple
User=dealgod
WorkingDirectory=/var/www/festifun/backend
# The app reads FESTIVAL4D_DATA_DIR for the project (db + media + point cloud).
Environment=FESTIVAL4D_DATA_DIR=/var/www/festifun/data
# NOTE: no GEMINI_API_KEY / OPENROUTER creds here on purpose — see DEPLOY.md "AI classification".
# Moment labels are baked into the shipped DB by running `events` locally before deploy, so the
# public box needs no keys and POST /api/events/detect degrades to candidates-only (no spend).
ExecStart=/var/www/festifun/.venv/bin/python -m uvicorn festival4d.api:app --host 127.0.0.1 --port 8000
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target

View File

@ -0,0 +1,46 @@
# Festival 4D — nginx location block for hosting under digalot.fyi/festifun
#
# Drop these `location` blocks inside the existing `server { }` for digalot.fyi (the one that
# already terminates TLS for the domain). The app is served entirely under the /festifun prefix:
# /festifun/ -> static SPA build (frontend/dist)
# /festifun/api/... -> uvicorn backend on 127.0.0.1:8000 (prefix stripped)
# /festifun/media/... -> uvicorn backend (video files, Range-capable)
#
# Adjust FESTIFUN_ROOT to wherever you rsync the build (see deploy/DEPLOY.md).
# --- static SPA (built with FESTIVAL4D_BASE=/festifun/) ---
location /festifun/ {
alias /var/www/festifun/dist/;
try_files $uri $uri/ /festifun/index.html; # SPA fallback
}
# bare /festifun -> /festifun/
location = /festifun {
return 301 /festifun/;
}
# --- API: strip the /festifun prefix, proxy to uvicorn ---
# The trailing slash on proxy_pass performs the prefix strip:
# /festifun/api/manifest -> http://127.0.0.1:8000/api/manifest
location /festifun/api/ {
proxy_pass http://127.0.0.1:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s; # POST /api/events/detect runs ffmpeg + (optional) an AI call
}
# --- media: video files, Range-capable (required for <video> seeking) ---
location /festifun/media/ {
proxy_pass http://127.0.0.1:8000/media/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Range $http_range; # forward Range for seeking
proxy_set_header If-Range $http_if_range;
proxy_force_ranges on;
}
# Optional, faster alternative for /festifun/media/: serve the files directly instead of
# proxying (uvicorn StaticFiles already does Range, but nginx is faster for large media):
# location /festifun/media/ { alias /var/www/festifun/data/raw/; }

77
docs/ideas.md Normal file
View File

@ -0,0 +1,77 @@
# Festival 4D — Future Ideas
Parking lot for post-v0.1.0 extensions (spec §5 future-extensions). Not committed work — a place
to capture direction so the prototype's scope stays honest. Roughly ordered by payoff-to-effort.
> **Status 2026-07-17 (evening):** splatting ✅ shipped (see `modelbeast-crossover.md`);
> spatial audio ✅ shipped (🎧, WebAudio master clock). Cinematic export ✅ **M12**,
> multi-modal audio analysis ✅ **M10/M11**, anchor manager ✅ **M13**, path persistence ✅
> **M16** — all SHIPPED in phase 5 (`plan/20-phase5.md`, suite floor 189). Still parked here:
> realtime ingest, object/artist tracking, per-project workspaces, sync-graph visualization,
> per-moment splats (crossover doc §3), and the new hardware ideas below.
## Neural rendering — Gaussian Splatting
Replace the sparse COLMAP point cloud with a **3D Gaussian Splatting** model for photorealistic
free-roam instead of a dot cloud. COLMAP poses + images already feed splatting trainers
(e.g. `gsplat`, Inria 3DGS) directly, so the reconstruction step's output is reusable. The viewer
would swap `Points` for a splat renderer (a WebGL splat viewer, or bake to a mesh). Biggest visual
upgrade; heaviest compute (needs a GPU training pass per project).
## Cinematic export — render the camera path to MP4
M9 already produces keyframed god's-eye paths. Add a headless render: step `t_global` at a fixed
fps, drive the Three.js camera along the path, capture frames (offscreen canvas → `captureStream`
or server-side `puppeteer` + `ffmpeg`), and mux to MP4. Turns the app from a viewer into a
"director's cut" clip exporter. Path JSON export/import is already in place, so this is additive.
## Real-time / streaming ingest
Instead of pre-recorded files, ingest live phone feeds (WebRTC/RTMP). Requires: rolling audio
sync on a sliding window (GCC-PHAT already windowed for drift — reuse it), incremental SfM or
pose tracking against a pre-built map, and a streaming transport in the frontend. Large effort;
the offline pipeline is the right foundation.
## Multi-modal audio analysis for moment detection
Today's `events` step finds candidates from RMS + spectral flux, then a VLM classifies video.
Add **audio-native** analysis in parallel: beat/tempo tracking, drop detection from
spectrograms, song-boundary segmentation. Fuse audio-derived and vision-derived labels for
higher-confidence events and beat-aligned auto camera cuts. Cheap; complements the existing
classifier providers.
## Object / artist tracking
Extend M8 (manual bbox → 3D anchor) with automatic per-frame tracking (e.g. a detector +
tracker), so an anchor follows the lead performer across time instead of being static. Enables
a "follow artist" auto-camera mode in the 3D viewer.
## Stereo / spatial-video ingest (split the eyes, pin the scale)
Spatial video (iPhone 15 Pro+ MV-HEVC, QooCam, Canon dual-fisheye) is two synced views with a
**factory-known baseline**. Ingest tweak: detect multi-view files and split L/R into two
pipeline videos (ffmpeg can demux the streams; identical offset, shared audio). Payoff beyond
one extra viewpoint: COLMAP reconstructions are scale-free, and a known stereo baseline anchors
**metric scale** for the whole scene — real-meter units for anchors, paths, and spatial audio
distances. Cheap ingest change, high leverage.
## Depth-camera seeding (small venues only)
RGB-D cams (ZED 2i, OAK-D, LiDAR phones) can seed/regularize splat training (depth-supervised
3DGS → fewer floaters, faster convergence) and densify the point cloud without SfM guessing.
Honest constraint: consumer depth dies past ~1015 m (LiDAR ~5 m), so this only pays at club /
small-venue range, not a festival main stage. Ingest: accept a per-video depth track or
per-frame depth PNGs alongside the RGB.
## Microcontroller rig kit (Arduino as crew, not camera)
An ESP32-CAM is not concert-grade — but a microcontroller on the rig earns its place three ways:
- **Sync/calibration beacon:** an LED blinking a known pattern visible to all cameras =
visual ground truth to validate the audio sync (and a free calibration target).
- **Servo pan/tilt or slider mount:** one slowly-sweeping fixed camera contributes rich
parallax from a single device; the per-frame pose track already handles moving cameras.
- **Record start/stop trigger** for the whole rig.
DIY camera *nodes* should be Raspberry Pi + **Global Shutter** camera module (kills
rolling-shutter wobble under fast stage lights) pushing into the existing `/capture` endpoint
(`FESTIVAL4D_CAPTURE=1`) — which was built for exactly this.
## Quality-of-life
- **Standalone anchor manager** — the delete UI currently lives in the event correction panel;
a dedicated always-available anchor list would decouple anchor management from events.
- **Persist camera paths server-side** — paths are JSON export/import only; a `paths` table +
endpoints would let them live with the project.
- **Per-project workspaces** — the app assumes one project at a time (SQLite at `data/project.db`).
- **Sync-graph visualization** — show which videos aligned into which connected component when
some clips share no audio.

View File

@ -0,0 +1,24 @@
# Festival 4D × MODELBEAST — crossover map
*2026-07-16. Both projects live on the same fleet (M3 Ultra 256GB primary, M1 Ultra 128GB, M4 Pro 24GB — see MODELBEAST's `CLUSTER.md`). They stay separate products — asset factory vs. experience viewer — but share infrastructure and pipelines.*
## Shipped: the splat upgrade (ideas.md #1) ✅
`docs/ideas.md`'s top item ("replace the sparse point cloud with 3D Gaussian Splatting") is now wired:
- **Backend:** `GET /api/splat` serves `data/work/splat.ply` when present; `/api/manifest` reports `has_splat`.
- **Viewer:** `scene3d.js` lazy-loads `@mkkellogg/gaussian-splats-3d` and renders the splat as a `DropInViewer` in the existing scene (camera rigs, anchors, and fly-paths overlay as before). Falls back to the point cloud when absent or on load error.
- **Training:** `scripts/splat_via_modelbeast.sh` sends this project's frames to the MODELBEAST queue (`colmap_poses` → `brush_train` on whichever GPU node is free) and drops the trained `.ply` into place. No local GPU blocking; the farm handles it.
## The bigger crossover map
1. **Fleet compute for every heavy step.** MODELBEAST's queue pools gpu (1 job/Mac) and cpu lanes across all three nodes. Festival 4D's COLMAP/splat/classification steps can submit there (`mb` CLI + token) instead of hogging the local machine.
2. **Poor-man's volumetric capture** (with CorridorKey): several phones film one subject on a green screen → *our GCC-PHAT audio sync* aligns them with no clapboard → CorridorKey (neural keyer, tuned on this fleet — see MODELBEAST's `CORRIDORKEY.md`) extracts true-edge mattes per synced frame → per-instant multi-view cutouts → 3D-gen or per-moment splats. DIY volumetric video from phones; every part exists in-house today.
3. **Per-moment splats = pragmatic "4D".** True temporal 4DGS has no credible Mac port yet (honest limit). But we already detect *moments*: pool all cameras' frames in a ±1s window around each moment → COLMAP → splat → one splat per moment → scrub between them on the timeline. Static splats, time-indexed — feels 4D where it counts. Caveat: needs decent multi-camera coverage; 3 fan phones is thin.
4. **Back-flow to MODELBEAST:** our audio-sync module is the missing piece for any multi-camera capture rig there; our moment detection could auto-pick hero frames for video→3D.
5. **GVM wildcard:** CorridorKey's ~80GB auto-matting module (untested on Metal, fits only the M3 256GB) would key *real concert footage* — performer/crowd separation without green screens — feeding use case 2 with actual festival material.
## Practical notes
- The `mb` CLI needs `MB_HOST` + `MB_TOKEN` (MODELBEAST Settings → Users). The M3 primary queue is at `100.89.131.57:8777` on the tailnet.
- Splat training cost: a ~40s 4K room clip → ~190 frames → COLMAP (~1 min, GLOMAP) → Brush 30k steps (~15 min on the M3). Concert scenes with crowds will be harder than rooms — expect floaters; SuperSplat can crop/clean.
- License note for use case 2: CorridorKey is CC BY-NC-SA (non-commercial).

View File

@ -5,39 +5,295 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Festival 4D</title>
<style>
:root { color-scheme: dark; }
:root {
color-scheme: dark;
--bg: #0b0d12;
--panel: #141824;
--panel-2: #1c2233;
--border: #232838;
--text: #e6e8ee;
--dim: #8a92a6;
--ok: #59d499;
--bad: #ff6b6b;
--accent: #59d499;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0b0d12;
color: #e6e8ee;
padding: 2rem;
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
overflow: hidden;
}
h1 { margin: 0 0 0.25rem; font-size: 1.6rem; }
.sub { color: #8a92a6; margin-bottom: 1.5rem; }
.card {
background: #141824;
border: 1px solid #232838;
border-radius: 10px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
max-width: 720px;
#app { display: flex; flex-direction: column; height: 100vh; }
/* Top bar */
#topbar {
display: flex; align-items: baseline; gap: 0.6rem;
padding: 0.5rem 0.9rem; border-bottom: 1px solid var(--border); flex: 0 0 auto;
}
.ok { color: #59d499; }
.bad { color: #ff6b6b; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid #232838; }
th { color: #8a92a6; font-weight: 600; }
code { background: #1c2233; padding: 0.1rem 0.35rem; border-radius: 4px; }
.pill { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 999px; font-size: 0.8rem; }
.pill.ok { background: rgba(89,212,153,0.15); }
.pill.bad { background: rgba(255,107,107,0.15); }
#topbar h1 { margin: 0; font-size: 1.05rem; font-weight: 700; letter-spacing: 0.2px; }
#topbar .sub { color: var(--dim); font-size: 0.8rem; }
/* Stage: 3D on the left, video grid on the right */
#stage { flex: 1 1 auto; display: flex; min-height: 0; }
#scene-pane { flex: 1 1 55%; position: relative; min-width: 0; background: #0a0c11; }
#scene3d { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#scene-controls {
position: absolute; top: 0.6rem; left: 0.6rem; display: flex; gap: 0.4rem; z-index: 5;
align-items: center; flex-wrap: wrap; max-width: calc(100% - 260px);
}
#scene-controls .btn { padding: 0.25rem 0.5rem; font-size: 0.78rem; }
#key-count { color: var(--accent); font-variant-numeric: tabular-nums; }
.ctl-sep { width: 1px; height: 18px; background: var(--border); margin: 0 0.15rem; }
#scene-hint {
position: absolute; bottom: 0.5rem; left: 0.6rem; color: var(--dim);
font-size: 0.72rem; z-index: 5; pointer-events: none;
background: rgba(10,12,17,0.6); padding: 0.2rem 0.45rem; border-radius: 5px;
}
#grid-pane {
flex: 1 1 45%; min-width: 0; border-left: 1px solid var(--border);
overflow: auto; background: #0d1017;
}
#video-grid {
display: grid; gap: 6px; padding: 6px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.cell {
position: relative; aspect-ratio: 16 / 9; background: #000;
border: 1px solid var(--border); border-radius: 8px; overflow: hidden;
transition: opacity 0.15s ease, border-color 0.15s ease;
}
.cell.out { opacity: 0.32; }
.cell.audio-on { border-color: var(--accent); }
.cell.followed { box-shadow: inset 0 0 0 2px var(--accent); }
.cell-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.cell-overlay { position: absolute; inset: 0; pointer-events: none; }
.cell-bar {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center;
gap: 0.35rem; padding: 0.25rem 0.4rem; font-size: 0.72rem;
background: linear-gradient(transparent, rgba(6,8,14,0.85));
}
.cell-label { font-weight: 600; }
.cell-status { color: var(--dim); margin-left: 0.1rem; }
.cell-status.ok { color: var(--ok); }
.cell-status.bad { color: var(--bad); }
.cell-btn {
margin-left: auto; background: rgba(255,255,255,0.08); color: var(--text);
border: 1px solid var(--border); border-radius: 5px; cursor: pointer;
width: 22px; height: 20px; font-size: 0.72rem; line-height: 1; padding: 0;
}
.cell-btn + .cell-btn { margin-left: 0.25rem; }
.cell-btn:hover { background: rgba(255,255,255,0.16); }
.cell.audio-on .audio-btn { background: var(--accent); color: #071018; border-color: var(--accent); }
/* Bottom bar: transport + timeline */
#bottombar {
flex: 0 0 auto; border-top: 1px solid var(--border); padding: 0.5rem 0.9rem 0.7rem;
background: var(--panel);
}
#controls { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.btn {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; cursor: pointer; padding: 0.3rem 0.6rem; font-size: 0.85rem;
}
.btn:hover { background: #262d42; }
.btn.active { background: var(--accent); color: #071018; border-color: var(--accent); }
#btn-play { min-width: 2.4rem; font-size: 1rem; }
#time-display { font-variant-numeric: tabular-nums; color: var(--dim); margin-left: 0.2rem; }
select.btn { padding-right: 0.4rem; }
.spacer { flex: 1 1 auto; }
.ctl-label { color: var(--dim); font-size: 0.78rem; }
/* Timeline */
#timeline { position: relative; }
.tl-track {
position: relative; height: 26px; background: var(--panel-2);
border: 1px solid var(--border); border-radius: 6px; cursor: pointer; overflow: visible;
touch-action: none;
}
.tl-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0;
background: rgba(89,212,153,0.14); border-radius: 6px 0 0 6px; }
.tl-markers { position: absolute; inset: 0; }
.tl-marker {
position: absolute; top: 0; bottom: 0; width: 3px; margin-left: -1.5px;
border-radius: 2px; cursor: pointer; box-shadow: 0 0 0 1px rgba(0,0,0,0.35);
}
.tl-marker:hover { width: 5px; margin-left: -2.5px; }
.tl-playhead {
position: absolute; top: -3px; bottom: -3px; width: 2px; margin-left: -1px;
background: #fff; box-shadow: 0 0 6px rgba(255,255,255,0.6); pointer-events: none;
}
.tl-tooltip {
position: absolute; bottom: 34px; transform: translateX(-50%);
background: #0f1420; border: 1px solid var(--border); border-radius: 7px;
padding: 0.4rem 0.55rem; font-size: 0.78rem; max-width: 260px; z-index: 20;
pointer-events: none; box-shadow: 0 6px 20px rgba(0,0,0,0.5);
}
.tl-tooltip strong { text-transform: capitalize; }
.tl-conf { color: var(--dim); font-size: 0.72rem; }
.tl-legend {
display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.45rem;
font-size: 0.72rem; color: var(--dim);
}
.tl-legend-item { display: inline-flex; align-items: center; gap: 0.3rem; text-transform: capitalize; }
.tl-legend-item i { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
/* Dev sync overlay */
#dev-overlay {
position: fixed; top: 0.6rem; right: 0.6rem; z-index: 30;
background: rgba(15,20,32,0.9); border: 1px solid var(--border); border-radius: 8px;
padding: 0.45rem 0.6rem; font-size: 0.72rem; min-width: 132px;
backdrop-filter: blur(4px);
}
#dev-overlay .hd { color: var(--dim); font-weight: 600; margin-bottom: 0.3rem; }
.dev-row { display: flex; justify-content: space-between; gap: 0.8rem; font-variant-numeric: tabular-nums; }
.ok { color: var(--ok); }
.bad { color: var(--bad); }
.dim { color: var(--dim); }
/* M8 annotation correction panel */
#annot-panel {
position: absolute; top: 0.6rem; right: 0.6rem; width: 232px; z-index: 8;
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);
}
.ap-head {
display: flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.55rem;
border-bottom: 1px solid var(--border);
}
.ap-dot { width: 9px; height: 9px; border-radius: 3px; flex: 0 0 auto; }
.ap-head strong { flex: 1 1 auto; font-size: 0.8rem; }
.ap-x {
background: none; border: none; color: var(--dim); cursor: pointer; font-size: 0.85rem;
padding: 0 0.2rem;
}
.ap-x:hover { color: var(--text); }
.ap-body { padding: 0.5rem 0.55rem 0.6rem; display: flex; flex-direction: column; gap: 0.45rem; }
.ap-row { display: flex; align-items: center; justify-content: space-between; gap: 0.4rem; color: var(--dim); }
.ap-type { flex: 1 1 auto; margin-left: 0.4rem; background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 5px; padding: 0.2rem 0.3rem; text-transform: capitalize; }
.ap-desc { color: var(--text); line-height: 1.35; }
.ap-meta { text-transform: capitalize; }
.ap-annotate { width: 100%; text-align: center; }
.ap-hint { line-height: 1.35; min-height: 1.1em; }
.ap-anchors { border-top: 1px solid var(--border); margin-top: 0.15rem; padding-top: 0.4rem; }
.ap-anchors-hd { font-size: 0.72rem; margin-bottom: 0.25rem; }
.ap-anchors-list { max-height: 132px; overflow-y: auto; display: flex; flex-direction: column; gap: 0.15rem; }
.ap-anchors-empty { font-size: 0.74rem; }
.ap-anchor-row { display: flex; align-items: center; gap: 0.35rem; font-size: 0.74rem; }
.ap-anchor-dot { width: 8px; height: 8px; border-radius: 3px; flex: 0 0 auto; }
.ap-anchor-label { flex: 1 1 auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ap-anchor-del {
background: none; border: none; color: var(--dim); cursor: pointer; font-size: 0.75rem;
padding: 0 0.15rem; flex: 0 0 auto;
}
.ap-anchor-del:hover { color: var(--bad); }
.annot-rect {
position: absolute; border: 1.5px solid var(--accent); background: rgba(89,212,153,0.14);
pointer-events: none; z-index: 6; border-radius: 2px;
}
/* Capsule mode (M17, phase-5 contract #5): a static baked bundle has no write API, so
everything marked .write-ui disappears when main.js sets body.capsule from the manifest. */
body.capsule .write-ui { display: none !important; }
/* Loading / error */
#loading {
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--bg); z-index: 100; color: var(--dim);
}
#loading .err { text-align: center; color: var(--text); }
code { background: var(--panel-2); padding: 0.1rem 0.35rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>Festival 4D</h1>
<div class="sub">Foundation hello page — proves the API contract, CORS, and the frozen pose helper.</div>
<div id="app"><div class="card">Loading…</div></div>
<div id="app">
<div id="topbar">
<h1>Festival 4D</h1>
<span class="sub">synchronized multi-cam replay · 3D viewer · x-ray overlays</span>
<span class="spacer"></span>
<!-- shown only when the backend has capture enabled (FESTIVAL4D_CAPTURE=1) -->
<a id="capture-link" class="btn" style="display:none; text-decoration:none"
title="Record from webcams / phones into this project" target="_blank">📹 Capture</a>
</div>
<div id="stage">
<div id="scene-pane">
<canvas id="scene3d"></canvas>
<div id="scene-controls">
<button class="btn active" id="btn-roam" title="Free roam (Esc)">Free roam</button>
<span class="ctl-sep"></span>
<button class="btn" id="btn-key" title="Add camera keyframe here (K)"> Key <b id="key-count">0</b></button>
<button class="btn" id="btn-path" title="Play the keyframed camera path">▶ Path</button>
<button class="btn" id="btn-path-export" title="Export path as JSON"></button>
<button class="btn" id="btn-path-import" title="Import path JSON"></button>
<button class="btn" id="btn-path-clear" title="Clear all keyframes"></button>
<!-- Phase 5: hidden until the owning lane's module reports ready (plan/20-phase5.md) -->
<button class="btn" id="btn-director" style="display:none"
title="Auto-director: build a camera path from the detected events (M11)">🎬 Auto-path</button>
<button class="btn write-ui" id="btn-path-save" style="display:none"
title="Save the current camera path to the project (M16)">💾</button>
<select class="btn" id="path-load-select" style="display:none"
title="Load a saved camera path (M16)"></select>
<button class="btn write-ui" id="btn-path-del" style="display:none"
title="Delete the selected saved path (M16)">🗑</button>
<span class="ctl-sep"></span>
<button class="btn" id="btn-spatial" style="display:none"
title="Spatial audio: hear every camera from its position in the scene">🎧 3D audio</button>
<button class="btn" id="btn-export" style="display:none"
title="Export the camera path as a cinematic webm (M12)">⏺ Export</button>
<button class="btn" id="btn-photo" style="display:none"
title="Photo mode: hi-res still of the 3D scene (M14, P)">📷</button>
<button class="btn" id="btn-fx" style="display:none"
title="Moment FX: bursts and pulses on event markers (M15)">✨</button>
<button class="btn" id="btn-anchors" style="display:none"
title="Anchors & friend tags (M13)">⚓</button>
<button class="btn" id="btn-friends" style="display:none"
title="Friend tracks: ribbons, moving labels, follow-a-friend (M21/M22)">👣 Friends</button>
<input type="file" id="path-file" accept="application/json,.json" hidden />
</div>
<div id="scene-hint">drag to orbit · 19 snap to camera · K add keyframe · Esc detach</div>
<div id="annot-panel" class="write-ui"></div>
<!-- M13 anchor manager mounts here (lane F); hidden until anchorPanel.js is filled -->
<div id="anchor-panel" style="display:none"></div>
<!-- M21 friends panel mounts here (lane J); hidden until friendTracks.js is filled -->
<div id="friends-panel" style="display:none"></div>
</div>
<div id="grid-pane">
<div id="video-grid"></div>
</div>
</div>
<div id="bottombar">
<div id="controls">
<button class="btn" id="btn-back" title="Back 1s (←)"></button>
<button class="btn" id="btn-play" title="Play/Pause (Space)"></button>
<button class="btn" id="btn-fwd" title="Forward 1s (→)"></button>
<span id="time-display">0:00.0 / 0:00.0</span>
<span class="spacer"></span>
<span class="ctl-label">speed</span>
<select class="btn" id="rate-select">
<option value="0.25">0.25×</option>
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
</select>
</div>
<div id="timeline"></div>
</div>
</div>
<div id="dev-overlay">
<div class="hd">sync error</div>
<div id="dev-rows"></div>
</div>
<div id="loading">Loading project…</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -8,6 +8,7 @@
"name": "festival4d-frontend",
"version": "0.1.0",
"dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"three": "^0.170.0"
},
"devDependencies": {
@ -456,6 +457,15 @@
"node": ">=18"
}
},
"node_modules/@mkkellogg/gaussian-splats-3d": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz",
"integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.160.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",

View File

@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"three": "^0.170.0"
},
"devDependencies": {

332
frontend/src/anchorPanel.js Normal file
View File

@ -0,0 +1,332 @@
// Anchor manager & friend tags (spec M13, lane F).
//
// 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).
//
// 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,
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,6 +1,313 @@
// bbox drawing + submission → POST /api/annotations (spec M8).
// STUB — this is phase-3 (integration) work; lane C leaves it as a clean seam. Drag a
// rectangle on a video's overlay canvas → normalized bbox → POST /api/annotations; the
// resolved anchor then appears live in overlays + the 3D scene.
// Correction panel + bbox annotation → 3D (spec M8, integration phase).
//
// Clicking a timeline event opens a panel: it shows the classification, a dropdown to correct
// the event_type (PATCH /api/events/{id}, source→'user'), and an "annotate location" mode.
// In annotate mode the user drags a rectangle on ANY video's overlay; the normalized bbox is
// POSTed to /api/annotations. The backend casts a ray through the bbox center and resolves a
// 3D point (triangulate across two views of the same event, else nearest point-cloud point /
// centroid-depth ray). The resolved anchor is pushed live into state so overlays + the 3D
// scene show it immediately.
export {};
import { state, emit, API_BASE } from "./state.js";
import { contentRect } from "./videoGrid.js";
import { eventColor } from "./timeline.js";
const EVENT_TYPES = [
"bass_drop", "pyro", "confetti", "crowd_wave",
"artist_moment", "light_show", "quiet_moment", "candidate", "other",
];
export class Annotator {
constructor() {
this.panel = null;
this.cells = [];
this.currentEvent = null;
this.annotating = false;
this._rubber = null; // { cell, div, sx, sy }
this._cleanup = [];
}
init(panelEl, cells) {
this.panel = panelEl;
this.cells = cells;
this.panel.style.display = "none";
}
/** Open the correction panel for a timeline event. */
openForEvent(ev) {
this.currentEvent = ev;
this.stopAnnotateMode();
this._render();
this.panel.style.display = "block";
}
close() {
this.stopAnnotateMode();
this.currentEvent = null;
if (this.panel) this.panel.style.display = "none";
}
_render() {
const ev = this.currentEvent;
if (!ev) return;
const options = EVENT_TYPES.map(
(t) => `<option value="${t}"${t === ev.event_type ? " selected" : ""}>${t.replace(/_/g, " ")}</option>`
).join("");
this.panel.innerHTML = `
<div class="ap-head">
<span class="ap-dot" style="background:${eventColor(ev.event_type)}"></span>
<strong>Event @ ${ev.t_global_s.toFixed(1)}s</strong>
<button class="ap-x" title="Close"></button>
</div>
<div class="ap-body">
<label class="ap-row">type
<select class="ap-type">${options}</select>
</label>
<div class="ap-desc">${ev.description || "<span class='dim'>no description</span>"}</div>
<div class="ap-meta dim">${ev.source}${ev.confidence != null ? ` · ${(ev.confidence * 100).toFixed(0)}%` : ""}</div>
<button class="ap-annotate btn">📍 Annotate location</button>
<div class="ap-hint dim"></div>
<div class="ap-anchors">
<div class="ap-anchors-hd dim">anchors <span class="ap-anchors-n"></span></div>
<div class="ap-anchors-list"></div>
</div>
</div>`;
this.panel.querySelector(".ap-x").addEventListener("click", () => this.close());
this.panel.querySelector(".ap-type").addEventListener("change", (e) =>
this._correctType(e.target.value)
);
this.panel.querySelector(".ap-annotate").addEventListener("click", () =>
this.annotating ? this.stopAnnotateMode() : this.startAnnotateMode()
);
this._renderAnchorList();
}
/** Render the deletable anchor list in the panel (M8 / Phase 4). */
_renderAnchorList() {
const list = this.panel?.querySelector(".ap-anchors-list");
const n = this.panel?.querySelector(".ap-anchors-n");
if (!list) return;
if (n) n.textContent = `(${state.anchors.length})`;
if (state.anchors.length === 0) {
list.innerHTML = `<div class="dim ap-anchors-empty">none yet</div>`;
return;
}
list.innerHTML = state.anchors
.map(
(a) =>
`<div class="ap-anchor-row" data-id="${a.id}">` +
`<span class="ap-anchor-dot" style="background:${a.color || "#59d499"}"></span>` +
`<span class="ap-anchor-label" title="${a.label || ""}">${a.label || "(unnamed)"}</span>` +
`<button class="ap-anchor-del" title="Delete anchor">✕</button></div>`
)
.join("");
for (const row of list.querySelectorAll(".ap-anchor-row")) {
const id = Number(row.dataset.id);
row.querySelector(".ap-anchor-del").addEventListener("click", () => this._deleteAnchor(id));
}
}
async _deleteAnchor(id) {
try {
const resp = await fetch(`${API_BASE}/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); // scene3d refresh; overlays read state.anchors each frame
this._renderAnchorList();
} catch (err) {
this._hint(`<span class="bad">delete failed: ${err.message}</span>`);
}
}
_hint(msg) {
const h = this.panel?.querySelector(".ap-hint");
if (h) h.innerHTML = msg;
}
async _correctType(newType) {
const ev = this.currentEvent;
if (!ev) return;
try {
const resp = await fetch(`${API_BASE}/api/events/${ev.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ event_type: newType }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const updated = await resp.json();
// Reflect into state + timeline marker color.
const i = state.events.findIndex((e) => e.id === ev.id);
if (i >= 0) state.events[i] = updated;
this.currentEvent = updated;
emit("events-changed", updated);
this._hint(`corrected → <b>${newType.replace(/_/g, " ")}</b> (source: user)`);
} catch (err) {
this._hint(`<span class="bad">correction failed: ${err.message}</span>`);
}
}
startAnnotateMode() {
this.annotating = true;
const btn = this.panel?.querySelector(".ap-annotate");
if (btn) {
btn.textContent = "✓ Done annotating";
btn.classList.add("active");
}
this._hint("drag a box on any video over the object. Repeat on a 2nd video to triangulate.");
for (const cell of this.cells) {
cell.canvas.style.pointerEvents = "auto";
cell.canvas.style.cursor = "crosshair";
const down = (e) => this._onDown(cell, e);
cell.canvas.addEventListener("pointerdown", down);
this._cleanup.push(() => cell.canvas.removeEventListener("pointerdown", down));
}
}
stopAnnotateMode() {
this.annotating = false;
const btn = this.panel?.querySelector(".ap-annotate");
if (btn) {
btn.textContent = "📍 Annotate location";
btn.classList.remove("active");
}
for (const cell of this.cells) {
cell.canvas.style.pointerEvents = "none";
cell.canvas.style.cursor = "";
}
this._teardownRubber();
for (const fn of this._cleanup) fn();
this._cleanup = [];
}
_onDown(cell, e) {
e.preventDefault();
cell.canvas.setPointerCapture(e.pointerId);
const rect = cell.el.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const div = document.createElement("div");
div.className = "annot-rect";
cell.el.appendChild(div);
this._rubber = { cell, div, sx, sy, rect };
this._positionRubber(sx, sy);
const move = (ev) => this._onMove(ev);
const up = (ev) => this._onUp(ev, move, up);
cell.canvas.addEventListener("pointermove", move);
cell.canvas.addEventListener("pointerup", up);
cell.canvas.addEventListener("pointercancel", up);
}
_positionRubber(cx, cy) {
const r = this._rubber;
if (!r) return;
const x0 = Math.min(r.sx, cx);
const y0 = Math.min(r.sy, cy);
r.div.style.left = `${x0}px`;
r.div.style.top = `${y0}px`;
r.div.style.width = `${Math.abs(cx - r.sx)}px`;
r.div.style.height = `${Math.abs(cy - r.sy)}px`;
}
_onMove(e) {
const r = this._rubber;
if (!r) return;
const rect = r.cell.el.getBoundingClientRect();
this._positionRubber(e.clientX - rect.left, e.clientY - rect.top);
}
async _onUp(e, move, up) {
const r = this._rubber;
if (!r) return;
const { cell } = r;
cell.canvas.removeEventListener("pointermove", move);
cell.canvas.removeEventListener("pointerup", up);
cell.canvas.removeEventListener("pointercancel", up);
const rect = cell.el.getBoundingClientRect();
const ex = e.clientX - rect.left;
const ey = e.clientY - rect.top;
const bbox = this._toNormalizedBbox(cell, r.sx, r.sy, ex, ey);
this._teardownRubber();
if (!bbox) {
this._hint('<span class="bad">draw the box over the video content area.</span>');
return;
}
await this._submit(cell, bbox);
}
_teardownRubber() {
if (this._rubber?.div?.parentNode) this._rubber.div.parentNode.removeChild(this._rubber.div);
this._rubber = null;
}
/** Element-px rectangle → normalized [0,1] bbox in the video's CONTENT space (letterbox-aware). */
_toNormalizedBbox(cell, ax, ay, bx, by) {
const cr = contentRect(cell.video);
const nx = (v) => (v - cr.ox) / cr.cw;
const ny = (v) => (v - cr.oy) / cr.ch;
let x0 = nx(Math.min(ax, bx));
let y0 = ny(Math.min(ay, by));
let x1 = nx(Math.max(ax, bx));
let y1 = ny(Math.max(ay, by));
x0 = Math.max(0, Math.min(1, x0));
y0 = Math.max(0, Math.min(1, y0));
x1 = Math.max(0, Math.min(1, x1));
y1 = Math.max(0, Math.min(1, y1));
// A near-zero drag (a click) becomes a tiny box centered on the point.
if (x1 - x0 < 1e-4 && y1 - y0 < 1e-4) {
const cx = (x0 + x1) / 2;
const cy = (y0 + y1) / 2;
x0 = Math.max(0, cx - 0.01); x1 = Math.min(1, cx + 0.01);
y0 = Math.max(0, cy - 0.01); y1 = Math.min(1, cy + 0.01);
}
if (x1 <= x0 || y1 <= y0) return null;
return [x0, y0, x1, y1];
}
async _submit(cell, bbox) {
const ev = this.currentEvent;
this._hint("resolving 3D position…");
try {
const resp = await fetch(`${API_BASE}/api/annotations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_id: cell.id,
t_video_s: cell.video.currentTime,
bbox,
event_id: ev ? ev.id : null,
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const res = await resp.json();
if (res.anchor) {
// Upsert: a supersede returns an EXISTING anchor id (moved in place) — replace, don't
// duplicate. A fresh annotation returns a new id — append.
const i = state.anchors.findIndex((a) => a.id === res.anchor.id);
if (i >= 0) state.anchors[i] = res.anchor;
else state.anchors.push(res.anchor);
emit("anchors-changed", res.anchor);
this._renderAnchorList();
const how =
res.method === "triangulated"
? `triangulated (gap ${res.gap != null ? res.gap.toFixed(2) : "?"})`
: res.method === "nearest_point"
? "nearest point-cloud point"
: "ray at scene depth";
const note = res.superseded
? "refined the anchor in place."
: "Annotate another view to refine.";
this._hint(`anchor placed via <b>${how}</b>. ${note}`);
} else {
this._hint('<span class="bad">could not resolve a 3D point (no poses?).</span>');
}
} catch (err) {
this._hint(`<span class="bad">annotation failed: ${err.message}</span>`);
}
}
}
export const annotator = new Annotator();

View File

@ -1,6 +1,167 @@
// Keyframed camera paths (spec M9).
// STUB — this is phase-3 (integration) work; lane C leaves it as a clean seam. "Add
// keyframe" captures the current free-roam pose at the current t_global; playback
// interpolates position (Catmull-Rom) + orientation (slerp); export/import path as JSON.
// Keyframed "god's-eye" camera paths (spec M9, integration phase).
//
// "Add keyframe" captures the current free-roam camera pose at the current t_global. Keyframes
// render as octahedron gizmos with a smooth preview line. On playback (while the transport
// plays) the viewer camera follows the path: position is interpolated with a Catmull-Rom curve
// through the keyframe centers, orientation with quaternion slerp, both parameterized by
// t_global between the first and last keyframe. Paths export/import as JSON.
export {};
import * as THREE from "three";
import { state } from "./state.js";
import { scene3d } from "./scene3d.js";
const _qa = new THREE.Quaternion();
const _qb = new THREE.Quaternion();
const _pos = new THREE.Vector3();
export class CamPath {
constructor() {
this.keyframes = []; // [{ t_global, pos:[x,y,z], quat:[x,y,z,w], fov }]
this.playing = false;
this.group = null; // gizmos + preview line
this._curve = null;
this.onChange = null; // optional UI callback (count)
}
_ensureGroup() {
if (!this.group) {
this.group = new THREE.Group();
scene3d.addObject(this.group);
scene3d.registerHelper(this.group); // keyframe gizmos hide with the helpers (M14)
}
}
/** Capture the current viewer-camera pose at the current master time. */
addKeyframe() {
const cam = scene3d.camera;
if (!cam) return;
this.keyframes.push({
t_global: state.tGlobal,
pos: [cam.position.x, cam.position.y, cam.position.z],
quat: [cam.quaternion.x, cam.quaternion.y, cam.quaternion.z, cam.quaternion.w],
fov: cam.fov,
});
this.keyframes.sort((a, b) => a.t_global - b.t_global);
this._rebuild();
}
clear() {
this.stop();
this.keyframes = [];
this._rebuild();
}
count() {
return this.keyframes.length;
}
_rebuild() {
this._ensureGroup();
for (const child of [...this.group.children]) {
this.group.remove(child);
child.geometry?.dispose?.();
child.material?.dispose?.();
}
this._curve = null;
for (const k of this.keyframes) {
const giz = new THREE.Mesh(
new THREE.OctahedronGeometry(0.16),
new THREE.MeshBasicMaterial({ color: 0xffd23b, wireframe: true })
);
giz.position.set(k.pos[0], k.pos[1], k.pos[2]);
this.group.add(giz);
}
if (this.keyframes.length >= 2) {
this._curve = new THREE.CatmullRomCurve3(
this.keyframes.map((k) => new THREE.Vector3(k.pos[0], k.pos[1], k.pos[2]))
);
const pts = this._curve.getPoints(Math.max(20, this.keyframes.length * 12));
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color: 0xffd23b, transparent: true, opacity: 0.6 })
);
this.group.add(line);
}
this.onChange?.(this.keyframes.length);
}
/** Begin following the path (requires >= 2 keyframes). Caller should also start the transport. */
play() {
if (this.keyframes.length < 2) return false;
this.playing = true;
scene3d.enterPathMode();
return true;
}
stop() {
if (!this.playing) return;
this.playing = false;
scene3d.exitPathMode();
}
/** Per-frame: if playing, drive the viewer camera from the path at the current master time. */
update(tGlobal) {
if (!this.playing) return;
// If something else took the camera (snap-to-camera exits path mode), stop driving it.
if (!scene3d.pathMode) {
this.playing = false;
this.onChange?.(this.keyframes.length);
return;
}
if (!this._curve || this.keyframes.length < 2) return;
const kf = this.keyframes;
const n = kf.length;
const t0 = kf[0].t_global;
const t1 = kf[n - 1].t_global;
// Locate the bracketing keyframes by time; clamp outside the path's time span.
let i = 0;
if (tGlobal <= t0) {
i = 0;
} else if (tGlobal >= t1) {
i = n - 2;
} else {
while (i < n - 2 && kf[i + 1].t_global <= tGlobal) i++;
}
const a = kf[i];
const b = kf[i + 1];
const span = b.t_global - a.t_global;
let f = span > 1e-6 ? (tGlobal - a.t_global) / span : 0;
f = Math.max(0, Math.min(1, f));
// Position: Catmull-Rom, parameterized by (segment index + local fraction) / (n - 1).
const u = Math.max(0, Math.min(1, (i + f) / (n - 1)));
this._curve.getPoint(u, _pos);
// Orientation: slerp the two bracketing keyframe quaternions.
_qa.set(a.quat[0], a.quat[1], a.quat[2], a.quat[3]);
_qb.set(b.quat[0], b.quat[1], b.quat[2], b.quat[3]);
_qa.slerp(_qb, f);
const fov = a.fov + (b.fov - a.fov) * f;
scene3d.applyCameraPose(_pos, _qa, fov);
}
toJSON() {
return JSON.stringify({ version: 1, keyframes: this.keyframes }, null, 2);
}
fromJSON(text) {
const data = typeof text === "string" ? JSON.parse(text) : text;
if (!data || !Array.isArray(data.keyframes)) throw new Error("invalid camera-path file");
this.keyframes = data.keyframes
.filter((k) => Array.isArray(k.pos) && Array.isArray(k.quat))
.map((k) => ({
t_global: +k.t_global || 0,
pos: k.pos.slice(0, 3).map(Number),
quat: k.quat.slice(0, 4).map(Number),
fov: +k.fov || 50,
}))
.sort((a, b) => a.t_global - b.t_global);
this._rebuild();
}
}
export const camPath = new CamPath();

70
frontend/src/director.js Normal file
View File

@ -0,0 +1,70 @@
// Auto-director UI (spec M11, lane E).
//
// Pre-wired by foundation2: the hidden 🎬 button (#btn-director in index.html) and this
// module's import + init() call in main.js. This module wires the click: POST /api/director
// -> the response is ALWAYS a valid camPath JSON (frozen contract #2) -> load it with
// camPath.fromJSON and play it via the existing #btn-path flow (never edit camPath.js).
// Zero keyframes means "no events / no poses yet" — surfaced on the button, never a crash
// (pitfall #5). Failures degrade the same way; the button always recovers.
import { state } from "./state.js";
import { camPath } from "./camPath.js";
const BTN_LABEL = "🎬 Auto-path";
let flashTimer = null;
/** Show a transient message on the button, then restore the label. */
function flash(btn, text, ms = 2200) {
btn.textContent = text;
clearTimeout(flashTimer);
flashTimer = setTimeout(() => (btn.textContent = BTN_LABEL), ms);
}
async function requestPath(btn) {
btn.disabled = true;
btn.textContent = "🎬 …";
try {
const resp = await fetch(`${state.apiBase}/api/director`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}), // server defaults: top_n=8, lead_s=2.0
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const path = await resp.json();
if (!Array.isArray(path.keyframes) || path.keyframes.length === 0) {
// Valid-empty path: no events/poses yet (or still stubbed). Surface, don't crash.
console.info("[director] empty path from /api/director", path.note ?? "");
flash(btn, "🎬 no events yet");
return;
}
camPath.fromJSON(path); // replaces any existing keyframes; updates the Key counter
flash(btn, `🎬 ${path.keyframes.length} keys`);
// Play through the existing #btn-path flow so its UI state (⏹ label, transport
// auto-start) stays consistent with a manually loaded path.
const playBtn = document.getElementById("btn-path");
if (playBtn && !camPath.playing && path.keyframes.length >= 2) playBtn.click();
} catch (err) {
console.error("[director] /api/director failed", err);
flash(btn, "🎬 error — see console");
} finally {
btn.disabled = false;
}
}
export const director = {
ready: false,
init() {
const btn = document.getElementById("btn-director");
if (!btn) return; // markup missing: stay hidden, degrade silently
btn.addEventListener("click", () => {
if (btn.disabled) return;
requestPath(btn);
});
btn.style.display = ""; // unhide: this lane has landed
this.ready = true;
},
};

211
frontend/src/exportVideo.js Normal file
View File

@ -0,0 +1,211 @@
// Cinematic export (spec M12, lane E).
//
// Pre-wired by foundation2: the hidden ⏺ button (#btn-export in index.html), this module's
// import + init() call in main.js, and the transport hooks transport.captureAudioStream()
// (a MediaStream tapping the live WebAudio mix — post-gain, spatial included) +
// transport.releaseAudioStream() when done.
//
// Flow: seek to the first camPath keyframe, play the path via the existing #btn-path flow
// (so its UI state stays truthful), record scene3d's canvas captureStream(30) muxed with the
// audio tap through MediaRecorder (vp9/opus webm, fallbacks below), stop at the last
// keyframe, download `festival4d-cut.webm`, then restore ALL prior UI/transport state.
// Export runs at rate 1 regardless of the user's speed (the recording is wall-clock, so the
// file duration must equal the path's time span); the prior rate is restored afterwards.
// Degradation: fewer than 2 keyframes -> button disabled; no decoded WebAudio track -> the
// tap is silent but the export still works (pitfall #5); clicking ⏺ mid-export stops early
// and still produces a valid file. NOTE (pitfall #2): canvas.captureStream only delivers
// real frames in a FOCUSED tab — a hidden/backgrounded pane records a frozen image.
import { state } from "./state.js";
import { transport } from "./transport.js";
import { camPath } from "./camPath.js";
import { scene3d } from "./scene3d.js";
const BTN_LABEL = "⏺ Export";
const MIME_CANDIDATES = [
"video/webm;codecs=vp9,opus", // the spec'd target
"video/webm;codecs=vp8,opus",
"video/webm",
];
function pickMime() {
if (typeof MediaRecorder === "undefined") return null;
return MIME_CANDIDATES.find((m) => MediaRecorder.isTypeSupported(m)) ?? null;
}
function download(blob, filename) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
/** Toggle the shared #btn-path flow (main.js owns its UI state) into the desired state. */
function setPathPlaying(playing) {
if (camPath.playing === playing) return;
document.getElementById("btn-path")?.click();
}
export const exportVideo = {
ready: false,
exporting: false,
_btn: null,
_recorder: null,
_chunks: [],
_canvasStream: null,
_endT: 0,
_prior: null,
_finishing: false,
init() {
const btn = document.getElementById("btn-export");
if (!btn) return; // markup missing: stay hidden, degrade silently
this._btn = btn;
btn.addEventListener("click", () => {
if (this.exporting) this._finish("stopped early");
else this._start();
});
// Enabled only with a playable path (>= 2 keyframes). main.js assigned camPath.onChange
// before lane modules init, so chain it rather than replace it (camPath.js is frozen).
const chained = camPath.onChange;
camPath.onChange = (n) => {
chained?.(n);
if (!this.exporting) btn.disabled = n < 2;
};
btn.disabled = camPath.count() < 2;
btn.style.display = ""; // unhide: this lane has landed
this.ready = true;
},
_start() {
if (this.exporting || camPath.keyframes.length < 2) return;
const mime = pickMime();
if (!mime) {
console.error("[export] MediaRecorder/webm unsupported in this browser");
this._flash("⏺ unsupported");
return;
}
const kf = camPath.keyframes; // sorted by t_global (camPath invariant)
const startT = kf[0].t_global;
this._endT = kf[kf.length - 1].t_global;
// Snapshot everything we mutate, so _finish can restore it exactly.
this._prior = {
tGlobal: state.tGlobal,
playing: state.playing,
rate: state.rate,
pathPlaying: camPath.playing,
followCameraId: state.followCameraId,
};
// Park the world at the first keyframe, real-time rate, path engaged.
setPathPlaying(false);
if (state.playing) transport.pause();
transport.setRate(1);
transport.seek(startT);
// Canvas frames + the live WebAudio mix tap (foundation2 hook; silent when no track).
const canvas = scene3d.renderer.domElement;
this._canvasStream = canvas.captureStream(30);
const audioStream = transport.captureAudioStream();
const mixed = new MediaStream([
...this._canvasStream.getVideoTracks(),
...audioStream.getAudioTracks(),
]);
this._chunks = [];
this._recorder = new MediaRecorder(mixed, {
mimeType: mime,
videoBitsPerSecond: 8_000_000,
});
this._recorder.addEventListener("dataavailable", (e) => {
if (e.data && e.data.size > 0) this._chunks.push(e.data);
});
this._recorder.addEventListener("stop", () => this._deliver(mime));
this.exporting = true;
this._finishing = false;
this._btn.classList.add("active");
this._btn.textContent = "⏺ REC — click to stop";
// Roll: recorder + playback in the same tick so file duration ~= the path span.
setPathPlaying(true); // #btn-path flow: camPath.play() + transport starts the clock
this._recorder.start(250);
this._tick = this._tick.bind(this);
requestAnimationFrame(this._tick);
console.info(`[export] recording ${startT.toFixed(2)}s -> ${this._endT.toFixed(2)}s (${mime})`);
},
_tick() {
if (!this.exporting || this._finishing) return;
// Done when the playhead crosses the last keyframe — or when anything halts the ride
// (end of timeline pauses the transport; snap-to-camera exits path mode).
if (state.tGlobal >= this._endT - 1e-3 || !state.playing || !camPath.playing) {
this._finish("end of path");
return;
}
requestAnimationFrame(this._tick);
},
_finish(reason) {
if (!this.exporting || this._finishing) return;
this._finishing = true;
console.info(`[export] finishing (${reason})`);
try {
if (this._recorder && this._recorder.state !== "inactive") this._recorder.stop();
else this._deliver(null); // recorder never really started: still restore the UI
} catch (err) {
console.error("[export] recorder stop failed", err);
this._deliver(null);
}
},
/** MediaRecorder "stop" handler: build + download the file, then restore prior state. */
_deliver(mime) {
if (this._chunks.length > 0) {
const blob = new Blob(this._chunks, { type: mime ?? "video/webm" });
download(blob, "festival4d-cut.webm");
console.info(`[export] festival4d-cut.webm — ${(blob.size / 1e6).toFixed(2)} MB`);
} else {
console.warn("[export] no data recorded (hidden tab? — captureStream needs focus)");
}
this._chunks = [];
this._recorder = null;
// Release the shared hooks.
transport.releaseAudioStream();
this._canvasStream?.getTracks().forEach((t) => t.stop());
this._canvasStream = null;
// Restore ALL prior UI/transport state (spec M12).
const prior = this._prior ?? {};
this._prior = null;
setPathPlaying(false); // resets #btn-path label via main.js's own flow
if (state.playing) transport.pause();
transport.setRate(prior.rate ?? 1);
transport.seek(prior.tGlobal ?? 0);
if (prior.followCameraId != null) scene3d.snapTo(prior.followCameraId);
if (prior.pathPlaying) setPathPlaying(true);
if (prior.playing && !state.playing) transport.play();
this.exporting = false;
this._finishing = false;
this._btn.classList.remove("active");
this._btn.textContent = BTN_LABEL;
this._btn.disabled = camPath.count() < 2;
},
_flash(text, ms = 2200) {
const btn = this._btn;
btn.textContent = text;
setTimeout(() => {
if (!this.exporting) btn.textContent = BTN_LABEL;
}, ms);
},
};

View File

@ -0,0 +1,41 @@
// Friend tracks (spec M21 ribbons + moving labels + panel, M22 follow-a-friend cam; lane J).
// STUB shipped by foundation3 — lane J fills the body.
//
// main.js imports this, calls friendTracks.init() in buildUI (alongside the other lane
// modules) and friendTracks.update(state.tGlobal) every animation frame — the fx.js pattern
// (after transport.tick(), next to camPath.update / fx.update, before scene3d.update()).
// While stubbed, both are safe no-ops and the hidden #btn-friends button stays hidden; a
// broken or unfilled module degrades to no UI, exactly like the phase-5 stubs.
//
// Contract lane J must honor (frozen by foundation3):
// - state.tracks is loaded at boot by main.js when manifest.has_tracks, in the
// GET /api/tracks shape (contract #2):
// [{ id, marker_key, label, color,
// points: [{ t_global_s, x, y, z, quality, views }] }] // points sorted by t_global_s
// - Scene objects go through scene3d.addObject / scene3d.removeObject ONLY, and every
// helper is registered with scene3d.registerHelper so photo mode (M14) hides ribbons +
// labels. (Read anchorPanel.js for the panel style/UX; do not edit it.)
// - Rename / recolor / delete round-trip through PATCH / DELETE /api/tracks/{id}; those
// controls carry the `write-ui` class so the capsule (M17) hides them while ribbons +
// follow still work.
// - Follow-a-friend (M22) shares scene3d pathMode with camPath — mirror camPath's "someone
// else took the camera" contention checks exactly (pitfall #4), or two drivers fight.
// - update(tGlobal) MUST NEVER THROW: it runs inside the master loop and a throw kills
// playback. Guard the body and disable the module on error (fx.js does this).
export const friendTracks = {
ready: false,
/** Called once by main.js (buildUI). Lane J (M21): unhide #btn-friends, build the friends
* panel in #friends-panel, wire the toggle that renders ribbons + moving labels. */
init() {
// Stub: #btn-friends stays hidden until lane J fills M21. No-op is the safe degrade.
},
/** Called by main.js every animation frame with the master clock. Lane J (M21/M22):
* interpolate each track's position at tGlobal, move ribbons/labels, drive the chase cam.
* MUST NEVER THROW. */
update(_tGlobal) {
// Stub: no tracks rendered yet (M21/M22).
},
};

281
frontend/src/fx.js Normal file
View File

@ -0,0 +1,281 @@
// Moment FX (spec M15, lane F).
//
// 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.
//
// 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,
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

@ -0,0 +1,57 @@
// Pose lookup + interpolation over a video's `camera_poses` track (from GET /api/videos/{id}/poses).
//
// Poses arrive keyed by frame_idx / t_video_s (every ~0.5 s on the synthetic fixture, all
// registered). To render a smooth frustum / follow-cam at an arbitrary t_video we interpolate
// BETWEEN stored poses in COLMAP space — slerp the world->camera quaternion, lerp the
// translation — then hand the interpolated (q, t) to the FROZEN colmapToThreejs() helper.
// We interpolate the CONTRACT INPUTS; we never reimplement the COLMAP->Three.js conversion.
import * as THREE from "three";
const _qa = new THREE.Quaternion();
const _qb = new THREE.Quaternion();
/**
* Interpolated pose at local video time `tv` (seconds). Returns a COLMAP-convention pose
* `{ q:[w,x,y,z], t:[x,y,z], intrinsics, registered, t_video_s }` ready for colmapToThreejs,
* or null if the track is empty. Clamps to the endpoints (no extrapolation).
* @param {Array} poses ascending-by-t_video_s pose list
* @param {number} tv local video time (seconds)
*/
export function poseAt(poses, tv) {
if (!poses || poses.length === 0) return null;
const n = poses.length;
if (tv <= poses[0].t_video_s) return poses[0];
if (tv >= poses[n - 1].t_video_s) return poses[n - 1];
// binary search for the bracketing pair [lo, hi] with poses[lo].t <= tv < poses[hi].t
let lo = 0;
let hi = n - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (poses[mid].t_video_s <= tv) lo = mid;
else hi = mid;
}
const a = poses[lo];
const b = poses[hi];
const span = b.t_video_s - a.t_video_s;
const f = span > 1e-9 ? (tv - a.t_video_s) / span : 0;
// COLMAP quaternion order is [w, x, y, z]; THREE.Quaternion is (x, y, z, w).
_qa.set(a.q[1], a.q[2], a.q[3], a.q[0]);
_qb.set(b.q[1], b.q[2], b.q[3], b.q[0]);
_qa.slerp(_qb, f);
const q = [_qa.w, _qa.x, _qa.y, _qa.z];
const t = [
a.t[0] + (b.t[0] - a.t[0]) * f,
a.t[1] + (b.t[1] - a.t[1]) * f,
a.t[2] + (b.t[2] - a.t[2]) * f,
];
return {
q,
t,
intrinsics: a.intrinsics,
registered: a.registered && b.registered,
t_video_s: tv,
};
}

View File

@ -0,0 +1,18 @@
// Timebase convention (FROZEN CONTRACT) — the JS mirror of backend/festival4d/config.py
// (`t_video_from_global` / `t_global_from_video`). Keep these identical to the Python side.
//
// t_global is the master timeline in seconds. For a video v:
// 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. Never re-derive this algebra inline — call these helpers everywhere.
/** Master-timeline seconds -> a video's local playhead seconds (FROZEN). */
export function tVideoFromGlobal(tGlobal, offsetMs, driftPpm = 0) {
return (tGlobal - offsetMs / 1000) * (1 + driftPpm * 1e-6);
}
/** Inverse of tVideoFromGlobal (FROZEN). */
export function tGlobalFromVideo(tVideo, offsetMs, driftPpm = 0) {
return tVideo / (1 + driftPpm * 1e-6) + offsetMs / 1000;
}

View File

@ -1,91 +1,330 @@
// Foundation hello page. Fetches /api/manifest (proving the API + CORS work cross-origin)
// and runs the frozen pose.js self-test in the browser. Lane C (M4M6) replaces this with
// the real app; the modules alongside it are stubs to fill in.
// Festival 4D viewer — composition root (spec M4/M5/M6 + timeline markers).
//
// Boots by loading the frozen synthetic API (manifest, poses, anchors, events), builds the
// video grid + 3D scene + timeline, wires transport controls + keyboard shortcuts, and runs a
// single master animation loop that: advances the master clock & corrects every video
// (transport), redraws each overlay for its displayed frame, updates the 3D frusta / follow-cam,
// moves the timeline playhead, and refreshes the dev sync overlay. The master clock lives in
// transport.js and is derived from performance.now() — never from a <video> element.
import { selfTest as poseSelfTest } from "./lib/pose.js";
import { state, on, API_BASE, referenceVideoId } from "./state.js";
import { transport } from "./transport.js";
import { createVideoGrid } from "./videoGrid.js";
import { drawOverlay, projectWorldToVideoPx } from "./overlays.js";
import { poseAt } from "./lib/poseTrack.js";
import { scene3d, videoColor } from "./scene3d.js";
import { timeline } from "./timeline.js";
import { annotator } from "./annotate.js";
import { camPath } from "./camPath.js";
// Phase-5 lane modules (stubs until lanes E/F/G fill them; each wires its own hidden button).
import { director } from "./director.js";
import { exportVideo } from "./exportVideo.js";
import { anchorPanel } from "./anchorPanel.js";
import { photoMode } from "./photoMode.js";
import { fx } from "./fx.js";
import { pathsStore } from "./pathsStore.js";
// Phase-6 lane module (stub until lane J fills it — M21 ribbons/panel, M22 follow-a-friend).
import { friendTracks } from "./friendTracks.js";
// The FastAPI backend (`python -m festival4d serve`). The Vite dev server runs on :5173;
// this cross-origin fetch exercises the CORS config in api.py.
const API_BASE = "http://localhost:8000";
const laneModules = { director, exportVideo, anchorPanel, photoMode, fx, pathsStore, friendTracks };
const app = document.getElementById("app");
function card(html) {
const el = document.createElement("div");
el.className = "card";
el.innerHTML = html;
return el;
async function getJSON(path) {
const resp = await fetch(API_BASE + path);
if (!resp.ok) throw new Error(`${path} -> HTTP ${resp.status}`);
return resp.json();
}
function pill(ok, text) {
return `<span class="pill ${ok ? "ok" : "bad"}">${text}</span>`;
function fmtTime(s) {
s = Math.max(0, s);
const m = Math.floor(s / 60);
const sec = s - m * 60;
return `${m}:${sec.toFixed(1).padStart(4, "0")}`;
}
function renderPoseSelfTest() {
function el(id) {
return document.getElementById(id);
}
async function boot() {
const loading = el("loading");
try {
poseSelfTest();
return card(
`<strong>Pose contract</strong> ${pill(true, "self-test passed")}<br />` +
`<span class="sub">frontend/src/lib/pose.js matches the frozen COLMAP→Three.js vectors.</span>`
);
const manifest = await getJSON("/api/manifest");
state.manifest = manifest;
state.videos = manifest.videos;
state.tGlobalMax = manifest.t_global_max;
state.hasPoses = manifest.has_poses;
// Capture is opt-in on the backend; only surface the link when it's actually mounted.
if (manifest.has_capture) {
const link = el("capture-link");
link.href = `${API_BASE}/capture`;
link.style.display = "";
}
state.hasSplat = !!manifest.has_splat;
state.hasTracks = !!manifest.has_tracks; // phase 6 (M18): any solved friend track
// Capsule (M17): a static baked bundle has no write API — hide everything .write-ui.
state.capsule = !!manifest.capsule;
document.body.classList.toggle("capsule", state.capsule);
for (const v of state.videos) state.enabled[v.id] = true;
state.audioSourceId = referenceVideoId();
const [anchors, events] = await Promise.all([
getJSON("/api/anchors"),
getJSON("/api/events"),
]);
state.anchors = anchors;
state.events = events;
// Friend tracks (M18): only fetched when the backend reports solved tracks, so a project
// without them pays nothing. Failure is non-fatal — tracks are an overlay, not core.
if (state.hasTracks) {
try {
state.tracks = await getJSON("/api/tracks");
} catch (err) {
console.warn("[festival4d] friend tracks failed to load", err);
}
}
if (state.hasPoses) {
const lists = await Promise.all(
state.videos.map((v) => getJSON(`/api/videos/${v.id}/poses`))
);
state.videos.forEach((v, i) => (state.poses[v.id] = lists[i]));
}
buildUI();
startLoop();
loading.style.display = "none";
// Debug hook so the transport/scene can be driven in tests (e.g. a setTimeout pump when
// a headless pane throttles rAF). Dev builds always; production only when opted in via
// localStorage.setItem("f4dDebug", "1").
if (import.meta.env?.DEV || localStorage.getItem("f4dDebug")) {
window.__f4d = {
state, transport, scene3d, timeline, camPath, cells: () => cells,
poseAt, projectWorldToVideoPx, ...laneModules,
};
}
} catch (err) {
return card(
`<strong>Pose contract</strong> ${pill(false, "self-test FAILED")}<br />` +
`<code>${err.message}</code>`
);
console.error("[festival4d] boot failed", err);
loading.innerHTML = `<div class="err">Failed to load the project.<br><code>${err.message}</code>` +
`<br><span class="dim">Is the backend running? <code>python -m festival4d serve</code></span></div>`;
}
}
function renderManifest(m) {
const rows = m.videos
.map(
(v) => `
<tr>
<td>${v.id}</td>
<td><code>${v.filename}</code></td>
<td>${v.duration_s.toFixed(2)} s</td>
<td>${v.width}×${v.height} @ ${v.fps}</td>
<td>${v.offset_ms === null ? "—" : v.offset_ms + " ms"}</td>
</tr>`
)
.join("");
return card(`
<strong>API manifest</strong> ${pill(true, "fetched (CORS ok)")}<br />
<span class="sub">
t_global_max = ${m.t_global_max.toFixed(2)} s ·
has_poses = ${m.has_poses ? "yes" : "no"} ·
${m.videos.length} videos
</span>
<table>
<thead><tr><th>id</th><th>file</th><th>duration</th><th>frame</th><th>offset</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="sub" style="margin-top:0.75rem">
Videos served with HTTP Range from <code>${API_BASE}/media/</code>
</div>
`);
let cells = [];
function buildUI() {
// Video grid
const grid = el("video-grid");
cells = createVideoGrid(grid, state.videos, API_BASE, transport);
transport.register(cells);
// Per-video snap buttons -> scene3d (grid module leaves them unwired).
cells.forEach((cell, i) => {
cell.el.style.setProperty("--accent", videoColor(i));
cell.snapBtn.addEventListener("click", (e) => {
e.stopPropagation();
scene3d.snapTo(cell.id);
});
});
// 3D scene
scene3d.init(el("scene3d"));
// Timeline
timeline.init(el("timeline"), transport);
// M8: correction panel + bbox annotation → 3D anchors.
annotator.init(el("annot-panel"), cells);
on("event-selected", (ev) => annotator.openForEvent(ev));
on("anchors-changed", () => scene3d.refreshAnchors());
on("events-changed", () => timeline.refresh());
wireTransportControls();
wireCamPath();
wireKeyboard();
positionVideosWhenReady();
// Phase-5 lane modules: each init() wires its own (hidden) controls and unhides them when
// the lane has filled the stub. A broken module degrades to its button staying hidden.
for (const [name, mod] of Object.entries(laneModules)) {
try {
mod.init();
} catch (err) {
console.warn(`[festival4d] lane module "${name}" init failed`, err);
}
}
// Reflect follow/roam state on the free-roam button; snapping cancels a playing path.
on("follow", (id) => {
el("btn-roam").classList.toggle("active", id == null);
if (id != null) resetPathButton();
});
}
async function main() {
app.innerHTML = "";
app.appendChild(renderPoseSelfTest());
function resetPathButton() {
const b = el("btn-path");
b.textContent = "▶ Path";
b.classList.remove("active");
}
try {
const resp = await fetch(`${API_BASE}/api/manifest`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const manifest = await resp.json();
app.appendChild(renderManifest(manifest));
console.log("[festival4d] manifest loaded", manifest);
} catch (err) {
app.appendChild(
card(
`<strong>API manifest</strong> ${pill(false, "fetch failed")}<br />` +
`<code>${err.message}</code><br />` +
`<span class="sub">Is the backend running? <code>python -m festival4d serve</code></span>`
)
);
console.error("[festival4d] manifest fetch failed", err);
function wireCamPath() {
camPath.onChange = (n) => {
el("key-count").textContent = String(n);
if (!camPath.playing) resetPathButton();
};
el("btn-key").addEventListener("click", () => camPath.addKeyframe());
el("btn-path").addEventListener("click", () => {
const b = el("btn-path");
if (camPath.playing) {
camPath.stop();
resetPathButton();
} else if (camPath.play()) {
b.textContent = "⏹ Path";
b.classList.add("active");
if (!state.playing) transport.toggle(); // advance master time so the path animates
}
});
el("btn-path-export").addEventListener("click", () => {
const blob = new Blob([camPath.toJSON()], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "festival4d-campath.json";
a.click();
URL.revokeObjectURL(a.href);
});
const fileInput = el("path-file");
el("btn-path-import").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", async () => {
const f = fileInput.files?.[0];
if (f) {
try {
camPath.fromJSON(await f.text());
} catch (err) {
console.error("[festival4d] camera-path import failed", err);
}
}
fileInput.value = "";
});
el("btn-path-clear").addEventListener("click", () => {
camPath.clear();
resetPathButton();
});
}
function wireTransportControls() {
el("btn-play").addEventListener("click", () => transport.toggle());
el("btn-back").addEventListener("click", () => transport.seekBy(-1));
el("btn-fwd").addEventListener("click", () => transport.seekBy(1));
el("rate-select").addEventListener("change", (e) =>
transport.setRate(parseFloat(e.target.value))
);
el("btn-roam").addEventListener("click", () => {
if (camPath.playing) {
camPath.stop();
resetPathButton();
}
scene3d.freeRoam();
});
// 🎧 spatial audio — only offered when there are camera poses to place emitters at.
const spatialBtn = el("btn-spatial");
if (state.hasPoses) spatialBtn.style.display = "";
spatialBtn.addEventListener("click", () => transport.setSpatial(!state.spatialAudio));
on("spatial", (enabled) => spatialBtn.classList.toggle("active", enabled));
}
function wireKeyboard() {
window.addEventListener("keydown", (e) => {
if (e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName)) return;
if (e.code === "Space") {
e.preventDefault();
transport.toggle();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
transport.seekBy(e.shiftKey ? -5 : -1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
transport.seekBy(e.shiftKey ? 5 : 1);
} else if (e.code === "Escape" || e.code === "Digit0") {
if (camPath.playing) {
camPath.stop();
resetPathButton();
}
scene3d.freeRoam();
} else if (e.code === "KeyK") {
e.preventDefault();
camPath.addKeyframe();
} else if (/^Digit[1-9]$/.test(e.code)) {
const n = parseInt(e.code.slice(5), 10) - 1;
const v = state.videos[n];
if (v) scene3d.snapTo(v.id);
}
});
}
function positionVideosWhenReady() {
let remaining = cells.length;
if (remaining === 0) return;
const done = () => {
if (--remaining === 0) transport.seek(0); // park every video on its t=0 frame
};
for (const { video } of cells) {
if (video.readyState >= 1) done();
else video.addEventListener("loadedmetadata", done, { once: true });
}
}
main();
function startLoop() {
const frame = () => {
transport.tick();
for (const cell of cells) drawOverlay(cell);
fx.update(state.tGlobal); // M15: playhead-crossing effects (no-op while stubbed)
friendTracks.update(state.tGlobal); // M21/M22: move ribbons/labels + chase cam (no-op while stubbed)
camPath.update(state.tGlobal); // M9: drive the viewer camera before the scene renders
scene3d.update();
timeline.update(state.tGlobal);
updateHud();
requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
function updateHud() {
el("btn-play").textContent = state.playing ? "⏸" : "▶";
el("time-display").textContent = `${fmtTime(state.tGlobal)} / ${fmtTime(state.tGlobalMax)}`;
// Dev sync overlay + per-cell status.
let devRows = "";
for (const cell of cells) {
const { id, meta } = cell;
const err = state.syncErrorMs[id];
const inRange = state.inRange[id];
const enabled = state.enabled[id] !== false;
let label, cls;
if (!enabled) {
label = "disabled";
cls = "dim";
} else if (!inRange) {
label = "out of range";
cls = "dim";
} else if (err == null) {
label = "—";
cls = "dim";
} else {
label = `${err >= 0 ? "+" : ""}${err.toFixed(0)} ms`;
cls = Math.abs(err) < 50 ? "ok" : "bad";
}
devRows += `<div class="dev-row"><span>${meta.filename}</span><span class="${cls}">${label}</span></div>`;
// Cell overlay state (dim out-of-range / disabled, mark audio + follow).
cell.el.classList.toggle("out", !inRange || !enabled);
cell.el.classList.toggle("audio-on", state.audioSourceId === id);
cell.el.classList.toggle("followed", state.followCameraId === id);
cell.statusEl.textContent = enabled ? (inRange ? label : "waiting…") : "off";
cell.statusEl.className = `cell-status ${cls}`;
}
el("dev-rows").innerHTML = devRows;
}
boot();

View File

@ -1,6 +1,137 @@
// 3D→2D anchor projection onto each video's overlay canvas — the "x-ray" HUD (spec M6).
// STUB — lane C fills this in. For each anchor + visible video: build the video's current
// Three.js camera (pose.js helper), project the anchor, convert NDC→canvas px, skip if
// behind camera (check z_cam < 0). Anchors render regardless of occlusion — that's the feature.
// 3D -> 2D anchor projection onto each video's overlay canvas — the "x-ray" HUD (spec M6).
//
// For each visible video we build a Three.js PerspectiveCamera at that video's CURRENT pose
// (from the FROZEN colmapToThreejs helper, matrixWorld path) with an fov derived from the
// stored intrinsics, project every anchor, drop anchors behind the camera (camera-space z >= 0),
// convert NDC -> native video pixels -> canvas pixels through the letterbox content rect, and
// draw a dot + halo + label. Anchors are drawn regardless of real-world occlusion — that IS the
// x-ray feature. We consume pose.js; we never reimplement the COLMAP->Three.js conversion.
export {};
import * as THREE from "three";
import { colmapToThreejs } from "./lib/pose.js";
import { poseAt } from "./lib/poseTrack.js";
import { contentRect } from "./videoGrid.js";
import { state } from "./state.js";
// Reused across all videos/frames (every synthetic video shares intrinsics + resolution, and
// we fully overwrite the matrices each call, so a single scratch camera is safe and cheap).
const _cam = new THREE.PerspectiveCamera();
_cam.matrixAutoUpdate = false;
const _m4 = new THREE.Matrix4();
const _v = new THREE.Vector3();
function configureProjector(pose, W, H) {
// Centered-principal-point pinhole -> PerspectiveCamera (spec M5/M6 prototype assumption;
// the synthetic fixture has cx=W/2, cy=H/2 exactly).
const fy = pose.intrinsics.fy;
_cam.fov = (2 * Math.atan(H / (2 * fy)) * 180) / Math.PI;
_cam.aspect = W / H;
_cam.near = 0.01;
_cam.far = 2000;
_cam.updateProjectionMatrix();
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
_m4.fromArray(matrixWorld);
_cam.matrixWorld.copy(_m4);
_cam.matrixWorldInverse.copy(_m4).invert();
}
/** Project a world point to native video pixels, or null if behind the camera. */
function projectToVideoPx(x, y, z, W, H) {
_v.set(x, y, z);
// Behind-camera test in camera space: Three.js cameras look down -z, so a visible point has
// camera-space z < 0. Do this BEFORE .project() (which mutates _v into NDC).
_v.applyMatrix4(_cam.matrixWorldInverse);
if (_v.z >= 0) return null;
_v.applyMatrix4(_cam.projectionMatrix); // perspective divide happens inside .project(); do it manually
// _v is now clip space already divided (applyMatrix4 on a Vector3 divides by w). NDC in [-1,1].
const u = (_v.x * 0.5 + 0.5) * W;
const vpx = (1 - (_v.y * 0.5 + 0.5)) * H; // flip Y: NDC +y is up, pixels grow downward
return { u, v: vpx };
}
/**
* Project a world point to native video pixels through the FROZEN pose helper for a given
* COLMAP pose (as returned by poseAt) and video resolution. Returns {u, v} or null (behind).
* Single source of truth for overlay projection used by drawOverlay and by tests.
*/
export function projectWorldToVideoPx(pose, x, y, z, W, H) {
configureProjector(pose, W, H);
return projectToVideoPx(x, y, z, W, H);
}
function drawMarker(ctx, x, y, anchor) {
const color = anchor.color || "#59d499";
// halo
ctx.beginPath();
ctx.arc(x, y, 5.5, 0, Math.PI * 2);
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.fill();
// dot
ctx.beginPath();
ctx.arc(x, y, 4, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
// label
const text = anchor.label || "";
if (text) {
ctx.font = "600 11px -apple-system, system-ui, sans-serif";
ctx.textBaseline = "middle";
const tx = x + 9;
const ty = y;
const w = ctx.measureText(text).width;
ctx.fillStyle = "rgba(6,8,14,0.72)";
ctx.fillRect(tx - 3, ty - 8, w + 6, 16);
ctx.fillStyle = "#e6e8ee";
ctx.fillText(text, tx, ty + 0.5);
}
}
/** Redraw one cell's overlay for the frame currently displayed by its <video>. */
export function drawOverlay(cell) {
const { video, canvas } = cell;
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const elW = video.clientWidth;
const elH = video.clientHeight;
if (elW === 0 || elH === 0) return;
// Resolution-match the canvas backing store to the element (dpr-aware).
const bw = Math.round(elW * dpr);
const bh = Math.round(elH * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
canvas.style.width = elW + "px";
canvas.style.height = elH + "px";
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, elW, elH);
if (!state.hasPoses || !state.inRange[cell.id]) return;
if (video.videoWidth === 0) return;
const poses = state.poses[cell.id];
if (!poses || poses.length === 0 || state.anchors.length === 0) return;
// Use the frame the <video> is ACTUALLY showing (currentTime), not the master target, so the
// overlay tracks the displayed picture even mid-correction.
const pose = poseAt(poses, video.currentTime);
if (!pose) return;
const W = video.videoWidth;
const H = video.videoHeight;
configureProjector(pose, W, H);
const rect = contentRect(video);
for (const a of state.anchors) {
const p = projectToVideoPx(a.x, a.y, a.z, W, H);
if (!p) continue;
const cx = rect.ox + (p.u / W) * rect.cw;
const cy = rect.oy + (p.v / H) * rect.ch;
// Skip anchors that fall well outside the visible content rect.
const m = 24;
if (cx < rect.ox - m || cx > rect.ox + rect.cw + m) continue;
if (cy < rect.oy - m || cy > rect.oy + rect.ch + m) continue;
drawMarker(ctx, cx, cy, a);
}
}

125
frontend/src/pathsStore.js Normal file
View File

@ -0,0 +1,125 @@
// 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
// main.js, and the API: GET /api/paths (list of {id,name,created_at}),
// GET /api/paths/{id} ({..., json: <camPath JSON>}), POST /api/paths {name, json:<text>}
// (422 on invalid), DELETE /api/paths/{id}.
//
// 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() {
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();
},
};

117
frontend/src/photoMode.js Normal file
View File

@ -0,0 +1,117 @@
// Photo mode (spec M14, lane F).
//
// 📷 / "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.
//
// 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,
_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

@ -1,6 +1,497 @@
// Three.js scene: point cloud (/api/pointcloud via PLYLoader), per-video camera paths +
// current-pose frusta, OrbitControls, snap-to-camera (spec M5).
// STUB — lane C fills this in. Use frontend/src/lib/pose.js (frozen contract) for ALL
// COLMAP→Three.js conversion — do not reimplement the diag(1,-1,-1) math.
// Three.js scene (spec M5): point cloud (/api/pointcloud via PLYLoader), each video's camera
// path as a line + current-pose frustum wireframe (colored per video), OrbitControls free roam,
// and snap-to-camera. ALL COLMAP->Three.js conversion goes through the FROZEN lib/pose.js
// helper (colmapToThreejs) — the diag(1,-1,-1) math is never reimplemented here.
export {};
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { PLYLoader } from "three/addons/loaders/PLYLoader.js";
import { colmapToThreejs } from "./lib/pose.js";
import { poseAt } from "./lib/poseTrack.js";
import { tVideoFromGlobal } from "./lib/timebase.js";
import { state, emit } from "./state.js";
import { transport } from "./transport.js";
// Per-video colors (match the frustum, path line, and grid accents).
export const VIDEO_COLORS = ["#ff5d73", "#4dd0ff", "#c9a2ff", "#ffcf5d", "#7dffb0", "#ff9d5d"];
export function videoColor(index) {
return VIDEO_COLORS[index % VIDEO_COLORS.length];
}
const FRUSTUM_DEPTH = 1.2;
const DEFAULT_FOV = 50;
const STAGE_TARGET = new THREE.Vector3(0, 0.5, 0);
const _m4 = new THREE.Matrix4();
const _pos = new THREE.Vector3();
const _quat = new THREE.Quaternion();
const _scale = new THREE.Vector3();
const _fwd = new THREE.Vector3();
const _up = new THREE.Vector3();
/** Build a camera-facing text label sprite (used for 3D anchor labels). */
function makeLabelSprite(text) {
const font = 48;
const pad = 10;
const c = document.createElement("canvas");
let ctx = c.getContext("2d");
ctx.font = `600 ${font}px -apple-system, system-ui, sans-serif`;
const w = Math.ceil(ctx.measureText(text).width) + pad * 2;
const h = font + pad * 2;
c.width = w;
c.height = h;
ctx = c.getContext("2d");
ctx.font = `600 ${font}px -apple-system, system-ui, sans-serif`;
ctx.fillStyle = "rgba(6,8,14,0.74)";
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = "#e6e8ee";
ctx.textBaseline = "middle";
ctx.fillText(text, pad, h / 2 + 1);
const tex = new THREE.CanvasTexture(c);
tex.minFilter = THREE.LinearFilter;
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true })
);
const s = 0.006;
sprite.scale.set(w * s, h * s, 1);
return sprite;
}
export class Scene3D {
constructor() {
this.videoIndex = {}; // id -> index (for stable colors)
this.rigs = {}; // id -> { frustum, marker, pick, path }
this._tween = null;
this._raf = null;
this.anchorGroup = null; // 3D anchor spheres + labels (M6 anchors + M8 resolved)
this.pathMode = false; // when true, camPath (M9) drives the camera; we don't touch it
this.helpersVisible = true; // M14 hook: grid/axes/frusta/gizmos/labels toggle
this._helperObjs = []; // extra helper objects registered by other modules (camPath gizmos)
}
init(canvas) {
this.canvas = canvas;
const parent = canvas.parentElement;
const w = parent.clientWidth || 1;
const h = parent.clientHeight || 1;
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
this.renderer.setSize(w, h, false);
this.renderer.setClearColor(0x0a0c11, 1);
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(DEFAULT_FOV, w / h, 0.05, 3000);
this.camera.position.set(12, 9, 15);
this.camera.lookAt(STAGE_TARGET);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.08;
this.controls.target.copy(STAGE_TARGET);
this.controls.update();
// Lights (points ignore lighting, but frusta/markers use MeshBasic so this is mostly cosmetic).
this.scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const grid = new THREE.GridHelper(40, 40, 0x22384a, 0x161c26);
grid.position.y = 0;
this.scene.add(grid);
const axes = new THREE.AxesHelper(1.5);
this.scene.add(axes);
this._grid = grid;
this._axes = axes;
this.anchorGroup = new THREE.Group();
this.scene.add(this.anchorGroup);
this._buildRigs();
this._loadPointCloud();
this.refreshAnchors();
this._wirePicking();
this._wireResize(parent);
}
/** Rebuild the 3D anchor markers from state.anchors (M6 seeded + M8 resolved). */
refreshAnchors() {
if (!this.anchorGroup) return;
for (const child of [...this.anchorGroup.children]) {
this.anchorGroup.remove(child);
child.geometry?.dispose?.();
if (child.material) {
child.material.map?.dispose?.();
child.material.dispose();
}
}
for (const a of state.anchors) {
const color = new THREE.Color(a.color || "#59d499");
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(0.13, 16, 16),
new THREE.MeshBasicMaterial({ color })
);
sphere.position.set(a.x, a.y, a.z);
this.anchorGroup.add(sphere);
if (a.label) {
const label = makeLabelSprite(a.label);
label.position.set(a.x, a.y + 0.3, a.z);
label.visible = this.helpersVisible; // photo mode (M14) may be hiding labels right now
this.anchorGroup.add(label);
}
}
}
/** Expose the scene graph for auxiliary overlays (e.g. camPath gizmos M9, moment FX M15). */
addObject(obj) {
this.scene?.add(obj);
}
removeObject(obj) {
this.scene?.remove(obj);
}
/** Register an added object as a "helper" so setHelpersVisible governs it (camPath gizmos). */
registerHelper(obj) {
this._helperObjs.push(obj);
obj.visible = this.helpersVisible;
}
/**
* M14 hook (phase-5 contract #6): show/hide every non-content visual in one call grid,
* axes, camera frusta + markers + path lines, registered gizmo groups, and anchor LABEL
* sprites (anchor spheres stay: friend tags belong in photos). update() keeps enforcing
* the flag on the per-video rigs, whose visibility it recomputes every frame.
*/
setHelpersVisible(visible) {
this.helpersVisible = !!visible;
if (this._grid) this._grid.visible = this.helpersVisible;
if (this._axes) this._axes.visible = this.helpersVisible;
for (const obj of this._helperObjs) obj.visible = this.helpersVisible;
if (this.anchorGroup)
for (const child of this.anchorGroup.children)
if (child.isSprite) child.visible = this.helpersVisible;
}
/**
* M13 hook (phase-5 contract #6): fly the free-roam camera's attention to a world point
* (anchor jump-to). Detaches follow-cam / path mode, retargets OrbitControls, and backs the
* camera off along its current viewing direction.
*/
focusOn(x, y, z, dist = 6) {
if (this.pathMode) this.exitPathMode();
if (state.followCameraId != null) {
state.followCameraId = null;
this._tween = null;
emit("follow", null);
}
this.camera.fov = DEFAULT_FOV;
this.camera.updateProjectionMatrix();
const dir = this.camera.position.clone().sub(this.controls.target);
if (dir.lengthSq() < 1e-6) dir.set(0, 0.5, 1);
dir.normalize().multiplyScalar(dist);
this.controls.target.set(x, y, z);
this.camera.position.set(x + dir.x, y + dir.y, z + dir.z);
this.controls.enabled = true;
this.controls.update();
}
/** M9: hand camera control to camPath. update() then leaves the camera alone. */
enterPathMode() {
this.pathMode = true;
this.controls.enabled = false;
state.followCameraId = null;
this._tween = null;
emit("follow", null);
}
exitPathMode() {
if (!this.pathMode) return;
this.pathMode = false;
this.controls.target.copy(STAGE_TARGET);
this.controls.enabled = true;
this.controls.update();
}
/** M9: set the camera pose directly (called by camPath each frame while path-playing). */
applyCameraPose(pos, quat, fov) {
this.camera.position.copy(pos);
this.camera.quaternion.copy(quat);
if (fov != null && Math.abs(this.camera.fov - fov) > 1e-3) {
this.camera.fov = fov;
this.camera.updateProjectionMatrix();
}
}
_buildRigs() {
state.videos.forEach((v, i) => {
this.videoIndex[v.id] = i;
const color = new THREE.Color(videoColor(i));
const group = new THREE.Group();
// Frustum wireframe (built in Three.js camera-local space: looks down -z, +y up).
const poses = state.poses[v.id] || [];
const intr = poses[0]?.intrinsics;
const W = v.width;
const H = v.height;
const hx = intr ? (FRUSTUM_DEPTH * W) / (2 * intr.fx) : FRUSTUM_DEPTH * 0.6;
const hy = intr ? (FRUSTUM_DEPTH * H) / (2 * intr.fy) : FRUSTUM_DEPTH * 0.34;
const d = FRUSTUM_DEPTH;
const c = [
[-hx, -hy, -d],
[hx, -hy, -d],
[hx, hy, -d],
[-hx, hy, -d],
];
const apex = [0, 0, 0];
const segs = [
apex, c[0], apex, c[1], apex, c[2], apex, c[3],
c[0], c[1], c[1], c[2], c[2], c[3], c[3], c[0],
];
const fg = new THREE.BufferGeometry();
fg.setAttribute("position", new THREE.Float32BufferAttribute(segs.flat(), 3));
const frustum = new THREE.LineSegments(
fg,
new THREE.LineBasicMaterial({ color })
);
frustum.matrixAutoUpdate = true;
group.add(frustum);
// Camera-center marker (visible) + an invisible larger sphere for click picking.
const marker = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 12, 12),
new THREE.MeshBasicMaterial({ color })
);
group.add(marker);
this.scene.add(group);
const pick = new THREE.Mesh(
new THREE.SphereGeometry(0.55, 8, 8),
new THREE.MeshBasicMaterial({ visible: false })
);
pick.userData.videoId = v.id;
group.add(pick);
// Camera path line (one vertex per stored pose center).
let path = null;
if (poses.length > 1) {
const pts = [];
for (const p of poses) {
const { position } = colmapToThreejs(p.q, p.t);
pts.push(position[0], position[1], position[2]);
}
const pg = new THREE.BufferGeometry();
pg.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
path = new THREE.Line(
pg,
new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.5 })
);
this.scene.add(path);
}
this.rigs[v.id] = { group, frustum, marker, pick, path };
});
}
_loadPointCloud() {
// Prefer a trained 3DGS splat when the backend has one (docs/modelbeast-crossover.md);
// fall back to the sparse COLMAP point cloud. The splat library is heavy, so it's
// lazy-imported only when actually needed.
if (state.hasSplat) {
this._loadSplat();
return;
}
const loader = new PLYLoader();
loader.load(
state.apiBase + "/api/pointcloud",
(geometry) => {
geometry.computeBoundingBox();
const hasColor = !!geometry.getAttribute("color");
const material = new THREE.PointsMaterial({
size: 0.05,
sizeAttenuation: true,
vertexColors: hasColor,
color: hasColor ? 0xffffff : 0x88ccff,
});
this.points = new THREE.Points(geometry, material);
this.scene.add(this.points);
emit("pointcloud-loaded", geometry.getAttribute("position")?.count ?? 0);
},
undefined,
(err) => {
console.warn("[scene3d] point cloud load failed", err);
}
);
}
async _loadSplat() {
try {
const GS = await import("@mkkellogg/gaussian-splats-3d");
// DropInViewer is a THREE.Object3D — it joins the existing scene and
// renders with our camera/controls, so rigs/anchors/paths overlay as usual.
const splatViewer = new GS.DropInViewer({ sharedMemoryForWorkers: false });
await splatViewer.addSplatScene(state.apiBase + "/api/splat", {
showLoadingUI: false,
progressiveLoad: true,
format: GS.SceneFormat.Ply,
});
this.splat = splatViewer;
this.scene.add(splatViewer);
emit("pointcloud-loaded", -1); // -1 = splat (no discrete point count)
} catch (err) {
console.warn("[scene3d] splat load failed — falling back to point cloud", err);
state.hasSplat = false;
this._loadPointCloud();
}
}
// Live pose (COLMAP convention) for a video at the current master time, or null.
_livePose(videoId) {
const poses = state.poses[videoId];
if (!poses || poses.length === 0) return null;
const meta = state.videos.find((v) => v.id === videoId);
const tv = tVideoFromGlobal(state.tGlobal, meta.offset_ms || 0, meta.drift_ppm || 0);
return poseAt(poses, tv);
}
/** Snap the viewer camera to a video's pose and follow it while playing. */
snapTo(videoId) {
const pose = this._livePose(videoId);
if (!pose) return;
if (this.pathMode) this.exitPathMode();
state.followCameraId = videoId;
this.controls.enabled = false;
const targetFov = (2 * Math.atan(state.videos.find((v) => v.id === videoId).height /
(2 * pose.intrinsics.fy)) * 180) / Math.PI;
this._tween = {
start: performance.now(),
dur: 600,
fromPos: this.camera.position.clone(),
fromQuat: this.camera.quaternion.clone(),
fromFov: this.camera.fov,
toFov: targetFov,
};
emit("follow", videoId);
}
/** Detach to free roam (OrbitControls). */
freeRoam() {
if (state.followCameraId == null) return;
state.followCameraId = null;
this._tween = null;
this.camera.fov = DEFAULT_FOV;
this.camera.updateProjectionMatrix();
// Re-anchor OrbitControls to the stage, orbiting from the current position.
this.controls.target.copy(STAGE_TARGET);
this.controls.enabled = true;
this.controls.update();
emit("follow", null);
}
_applyPoseToCamera(pose, targetFov) {
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
_m4.fromArray(matrixWorld);
_m4.decompose(_pos, _quat, _scale);
const t = this._tween;
if (t) {
const k = Math.min(1, (performance.now() - t.start) / t.dur);
const e = k * k * (3 - 2 * k); // smoothstep
this.camera.position.lerpVectors(t.fromPos, _pos, e);
this.camera.quaternion.copy(t.fromQuat).slerp(_quat, e);
this.camera.fov = t.fromFov + (t.toFov - t.fromFov) * e;
this.camera.updateProjectionMatrix();
if (k >= 1) this._tween = null;
} else {
this.camera.position.copy(_pos);
this.camera.quaternion.copy(_quat);
if (Math.abs(this.camera.fov - targetFov) > 1e-3) {
this.camera.fov = targetFov;
this.camera.updateProjectionMatrix();
}
}
}
/** Per-frame update: move frusta to current poses, drive follow-cam, render. */
update() {
for (const v of state.videos) {
const rig = this.rigs[v.id];
if (!rig) continue;
const pose = this._livePose(v.id);
const on = pose && state.enabled[v.id] !== false;
rig.group.visible = !!on && this.helpersVisible;
if (rig.path) rig.path.visible = state.enabled[v.id] !== false && this.helpersVisible;
if (!on) continue;
const { matrixWorld } = colmapToThreejs(pose.q, pose.t);
_m4.fromArray(matrixWorld);
_m4.decompose(rig.group.position, rig.group.quaternion, rig.group.scale);
}
if (this.pathMode) {
// camPath (M9) already set the camera pose this frame; don't fight it.
} else if (state.followCameraId != null) {
const pose = this._livePose(state.followCameraId);
if (pose) {
const meta = state.videos.find((v) => v.id === state.followCameraId);
const targetFov =
(2 * Math.atan(meta.height / (2 * pose.intrinsics.fy)) * 180) / Math.PI;
this._applyPoseToCamera(pose, targetFov);
}
} else {
this.controls.update();
}
this._updateAudio();
this.renderer.render(this.scene, this.camera);
}
// 🎧 Feed the transport's spatial audio: listener = viewer camera, emitters = camera rigs
// (rig.group already carries each camera's live pose from the loop above).
_updateAudio() {
if (!state.spatialAudio) return;
this.camera.getWorldPosition(_pos);
_fwd.set(0, 0, -1).applyQuaternion(this.camera.quaternion);
_up.set(0, 1, 0).applyQuaternion(this.camera.quaternion);
const positions = {};
for (const v of state.videos) {
const rig = this.rigs[v.id];
if (rig?.group.visible) positions[v.id] = rig.group.position;
}
transport.updateSpatial(
{ px: _pos.x, py: _pos.y, pz: _pos.z, fx: _fwd.x, fy: _fwd.y, fz: _fwd.z, ux: _up.x, uy: _up.y, uz: _up.z },
positions
);
}
_wirePicking() {
const el = this.renderer.domElement;
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let downX = 0;
let downY = 0;
el.addEventListener("pointerdown", (e) => {
downX = e.clientX;
downY = e.clientY;
});
el.addEventListener("pointerup", (e) => {
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 5) return; // was a drag, not a click
const rect = el.getBoundingClientRect();
ndc.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
ray.setFromCamera(ndc, this.camera);
const picks = Object.values(this.rigs)
.map((r) => r.pick)
.filter((m) => m.parent && m.parent.visible);
const hits = ray.intersectObjects(picks, false);
if (hits.length) this.snapTo(hits[0].object.userData.videoId);
});
}
_wireResize(parent) {
const ro = new ResizeObserver(() => {
const w = parent.clientWidth || 1;
const h = parent.clientHeight || 1;
this.renderer.setSize(w, h, false);
// Aspect always follows the 3D canvas (never the video) so nothing stretches; snap-to-
// camera only overrides the vertical fov + pose, matching the video's vertical framing.
this.camera.aspect = w / h;
this.camera.updateProjectionMatrix();
});
ro.observe(parent);
}
}
export const scene3d = new Scene3D();

View File

@ -1,9 +1,66 @@
// Global store: manifest, playhead (t_global), playing, rate, selected camera, audio source.
// STUB — lane C (M4) fills this in. Kept as an empty module so the file exists per the
// spec's repo layout; main.js does not import it yet.
// Global store: manifest, playhead (t_global), playing, rate, selected camera, audio source,
// plus the loaded poses / anchors / events and per-video live playback status. Single source
// of truth: the transport writes `tGlobal` every animation frame; every module reads it.
// Discrete changes (play/pause/seek/selection) go through the tiny pub/sub below.
// Example shape lane C will build toward:
// export const state = { manifest: null, tGlobal: 0, playing: false, rate: 1,
// selectedCamera: null, audioSource: null };
// API origin/prefix. Local dev talks to the uvicorn server cross-origin on :8000 (default).
// A hosted build sets VITE_API_BASE to a same-origin path prefix (e.g. "/festifun") so every
// request — fetch, <video> src, PLY loader — goes through the reverse proxy under that path.
// Empty string = same-origin at root. See deploy/DEPLOY.md.
// Phase-5 contract #5: a RUNTIME override wins over the build-time value — the memory capsule
// (M17) injects `window.__F4D_API_BASE__ = ""` into its baked index.html so the same dist
// bundle turns zero-backend. `??` (not `||`): empty string is a valid value (same-origin).
export const API_BASE =
window.__F4D_API_BASE__ ?? import.meta.env.VITE_API_BASE ?? "http://localhost:8000";
export {};
export const state = {
apiBase: API_BASE,
// loaded from the API (see main.js boot)
manifest: null,
videos: [], // [{id, filename, url, duration_s, fps, width, height, offset_ms, drift_ppm}]
tGlobalMax: 0,
hasPoses: false,
hasSplat: false, // optional 3DGS splat at /api/splat (preferred over the point cloud)
capsule: false, // manifest.capsule (M17): static baked bundle => hide ALL write UI
poses: {}, // id -> [{frame_idx, t_video_s, q:[w,x,y,z], t:[x,y,z], intrinsics, registered}]
anchors: [], // [{id, label, x, y, z, color}]
events: [], // [{id, t_global_s, duration_s, event_type, confidence, description, source}]
hasTracks: false, // manifest.has_tracks (M18): any solved friend track => load + render tracks
tracks: [], // [{id, marker_key, label, color, points:[{t_global_s,x,y,z,quality,views}]}] (M18)
// transport / playback
tGlobal: 0,
playing: false,
rate: 1,
audioSourceId: null, // video id whose audio you hear (WebAudio soundtrack; fallback: unmuted <video>)
spatialAudio: false, // 🎧 every camera is a positional emitter at its 3D pose; listener = viewer cam
enabled: {}, // id -> bool (per-video enable; disabled videos pause + dim + drop from sync)
inRange: {}, // id -> bool (t_video within [0, duration] and enabled)
syncErrorMs: {}, // id -> number | null (currentTime - target, ms; null when out of range)
// 3D viewer
followCameraId: null, // video id the viewer camera is snapped to, or null for free roam
};
// --- tiny pub/sub for discrete events -------------------------------------------------------
const listeners = new Map();
/** Subscribe to an event; returns an unsubscribe fn. */
export function on(evt, fn) {
if (!listeners.has(evt)) listeners.set(evt, new Set());
listeners.get(evt).add(fn);
return () => listeners.get(evt)?.delete(fn);
}
/** Emit an event to all subscribers. */
export function emit(evt, payload) {
const set = listeners.get(evt);
if (set) for (const fn of [...set]) fn(payload);
}
/** The reference video (offset 0) if present, else the first video. Used as default audio source. */
export function referenceVideoId() {
const ref = state.videos.find((v) => (v.offset_ms ?? 0) === 0);
return ref ? ref.id : state.videos[0]?.id ?? null;
}

View File

@ -1,6 +1,161 @@
// Scrubber + event markers (spec M4 transport + M7 timeline markers).
// STUB — lane C fills this in. Render GET /api/events as colored markers (color by
// event_type, legend in a corner); hover → description tooltip; click → jump the playhead.
// The event data is already seeded synthetically; classification quality is lane D's job.
// Timeline scrubber + event markers (spec M4 transport + M7 markers).
//
// Renders a draggable scrubber over [0, tGlobalMax] with the playhead, plus GET /api/events as
// colored ticks (color by event_type, legend in the corner). Hover a marker -> description
// tooltip; click a marker -> jump the playhead there. Event data is already seeded
// synthetically; classification QUALITY is lane D's concern, not this renderer's.
export {};
import { state, emit } from "./state.js";
export const EVENT_COLORS = {
bass_drop: "#ff3b6b",
pyro: "#ff8c1a",
confetti: "#ffd23b",
crowd_wave: "#3bc9ff",
artist_moment: "#c77dff",
light_show: "#59d499",
quiet_moment: "#8a92a6",
candidate: "#c0c0c0",
other: "#9aa3b2",
};
export function eventColor(type) {
return EVENT_COLORS[type] || EVENT_COLORS.other;
}
function fmtTime(s) {
s = Math.max(0, s);
const m = Math.floor(s / 60);
const sec = s - m * 60;
return `${m}:${sec.toFixed(1).padStart(4, "0")}`;
}
export class Timeline {
constructor() {
this.max = 1;
}
init(root, transport) {
this.transport = transport;
this.max = state.tGlobalMax || 1;
root.innerHTML = "";
const track = document.createElement("div");
track.className = "tl-track";
const fill = document.createElement("div");
fill.className = "tl-fill";
const markers = document.createElement("div");
markers.className = "tl-markers";
const playhead = document.createElement("div");
playhead.className = "tl-playhead";
track.append(fill, markers, playhead);
const tip = document.createElement("div");
tip.className = "tl-tooltip";
tip.style.display = "none";
root.append(track, tip);
this.root = root;
this.track = track;
this.fill = fill;
this.markers = markers;
this.playhead = playhead;
this.tip = tip;
this._buildMarkers(markers, tip);
this._buildLegend(root);
this._wireScrub(track);
}
/** Rebuild markers + legend after events change (e.g. an M8 type correction). */
refresh() {
if (!this.markers) return;
this.markers.innerHTML = "";
this._buildMarkers(this.markers, this.tip);
this.root.querySelector(".tl-legend")?.remove();
this._buildLegend(this.root);
}
_buildMarkers(container, tip) {
for (const ev of state.events) {
const m = document.createElement("div");
m.className = "tl-marker";
m.style.left = `${(ev.t_global_s / this.max) * 100}%`;
m.style.background = eventColor(ev.event_type);
m.addEventListener("click", (e) => {
e.stopPropagation();
this.transport.seek(ev.t_global_s);
emit("event-selected", ev); // open the M8 correction panel
});
const show = (e) => {
tip.innerHTML =
`<strong>${ev.event_type.replace(/_/g, " ")}</strong> · ${fmtTime(ev.t_global_s)}` +
`<br>${ev.description || ""}` +
(ev.confidence != null
? `<br><span class="tl-conf">confidence ${(ev.confidence * 100).toFixed(0)}% · ${ev.source}</span>`
: "");
tip.style.display = "block";
const rect = this.track.getBoundingClientRect();
const x = (ev.t_global_s / this.max) * rect.width;
tip.style.left = `${Math.max(4, Math.min(rect.width - 4, x))}px`;
};
m.addEventListener("mouseenter", show);
m.addEventListener("mousemove", show);
m.addEventListener("mouseleave", () => (tip.style.display = "none"));
container.append(m);
}
}
_buildLegend(root) {
const types = [...new Set(state.events.map((e) => e.event_type))];
if (types.length === 0) return;
const legend = document.createElement("div");
legend.className = "tl-legend";
for (const t of types) {
const item = document.createElement("span");
item.className = "tl-legend-item";
item.innerHTML = `<i style="background:${eventColor(t)}"></i>${t.replace(/_/g, " ")}`;
legend.append(item);
}
root.append(legend);
}
_wireScrub(track) {
let dragging = false;
const seekTo = (clientX) => {
const rect = track.getBoundingClientRect();
const f = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
this.transport.seek(f * this.max);
};
track.addEventListener("pointerdown", (e) => {
dragging = true;
track.setPointerCapture(e.pointerId);
seekTo(e.clientX);
});
track.addEventListener("pointermove", (e) => {
if (dragging) seekTo(e.clientX);
});
const end = (e) => {
dragging = false;
try {
track.releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
track.addEventListener("pointerup", end);
track.addEventListener("pointercancel", end);
}
/** Move the playhead + fill to the current master time. */
update(tGlobal) {
const f = this.max > 0 ? Math.max(0, Math.min(1, tGlobal / this.max)) : 0;
this.playhead.style.left = `${f * 100}%`;
this.fill.style.width = `${f * 100}%`;
}
}
export const timeline = new Timeline();

View File

@ -1,8 +1,525 @@
// Master clock + per-video sync (spec M4 — the heart of the app).
// STUB — lane C fills this in. Build and verify the master-clock transport before the 3D
// work. Use the timebase from config.py (mirrored here):
// t_video = (t_global - offset_ms/1000) * (1 + drift_ppm * 1e-6)
// Never trust a single <video> element as the clock; drive t_global from performance.now()
// and continuously correct each video (hard-seek >150ms, nudge playbackRate 20150ms).
// Master clock + per-video synchronization (spec M4 — the heart of the app).
//
// AUDIO IS THE MASTER CLOCK when possible: the selected audio source's soundtrack (fetched from
// /api/audio/{id}, decoded once) plays through WebAudio, and t_global is derived from
// AudioContext.currentTime — sample-accurate, immune to rAF throttling, and NEVER corrected.
// Every <video> element is muted picture-only and is seeked / rate-trimmed freely against that
// clock with zero audible artifacts (previously the unmuted video's own corrections warbled).
// If the track can't be fetched/decoded, we fall back per-session to the legacy behavior:
// performance.now() clock + the selected video's element audio unmuted.
// We NEVER trust a <video> element as the clock (HTML5 video is not frame-accurate —
// pitfall #2). Correction is continuous:
// |err| > 150 ms -> hard seek (fastSeek where available)
// 20 ms < |err| <= 150 ms -> nudge playbackRate within master rate x[0.95, 1.05]
// |err| <= 20 ms -> locked: playbackRate = master rate
//
// While PLAYING we measure error per presented frame via requestVideoFrameCallback: `mediaTime`
// is the exact timestamp of the frame on screen and `expectedDisplayTime` is when it shows, so
// err = mediaTime - target(clock @ expectedDisplayTime) is the true on-screen sync error (far
// better than sampling the frame-quantized currentTime in rAF). Browsers without rVFC fall back
// to a currentTime measurement inside the rAF tick(). While PAUSED / seeking, videos are parked
// on their target frame via currentTime. Videos whose target t_video falls outside [0, duration]
// pause and are marked out-of-range (the grid dims them).
export {};
import { state, emit, on } from "./state.js";
import { tVideoFromGlobal } from "./lib/timebase.js";
const HARD_SEEK_S = 0.15; // > this error => hard seek
const NUDGE_MIN_S = 0.02; // > this (and <= HARD_SEEK) => trim playbackRate
const RATE_TRIM = 0.05; // max ±5% playbackRate trim while nudging
export class Transport {
constructor() {
this.cells = []; // [{ id, meta, video, ... }] from videoGrid
this._t0 = 0; // master-timeline anchor (seconds)
this._wall0 = performance.now(); // wall-clock anchor (ms) matching _t0
this._visWired = false;
// WebAudio master clock (see header). All lazy: nothing is created until needed.
this._actx = null; // AudioContext
this._ctx0 = 0; // AudioContext.currentTime anchor (s) matching _t0
this._audioActive = false; // audio clock is currently driving t_global
this._buffers = new Map(); // video id -> AudioBuffer (decoded) | null (unavailable)
this._decoding = new Set(); // video ids with a fetch/decode in flight
this._tracks = new Map(); // playing tracks: video id -> { src, gain, panner|null }
this._captureDest = null; // MediaStreamAudioDestinationNode while an export taps the mix (M12)
}
/** Register the video cells the transport drives and start their frame loops. */
register(cells) {
this.cells = cells;
this._applyAudio();
this._loadTrack(state.audioSourceId); // start fetching the soundtrack immediately
for (const cell of this.cells) this._startFrameLoop(cell);
if (!this._enabledWired) {
// In spatial mode a toggled video's emitter must join/leave the running mix.
on("enabled", () => {
if (state.spatialAudio) this._restartIfPlaying();
});
this._enabledWired = true;
}
if (!this._visWired) {
// A throttled/backgrounded tab stalls rAF while decoders free-run; re-lock on return.
document.addEventListener("visibilitychange", () => {
if (!document.hidden) this._resync();
});
this._visWired = true;
}
}
/** Master-timeline seconds at a given wall-clock (ms) timestamp, clamped to [0, tGlobalMax]. */
_clockAt(wallMs) {
if (!state.playing) return this._t0;
let elapsed;
if (this._audioActive) {
// Audio clock: map the wall timestamp onto the AudioContext clock (the two clocks tick
// together; only their epochs differ, and this mapping is sub-ms), measure from anchor.
const ctxNow = this._actx.currentTime + (wallMs - performance.now()) / 1000;
elapsed = ctxNow - this._ctx0;
} else {
elapsed = (wallMs - this._wall0) / 1000;
}
const t = this._t0 + elapsed * state.rate;
if (t <= 0) return 0;
if (t >= state.tGlobalMax) return state.tGlobalMax;
return t;
}
computeTGlobal() {
return this._clockAt(performance.now());
}
_targetFor(meta, tGlobal) {
return tVideoFromGlobal(tGlobal, meta.offset_ms || 0, meta.drift_ppm || 0);
}
play() {
if (state.playing) return;
let t = state.tGlobal;
if (t >= state.tGlobalMax - 1e-3) t = 0; // restart from the top if parked at the end
this._t0 = t;
this._wall0 = performance.now();
state.tGlobal = t;
state.playing = true;
this._startAudio(t); // play() is a user gesture -> AudioContext may resume / videos unmute
emit("play");
}
pause() {
if (!state.playing) return;
state.tGlobal = this.computeTGlobal(); // BEFORE stopping the audio clock's source node
this._t0 = state.tGlobal;
state.playing = false;
this._stopTracks();
this._audioActive = false;
for (const { video } of this.cells) this._safePause(video);
emit("pause");
}
toggle() {
if (state.playing) this.pause();
else this.play();
}
/** Jump the master timeline; parks every in-range video on its target frame immediately. */
seek(t) {
t = Math.max(0, Math.min(state.tGlobalMax, t));
this._t0 = t;
this._wall0 = performance.now();
state.tGlobal = t;
if (state.playing) this._startAudio(t); // restart the soundtrack at the new position
for (const cell of this.cells) {
const target = this._targetFor(cell.meta, t);
const inRange =
state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s;
state.inRange[cell.id] = inRange;
if (inRange) {
this._hardSeek(cell.video, target);
cell.video.playbackRate = state.rate;
state.syncErrorMs[cell.id] = 0;
} else {
state.syncErrorMs[cell.id] = null;
this._safePause(cell.video);
}
}
emit("seek", t);
}
/** Nudge the playhead by a delta (keyboard ±1 s). */
seekBy(dt) {
this.seek((state.playing ? this.computeTGlobal() : this._t0) + dt);
}
/** Set playback speed, rebasing the clock so t_global stays continuous. */
setRate(r) {
state.tGlobal = this.computeTGlobal();
this._t0 = state.tGlobal;
this._wall0 = performance.now();
state.rate = r;
for (const { video } of this.cells) video.playbackRate = r; // re-trimmed by correction
if (state.playing) this._startAudio(state.tGlobal); // restart the track at the new rate
emit("rate", r);
}
/** Choose whose audio you hear (the WebAudio track when decoded, else that video unmuted). */
setAudioSource(id) {
state.audioSourceId = id;
this._loadTrack(id);
this._restartIfPlaying();
emit("audiosource", id);
}
/** 🎧 Toggle spatial audio: every camera becomes a positional emitter at its 3D pose. */
setSpatial(enabled) {
state.spatialAudio = !!enabled;
if (state.spatialAudio) for (const { id } of this.cells) this._loadTrack(id);
this._restartIfPlaying();
emit("spatial", state.spatialAudio);
}
_restartIfPlaying() {
if (state.playing) this._startAudio(this.computeTGlobal());
else this._applyAudio();
}
_applyAudio() {
// With a decoded soundtrack the WebAudio track is the only audible thing: every video is
// muted picture-only. Fallback (no track): the selected video's element audio, as before.
const trackReady = state.spatialAudio
? [...this._buffers.values()].some((b) => b instanceof AudioBuffer)
: this._buffers.get(state.audioSourceId) instanceof AudioBuffer;
for (const { id, video } of this.cells) {
const isSrc = !trackReady && id === state.audioSourceId;
video.muted = !isSrc;
if (isSrc) video.volume = 1;
}
}
// --- WebAudio master clock ----------------------------------------------------------------
_ensureCtx() {
if (!this._actx) this._actx = new (window.AudioContext || window.webkitAudioContext)();
return this._actx;
}
/** Fetch + decode a video's soundtrack once; upgrade the running clock when it lands. */
async _loadTrack(id) {
if (id == null || this._buffers.has(id) || this._decoding.has(id)) return;
if (typeof window === "undefined" || !(window.AudioContext || window.webkitAudioContext)) return;
this._decoding.add(id);
try {
const resp = await fetch(`${state.apiBase}/api/audio/${id}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const buf = await this._ensureCtx().decodeAudioData(await resp.arrayBuffer());
// Decoded PCM is big (~23 MB/min stereo 48k): keep only a few tracks around
// (spatial mode needs one per camera, so the cap widens to the video count).
const cap = state.spatialAudio ? Math.max(this.cells.length, 3) : 3;
for (const key of this._buffers.keys()) {
if (this._buffers.size < cap) break;
if (key !== state.audioSourceId) this._buffers.delete(key);
}
this._buffers.set(id, buf);
} catch (err) {
console.warn(`[transport] soundtrack for video ${id} unavailable — using <video> audio`, err);
this._buffers.set(id, null);
} finally {
this._decoding.delete(id);
}
// A track landing mid-play joins in place: in spatial mode it enters the running mix; as
// the selected source it upgrades the fallback clock (both clocks agree on t_global).
const decoded = this._buffers.get(id) instanceof AudioBuffer;
if (
state.playing &&
decoded &&
(state.spatialAudio || (id === state.audioSourceId && !this._audioActive))
) {
this._startAudio(this.computeTGlobal());
} else if (!state.playing) {
this._applyAudio();
}
}
/**
* (Re)start the soundtrack at t_global and re-anchor the master clock. Single-track mode
* plays the selected source; spatial mode plays every enabled camera's track through a
* PannerNode at its 3D pose. Falls back to the performance.now() clock (selected video
* unmuted) when no decoded track is available.
*/
_startAudio(tGlobal) {
this._stopTracks();
this._t0 = tGlobal;
this._wall0 = performance.now();
const ready = (id) => this._buffers.get(id) instanceof AudioBuffer;
const ids = state.spatialAudio
? this.cells.filter((c) => state.enabled[c.id] !== false && ready(c.id)).map((c) => c.id)
: ready(state.audioSourceId)
? [state.audioSourceId]
: [];
if (ids.length === 0) {
this._audioActive = false;
this._applyAudio(); // fallback: unmute the selected video
return;
}
const ctx = this._ensureCtx();
if (ctx.state === "suspended") ctx.resume(); // play()/click gestures allow this
// N time-aligned copies of the same sound sum loud — normalize by 1/sqrt(N).
const gainValue = 1 / Math.sqrt(ids.length);
for (const id of ids) this._startTrack(ctx, id, tGlobal, state.spatialAudio, gainValue);
// Even with no live node (source out of range => silence), the audio clock drives time.
this._ctx0 = ctx.currentTime;
this._audioActive = true;
this._applyAudio();
}
_startTrack(ctx, id, tGlobal, spatial, gainValue) {
const buf = this._buffers.get(id);
const meta =
this.cells.find((c) => c.id === id)?.meta ?? state.videos.find((v) => v.id === id);
const drift = 1 + (meta?.drift_ppm || 0) * 1e-6;
const tv = tVideoFromGlobal(tGlobal, meta?.offset_ms || 0, meta?.drift_ppm || 0);
if (tv >= buf.duration) return; // this camera's audio is already over
const src = ctx.createBufferSource();
src.buffer = buf;
// The track's local time must advance at rate·(1+drift) per global second.
src.playbackRate.value = state.rate * drift;
const gain = ctx.createGain();
gain.gain.value = gainValue;
src.connect(gain);
let panner = null;
if (spatial) {
panner = ctx.createPanner();
panner.panningModel = "HRTF";
panner.distanceModel = "inverse";
panner.refDistance = 2; // scene units — cameras sit ~515 from the stage at COLMAP scale
panner.rolloffFactor = 0.6;
gain.connect(panner);
panner.connect(ctx.destination);
} else {
gain.connect(ctx.destination);
}
if (this._captureDest) (panner ?? gain).connect(this._captureDest); // live export keeps hearing us
if (tv >= 0) src.start(0, tv);
else src.start(ctx.currentTime + -tv / state.rate, 0); // camera starts later than t_global
this._tracks.set(id, { src, gain, panner });
}
/**
* M12 hook (phase-5 contract #6): a MediaStream carrying the live WebAudio mix tapped
* post-gain, after the spatial panner when 🎧 is on for MediaRecorder muxing. Tracks that
* (re)start while the tap is live (seeks, source switches) join it automatically
* (_startTrack above). With no decoded track (fallback <video> audio) the stream is silent
* degrade, don't block (pitfall #5). Call releaseAudioStream() when the export ends.
*/
captureAudioStream() {
const ctx = this._ensureCtx();
if (!this._captureDest) {
this._captureDest = ctx.createMediaStreamDestination();
for (const { gain, panner } of this._tracks.values())
(panner ?? gain).connect(this._captureDest);
}
return this._captureDest.stream;
}
/** Drop the M12 export tap (the matching half of captureAudioStream()). */
releaseAudioStream() {
if (!this._captureDest) return;
for (const { gain, panner } of this._tracks.values()) {
try {
(panner ?? gain).disconnect(this._captureDest);
} catch {
/* already disconnected */
}
}
this._captureDest = null;
}
/**
* 🎧 Per-frame 3D audio update (called by scene3d): the listener follows the viewer camera
* and each playing track's panner sits at its camera's current pose. Takes plain numbers in
* scene (Three.js) space so the transport stays THREE-free.
*/
updateSpatial(listener, positions) {
if (!state.spatialAudio || !this._audioActive || !this._actx) return;
const L = this._actx.listener;
if (L.positionX) {
L.positionX.value = listener.px;
L.positionY.value = listener.py;
L.positionZ.value = listener.pz;
L.forwardX.value = listener.fx;
L.forwardY.value = listener.fy;
L.forwardZ.value = listener.fz;
L.upX.value = listener.ux;
L.upY.value = listener.uy;
L.upZ.value = listener.uz;
} else {
// Safari: AudioListener still uses the legacy setters.
L.setPosition(listener.px, listener.py, listener.pz);
L.setOrientation(listener.fx, listener.fy, listener.fz, listener.ux, listener.uy, listener.uz);
}
for (const [id, { panner }] of this._tracks) {
const pos = positions[id];
if (!panner || !pos) continue;
if (panner.positionX) {
panner.positionX.value = pos.x;
panner.positionY.value = pos.y;
panner.positionZ.value = pos.z;
} else {
panner.setPosition(pos.x, pos.y, pos.z);
}
}
}
_stopTracks() {
for (const { src, gain, panner } of this._tracks.values()) {
try {
src.stop();
} catch {
/* never started or already ended */
}
try {
src.disconnect();
gain.disconnect();
panner?.disconnect();
} catch {
/* ignore */
}
}
this._tracks.clear();
}
// Per-presented-frame correction via requestVideoFrameCallback (accurate). Self-sustaining:
// each callback re-registers the next. No-ops while paused / out of range; rAF tick() handles
// the fallback path for browsers without rVFC.
_startFrameLoop(cell) {
const { video } = cell;
if (typeof video.requestVideoFrameCallback !== "function") {
cell._useRvfc = false;
return;
}
cell._useRvfc = true;
cell._lastRvfc = -Infinity;
const cb = (now, meta) => {
cell._rvfcId = video.requestVideoFrameCallback(cb);
cell._lastRvfc = performance.now();
if (!state.playing || !state.inRange[cell.id]) return;
const displayWall = meta.expectedDisplayTime ?? now;
const target = this._targetFor(cell.meta, this._clockAt(displayWall));
// mediaTime is the exact PTS of the on-screen frame — no quantization compensation needed.
this._correct(cell, meta.mediaTime, target, true);
};
cell._rvfcId = video.requestVideoFrameCallback(cb);
}
_correct(cell, measured, target, allowSeek) {
const { id, video } = cell;
const err = measured - target; // + => video is ahead of where it should be
state.syncErrorMs[id] = err * 1000;
if (allowSeek && Math.abs(err) > HARD_SEEK_S) {
this._hardSeek(video, target);
video.playbackRate = state.rate;
} else if (Math.abs(err) > NUDGE_MIN_S) {
// Ahead (err>0) => slow down; behind (err<0) => speed up. Proportional, clamped ±5%.
const trim = Math.max(-1, Math.min(1, err / HARD_SEEK_S)) * RATE_TRIM;
video.playbackRate = state.rate * (1 - trim);
} else {
video.playbackRate = state.rate;
}
}
_hardSeek(video, target) {
if (typeof video.fastSeek === "function") {
try {
video.fastSeek(target);
return;
} catch {
/* fall through */
}
}
video.currentTime = target;
}
/** Re-lock every in-range video to the current master time (after a tab-visibility stall). */
_resync() {
// Compute fresh: with the audio clock the soundtrack kept playing while rAF (and therefore
// state.tGlobal) was frozen, so the live clock may be well past the last stored value.
const t = this.computeTGlobal();
state.tGlobal = t;
for (const cell of this.cells) {
const target = this._targetFor(cell.meta, t);
if (state.enabled[cell.id] !== false && target >= 0 && target <= cell.meta.duration_s) {
this._hardSeek(cell.video, target);
cell.video.playbackRate = state.rate;
}
}
}
/** Drive one animation-frame tick: advance the clock and manage each video. */
tick() {
const t = this.computeTGlobal();
state.tGlobal = t;
if (state.playing && t >= state.tGlobalMax) {
this.pause();
return;
}
for (const cell of this.cells) this._manage(cell, t);
}
// Play/pause + range management every rAF. For non-rVFC browsers this also runs correction
// from the (frame-quantized) currentTime; with rVFC, correction happens in the frame loop.
_manage(cell, tGlobal) {
const { id, meta, video } = cell;
const target = this._targetFor(meta, tGlobal);
const enabled = state.enabled[id] !== false;
const inRange = enabled && target >= 0 && target <= meta.duration_s;
state.inRange[id] = inRange;
if (!inRange) {
state.syncErrorMs[id] = null;
this._safePause(video);
return;
}
if (state.playing) {
this._ensurePlaying(video);
// rVFC drives correction when it's firing. Fall back to a currentTime measurement when
// rVFC is unavailable OR stale (>100 ms) — e.g. a tab where rVFC is throttled but rAF runs.
const rvfcStale = !cell._useRvfc || performance.now() - (cell._lastRvfc ?? -Infinity) > 100;
if (rvfcStale) this._correct(cell, this._measureFallback(cell), target, true);
} else {
this._safePause(video);
state.syncErrorMs[id] = (this._measureFallback(cell) - target) * 1000;
}
}
// Estimate a playing video's true position from currentTime. currentTime is quantized to the
// displayed frame (whose PTS is <= the true position), so add half a frame to de-bias it. The
// rVFC path uses exact mediaTime instead and needs no such correction.
_measureFallback(cell) {
const fps = cell.meta.fps || 30;
const halfFrame = state.playing ? (0.5 / fps) * state.rate : 0;
return cell.video.currentTime + halfFrame;
}
_ensurePlaying(video) {
if (video.paused && !video._playPending && video.readyState >= 2) {
video._playPending = true;
video.play().then(
() => (video._playPending = false),
() => (video._playPending = false)
);
}
}
_safePause(video) {
if (video._playPending) return; // let the pending play() settle; next tick re-evaluates
if (!video.paused) {
try {
video.pause();
} catch {
/* ignore */
}
}
}
}
export const transport = new Transport();

View File

@ -1,5 +1,114 @@
// <video> grid + per-video transparent overlay canvases (spec M4/M6).
// STUB — lane C fills this in. Mind spec pitfall #4: object-fit letterboxing means canvas
// px != video px; compute each video's content rect for the overlay canvas.
// <video> grid + per-video transparent overlay canvases (spec M4 / M6).
//
// Each cell stacks a <video> (object-fit: contain) under a transparent <canvas> that exactly
// covers the element box. Because object-fit letterboxes, the video's CONTENT rect is smaller
// than the element (pitfall #4) — contentRect() computes it so overlays.js can map projected
// video-pixel coordinates to the right place on the canvas.
export {};
import { state, emit } from "./state.js";
/**
* Build the grid inside `container`. Returns cell descriptors:
* { id, meta, el, video, canvas, statusEl, audioBtn, snapBtn }
*/
export function createVideoGrid(container, videos, apiBase, transport) {
container.innerHTML = "";
const cells = [];
for (const meta of videos) {
const el = document.createElement("div");
el.className = "cell";
el.dataset.id = String(meta.id);
const video = document.createElement("video");
video.className = "cell-video";
video.src = apiBase + meta.url;
video.muted = true;
video.loop = false;
video.playsInline = true;
video.setAttribute("playsinline", "");
video.setAttribute("webkit-playsinline", "");
video.preload = "auto";
// No crossOrigin: we never read video pixels (overlays are a separate canvas), so we avoid
// making playback depend on CORS headers for the /media mount.
const canvas = document.createElement("canvas");
canvas.className = "cell-overlay";
const bar = document.createElement("div");
bar.className = "cell-bar";
const label = document.createElement("span");
label.className = "cell-label";
label.textContent = meta.filename;
const audioBtn = document.createElement("button");
audioBtn.className = "cell-btn audio-btn";
audioBtn.title = "Use this video's audio";
audioBtn.textContent = "🔈";
audioBtn.addEventListener("click", (e) => {
e.stopPropagation();
transport.setAudioSource(meta.id);
});
const snapBtn = document.createElement("button");
snapBtn.className = "cell-btn snap-btn";
snapBtn.title = "Snap 3D view to this camera";
snapBtn.textContent = "⛶";
// wired in main.js (needs scene3d)
const enableBtn = document.createElement("button");
enableBtn.className = "cell-btn enable-btn";
enableBtn.title = "Enable / disable this video";
enableBtn.textContent = "◉";
enableBtn.addEventListener("click", (e) => {
e.stopPropagation();
const now = state.enabled[meta.id] !== false;
state.enabled[meta.id] = !now;
emit("enabled", { id: meta.id, enabled: !now });
});
const status = document.createElement("span");
status.className = "cell-status";
bar.append(label, status, enableBtn, snapBtn, audioBtn);
el.append(video, canvas, bar);
container.append(el);
// Clicking the video frame selects its audio (natural "listen to this one" gesture).
video.addEventListener("click", () => transport.setAudioSource(meta.id));
cells.push({ id: meta.id, meta, el, video, canvas, statusEl: status, audioBtn, snapBtn });
}
return cells;
}
/**
* The video's displayed content rectangle inside its element, in CSS pixels, accounting for
* object-fit: contain letterboxing. Maps native video pixels -> element pixels via:
* x = ox + (u / vw) * cw , y = oy + (v / vh) * ch
*/
export function contentRect(video) {
const elW = video.clientWidth;
const elH = video.clientHeight;
const vw = video.videoWidth || 1;
const vh = video.videoHeight || 1;
const elAspect = elW / elH;
const vidAspect = vw / vh;
let cw, ch, ox, oy;
if (vidAspect > elAspect) {
// wider than the box -> pillarbox: full width, bars top/bottom
cw = elW;
ch = elW / vidAspect;
ox = 0;
oy = (elH - ch) / 2;
} else {
// taller than the box -> letterbox: full height, bars left/right
ch = elH;
cw = elH * vidAspect;
oy = 0;
ox = (elW - cw) / 2;
}
return { ox, oy, cw, ch, elW, elH, vw, vh };
}

9
frontend/vite.config.js Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from "vite";
// `base` is the public path the built assets are served from.
// - local dev / root hosting: "/" (default)
// - hosted under a path prefix: "/festifun/" via FESTIVAL4D_BASE
// Pair with VITE_API_BASE (see src/state.js) so API + media requests use the same prefix.
export default defineConfig({
base: process.env.FESTIVAL4D_BASE || "/",
});

198
plan/20-phase5.md Normal file
View File

@ -0,0 +1,198 @@
# Phase 5 — Friends, Director's Cut & Capsules (M10M17)
Canonical spec for the phase-5 milestones. **M0M9 live in [`../OPUS_BUILD_INSTRUCTIONS.md`](../OPUS_BUILD_INSTRUCTIONS.md)
and every contract frozen there still applies** (timebase, DB schema, pose convention,
classifier contract, synthetic fixture). Standing protocol (status files, directives loop,
evidence discipline, worktrees) is unchanged from [`README.md`](README.md); the newest round of
[`DIRECTIVES.md`](DIRECTIVES.md) always wins.
## Context — what shipped since v0.1.0 (read before planning anything)
- **Splat rendering:** `GET /api/splat` + viewer `DropInViewer` with point-cloud fallback
(`docs/modelbeast-crossover.md`).
- **Live capture:** `FESTIVAL4D_CAPTURE=1` mounts `/capture` (README).
- **WebAudio master clock:** `t_global` derives from `AudioContext.currentTime`;
`GET /api/audio/{id}` serves a lazily-extracted stereo AAC per video; all `<video>` elements
are muted picture-only, with graceful fallback to the old `performance.now()` clock
(`frontend/src/transport.js` header comment is the reference).
- **Spatial audio (🎧):** per-camera HRTF `PannerNode`s at live poses; listener follows the
viewer camera (`transport.updateSpatial`, fed by `scene3d._updateAudio`).
- **Sync solver:** pairwise GCC-PHAT now reuses one rFFT per signal (`audio_sync.pairwise_offsets`).
- **Suite floor is 121 passed.** It only goes up.
## Phase map
```
Phase 5a (serial, ONE agent) Phase 5b (parallel, one agent per lane) Phase 5c (serial, ONE agent)
┌───────────────────────┐ ┌ lane/e-director ── M10 M11 M12 ─┐ ┌───────────────────────────┐
│ foundation2 │ ├ lane/f-social ──── M13 M14 M15 ─┤ │ integration2 │
│ contracts + stubs + │──merge──┤ ├──merge──│ full-system pass + │
│ UI wiring points │ └ lane/g-capsule ─── M16 M17 ─────┘ │ real-footage field test │
│ branch: foundation2 │ (independent — merge in any order) │ branch: integration2 │
└───────────────────────┘ └─────────────────────────────────── └───────────────────────────┘
```
Lanes depend **only** on foundation2, never on each other. Everything is developed and verified
against the synthetic fixture first; real footage is integration2's job.
## Milestones
### M10 — Audio features: beats & onsets (lane E, backend)
`backend/festival4d/audio_features.py`, entrypoint `run_features()`, CLI `python -m festival4d features`.
Load the **reference** video's ingest WAV (16 kHz mono; the reference timeline *is* `t_global`).
Beat/tempo tracking + onset-strength detection (librosa is already a dependency). Write
`data/work/beats.json` (shape frozen below); `GET /api/beats` serves it, 404 when absent.
Quiet/short/beatless audio must produce a *valid, empty* result — never a crash.
**Accept:** synthetic master audio's known pulse grid recovered (beats within ±50 ms, tempo ±2 BPM);
silence → empty-but-valid JSON; tests in `backend/tests/test_audio_features.py`.
### M11 — Auto-director (lane E, backend + thin UI)
`backend/festival4d/director.py`, entrypoint `generate_path(top_n=8, lead_s=2.0)`, CLI
`python -m festival4d direct`, `POST /api/director` (stubbed by foundation2) returning a
**camPath-compatible JSON** (contract below). Deterministic v1 rules — no ML:
pick the top-N events by confidence (ties → earlier); for each, choose the registered camera
with a pose nearest the event time (via `db` + `geometry.colmap_to_threejs`); keyframes at
`t_event lead_s` and `t_event + duration`; when `beats.json` exists, snap keyframe times to
the nearest beat; FOV from that camera's intrinsics (same formula as `scene3d.snapTo`).
Frontend: fill the stub `frontend/src/director.js` — the hidden **🎬 Auto-path** button fetches
`/api/director` and loads it via `camPath.fromJSON` (do not edit camPath.js).
**Accept:** output round-trips through `camPath.fromJSON` (structure-locked test against the
frozen shape); covers the top-N events; keyframe times land on beats when beats exist; live
browser evidence of the path playing.
### M12 — Cinematic export (lane E, frontend)
Fill the stub `frontend/src/exportVideo.js`. The hidden **⏺ Export** button: seek to the first
keyframe, play the path, capture `scene3d` canvas via `captureStream(30)` + the soundtrack via
the `transport.captureAudioStream()` hook (foundation2 provides it — taps the WebAudio graph
post-gain, spatial mix included), mux with `MediaRecorder` (vp9/opus webm), stop at the last
keyframe, download `festival4d-cut.webm`. Restore all prior UI/transport state afterwards.
**Accept:** exported file duration within ±5% of the path span, contains an audio track, plays
in Chrome — browser evidence (file size, duration probe via ffprobe) in the status file.
### M13 — Anchor manager & friend tags (lane F, frontend)
Fill the stub `frontend/src/anchorPanel.js`: an always-available collapsible panel listing every
anchor (color dot, label, jump-to, delete, rename/recolor via the new `PATCH /api/anchors/{id}`).
Jump-to uses the foundation2 hook `scene3d.focusOn(x, y, z)`. Add the "friend tag" flow: a
** Tag** action that runs the existing M8 bbox-annotation flow *without* an event and names the
resulting anchor (friends become labeled, colored anchors that x-ray onto every video for free).
**Accept:** create → rename → recolor → jump → delete round-trip live in the browser; labels
visible in the 3D scene and video overlays; anchors survive reload.
### M14 — Photo mode (lane F, frontend)
Fill the stub `frontend/src/photoMode.js`. **📷 / `P`**: pause, hide helpers + gizmos + anchor
labels (foundation2 hook `scene3d.setHelpersVisible(false)`), render one frame at 3840-wide
(preserving aspect) to an offscreen target, download PNG, restore everything.
**Accept:** PNG ≥ 3840 px wide with no grid/axes/frusta/gizmos; works in free-roam and
follow-cam; with a splat present it renders the splat (verify on the synthetic point cloud +
note the splat path).
### M15 — Moment FX (lane F, frontend)
Fill the stub `frontend/src/fx.js`. When the playhead crosses a `pyro`/`confetti` event, fire a
short particle burst at the event's resolved anchor (fall back to the stage centroid); on
`bass_drop`, pulse the point-cloud/splat scale for ~a beat. Toggle via the hidden **✨** button;
uses only `scene3d.addObject/removeObject`. Must fire when crossing markers in play *and* scrub.
**Accept:** browser evidence of both effect types; frame rate stays ≥ 30 fps on the synthetic
scene with FX enabled.
### M16 — Server-side camera paths (lane G, backend + thin UI)
Implement the foundation2-stubbed routes over the new `paths` table:
`GET /api/paths` (list), `GET /api/paths/{id}`, `POST /api/paths {name, json}`,
`DELETE /api/paths/{id}``json` is the frozen camPath JSON as text, validated on POST.
Fill the stub `frontend/src/pathsStore.js`: save-current-path (name prompt), load dropdown,
delete — wired to the hidden path-save controls.
**Accept:** endpoint tests (roundtrip deep-equal, 404s, invalid-JSON 422) in
`backend/tests/test_paths.py`; live save → reload page → load reproduces keyframes exactly.
### M17 — Memory capsule: static shareable export (lane G, backend)
`backend/festival4d/capsule.py`, CLI `python -m festival4d capsule [--out DIR]` (default
`data/capsule/`). Build a **zero-backend** bundle:
copy `frontend/dist` (error clearly if missing — tell the user to `npm run build`); copy media;
bake every read-only API response to files at the *same relative paths* (`api/manifest`,
`api/events`, `api/anchors`, `api/beats`, `api/videos/{id}/poses`, `api/pointcloud`, `api/splat`,
`api/audio/{id}` — extracting audio if not yet cached); inject
`<script>window.__F4D_API_BASE__=""</script>` into the copied `index.html`; set
`"capsule": true` in the baked manifest (the frontend hides all write UI — foundation2 wires
the flag); ship a stdlib **`serve.py`** in the bundle root that serves with HTTP Range support
(video seeking needs 206s; plain `http.server` can't).
**Accept:** `python -m festival4d capsule` on the synthetic project, then `python serve.py` in
the bundle: app loads with **zero** requests to :8000, videos play *and seek*, 3D scene +
timeline + spatial audio work, write UI absent; `backend/tests/test_capsule.py` covers bundle
structure, baked-JSON validity, and serve.py Range (206) behavior.
## Contracts (frozen at foundation2 merge)
1. **beats.json** (and the `GET /api/beats` body):
`{"tempo_bpm": float|null, "beats_s": [float], "onsets": [{"t_global_s": float, "strength": float 0..1}], "generated_by": "festival4d.audio_features"}`
— all times are `t_global` seconds.
2. **camPath JSON** (already shipped in `camPath.js`; now frozen):
`{"version": 1, "keyframes": [{"t_global": float, "pos": [x,y,z], "quat": [x,y,z,w], "fov": float}]}`
**Three.js scene space**, quat order **[x,y,z,w]** (NOT COLMAP's [w,x,y,z]; convert via
`geometry.colmap_to_threejs` on the backend, never inline).
3. **paths table:** `paths(id INTEGER PK, name TEXT NOT NULL, created_at TEXT, path_json TEXT NOT NULL)`
+ the four routes in M16.
4. **`PATCH /api/anchors/{id}`** `{label?, color?}` → updated anchor dict (404 on missing).
5. **Runtime API base override:** `state.js` resolves `window.__F4D_API_BASE__` first, then
`VITE_API_BASE`, then the localhost default. **`manifest.capsule: true`** ⇒ read-only UI.
6. **`transport.captureAudioStream()`** → `MediaStream` tapping the live WebAudio mix (and a
matching `releaseAudioStream()`); `scene3d.focusOn(x,y,z)` and `scene3d.setHelpersVisible(bool)`.
7. **Frozen files during 5b:** everything frozen in phase 1 **plus**
`frontend/src/{transport,scene3d,state,main,camPath,annotate}.js` and `frontend/index.html`
(foundation2 pre-wires every button — hidden — and every module import; lanes fill their own
stub modules only). Believe a frozen file must change → `plan/CHANGE_REQUESTS.md`, work
around locally, integration2 adjudicates.
## foundation2 scope (Phase 5a, one agent, branch `foundation2`)
- DB: `paths` table + `db.py` helpers. API: `/api/beats`, `/api/director`, `/api/paths*`,
`PATCH /api/anchors/{id}` — implemented against synthetic data where cheap, stubbed with the
spec's graceful-degradation pattern where a lane owns the logic. CLI: `features`, `direct`,
`capsule` subcommands dispatching into stubs.
- Backend stubs: `audio_features.py`, `director.py`, `capsule.py` (raise `NotImplementedError`;
API/CLI catch and degrade, exactly like phase 1).
- Frontend: create empty stub modules `director.js`, `exportVideo.js`, `anchorPanel.js`,
`photoMode.js`, `fx.js`, `pathsStore.js`; import them from `main.js`; add ALL new buttons to
`index.html` (hidden until their module reports ready); add the transport/scene3d hooks and
the `__F4D_API_BASE__` + `manifest.capsule` wiring from the contracts above.
- Tests for every new route shape (mirroring `test_api.py`'s exact-set style — note it locks
the manifest key set; add `capsule` consciously).
- Update the ownership table below if anything moved. Suite ≥ 121 + your new tests. Merge fast.
## integration2 scope (Phase 5c, one agent, branch `integration2`)
- Merge order irrelevant; resolve CHANGE_REQUESTS; full synthetic pass of every milestone
(evidence per feature); raise the suite floor to the new count.
- **Real-footage field test** (the actual milestone): 24 real overlapping clips through
`ingest → sync → reconstruct → events → features → direct → serve`, then capsule the result.
Log every failure as a `plan/ISSUES.md` entry (ISSUE-1 is the format) — fix nothing mid-test.
- Tag `v0.2.0` when accepted by the coordinator.
## File ownership (Phase 5b)
| Owner | Files |
|---|---|
| Lane E | `backend/festival4d/audio_features.py`, `director.py`, `backend/tests/test_audio_features.py`, `test_director.py`, `frontend/src/director.js`, `exportVideo.js` |
| Lane F | `frontend/src/anchorPanel.js`, `photoMode.js`, `fx.js` |
| Lane G | `backend/festival4d/capsule.py`, `backend/tests/test_paths.py`, `test_capsule.py`, `frontend/src/pathsStore.js` — the paths-route bodies were CRUD one-liners over the db helpers, so foundation2 implemented them (basic tests in `test_phase5_api.py`); lane G owns deepening `test_paths.py` + the UI |
| Every agent | its own `plan/status/<lane>.md` |
| **Coordinator only** | `plan/DIRECTIVES.md` |
| **Frozen during 5b** | contract item 7 above |
## Phase-5 pitfalls
1. **Quaternion order.** camPath is `[x,y,z,w]` (Three.js); COLMAP storage is `[w,x,y,z]`.
Every past pose bug in this repo was a convention slip — go through `geometry.colmap_to_threejs`
/ `lib/pose.js`, never inline the math.
2. **The pane you verify in throttles rAF** (backgrounded tab). Drive the loop with the
`window.__f4d` pump (`localStorage.setItem("f4dDebug","1")` in production builds); don't
file "playback frozen" issues against your own hidden tab. MediaRecorder export (M12) needs
a *focused* tab to capture real frames — say so in your evidence if you couldn't.
3. **`test_api.py` locks response key-sets on purpose.** Adding a manifest key or route means
consciously updating the exact-set assertion in the same commit — a failing set-lock is a
feature, not an obstacle.
4. **Range serving.** Any static path that serves video must answer 206 (spec M3 pitfall #3);
`python -m http.server` does not — that's why M17 ships `serve.py`.
5. **Never block on a missing optional input.** No beats.json → director still works (no beat
snapping). No splat → photo mode shoots the point cloud. No audio track → transport already
falls back. Follow the degradation pattern everywhere; it's the house style.
6. **Decoded audio is big** (~23 MB/min stereo). M12/M17 must not decode all tracks just
because they can — reuse what the transport already has.

214
plan/30-phase6.md Normal file
View File

@ -0,0 +1,214 @@
# Phase 6 — Friend Tracks (M18M24): markers → moving people in the 4D scene
Canonical spec for phase 6. **Everything frozen in `../OPUS_BUILD_INSTRUCTIONS.md` (M0M9)
and `20-phase5.md` (M10M17 contracts) still applies.** Standing protocol (status files,
directives loop, evidence discipline, per-agent worktrees) is unchanged from
[`README.md`](README.md); the newest round of [`DIRECTIVES.md`](DIRECTIVES.md) always wins.
## The idea
A friend tag today is a *static* anchor. Phase 6 makes friends **move**: wearable markers
(distinct-hue patches or blinking LED badges) are detected in every camera, associated across
synced views, triangulated per timestep, and become **tracks**`t_global → (x,y,z)` paths
rendered as ribbons in the 3D scene with moving labels, a follow-a-friend camera mode, and
capsule support. Same machinery as annotation resolution (M8), run automatically over time.
## Context — what exists that you build on (read, don't re-invent)
- `geometry.py` — COLMAP↔Three.js conversion, ray casting, triangulation primitives
(`resolve.py` shows exactly how a bbox becomes a 3D point; your solver is that, per-timestep).
- `frames.py` — frame sampling via ffmpeg (SfM uses it; detection re-uses it).
- `config.t_video_from_global` / `t_global_from_video` — the FROZEN timebase; detections are
found at `t_video`, tracks live at `t_global`.
- The synthetic fixture (`synthetic.py`) — every acceptance below is against it first.
- Suite floor is **189 passed**. It only goes up.
## Phase map
```
Phase 6a (serial, ONE agent) Phase 6b (parallel, one agent per lane) Phase 6c (serial)
┌───────────────────────┐ ┌ lane/h-tracker ─── M19 M20 ──────┐ ┌─────────────────┐
│ foundation3 │ ├ lane/j-ribbons ─── M21 M22 ──────┤ │ integration3 │
│ fixture ground truth +│──merge──┤ ├──merge─│ full pass + │
│ contracts + stubs │ └ lane/k-badges ──── M23 M24 ──────┘ │ tracked field │
│ branch: foundation3 │ (independent — merge in any order) │ test → v0.3.0 │
└───────────────────────┘ └────────────────────────────────── └─────────────────┘
```
Lanes depend **only** on foundation3, never on each other.
## Milestones
### M18 — foundation3: fixture ground truth, contracts, stubs (serial, ONE agent)
1. **Synthetic fixture upgrade** (`synthetic.py` — coordinator-sanctioned edit): render **two
moving markers** into every camera's video via the existing camera projection:
- marker A: a solid bright disc (frozen hue, e.g. pure magenta #ff00ff) following a known
smooth trajectory across the stage (e.g. a slow circle, radius ~2, y≈1.5);
- marker B: a **blinking** white/bright disc following a different known path, blinking the
frozen protocol below with ID=5.
Markers must be small (≤2 % of frame area) and rendered *after* the existing content so the
full existing suite stays green (offsets, events, poses, SfM tests untouched). Write ground
truth to `data/work/track_truth.json`: `{"markers": [{"marker_key": ..., "points":
[{"t_global_s", "x", "y", "z"}]}]}` (Three.js scene space).
2. **DB:** `tracks` + `track_points` tables (contract #2) + `db.py` helpers
(`add_track`, `get_tracks`, `set_track_points`, `patch_track`, `delete_track`).
3. **API:** `GET /api/tracks`, `PATCH /api/tracks/{id}` `{label?, color?}`,
`DELETE /api/tracks/{id}`, `POST /api/tracks/solve` (dispatches into the lane-H stub with
the house degradation: valid empty result + note while stubbed). Manifest gains
`"has_tracks": bool` — update `test_api.py`'s key-set lock consciously in the same commit.
4. **CLI:** `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
5. **Backend stubs:** `tracker_detect.py` (`run_detect()`), `tracker_solve.py`
(`run_solve()`) — `NotImplementedError` with the full contract in the docstring.
6. **Frontend:** stub `frontend/src/friendTracks.js` imported from `main.js`, `init()` +
per-frame `friendTracks.update(tGlobal)` wired into the master loop (fx.js pattern);
hidden **👣 Friends** button `#btn-friends` + empty `#friends-panel` container in
`index.html`; `state.tracks = []` loaded at boot when `manifest.has_tracks`.
7. **Capsule:** add `api/tracks` to `capsule.py`'s bake list (additive; update lane G's
bundle-structure test consciously in the same commit, CR-style note in the commit message).
8. Tests for every new route shape; suite ≥ 189 + new tests. Merge fast — three lanes wait.
### M19 — Marker detection (lane H, backend)
Fill `tracker_detect.py`. `run_detect()`: sample frames (~8 fps via `frames.py` machinery) from
every video; detect markers two ways: **color mode** (HSV blob of the frozen marker hues;
`marker_key = "hue:<bucket>"`) and **blink mode** (per-blob brightness over a sliding window,
decode the frozen OOK protocol; `marker_key = "code:<id>"`). Write
`data/work/detections.json` (contract #3). OpenCV (`opencv-python-headless`) is already a
dependency. No markers → valid-empty detections, never a crash.
**Accept:** on the synthetic fixture, ≥90 % of sampled frames where a marker is visible yield
a detection within 3 px (normalized ≤0.008) of the projected ground-truth center; blink ID 5
decoded; tests in `test_tracker_detect.py`.
### M20 — Track solving (lane H, backend)
Fill `tracker_solve.py`. `run_solve()`: load detections; group by `marker_key`; per time bucket
(≈0.25 s) collect same-key detections across cameras at matching `t_global`; **2+ views →
triangulate** through `geometry` primitives (the `resolve.py` pattern — never inline pose
math); **1 view → ray ∩ ground plane** (contract #5); smooth each track (moving average or
Catmull-Rom, your call — document it); write `tracks`/`track_points` through `db` helpers;
solid color for hue markers, palette color for code markers. `POST /api/tracks/solve` and the
CLI now run detect→solve end-to-end.
**Accept:** synthetic recovery — median 3D error of both tracks < 0.3 scene units against
`track_truth.json`, ≥80 % temporal coverage of each marker's visible span; single-view
fallback exercised by a test that hides all-but-one camera's detections; degradation (no
detections → zero tracks + note); tests in `test_tracker_solve.py`.
### M21 — Ribbons, moving labels, friends panel (lane J, frontend)
Fill `friendTracks.js`. On init (when `manifest.has_tracks`): unhide 👣; fetch
`/api/tracks` into `state.tracks`. Toggle renders per-track: a **ribbon** polyline through the
points (track color) + a **moving marker** (sphere + label sprite, position interpolated at
the current `t_global`, hidden outside the track's time span) — objects via
`scene3d.addObject/removeObject` only, registered with `scene3d.registerHelper` so photo mode
hides them. Panel in `#friends-panel` (anchorPanel.js is the style/UX reference — read it,
don't edit it): list tracks (dot, label, time span), rename/recolor via
`PATCH /api/tracks/{id}` (controls carry `write-ui`), delete, and a **Follow** button per row.
**Accept:** live-browser evidence — ribbons + moving labels track the synthetic markers in
play and scrub; rename/recolor/delete round-trip; photo mode hides ribbons; capsule mode
hides write controls but keeps ribbons/follow.
### M22 — Follow-a-friend camera (lane J, frontend)
Chase cam on any track: `scene3d.enterPathMode()` hands you the camera (the camPath.js
pattern — read it); every `update(tGlobal)` compute the track position, place the camera at a
smoothed offset (behind/above, ~4 units, low-pass the position so it glides), look at the
friend (`applyCameraPose`). Esc / Free-roam / snap-to-camera exits (pathMode already handles
contention — mirror camPath's "someone else took the camera" check).
**Accept:** live-browser evidence — camera glides after the moving marker in play and scrub;
clean handoff to/from free-roam, snap-to-cam, and camPath playback; no fighting.
### M23 — LED badge firmware + hardware kit (lane K, hardware/docs)
`hardware/badge/badge.ino` — ESP32/ATtiny-compatible Arduino sketch blinking the frozen OOK
protocol (contract #4): configurable 6-bit ID, bright single LED or WS2812; plus
`hardware/beacon/beacon.ino` — the sync/calibration beacon (distinct reserved ID 63,
double-brightness). `docs/hardware.md`: parts list (boards, LEDs, battery, diffuser), build
photos placeholders, wearing guidance (chest/hat height, diffusion, spacing), and the
camera-side truth: what ranges/framerates the protocol survives (≥24 fps, exposure caveats).
**Accept:** sketches compile (`arduino-cli compile` for esp32 + attiny if available — else
document exact compile commands and mark unverified); protocol constants byte-identical to
contract #4 (import/duplicate them in ONE header with a test-facing copy check documented).
### M24 — Bench self-test tool (lane K, backend script)
`scripts/badge_selftest.py`: given a short phone clip of a badge on a desk, run the SAME
decoder path as lane H (`tracker_detect` blink mode) and print the decoded ID + confidence —
John's hardware smoke test before a shoot. Works on a synthetic self-test too:
`--synthetic` renders a blinking clip via the fixture helpers and asserts round-trip.
**Accept:** `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID;
clear failure text for: no blob, ID mismatch, too-short clip. Small test in
`test_badge_selftest.py` (synthetic path only).
## Contracts (frozen at foundation3 merge)
1. **Marker hues (color mode):** magenta `#ff00ff` and cyan `#00ffff` are the two supported
fixture hues; real shoots may configure others via `FESTIVAL4D_MARKER_HUES` (comma-sep hex).
2. **Schema:**
`tracks(id INTEGER PK, marker_key TEXT NOT NULL, label TEXT, color TEXT, created_at TEXT)`
`track_points(id INTEGER PK, track_id FK, t_global_s FLOAT, x FLOAT, y FLOAT, z FLOAT,
quality FLOAT, views INTEGER)` — points ordered by `t_global_s`; `views` = cameras used
(1 = ground-plane fallback); coordinates in **Three.js scene space**.
`GET /api/tracks``[{id, marker_key, label, color, points: [{t_global_s,x,y,z,quality,views}]}]`.
3. **detections.json:** `{"videos": {"<video_id>": [{"t_video_s": float, "marker_key": str,
"cx": float, "cy": float, "area": float, "conf": float 0..1}]}, "generated_by":
"festival4d.tracker_detect"}` — `cx`/`cy` normalized 0..1 (bbox-annotation convention).
4. **Blink protocol (OOK, camera-decodable):** bit period **133 ms** (4 frames @ 30 fps);
word = preamble `11100` + 6-bit ID (MSB first) + even parity = 12 bits, repeated
continuously (~1.6 s/word). ID 63 reserved for the sync beacon. Decoder needs ≥24 fps
video and ≥1 full word of visibility.
5. **Ground plane:** the synthetic fixture's ground is `y = 0` (scene space). For real
projects the solver estimates it as the 5th-percentile-y horizontal plane of the point
cloud and records it in its result dict. Single-view fallback intersects the pixel ray
with this plane; such points get `views = 1` and `quality ≤ 0.5`.
6. **Frozen during 6b:** everything frozen in phases 15 **plus** all phase-5 lane modules
(`director.js`, `exportVideo.js`, `anchorPanel.js`, `photoMode.js`, `fx.js`,
`pathsStore.js`, `audio_features.py`, `director.py`, `capsule.py`) and foundation3's edits
(`synthetic.py`, `main.js`, `index.html`, api/cli/db). Lanes fill their own new files only.
Believe a frozen file must change → `plan/CHANGE_REQUESTS.md`, work around, integration3
adjudicates. Stub-era test assertions superseded by your milestone landing follow the
CR-1/-4/-5 pattern: minimal edit + CR entry.
## integration3 scope (Phase 6c)
Merge order irrelevant; resolve CRs; full synthetic pass (evidence per milestone); raise the
suite floor; **tracked field test** — real clips with at least one badge/marker in frame
(combine with the still-open phase-5 field test); failures → `plan/ISSUES.md`; propose
`v0.3.0` (with `v0.2.0` if the phase-5 field test passes in the same session).
## File ownership (Phase 6b)
| Owner | Files |
|---|---|
| Lane H | `backend/festival4d/tracker_detect.py`, `tracker_solve.py`, `backend/tests/test_tracker_detect.py`, `test_tracker_solve.py` |
| Lane J | `frontend/src/friendTracks.js` |
| Lane K | `hardware/**`, `docs/hardware.md`, `scripts/badge_selftest.py`, `backend/tests/test_badge_selftest.py` |
| Every agent | its own `plan/status/<lane>.md` |
| **Coordinator only** | `plan/DIRECTIVES.md` |
| **Frozen during 6b** | contract #6 above |
## Phase-6 pitfalls
1. **Time alignment is the whole game.** Detections happen at `t_video`; association happens
at `t_global`. One `t_video_from_global` inversion mistake silently shears every track.
Use the frozen helpers; test association against ground truth, not against itself.
2. **Stage lighting lies about color.** The color detector must gate on saturation+value, not
hue alone, and the acceptance is on the *synthetic* fixture — note in your status file that
real-world hue robustness is a known field-test risk (that's why blink mode exists).
3. **Blink decoding vs. video fps:** cameras at 2460 fps sample the 133 ms bit period
differently. Decode by integrating brightness per bit window using the video's actual fps
(`meta.fps`), never by counting frames.
4. **Don't fight the camera owners.** Follow-mode (M22) shares `pathMode` with camPath —
mirror camPath's contention checks exactly, or two drivers will interleave writes.
5. **rAF throttle (standing):** verify with the `__f4d` pump in hidden panes
(`localStorage f4dDebug=1`); the WebAudio timer exemption gives you ~50 Hz `setTimeout`.
6. **Fixture compatibility:** foundation3's synthetic edit runs UNDER the existing 189 tests —
markers must not move offsets, events, poses, or break SfM-adjacent tests.
## Kickoff prompts (copy-paste one per Opus 4.8 session, in run order)
**foundation3 (first, alone):**
> Clone ssh://git@100.71.119.27:222/monster/festifun.git. Read OPUS_BUILD_INSTRUCTIONS.md,
> plan/20-phase5.md, plan/30-phase6.md (M18 is yours), and the latest round of
> plan/DIRECTIVES.md. Execute foundation3 on branch `foundation3`; maintain
> plan/status/foundation3.md per plan/status/TEMPLATE.md; suite ≥ 189 + your tests; push the
> branch and report — the coordinator reviews and merges.
**lane H / lane J / lane K (parallel, after foundation3 merges):**
> Clone ssh://git@100.71.119.27:222/monster/festifun.git and create your own git worktree
> before any edit. Read OPUS_BUILD_INSTRUCTIONS.md, plan/30-phase6.md, plan/lane-<X>.md, and
> the latest round of plan/DIRECTIVES.md. Execute your lane on branch `lane/<x>-<name>`;
> maintain plan/status/lane-<X>.md; obey the ownership table and frozen-files contract; push
> your branch and report — the coordinator reviews and merges. Do not merge to main.

View File

@ -18,3 +18,118 @@ Instead append an entry here and work around it locally; the integration agent a
---
<!-- entries below -->
### CR-1 — lane D — 2026-07-16 — Update obsolete stub assertion in test_api.py
- **File:** `backend/tests/test_api.py::test_detect_degrades_gracefully`
- **Problem:** That foundation test asserted the *stub* contract of `POST /api/events/detect`
(`"note" in data`), which was correct only while lane D was unimplemented. With M7 landed,
`run_events` no longer raises `NotImplementedError`, so `api.py` returns the real
`{result, events}` shape (spec M3) and the stub assertion fails.
- **Proposed change:** rename to `test_detect_events_endpoint` and assert the real landed
contract (200; `result` is the run_events summary with candidates/classified/… keys; `events`
is a list; idempotent on repeat).
- **Workaround in place:** applied the minimal edit directly (test file, not a frozen contract
file). The frozen `api.py` app is **unchanged**. Only lane D's owned modules + this obsolete
stub assertion were touched. Full suite green (89 passed).
- **Decision:** **APPLIED** (integration, 2026-07-16). Confirmed against the frozen api.py
docstring: `POST /api/events/detect` returns `{result, events}` (spec M3). The renamed
`test_detect_events_endpoint` asserts exactly that shape and is idempotent. No frozen file
changed; approval also recorded in DIRECTIVES round 1.
### CR-2 — integration — 2026-07-16 — M8 needs annotation↔event link + event-type correction
- **File:** `db.py` (`Annotation` schema), `api.py` (`AnnotationIn`, events routes)
- **Problem:** Spec M8 requires (a) triangulating "another annotation **for the same event**
from a different video" — but the frozen `annotations` schema has no event reference, and
`AnnotationIn` carries no event id, so annotations can't be grouped per event; and (b) a
correction dropdown that "writes `source='user'`" to an event's type — but there is no
event-update helper or endpoint (only add/clear).
- **Proposed change (additive only — no existing shape altered):**
1. `annotations.event_id` — new **nullable** `ForeignKey("events.id")` column. Nullable ⇒
existing rows/inserts unaffected; the synthetic fixture creates annotations = none, so no
migration. Serves the two-view grouping.
2. `AnnotationIn.event_id: int | None = None` — additive optional request field.
3. `db.update_event(event_id, event_type=?, source=?, description=?)` helper +
`PATCH /api/events/{event_id}` — additive route; existing GET/POST/detect unchanged.
- **Workaround in place:** none needed — adjudicated and applied directly in this integration
phase (the phase where the ownership matrix ends and frozen contracts may change on an
approved CR). All changes are strictly additive; every pre-existing route/shape/column and
the timebase + pose contracts are byte-for-byte unchanged. Full suite stays green.
- **Decision:** **APPLIED** (integration, 2026-07-16). Additive, backward-compatible, and
required for M8 acceptance. Frontend and backend M8 both depend on it.
### CR-3 — phase4 — 2026-07-16 — Anchor lifecycle: supersede + delete
- **File:** `api.py` (`create_annotation`, new `DELETE /api/anchors/{id}`), `db.py`
(`get_anchor`/`update_anchor`/`delete_anchor`, `get_annotations` event filter)
- **Problem:** Round-3 review found each annotation created a NEW anchor, so the two-view
"refine" flow left two identically-labeled anchors (a rough fallback + the triangulation), and
there was no way to remove an anchor at all.
- **Change (additive — no existing shape altered):** one anchor per event — a later view that
triangulates supersedes the fallback anchor **in place** (same id; `create_annotation` response
gains a `superseded` bool); a later fallback links to the existing anchor without moving it.
New `DELETE /api/anchors/{id}` (unlinks annotations first, 404 on unknown). `db` gains
`get_anchor`/`update_anchor`/`delete_anchor` and an `event_id` filter on `get_annotations`.
- **Workaround in place:** none — applied directly (Phase 4, ownership matrix ended). Every
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.
### CR-5 — lane E — 2026-07-17 — Retire remaining stub-era assertions in test_phase5_api.py (M10/M11 landed)
- **File:** `backend/tests/test_phase5_api.py::test_cli_dispatches_stubs_gracefully` and
`::test_director_degrades_to_valid_empty_campath`
- **Problem:** Both assert lane E's *stub* contracts, correct only while M10/M11 were
unimplemented. With `run_features`/`generate_path` landed: (a) `cli.main(["features"])` /
`["direct"]` no longer raise `NotImplementedError`, so they exit 0, not 2; (b)
`POST /api/director` on the synthetic fixture (events + poses present) returns a real
non-empty camPath, so `keyframes == []` / `"note" in data` fail. Identical in kind to
**CR-1** and to **CR-4** (lane G, coordinator-ratified the same day for `capsule`).
- **Proposed change:** (a) with CR-4 already applied, lane E's landing empties the CLI stub
loop entirely — retire `test_cli_dispatches_stubs_gracefully` (a comment points at the real
dispatch coverage: test_capsule.py / test_audio_features.py / test_director.py); (b) rename
the director test to `test_director_returns_valid_campath`, asserting the frozen camPath
*shape* (version 1, exact keyframe key-set — holds stubbed or implemented); the empty+note
degradation is now covered by lane E's own `test_director.py` (no-events / no-poses cases).
- **Workaround in place:** applied the minimal edit directly per the CR-1/CR-4 precedent (test
file, not a frozen contract file; `api.py`/`cli.py` byte-for-byte unchanged), including the
merge reconciliation with CR-4's side of the same loop. Suite green on the merged tree.
- **Decision:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Same
supersession pattern as CR-1/CR-4; the shape-locked director test is stronger than the
stub assertion it replaces. Suite 169 → **189** on the merged tree.
### CR-6 — foundation3 — 2026-07-17 — Conscious test-lock updates for the two additive M18 contract fields
- **File:** `backend/tests/test_api.py::test_manifest_shape` (manifest exact-set lock) and
`backend/tests/test_capsule.py::test_bundle_structure` (baked-paths list).
- **Problem:** Not a supersession — these are the two *deliberate* lock edits M18 mandates
(spec `plan/30-phase6.md` steps 3 & 7). foundation3 adds an additive manifest field
`has_tracks` (drives the frontend loading friend tracks) and an additive capsule bake
`api/tracks` (so ribbons + follow work zero-backend). The manifest key-set lock is
intentionally exact ("has already caught two silent drifts"), and the capsule
bundle-structure test enumerates baked paths, so both must be updated **consciously in the
same commit** as the source change — the spec calls for exactly this + a CR note.
- **Proposed change:** (a) add `"has_tracks"` to the `test_manifest_shape` exact-set and assert
it is `False` on the live synthetic build (no solver has run — solving is lane H); (b) add
`"api/tracks"` to the `test_bundle_structure` baked-paths tuple so the test now asserts the
new read-only bake exists. No behavior removed; both are stronger post-edit.
- **Workaround in place:** N/A — these are foundation-sanctioned edits to test files (not to a
frozen *contract* file: `db.py`/`api.py`/`capsule.py` were extended additively per M18, and
`synthetic.py`'s marker code is a coordinator-sanctioned M18 edit). Applied directly in the
foundation3 commit per the house pattern; flagged here for integration3 to ratify.
- **Decision:** **APPLIED — RATIFIED by coordinator at merge** (2026-07-17). Both edits are
the spec-mandated conscious lock updates (30-phase6.md steps 3 & 7), additive and stronger
post-edit. Suite 189 → **209** on the merged tree.

View File

@ -4,6 +4,213 @@ Written **only** by the coordinator (the human's planning session). Agents: read
---
## Round 6 — 2026-07-17 — PHASE 6 planned: Friend Tracks (M18M24)
**Spec:** `plan/30-phase6.md` (canonical). Lane briefs: `plan/lane-H-tracker.md`,
`plan/lane-J-ribbons.md`, `plan/lane-K-badges.md`. Kickoff prompts are at the bottom of the
spec — the human launches each Opus session with one.
**Directives:**
1. **Now: `foundation3` only** (one agent, branch `foundation3`, M18). Contracts correctness
beats speed — three lanes hard-depend on your fixture ground truth, schema, detections
shape, and blink protocol. The synthetic-fixture edit must keep all 189 existing tests
green (spec pitfall #6). Maintain `plan/status/foundation3.md`. **Push your branch; do NOT
merge to main** — this phase the coordinator merges after review (see #4).
2. **After foundation3 merges — lanes in priority order** (all three concurrently if capacity
allows): **H** (tracker — longest, validates every contract), **J** (ribbons/follow — the
visible payoff), **K** (badges — independent, hardware-facing). Per-agent worktrees remain
MANDATORY.
3. **Merge policy (changed from Round 4):** agents do NOT merge to main and do NOT tag.
Push your lane branch + status file, report completion to the human; the human pings the
coordinator session, which reviews (independent suite re-run, ownership check, CR
adjudication) and merges in completion order. Everything else from Round 0 protocol
(status cadence, blocker rule, evidence discipline) unchanged.
4. **Review loop:** coordinator reads `plan/status/<lane>.md` + the branch diff on each ping;
findings come back as a new DIRECTIVES round or direct merge. Stub-era assertions
superseded by a landing milestone: CR entry + minimal edit (CR-1/-4/-5 house pattern).
5. **integration3** (after all three merge): full synthetic pass, floor raise, tracked field
test (fold in the still-open phase-5 field test), `v0.3.0` proposal. `v0.2.0` remains held
per Round 5.
**For the human:** (a) launch foundation3 first, alone, with its kickoff prompt; tell the
coordinator when its status file says ready; (b) then launch H, J, K in parallel; (c) the
field-test gate is unchanged — real clips in `data/raw/`, now ideally with a marker or badge
in frame; (d) if you order badge parts, `docs/hardware.md` (lane K) will have the list.
## Round 5 — 2026-07-17 — PHASE 5 COMPLETE on synthetic; floor 189; v0.2.0 waits on the field test
**Execution report (all same-day):** foundation2 → lanes E/G/F in parallel worktrees (per
directive 3) → integration2. Every lane merged after independent coordinator verification
(suite re-run in the lane's worktree, then on the merged tree, live-browser checks per
feature). Full evidence: plan/status/{foundation2,lane-E,lane-F,lane-G,integration2}.md.
**Accepted:** M10M17 all pass acceptance on the synthetic fixture. Highlights: beats recover
the 120 BPM ground truth (worst beat 30 ms); director output is beat-snapped and shape-locked
through frozen geometry helpers; export produced a 13.98 s vp9/opus webm (±5% ✓, audio track
live) driven headless via the `__f4d` pump; the capsule boots with ZERO off-origin requests,
seeks over Range, hides all write UI. Lane F self-verified live in isolated servers — model
evidence discipline, worth repeating.
**Ratified:** CR-4 (lane G) and CR-5 (lane E) — stub-era test assertions superseded by
milestones landing, CR-1 precedent. **The test floor is now 189.** Build stays clean.
**Standing decisions:**
1. **v0.2.0 tag is HELD** until the real-footage field test runs (it is integration2's actual
milestone; synthetic-only doesn't earn the tag). Failures go to plan/ISSUES.md, not fixes.
2. Stub-era assertions in foundation tests are now an acknowledged lifecycle: when a milestone
lands, retiring its stub assertion via a CR entry + pointer to the real coverage is the
house pattern (CR-1/-4/-5). Foundation agents: prefer writing stub tests that survive the
landing (assert shape, not stub-ness) so future lanes don't need CRs.
3. `.gitignore` gained `data/capsule/` and `.claude/worktrees/`.
**For the human (the entire remaining critical path):**
(a) **Drop 24 real overlapping clips into `data/raw/`** — the field test and v0.2.0 gate on
this and only this.
(b) Focused-window eyeball pass (10 min, headphones): sync panel stays green during playback;
🎧 spatial audio while flying around; 🎬 auto-path ride; ⏺ export and CHECK THE FRAMES
(headless verified duration/audio only — rendered frame quality needs human eyes);
📷 photo PNG; ✨ FX bursts at full rAF rate; anchor panel roundtrip.
(c) Optional but high-impact: train a splat for the real project
(`scripts/splat_via_modelbeast.sh`) before capsuling it — photo mode + capsule get
dramatically better.
## Round 4 — 2026-07-17 — Fresh-eyes upgrades landed; PHASE 5 begins (M10M17)
**Coordinator work landed directly on `main` (self-reviewed + live-verified):**
- `95b7178` — sync solver caches one rFFT per signal across the pairwise GCC-PHAT stage
(1.8× on 6 cams × 10 min; equivalence locked by a new unequal-lengths test).
- `c24af80`**audio is now the master clock** (`GET /api/audio/{id}` AAC + WebAudio;
`t_global` from `AudioContext.currentTime`; all `<video>` muted picture-only; graceful
fallback) **+ spatial audio** (🎧 per-camera HRTF panners at live poses, listener = viewer
camera, gains 1/√N). Verified live: clock drives playback, mid-play source switching is
continuous, panners sit at the correct camera positions, toggle on/off mid-play is clean.
- `db42c42` — chore: `python-multipart` locked (declared-but-unlocked drift), launch configs.
- Suite is **121 passed**; frontend build clean. **The test floor is now 121.**
**Contract amendments (coordinator-ratified, additive only):** new route `GET /api/audio/{id}`;
`transport.js` is now the WebAudio clock owner (its header comment is the reference);
the `window.__f4d` debug hook is available in production builds via `localStorage f4dDebug=1`.
`test_api.py`'s manifest key-set lock is unchanged.
**Directives — Phase 5 (spec: `plan/20-phase5.md`; lane briefs: `plan/lane-E/F/G-*.md`):**
1. **Now: `foundation2` only** (one agent, branch `foundation2`, scope in 20-phase5.md).
Same rule as phase 1: contracts correctness beats speed — three lanes will hard-depend on
your route shapes, stubs, hooks, and UI wiring points. Maintain `plan/status/foundation2.md`.
2. **After foundation2 merges — lanes in priority order** (all three concurrently if capacity
allows): **E** (director — longest, earliest contract validation), **G** (capsule — the
shareable payoff), **F** (social UX — smallest, pure frontend).
3. **Worktree rule is MANDATORY this time:** every parallel agent creates its own `git worktree`
before its first edit (Round 1 standing lesson; the phase-2 shared-tree collision cost us a
stash cleanup). Single-agent phases (5a, 5c) may use the main working directory.
4. **Merge policy unchanged:** merge to `main` per-milestone where the lane brief allows it;
never wait on another lane. Then `integration2` (branch `integration2`), which owns the
real-footage field test and the `v0.2.0` tag proposal — failures go to `plan/ISSUES.md`,
not into hasty fixes.
5. Standing protocol (status files, evidence discipline, blocker rule, DIRECTIVES re-read after
every milestone) is unchanged from Round 0.
**For the human:** (a) the Round 3 focused-browser sync check is still open — now also put on
headphones, enable 🎧 3D audio, and fly around; (b) real overlapping clips for the field test
remain the gating item for integration2; (c) optional: a trained splat for a real project makes
photo mode (M14) and the capsule (M17) dramatically more impressive —
`scripts/splat_via_modelbeast.sh`.
## Round 3 — 2026-07-16 — Integration review: v0.1.0 ACCEPTED; polish + field test next
**Coordinator review of the integration work (independently verified):** `main` == `origin/main`
(d313c8d), tag `v0.1.0` on e5d6b2c. Re-ran the suite: **101 passed**; frontend build clean. The
M8 acceptance test is faithful (forward-projects ground truth, no self-confirmation); `resolve.py`
is clean orchestration over lane B's frozen primitives; CR-1/CR-2 correctly adjudicated and
strictly additive; camPath's Catmull-Rom parameterization maps exactly to three.js segment
lookup (checked). The live-browser M8/M9 verification methodology was sound given the
hidden-tab rAF throttle. **v0.1.0 accepted.**
**Ratification:** Round 2 was written by the integration agent; DIRECTIVES.md is coordinator-only.
Content verified accurate — ratified as written. Rule stands for future rounds.
**Review findings (all polish; none block the tag):**
1. **Anchor stacking — the one real wart.** Each annotation creates a NEW anchor, so the
documented two-view "refine" flow leaves BOTH the view-1 fallback anchor and the view-2
triangulated anchor, identically labeled (reproduced: 2 anchors for 1 object). On real
footage the fallback can be far off and there is no way to remove it — no
`DELETE /api/anchors/{id}`, no UI delete.
2. `docs/ideas.md` (spec §5 future-extensions note) was never created.
3. Human focused-browser M4 sync eyeball still open (carried from Round 2).
**Directives — Phase 4 (one agent, branch `phase4/polish-field-test`):**
1. **Polish pass (small, do first):**
- When an annotation resolves for an event that already has a resolved anchor from a prior
annotation of the same event, **update that anchor in place** (supersede the fallback with
the triangulation) instead of creating a sibling. Keep per-annotation `resolved_anchor_id`
history consistent.
- Add `DELETE /api/anchors/{id}` + a small delete affordance in the UI (e.g. in the
annotation panel or anchor list). Additive API only; file a CR entry noting it.
- Create `docs/ideas.md` (gaussian splatting, MP4 fly-through export, realtime ingest,
multi-modal audio analysis — from the spec's future-extensions list).
- Suite must stay ≥ 101; add tests for supersede + delete.
2. **Field test protocol (the real milestone):** when the user drops 24 real overlapping clips
into `data/raw/`, run `ingest → sync → reconstruct → events → serve`; record in
`plan/status/phase4.md`: per-pair sync confidence, offsets, COLMAP registration rate,
which milestones survived contact with real footage. **File failures as entries in a new
`plan/ISSUES.md` rather than rushing fixes** (per plan/10-integration.md item 4).
3. Maintain `plan/status/phase4.md`; same evidence discipline.
**For the human (unchanged + one new):** (a) open the app in a normal focused browser window
once and confirm the sync panel stays green during continuous playback; (b) shoot or collect
24 overlapping clips of the same performance for the field test; (c) ~~optionally set
`GEMINI_API_KEY`~~ done — see addendum.
**Round 3 addendum (coordinator, same day) — real API keys wired + first field finding:**
- A local **`.env`** now exists at the repo root (gitignored, chmod 600, **never commit**) with
`GEMINI_API_KEY` (default provider) and OpenRouter creds mapped to the `local` provider
(`FESTIVAL4D_OPENAI_*`, model `google/gemini-2.5-flash`). Load with
`set -a; . ./.env; set +a` before `python -m festival4d events`. Phase-4 agent: the file is
machine-local — if it's missing in your worktree, ask the coordinator, don't recreate keys.
- **Field finding #1 (fixed by coordinator hotfix):** Google retired `gemini-2.5-flash` for new
API keys (404 "no longer available to new users"). The Gemini model is now env-overridable
(`FESTIVAL4D_GEMINI_MODEL`) with default `gemini-3.1-flash-lite` — verified live: inline
video + JSON-schema structured output OK; **3/3 synthetic candidates classified correctly**
(`light_show`/`bass_drop`, coherent descriptions). Suite still 101 passed. The degradation
path also behaved exactly as designed during the failure — good lane D work.
- Phase-4 agent: consider a `plan/ISSUES.md` entry template seeded from this finding's shape
(symptom → root cause → fix → evidence).
## Round 2 — 2026-07-16 — Integration complete; v0.1.0 shipped
**Integration merged to `main` (e5d6b2c) and tagged `v0.1.0`.** M8 (annotation→3D correction +
triangulation) and M9 (keyframed camera paths) are done; README rewritten for v0.1.0. Both
change requests adjudicated and applied (CR-1, CR-2 — additive only; timebase/schema-columns/
pose/API shapes otherwise unchanged).
**Verification (coordinator-run on merged tree):** 101 backend tests pass (up from the 96
floor); two-view stage-corner recovery err **0.00000** (< 0.2); frontend build clean; live
in-browser M8 triangulation (gap 0.21) and M9 path (camera lands exactly on keyframe at t);
M4 sync ~16 ms via deterministic pump. Worktrees removed, debris stash dropped, `frontend/dist`
gitignored.
**One open item for the human (not code-blocking):** the automated browser pane runs
`document.hidden=true`, which throttles `requestAnimationFrame`, so continuous multi-cam
playback sync could only be verified deterministically, not by eye. **Open the app in a normal
focused browser window once** (`python -m festival4d serve` + `cd frontend && npm run dev`) and
confirm the top-right sync panel stays green (<50 ms) during continuous playback.
**Next:** the pipeline is ready for real footage — drop 24 overlapping concert clips into
`data/raw/` and run `ingest → sync → reconstruct → events → serve` (see README).
## Round 1 — 2026-07-16 — All lanes merged & verified; integration begins
**Coordinator review result:** foundation + all four lanes are merged to `main` (merge commits `e28dc78` A, `2cad2f7` B, `9d17b47` C; D fast-forwarded earlier at `5c2d7c6`). Independently verified on the merged tree: **96 backend tests pass**, synthetic `ingest → sync` recovers ground-truth offsets exactly (+0 / +1370 / 842 ms, drift 0), `events` finds 3 candidates and degrades gracefully unconfigured, frontend production build clean. Excellent evidence discipline across all lanes — keep it.
**Directives:**
1. **Integration phase starts now** — one agent, branch `integration` from `main`, per `plan/10-integration.md`. Maintain `plan/status/integration.md`.
2. **CR-1 (lane D, test_api.py stub assertion): APPROVED by coordinator.** The edit was a direct consequence of M7 landing and touched no frozen contract file. Integration agent: mark the Decision line in `plan/CHANGE_REQUESTS.md` and confirm the new assertion matches the spec M3 `POST /api/events/detect` response shape (`{result, events}`).
3. **Integration additions to the plan/10-integration.md scope** (small, discovered in review):
- The shared-worktree collision (see lane A/C status round 0) left a coordinator-stashed debris stash in the main working dir (`git stash list` — "stale lane A/B debris"). After confirming nothing of value remains, drop it, and remove the now-merged worktrees `../festifun-laneA` and `../festifun-laneB` (`git worktree remove`).
- Lane C's env note: continuous-playback sync was verified deterministically because the automated browser tab was backgrounded. During your full-system pass, verify the M4 sync overlay (<50 ms) once in a real focused browser window.
- Test count sanity: suite is 96 passed on merge day; it must never drop below that.
4. **Worktree protocol lesson (standing):** integration runs single-agent, so use the main working directory directly on branch `integration`. If parallel agents are ever launched again, each MUST create its own `git worktree` before its first edit — never share a working directory.
## Round 0 — 2026-07-16 — Run order & protocol
**Execution order:**

27
plan/ISSUES.md Normal file
View File

@ -0,0 +1,27 @@
# Field Issues
Problems found when the pipeline meets **real footage** (or real APIs), logged here instead of
being rushed into fixes mid-field-test (per plan/10-integration.md item 4). Coordinator triages;
polish/fixes land in a later phase.
**Format** (newest at the bottom):
```
### ISSUE-<n><area><date><one-line title>
- **Symptom:** what was observed (exact error / measurement)
- **Root cause:** why (if known; else "unknown — needs investigation")
- **Fix / workaround:** what was done, or "OPEN"
- **Evidence:** command output, test name, or measurement proving symptom/fix
- **Status:** OPEN | FIXED | WONTFIX
```
---
<!-- entries below -->
### ISSUE-1 — events (AI classifier) — 2026-07-16 — gemini-2.5-flash 404 for new API keys
- **Symptom:** `events` with a valid `GEMINI_API_KEY` returned `404 NOT_FOUND … models/gemini-2.5-flash is no longer available to new users`; 0/3 candidates classified (degraded to candidates-only).
- **Root cause:** Google retired `gemini-2.5-flash` for newly-provisioned API keys; the model id was hardcoded in `GeminiClassifier`.
- **Fix / workaround:** made the Gemini model env-overridable (`FESTIVAL4D_GEMINI_MODEL`), default `gemini-3.1-flash-lite` (verified to accept inline video + JSON-schema structured output). Coordinator hotfix, commit `8dca2f5`.
- **Evidence:** post-fix run `classified=3 candidates_only=0`; labels `light_show` / `bass_drop` with coherent descriptions; suite 101 passed.
- **Status:** FIXED

View File

@ -2,6 +2,13 @@
**Read [`../OPUS_BUILD_INSTRUCTIONS.md`](../OPUS_BUILD_INSTRUCTIONS.md) first — it is the canonical spec** (architecture, repo layout, data model, milestone details M0M9, pitfalls). This directory only organizes *who builds what, in what order, without colliding*. Lane briefs reference spec milestones by number; they do not restate them.
> **Current phase: 6** (M18M24, Friend Tracks) — spec and organization in
> [`30-phase6.md`](30-phase6.md), lane briefs `lane-H/J/K-*.md`, run order in DIRECTIVES
> Round 6 (note: this phase the **coordinator merges**, agents only push branches).
> Phases 15 below are complete (v0.1.0 shipped; phase 5 M10M17 accepted on synthetic,
> suite floor 189, v0.2.0 held for the field test); their protocol sections (git, status,
> directives loop, evidence discipline) remain the standing rules.
## Phase map
```

18
plan/lane-E-director.md Normal file
View File

@ -0,0 +1,18 @@
# Lane E — Director & export (branch `lane/e-director`)
**Scope: phase-5 milestones M10 + M11 + M12** (beats/onsets, auto-director, cinematic export) — see [`20-phase5.md`](20-phase5.md).
- Owned files: `backend/festival4d/audio_features.py`, `backend/festival4d/director.py`,
`backend/tests/test_audio_features.py`, `backend/tests/test_director.py`,
`frontend/src/director.js`, `frontend/src/exportVideo.js`. Nothing else.
- Fill the stub bodies foundation2 left; signatures, routes, and CLI dispatch already exist.
- Contracts you consume (frozen): `beats.json` shape, camPath JSON (quat `[x,y,z,w]`, Three.js
space — convert COLMAP poses via `geometry.colmap_to_threejs`, never inline),
`transport.captureAudioStream()`.
- Build order M10 → M11 → M12: the director consumes beats; export consumes the director's path.
Each milestone is independently mergeable — don't hold M10/M11 hostage to M12.
- Degradation: no beats → director skips beat-snapping; no poses → director returns a clear
error body, never a crash; export with no path → button stays disabled.
**Done when:** M10M12 acceptance passes (browser evidence for M11/M12 in your status file),
`pytest` ≥ 121 + your tests, merged to `main` with synthetic end-to-end intact.

17
plan/lane-F-social.md Normal file
View File

@ -0,0 +1,17 @@
# Lane F — Social & fun UX (branch `lane/f-social`)
**Scope: phase-5 milestones M13 + M14 + M15** (anchor manager + friend tags, photo mode, moment FX) — see [`20-phase5.md`](20-phase5.md).
- Owned files: `frontend/src/anchorPanel.js`, `frontend/src/photoMode.js`, `frontend/src/fx.js`.
Nothing else — buttons, imports, and hooks are pre-wired by foundation2.
- Contracts you consume (frozen): `PATCH /api/anchors/{id}` + existing anchor GET/POST/DELETE,
`scene3d.focusOn(x,y,z)`, `scene3d.setHelpersVisible(bool)`, `scene3d.addObject/removeObject`,
the M8 annotation flow (drive it, don't modify `annotate.js`).
- This is the "friends connect & remember" lane — labels, colors, and jump-to should feel like
tagging people, not editing database rows. Keep interactions one-click where possible.
- All verification is in-browser: use the `__f4d` debug pump (phase-5 pitfall #2) and put
screenshots/measurements in your status file. No backend tests to write; the suite must
simply stay green.
**Done when:** M13M15 acceptance passes with browser evidence, frontend `npm run build` clean,
merged to `main` with synthetic end-to-end intact.

17
plan/lane-G-capsule.md Normal file
View File

@ -0,0 +1,17 @@
# Lane G — Persistence & capsule (branch `lane/g-capsule`)
**Scope: phase-5 milestones M16 + M17** (server-side camera paths, static memory-capsule export) — see [`20-phase5.md`](20-phase5.md).
- Owned files: `backend/festival4d/capsule.py`, `backend/tests/test_paths.py`,
`backend/tests/test_capsule.py`, `frontend/src/pathsStore.js`, plus the paths-route bodies in
the region foundation2 marks for lane G. Nothing else.
- Contracts you consume (frozen): `paths` table + route shapes, camPath JSON (validate on POST),
`window.__F4D_API_BASE__` override, `manifest.capsule` read-only flag.
- M17 discipline: the capsule bakes API responses to files at the **same relative paths** the
live API serves — the frontend must not know the difference. Ship `serve.py` (stdlib,
Range-capable) in the bundle root; acceptance explicitly includes video *seeking* under it.
- The capsule is the shareable artifact of the whole project — treat "zero requests to :8000"
and "write UI absent" as hard acceptance, not nice-to-haves.
**Done when:** M16M17 acceptance passes (endpoint tests + a served-capsule browser pass with
evidence), `pytest` ≥ 121 + your tests, merged to `main` with synthetic end-to-end intact.

37
plan/lane-H-tracker.md Normal file
View File

@ -0,0 +1,37 @@
# Lane H — Tracker (M19 detection, M20 solving)
You turn wearable markers in the videos into 3D friend tracks. Backend only.
Spec: `30-phase6.md` M19/M20 + contracts #1#5. Branch `lane/h-tracker`, own worktree.
## What foundation3 gives you
- Stubs `tracker_detect.py` / `tracker_solve.py` with the contracts in their docstrings;
API (`POST /api/tracks/solve` etc.), CLI (`track`), DB helpers, and the synthetic fixture's
two moving markers + `data/work/track_truth.json` ground truth — all already wired.
- You fill the two stub bodies and write the two test files. Nothing else.
## M19 — detection, the parts that bite
- Sample frames with the `frames.py` machinery (~8 fps is plenty; document your rate).
- Color mode: convert to HSV, gate on **saturation AND value** before hue (pitfall #2);
connected components; centroid → normalized cx/cy. Hue buckets from contract #1 +
`FESTIVAL4D_MARKER_HUES`.
- Blink mode: track candidate bright blobs across the sampled window, integrate brightness
per 133 ms bit window **using the video's real fps** (pitfall #3), correlate against the
preamble, check parity, emit `code:<id>`.
- Empty/dark/markerless video → valid-empty detections. Never crash.
## M20 — solving, the parts that bite
- `t_video → t_global` through the frozen helpers ONLY (pitfall #1).
- Triangulation through `geometry` primitives exactly as `resolve.py` does — read it first.
- Bucket ≈0.25 s; 2+ cameras → triangulate (record `views`, `quality` from residual);
1 camera → ray ∩ ground plane per contract #5 (`views=1`, `quality ≤ 0.5`).
- Smooth per track; write through `db` helpers; re-solve replaces that marker's tracks
(idempotent — don't stack duplicates on every run).
## Acceptance (evidence in plan/status/lane-H.md)
- [ ] Detection: ≥90 % recall on visible synthetic markers, ≤0.008 normalized center error;
blink ID 5 decoded (`test_tracker_detect.py`)
- [ ] Solve: median 3D error < 0.3 vs `track_truth.json`, 80 % coverage; single-view
fallback test; idempotent re-solve; degradations (`test_tracker_solve.py`)
- [ ] CLI + `POST /api/tracks/solve` run detect→solve end-to-end on the fixture
- [ ] Suite ≥ foundation3's count + yours, green; stub-era assertions superseded by your
landing → CR entry (CR-1/-4/-5 pattern)

38
plan/lane-J-ribbons.md Normal file
View File

@ -0,0 +1,38 @@
# Lane J — Ribbons & follow-a-friend (M21, M22)
You make tracks visible and rideable. Pure frontend — ONE file: `frontend/src/friendTracks.js`.
Spec: `30-phase6.md` M21/M22 + contract #2. Branch `lane/j-ribbons`, own worktree.
## What foundation3 gives you
- Your stub module imported from main.js with `init()` + per-frame `update(tGlobal)` already
called; hidden `#btn-friends` + `#friends-panel` in index.html; `state.tracks` loaded at
boot when `manifest.has_tracks`.
- Read-only building blocks you may CALL but never edit: `scene3d.addObject/removeObject`,
`registerHelper` (photo mode hides you for free), `enterPathMode`/`exitPathMode`/
`applyCameraPose` (the camPath.js pattern — read that file first), `focusOn`;
`PATCH/DELETE /api/tracks/{id}`. anchorPanel.js is your UX/style reference (inject your own
<style> tag; reuse the CSS vars). Mark mutating controls `write-ui`.
## M21 — ribbons + panel, the parts that bite
- Interpolate the moving marker between track points at the current tGlobal (linear is fine);
HIDE it outside the track's span — don't park it at the ends.
- Rebuild ribbons only on tracks-changed, not per frame; per-frame work = one position lerp
per visible track (fx.js shows the hot-path discipline).
- Empty tracks (solver not run) → panel says so, no errors; capsule mode: ribbons + follow
stay, write controls hidden.
## M22 — follow cam, the parts that bite
- Mirror camPath's contention handling EXACTLY: if `scene3d.pathMode` goes false under you
(snap-to-cam, Esc, free-roam), stop following without a fight (pitfall #4).
- Low-pass the camera position (e.g. lerp factor ~0.08/frame) so it glides; look at the
friend, offset ~4 units behind/above; scrubbing far must not whiplash (clamp the lerp on
big jumps: teleport if the target moved > ~5 units).
- Starting a camPath playback or clicking 🎬 must cleanly cancel follow mode (and vice versa).
## Acceptance (evidence in plan/status/lane-J.md — you likely can't drive a browser;
write defensively, list exactly what the coordinator must verify live)
- [ ] Ribbons + moving labels track the synthetic markers in play AND scrub
- [ ] Panel: rename/recolor (PATCH) + delete round-trip; `write-ui` on mutators
- [ ] Follow glides after a friend; clean handoffs with free-roam / snap / camPath / 🎬
- [ ] Photo mode hides ribbons (registerHelper); capsule keeps read-only features
- [ ] `npm run build` clean; backend suite unchanged and green

40
plan/lane-K-badges.md Normal file
View File

@ -0,0 +1,40 @@
# Lane K — LED badges & bench kit (M23 firmware+docs, M24 self-test)
You build the wearable side: badge firmware speaking the frozen blink protocol, the hardware
guide, and the bench self-test John runs before a shoot. Spec: `30-phase6.md` M23/M24 +
contract #4. Branch `lane/k-badges`, own worktree.
## M23 — firmware + docs, the parts that bite
- Contract #4 is FROZEN: 133 ms bit, `11100` preamble + 6-bit ID + even parity, continuous
repeat, ID 63 = sync beacon. Keep the constants in ONE header (`hardware/badge/protocol.h`)
shared by badge.ino and beacon.ino; document the manual-sync rule with
`tracker_detect.py`'s decoder constants (can't import across languages — state both
locations in the header comment and in docs/hardware.md).
- badge.ino: plain Arduino APIs (millis()-paced, no delay()-drift), single bright LED on a
PWM pin OR a WS2812 (compile-time #define), ID set by #define (document per-badge flashing).
Hardware is never ideal on paper: expose a `BIT_TRIM_US` calibration knob for clock drift.
- beacon.ino: ID 63, double LED brightness/size guidance.
- docs/hardware.md: parts list with rough prices (ESP32 dev board / ATtiny85, LED + resistor
or WS2812, LiPo/coin cell, diffuser ping-pong ball), wiring, flashing (`arduino-cli`
commands), wearing guidance (chest/hat, diffusion, one badge per person, ≥24 fps cameras,
exposure caveat: blown-out video kills OOK contrast).
- Compile check: `arduino-cli compile --fqbn esp32:esp32:esp32` (and attiny if the core is
installable). If arduino-cli isn't available in your environment, document the exact
commands and mark the box unverified — do NOT claim compiled without output.
## M24 — bench self-test, the parts that bite
- `scripts/badge_selftest.py` must run lane H's REAL decoder (`tracker_detect` blink path) —
import it, don't re-implement; if lane H hasn't merged yet, code against the frozen
contract #3/#4 shapes and mark the integration box pending (integration3 re-runs it).
- `--synthetic` mode: render a small blinking clip using the fixture's marker-rendering
helpers (foundation3 exposes them in synthetic.py) and assert the ID round-trips.
- Clear, John-readable failure messages: no blob found / decoded wrong ID / clip too short
(< 1 word). Exit codes: 0 ok, 1 decode-fail, 2 usage.
## Acceptance (evidence in plan/status/lane-K.md)
- [ ] protocol.h constants byte-identical to contract #4; badge + beacon sketches compile
(output pasted) or compile commands documented + marked unverified
- [ ] docs/hardware.md complete enough that John can order parts from it
- [ ] `uv run python scripts/badge_selftest.py --synthetic` decodes the rendered ID
(`test_badge_selftest.py` covers it); failure paths print actionable text
- [ ] Backend suite green; `npm` untouched

38
plan/status/deploy.md Normal file
View File

@ -0,0 +1,38 @@
# Status — deploy (digalot.fyi/festifun)
## Round 1 — 2026-07-16 — STATUS: app deploy-ready; BLOCKED on server access
**Ask:** host the app live at `digalot.fyi/festifun` on `dealgod@100.94.195.115` (tailscale).
**Owner decision:** fully open (all features public + unauthenticated). Risk flagged + accepted.
### Done — the app is now deployable behind a path prefix
- **Frontend deploy-ready.** `API_BASE` is env-driven (`VITE_API_BASE`, default `localhost:8000`
so dev is unchanged); added `vite.config.js` with `base` from `FESTIVAL4D_BASE`. A prod build
`FESTIVAL4D_BASE=/festifun/ VITE_API_BASE=/festifun npm run build` emits assets under
`/festifun/` with the API base baked as `/festifun` (localhost:8000 absent from the bundle).
- **Validated end-to-end locally.** Built the prod bundle, ran a Python reverse proxy mirroring
the nginx config (static + prefix-stripping `/festifun/api` & `/festifun/media` with Range),
loaded it in a browser: **app boots, manifest/pointcloud proxy OK, media Range = 206, videos
play, 3D scene + overlays + timeline all render under the `/festifun` prefix.**
- **Deploy artifacts written** in `deploy/`: `festifun.nginx.conf` (location blocks, prefix
strip, Range), `festifun-api.service` (uvicorn on 127.0.0.1:8000, localhost-only, no keys),
`DEPLOY.md` (build → rsync → service → nginx, plus Caddy note).
- Default build unchanged (root asset paths); backend suite still **103 passed**.
### Deliberate deploy choice — keys OFF the public box
`POST /api/events/detect` spends real AI credits. Rather than expose that on an unauthenticated
public endpoint, moment labels are baked into the shipped DB by running `events` locally before
deploy; the server carries no `GEMINI_API_KEY`/OpenRouter creds and `detect` degrades to
candidate-only (no spend). Owner can opt into live classification, but that needs nginx
rate-limiting first (documented in DEPLOY.md).
### BLOCKED — cannot finish without server access
- My SSH key is **not authorized** on `dealgod@100.94.195.115` (rejected directly and via the
johnking box — both `Permission denied (publickey)`). Need the key added to that host's
`~/.ssh/authorized_keys`:
`ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPL3EE4wE6nv0V0BNMbJ0Gl+px3wOM8K+V6LvCJZa7PM monsterrobotparty@gmail.com`
- Also need to know the server's web stack (nginx vs caddy, where digalot.fyi's server block
lives). Recon command in the chat; I'll adapt the config once I can see it.
**Next (once access granted):** recon the web stack → adapt `deploy/festifun.nginx.conf` (or write
the Caddy equivalent) → build+rsync+service+reload per DEPLOY.md → verify `https://digalot.fyi/festifun/`.

View File

@ -0,0 +1,55 @@
# Status — foundation2
## Round 1 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** Round 4 of plan/DIRECTIVES.md. Single-agent phase 5a — main
working directory on branch `foundation2` (per directive 3).
**Acceptance checklist** (plan/20-phase5.md "foundation2 scope"):
- [x] DB: `paths` table (contract #3) + helpers `add/get/get_one/delete_path`, plus
`patch_anchor` (label/color only) — `db.py`
- [x] API: `GET /api/beats` (serves `config.BEATS_JSON`, 404 when absent),
`POST /api/director` (degrades to a VALID empty camPath + `note` while lane E is
stubbed), `GET/POST/DELETE /api/paths*` (implemented — CRUD over the helpers was
cheaper than stubbing; POST validates the camPath shape → 422),
`PATCH /api/anchors/{id}` (contract #4) — `api.py`
- [x] Manifest gained `"capsule": false``test_api.py` exact-set lock updated in the same
commit (pitfall #3)
- [x] CLI: `features`, `direct`, `capsule` subcommands dispatch into stubs; house
degradation (exit 2, no traceback) — `cli.py`
- [x] Backend stubs raise NotImplementedError with the frozen contract in the docstring:
`audio_features.py`, `director.py`, `capsule.py`
- [x] Frontend stub modules created + imported from `main.js`, each wiring its own hidden
button in init(): `director.js`, `exportVideo.js`, `anchorPanel.js`, `photoMode.js`
(+ per-frame `fx.update()` call in the master loop), `fx.js`, `pathsStore.js`
- [x] index.html: ALL phase-5 buttons pre-wired hidden — #btn-director, #btn-path-save,
#path-load-select, #btn-path-del, #btn-export, #btn-photo, #btn-fx, #btn-anchors,
#anchor-panel container; `.write-ui` class + `body.capsule .write-ui{display:none}`
- [x] Hooks (contract #6): `transport.captureAudioStream()/releaseAudioStream()` (post-gain
tap, spatial included, tracks re-join across seeks), `scene3d.focusOn(x,y,z)`,
`scene3d.setHelpersVisible(bool)` (+ `registerHelper`; camPath gizmos registered)
- [x] `state.js`: `window.__F4D_API_BASE__` ?? `VITE_API_BASE` ?? localhost (contract #5);
`state.capsule` from the manifest toggles `body.capsule`
- [x] Suite ≥ 121 + new tests: **127 passed** (121 floor + 6 in `test_phase5_api.py`)
- [x] Ownership table updated (paths-route bodies → foundation2; noted in 20-phase5.md)
**Done this round — evidence:**
- `uv run pytest backend/tests`**127 passed** (was 121).
- `npm run build` → clean (pre-existing chunk-size warning only).
- Live browser (Vite :5173 + uvicorn :8000, synthetic fixture): app boots (3 videos, sync
+0 ms); all 9 new controls present and `display:none`; `setHelpersVisible(false)` hides
grid (and restores); `focusOn(1,0.5,0)` retargets OrbitControls to exactly [1,0.5,0];
`captureAudioStream()` returns a MediaStream with 1 audio track, release clean;
`POST /api/director``{version:1, keyframes:[], note:…}` and round-trips
`camPath.fromJSON` (0 keyframes, no throw); paths save→load→fromJSON reproduces 2
keyframes, DELETE → 200 then 404; `PATCH /api/anchors/1 {color}` → updated dict;
`GET /api/beats` → 404; `body.capsule` hides #annot-panel/#btn-path-save/#btn-path-del.
**Blockers / questions for coordinator:** none. One conscious deviation, self-adjudicated
(coordinator == foundation2 agent this round): paths route bodies implemented here instead
of stub-marked for lane G — they were one-line calls into the db helpers I had to write
anyway; stubbing them would have been more code than implementing. Lane G scope shrinks to
capsule + pathsStore.js + deepening test_paths.py.
**Next:** merge to `main`, then lanes E, G, F in parallel (each in its own worktree —
directive 3 is mandatory for 5b).

View File

@ -0,0 +1,57 @@
# Status — foundation3 (Phase 6a / M18)
## Round 6 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** round 6 of plan/DIRECTIVES.md. Directive #1: `foundation3` only,
branch `foundation3`, push — do NOT merge. Contracts correctness beats speed; keep 189 green.
**Acceptance checklist** (M18, plan/30-phase6.md §M18):
- [x] 1. Synthetic fixture: two moving markers (A magenta `hue:150`, B blink `code:5`)
composited through each camera's real pose projection; `data/work/track_truth.json`
built from the SAME 3D paths (`synthetic.build_track_truth`, Three.js scene space).
`synthetic.project_point` is the exact inverse of `geometry.ray_from_pixel`. Base
`testsrc2` is saturation/brightness-muted (`BASE_SATURATION`/`BASE_BRIGHTNESS`) so its
own magenta/cyan colour bars don't swamp lane H's colour detector — the discs are the
only high-sat magenta/cyan + only bright small blobs (verified by a real-render test).
- [x] 2. DB: `Track`/`TrackPoint` tables (contract #2) + `add_track`/`get_tracks`/`get_track`/
`get_track_points`/`set_track_points`/`patch_track`/`delete_track`/`has_tracks`.
- [x] 3. API: GET /api/tracks, PATCH/DELETE /api/tracks/{id}, POST /api/tracks/solve (degrades
to valid empty + note while lane H stubbed), manifest `has_tracks`. Key-set lock edit → CR-6.
- [x] 4. CLI: `python -m festival4d track [--detect-only|--solve-only]` → lane-H stubs.
- [x] 5. Backend stubs: `tracker_detect.py`/`tracker_solve.py` — `NotImplementedError` + full
contracts #1#5 in docstrings; foundation-owned frozen constants (blink OOK, hue keys)
live in `tracker_detect` as the single source of truth (`synthetic` imports them).
- [x] 6. Frontend: `friendTracks.js` stub (never-throws, fx.js pattern) wired into `main.js`
loop; hidden `#btn-friends` + `#friends-panel`; `state.tracks` loaded when `has_tracks`.
`npm run build` clean (27 modules, 851 ms).
- [x] 7. Capsule: `capsule.py` bakes `api/tracks` (additive). Bundle-structure test edit → CR-6.
- [x] 8. Tests: `test_tracks_foundation.py` (20) — DB helpers, routes, solve degradation,
manifest, frozen protocol/encoders, track_truth shape, the **projection round-trip**, and
a real-ffmpeg render test proving marker A is the dominant magenta blob after base-muting.
**Done this round:** all 8 steps land. Evidence:
- Full suite **209 passed, exit 0** (`uv run pytest -q`) — the 14 pre-existing files sum to
exactly 189 (floor held), + 20 new in `test_tracks_foundation.py` (all passed, none skipped).
- `test_projection_round_trip_recovers_ground_truth`: projects both markers' 3D truth through
every camera pose, back-projects via frozen `ray_from_pixel`, triangulates via
`triangulate_rays`**worst recovery error < 1e-3** across ≥8 samples in 2+ views. Lane H's
M20 target (median 3D error < 0.3) is reachable by construction (~300× margin).
- `encode_word(5) == [1,1,1,0,0,0,0,0,1,0,1,1]`; all 64 IDs preamble+parity+MSB-recover checked.
- Frontend production build clean; manifest `has_tracks` reflects the DB live.
- CR-6 filed (plan/CHANGE_REQUESTS.md): the two sanctioned M18 test-lock edits (manifest key-set,
capsule bundle paths) — additive, flagged for integration3 ratification.
**Recovery note (2026-07-17):** a prior foundation3 attempt left M18 half-done and **uncommitted**
in a stale locked worktree — steps 2/3/4/5/7 (DB/API/CLI/stubs/capsule) + the `synthetic.py`
marker code present, but step 1's ground-truth never validated, steps 6/8 (frontend/tests) not
started, suite never run. This session salvaged it: committed the WIP as checkpoint `6a703c3`,
then completed steps 1/6/8 and verified every inherited contract choice against the spec. The
one real bug found: the base `testsrc2` visual carried solid magenta+cyan bars (the marker hues)
that would have swamped lane H's colour detector — fixed by muting base saturation/brightness
and locked with a real-render regression test (marker A dominant-blob within 2.5 px, 24/24
frames). No work was redone. Do NOT merge — coordinator reviews + merges (DIRECTIVES R6 #3).
**Blockers / questions for coordinator:** none. Ready for review + merge.
**Next:** coordinator reviews `git diff main..foundation3` + this file, merges to `main`.
After merge, lanes H / J / K launch in parallel worktrees (DIRECTIVES R6 #2).

View File

@ -0,0 +1,66 @@
# Status — integration
## Round 1 — 2026-07-16 — STATUS: ready_to_merge
**Directives acknowledged:** round 1 of plan/DIRECTIVES.md (all lanes merged & verified;
integration begins; CR-1 approved; drop debris stash + remove laneA/B worktrees; real-browser
sync check; test floor 96).
**Scope done: M8 + M9 + README + cross-lane wiring + cleanup.**
### Change requests adjudicated (plan/CHANGE_REQUESTS.md)
- **CR-1 (lane D): APPLIED** — confirmed `POST /api/events/detect` returns `{result, events}`;
renamed test asserts it.
- **CR-2 (integration, filed + APPLIED):** M8 needs an annotation↔event link and event-type
correction, neither of which the frozen contract had. Added — strictly additive:
- `annotations.event_id` (nullable FK) + `AnnotationIn.event_id` — two-view grouping.
- `db.update_event` + `db.get_event` + `PATCH /api/events/{id}` — the correction dropdown.
Every pre-existing route/shape/column and the timebase + pose contracts are unchanged.
- A second foundation stub assertion (`test_annotation_stored`: `anchor_id is None`) was, like
CR-1, only valid pre-M8; renamed to `test_annotation_resolves_to_anchor` for the landed shape.
### M8 — correction + annotation → 3D
- New `backend/festival4d/resolve.py`: ray from bbox center (pose interpolated at t_video via
`geometry.slerp_pose`), then triangulate across two same-event views (reject near-parallel /
gap > 0.5), else nearest point-cloud point (0.3 cyl), else ray at centroid depth. Pure
orchestration over lane B's frozen primitives.
- `POST /api/annotations` now resolves + creates+links an anchor (label = event description).
- Frontend `annotate.js`: click a timeline marker → correction panel (type dropdown → PATCH,
source→user; live marker recolor); **Annotate location** → drag a letterbox-correct box on
any video → POST. Resolved anchors pushed live into overlays **and** the 3D scene (new anchor
spheres + labels in `scene3d.js`).
- **Acceptance (spec M8): PASS.** `tests/test_resolve.py::test_two_view_triangulation_recovers_corner`
— two-view stage-corner annotation lands **0.00000 units** from ground truth (< 0.2). Verified
live in-browser too: two-view annotation triangulated with gap 0.21 through the full
frontend→API→geometry path; correction changed pyro→confetti with source=user and recolored
the marker.
### M9 — keyframed camera paths
- New `camPath.js`: ** Key**/`K` captures the free-roam pose at the current t_global; gizmos +
Catmull-Rom preview line; **▶ Path** follows position (Catmull-Rom) + orientation (slerp) by
t_global while the transport plays; ⤓/⤒ export/import JSON. `scene3d` gained
`enterPathMode`/`exitPathMode`/`applyCameraPose`; snap-to-camera and Free-roam/`Esc` cancel a
playing path.
- **Verified live:** 3 keyframes → path mode on → at t=6.0 s the viewer camera sat exactly on
keyframe #2 `[-10,6,12]` (Catmull-Rom driven).
- Polish present from lanes + this phase: loading state, per-video enable/disable, keyboard
(`Space`,`←/→`,`Shift`,`19`,`Esc/0`,`K`).
### Full-system pass
- **Backend: 101 passed** (`../.venv/bin/python -m pytest`) — up from the 96 merge-day floor
(+5: 4 resolve + 1 event-patch; annotation test renamed).
- **Frontend: `npm run build` clean** (18 modules).
- End-to-end on the synthetic fixture: `synthetic → ingest → sync → events → serve` + frontend
loads with 0 console errors; M4 sync error ~16 ms at t=8 s (deterministic pump; half-frame
residual, < 50 ms).
- Cleanup: dropped the shared-worktree debris stash; removed `../festifun-laneA` and
`../festifun-laneB` worktrees; `frontend/dist/` gitignored; pristine demo DB regenerated.
**Blockers / notes for coordinator:**
- **Real-focused-browser M4 check is the one item I could not fully close from here.** The
automated browser pane runs `document.hidden=true`, so rAF is throttled and continuous
playback can't be observed (same limitation lane C hit). I verified sync deterministically
(pump → ~16 ms) and every interaction via real handlers, but a human should open the app in a
normal focused window once to eyeball continuous multi-cam sync. Not code-blocking.
**Next:** merge `integration``main`; tag `v0.1.0`.

View File

@ -0,0 +1,49 @@
# Status — integration2
## Round 1 — 2026-07-17 — STATUS: merged (synthetic pass complete; field test GATED on real clips)
**Directives acknowledged:** Round 4. Single-agent phase 5c, main working directory on
branch `integration2`. Coordinator and integration agent are the same session this round.
**Merge state:** foundation2, lane/g-capsule, lane/f-social, lane/e-director2 all merged to
`main` in completion order; every merge independently re-verified (suite re-run in the lane
worktree before merging, then on the merged tree). CR-4 and CR-5 ratified at merge (CR-1
precedent; decisions recorded in plan/CHANGE_REQUESTS.md). Suite: 127 → 169 → **189 passed**;
`npm run build` clean at every step.
**Full-system synthetic pass — evidence per milestone:**
- **M10 (beats):** `ingest → features` on the repo project: 29 beats, tempo **120.0 BPM**
(ground truth 120), beat grid at 0.5 s spacing (~+20 ms bias, well inside ±50 ms), 40
onsets → `data/work/beats.json` matches contract #1.
- **M11 (director):** CLI `direct --top-n 4` emits frozen-shape camPath JSON, keyframe times
ON the beat grid (3.02, 4.02, …). Live browser: 🎬 visible, click → **10 keyframes loaded,
all beat-snapped (≤11 ms), path auto-plays** (⏹ Path state).
- **M12 (export):** live browser via the `__f4d` pump at 50 Hz (hidden pane, rAF=0 — pitfall
#2 workaround; WebAudio grants the timer exemption): full-length export auto-stopped at the
last keyframe → `video/webm;codecs=vp9,opus`, **duration 13.98 s vs 14.0 expected (±5% ✓)**,
audio track present and decoding (webkitAudioDecodedByteCount 28 770 after 1.5 s), state
restored, ⏺ re-armed. NOT verifiable headless: rendered-frame quality (needs a focused
window — flagged for the human).
- **M13M15 (lane F):** lane self-verified live in isolated servers (see plan/status/lane-F.md
— full create→rename→recolor→jump→delete roundtrip, 3840×3093 helper-free PNG, both FX
types, 0.101 ms/frame worst case). Coordinator re-check on merged main: all modules
ready=true, buttons visible, anchor panel lists 4 anchors with tag/jump/edit/delete, FX
fires on a scrub crossing of bass_drop, **zero console errors**.
- **M16 (paths):** live: save (via module's own prompt flow) → dropdown refreshes → load
reproduces keyframes **exactly** (deep-equal on toJSON) → delete refreshes. 30 lane tests.
- **M17 (capsule):** baked from the real dist (19 files, 6.8 MB), served by bundle
`serve.py`: app boots in the browser with **zero off-origin requests**
(performance.getEntriesByType — nothing to :8000), `capsule: true`, all `.write-ui` hidden,
**video seek to t=10 exact** (Range/206 over serve.py), `/api/audio/1` → 200 (WebAudio
clock live), 4 anchors + 7 events + point cloud + timeline all present. Screenshot on file.
**Housekeeping:** `.gitignore` now covers `data/capsule/` and `.claude/worktrees/`
(lane G's note).
**Field test:** NOT RUN — gated on 24 real overlapping clips in `data/raw/` (the standing
human item since Round 2). Per the adjusted plan (DIRECTIVES Round 5) the **v0.2.0 tag waits**
for the field test; the synthetic integration pass is complete and accepted.
**Blockers:** none for code. Human items listed in DIRECTIVES Round 5.
**Next:** field test on real footage when clips land → ISSUES.md entries → v0.2.0.

59
plan/status/lane-B.md Normal file
View File

@ -0,0 +1,59 @@
# Status — lane-B
## Round 0 — 2026-07-16 — STATUS: ready_to_merge
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (run order C→A→D→B; merge when
acceptance passes, don't wait on siblings; maintain this status file; evidence discipline).
**Environment:** COLMAP 4.1.0 (CPU-only) + ffmpeg both installed, so the pipeline was
exercised against **real COLMAP end-to-end**, not just the synthetic export path.
**Isolation note (see blocker):** worked in a dedicated git worktree
(`/Users/m3ultra/Documents/festifun-laneB` on `lane/b-recon`) because all lane agents were
sharing one working directory. Only the 6 lane-B-owned files were touched.
**Acceptance checklist** (spec M2 + M8 geometry + lane brief) — all pass:
- [x] `geometry.py` M8 stubs implemented: `slerp_pose` (shortest-arc, double-cover), `ray_from_pixel`
(COLMAP +y-down back-projection), `triangulate_rays` (closest-point + parallel guard),
`nearest_point_on_ray` (in-front radius cylinder). Frozen `colmap_to_threejs`/`quat_to_mat`/
`mat_to_quat` and the 3 frozen POSE_TEST_VECTORS untouched (`git diff 5fa7301` shows 0 removed
lines in test_geometry.py; the frozen funcs are unchanged).
- [x] `frames.py`: `sharpness` (variance of Laplacian) + windowed sharpest-frame `sample_frames`.
- [x] COLMAP TXT parsers (images/cameras/points3D) with hand-written-snippet tests + a truncated-PARAMS
guard (clean ValueError).
- [x] `normalize_scene`: centroid≈0, camera-sphere radius≈10, up≈+Y — verified by `test_normalize_scene_invariants`
and a projection-invariance test (`test_normalize_scene_preserves_projection`).
- [x] pose interpolation (slerp+lerp, `registered=False`, NO extrapolation past first/last registered) —
both guard directions tested.
- [x] PLY export via frozen `synthetic.write_ply`; poses written via atomic `db.set_poses`.
- [x] graceful degradation: COLMAP absent / <60% frames / <2 videos / no frames / missing files
diagnostic, existing poses UNTOUCHED, DB uncorrupted. Mixed-subset case (some videos registered,
others kept) tested for the DB-safety invariant.
- [x] real end-to-end: `synthetic``reconstruct` ran the full COLMAP CLI (feature_extractor →
exhaustive_matcher → mapper → image_undistorter → model_converter → my parsers), produced 6
components (2/15/11/16/17/37 imgs), correctly selected the largest by `images.bin` header count,
judged 37/120 (31%) weak, degraded gracefully, **left all 41 synthetic poses/video untouched, exit 0.**
- [x] `pytest` green — **61 passed** (24 foundation + 37 lane-B; test_geometry.py + test_sfm.py).
- [x] no edits outside owned files (`git diff 5fa7301 --name-only` = frames/geometry/sfm/test_geometry only;
untracked: test_sfm.py, this status file); no new deps.
**Bugs found & fixed while validating on real COLMAP:**
- COLMAP 4.x renamed `SiftExtraction`/`SiftMatching` → `FeatureExtraction`/`FeatureMatching`; option
detected from `--help` (which COLMAP prints to **stderr**) so the pipeline runs on 3.x and 4.x.
- `_largest_model_dir` ranked binary models by file size (tracks keypoints, not image count) → now reads
the exact registered-image count from the `images.bin` uint64 header (validated on the real 6-component run).
- workspace cleared before each run so a stale `database.db` can't fail reruns.
**Adversarial review:** 5-lens review + verify pass (23 agents) → 18 raw findings, 5 CONFIRMED (2 code, 3
test-coverage), all applied; 13 refuted (incl. triangulate/nearest-point "behind origin" — correct-as-written).
**Blockers / questions for coordinator:**
- **Shared working tree collision.** All four lane agents are operating in the *same* working directory
(`/Users/m3ultra/Documents/festifun`), not separate clones/worktrees as `plan/README.md` requires.
Mid-session the shared branch was switched to `lane/d-events` and the tree accumulated uncommitted edits
from lanes A/B/C/D at once. I isolated lane B in its own worktree so nothing is clobbered. **Recommend the
coordinator have each lane use `git worktree`/separate clones, and adjudicate merges centrally**, since a
naive `git branch -f main` from any lane could silently drop a sibling's merge. I did NOT auto-merge to
`main` for this reason — lane B is ready and awaiting a safe merge.
**Next:** commit lane B to `lane/b-recon`; coordinator to merge to `main` (safely, given the shared-tree issue).

53
plan/status/lane-C.md Normal file
View File

@ -0,0 +1,53 @@
# Status — lane-C
## Round 1 — 2026-07-16 — STATUS: ready_to_merge
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (run order C→A→D→B; C starts first
as the longest lane + earliest API-contract validation; merge when acceptance passes; maintain
this file; evidence discipline). No new rounds present on origin at time of writing.
**Acceptance checklist (lane brief M4/M5/M6 + timeline markers) — all pass:**
- [x] **M4 synchronized playback.** Master clock from `performance.now()` (never a `<video>`).
Timebase mapping proven exact by seek: at t_global=8.0 → cam0=8.000, cam1=6.630,
cam2=8.842 s, err=0 ms each. Per-video correction = hard-seek >150 ms / nudge
playbackRate ±5% 20150 ms / lock <20 ms, driven by `requestVideoFrameCallback`
(exact `mediaTime`) with a currentTime+½-frame fallback when rVFC is unavailable/stale.
Out-of-range videos pause+dim (cam1 at t=0, target 1.37 s). One audio source; dev
overlay shows per-video sync ms. Measured continuous-playback **inter-video desync =
13 ms mean / 46 ms max** over 6 s (< 50 ms), driven by a setTimeout pump (see env note).
- [x] **M5 3D viewer.** PLY point cloud (3106 pts, vertex colors); per-video camera-path lines
+ current-pose frusta (colored); OrbitControls; snap-to-camera tweens viewer to the pose
(following cam1: camPos≈(1.78,2.63,9.25)≈true center (0,2.4,9.4); fov=36°≈2·atan(H/2fy);
orbit disabled) and free-roam restores (fov→50°, orbit on). All COLMAP→Three via frozen
`lib/pose.js` — never reimplemented.
- [x] **M6 anchor overlays.** Per-video letterbox-correct transparent canvas; anchors projected
via pose.js camera. Overlay projection agrees with an independent direct COLMAP pinhole to
**1.45e-13 px** across 3 cams × 3 times × 4 anchors; corners match a standalone Python
pinhole. Behind-camera cull (z_cam<0). Stage corners render/track on all in-range videos.
- [x] **Timeline event markers.** 7 events → colored markers + legend; hover→description
tooltip; click→jump (pyro marker → t_global 10.00). Scrubber seeks/parks all videos.
- [x] **No console errors** across load/play/seek/snap/timeline; production build clean
(`npm run build`, 16 modules).
**Env note (not a blocker):** the automated browser pane reports `document.hidden=true`, so rAF
and rVFC only fire during screenshots — continuous playback can't be observed by polling here.
Verified deterministically instead (seek exactness to 0 ms; projection to 1e-13 px; a
setTimeout-driven run of the real correction code → 13 ms mean inter-video desync). A real
focused browser runs the rAF loop at 60 fps and rVFC drives even tighter absolute sync.
**Owned-files discipline:** committed only `frontend/**` + this status file. Left untouched the
other lanes' in-tree changes (A: audio_sync/ingest; B: frames/geometry/sfm; D: events_ai).
Added `frontend/src/lib/timebase.js` + `poseTrack.js` (new files inside lane C's ownership).
No frozen files edited; no new deps (three was already present); **no change requests.**
**Repo note for coordinator:** this working directory is shared/contended — `lane/c-viewer`
was reset to foundation (5fa7301) and HEAD moved to `lane/d-events` between my session-start
commit and now, and `main` has since advanced to lane D's self-merge (6ec55c7). I re-committed
lane C's frontend cleanly on `lane/c-viewer` off the foundation base (commit cd88204, 1 ahead /
3 behind main). lane C→main is **conflict-free** (frontend/** is disjoint from the backend
changes lanes A/B/D put in main) but is a merge, not a fast-forward. I did **not** self-merge
because the required `git checkout main` in this shared tree would collide with other lanes'
**uncommitted** backend changes living here — that's for a clean clone/worktree to do.
**Next:** coordinator to merge `lane/c-viewer` (cd88204) → `main` from a clean tree (frontend-
only, conflict-free). Phase-3 seams left as clean stubs: `annotate.js` (M8), `camPath.js` (M9).

65
plan/status/lane-D.md Normal file
View File

@ -0,0 +1,65 @@
# Status — lane-D
## Round 1 — 2026-07-16 — STATUS: merged
**Merged:** `lane/d-events``main` (fast-forward, commit `5c2d7c6`) after acceptance below
passed. Local only (not pushed to `origin` — shared working tree; coordinator drives origin).
**Adversarial review (self-run, 5 lenses → verify):** 3 findings, all confirmed & fixed —
(1) `detect_candidates` fabricated ~1 candidate/s on silent/DC/noise audio → added an AC-RMS
(std) floor `MIN_SIGNAL_STD`; silence/DC/noise now return `[]`, fixture unchanged [3,10,17];
(2) degradation test's raised tripwire was swallowed by `except Exception` → switched to a
call-recorder (`assert prepared == []`); (3) windowed test ran on a fresh DB → now seeds an
out-of-window `ai` event and asserts it survives. `pytest` **90 passed** after fixes.
**Directives acknowledged:** round 0 of plan/DIRECTIVES.md (foundation merged; lanes may start;
lane D is small + fully independent; merge when acceptance passes — no waiting on siblings).
**Acceptance checklist** (plan/lane-D-events.md + spec M7) — implementation complete, verified:
- [x] `detect_candidates` finds candidates within ±0.5 s of fixture ground-truth pulses
[3.0, 10.0, 17.0]. Evidence: `test_events_ai.py::test_detect_candidates_matches_ground_truth`
(recall+precision+count). RMS×spectral-flux, centered-10 s-window local-max, ≥ P90.
- [x] `MomentClassification` + `MomentClassifier` frozen contract preserved verbatim; import
of events_ai pulls in ZERO heavy libs/SDKs (all lazy). Evidence: import-cost probe → `[]`.
- [x] Three providers implemented against the installed SDKs (verified surfaces): Gemini
(google-genai 2.11.0, `Part.from_bytes(data,mime_type)` + `generate_content` structured
output), Claude (anthropic 0.116.0, `messages.parse(output_format=…, thinking=adaptive,
max_tokens)` → `parsed_output`, NO temperature, model `claude-opus-4-8`), Local
(openai 2.45.0, `chat.completions.create` json_object, 6 image_url parts, 1 retry).
- [x] Provider selection `FESTIVAL4D_CLASSIFIER=gemini|claude|local` (default gemini);
unconfigured → candidates-only + log line. Evidence: `test_get_classifier_*` (5 tests).
- [x] Per-candidate exception isolation. Evidence: `test_run_events_isolates_per_candidate_failure`
(2 ai + 1 fallback candidate; batch of 3 completes despite one raising).
- [x] `run_events` fills the frozen entrypoint (cli.py + api.py dispatch); writes via db helpers
only (insert-once in final state — db.py has no update-event helper; end state matches spec).
- [x] `pytest` green with NO network: **90 passed** across consecutive full-suite runs
(16 lane-D tests; failures observed once were a transient lane-A shared-`AUDIO_DIR` race).
- [x] Real-path end-to-end (ffmpeg-decoded AAC audio, not in-memory): isolated 20 s fixture →
`run_events()` degraded → candidates at 3.008/10.016/17.013 s (≤0.016 s error), exactly 3,
no spurious. Evidence: scratchpad/e2e_verify.py → RESULT PASS.
**Done this round:** implemented `backend/festival4d/events_ai.py` (candidate detection + 3
providers + get_classifier + prepare_inputs + run_events orchestration) and
`backend/tests/test_events_ai.py` (16 tests). Detection algorithm locked via a scratch
experiment before coding. Confirmed Claude call shape via claude-api skill and all three SDK
surfaces via introspection. Ran a 5-lens adversarial review (8 agents: spec-compliance /
provider-SDK / detection-orchestration / contract-ownership / test-quality → verify); 3 findings
confirmed and all fixed (see the review summary at the top of this round).
**Coordinator note (out-of-strict-ownership edit):** updated `backend/tests/test_api.py`
`test_detect_degrades_gracefully``test_detect_events_endpoint`. That foundation test asserted
the STUB behavior (`"note" in data`) of `POST /api/events/detect`; my landing replaces the stub
with real detection returning `{result, events}`. The frozen `api.py` app is UNCHANGED — only the
now-obsolete stub assertion was updated. Flagged here for transparency; see plan/CHANGE_REQUESTS.md CR-1.
**Note on shared tree:** this working directory holds all four lanes' uncommitted work
concurrently (A/B/C/D). I will stage and commit ONLY my owned files. One transient
`test_audio_sync.py` (lane A) failure was observed once mid-run (concurrent write) but the suite
is deterministically green (89/89 ×5); not caused by lane D.
**Blockers / questions for coordinator:** none.
**Next:** Lane D (M7) is done and merged to `main` (`5c2d7c6`). No follow-up needed in-lane.
Coordinator: proceed to integration (phase 3) once lanes A/B/C also merge.

66
plan/status/lane-E.md Normal file
View File

@ -0,0 +1,66 @@
# Status — lane-E (director & export, phase 5b — M10/M11/M12)
## Round 1 — 2026-07-17 — STATUS: ready_to_merge
**Directives acknowledged:** Round 4 of plan/DIRECTIVES.md (phase 5 begins; foundation2 merged;
suite floor 121 — was 127 at lane start after foundation2's tests).
**Branch:** `lane/e-director2` (own worktree per Round-4 rule). Commits: `0f8685b` M10,
`52e8912` M11, `ddb8dea` M12. Not merged, not pushed — coordinator merges.
**Acceptance checklist**
- [x] **M10** beats/onsets: synthetic 120 BPM pulse grid recovered — tempo **120.000** (±2 req),
worst beat **30 ms** off grid (±50 req) — `test_run_features_recovers_synthetic_pulse_grid`;
the 3 ground-truth bangs found as strong onsets — `test_onsets_catch_the_ground_truth_bangs`;
silence/short → valid-empty JSON, missing WAV → note + nothing written, beatless noise →
valid typed result, CLI end-to-end — 7 tests in `test_audio_features.py`. Key impl detail:
10 ms analysis hop (librosa's default 32 ms grid at 16 kHz misses the ±50 ms bound; measured
60 ms → 30 ms), tempo from median inter-beat interval (117.19 → 120.000).
- [x] **M11** auto-director: structure-locked against the frozen camPath shape (exact key sets,
unit quats, monotone times) — `test_output_matches_frozen_campath_shape`; top-3 grid ==
`[1,4,8,11,14]` incl. shared-keyframe dedupe; **quat order [x,y,z,w] locked** via
round-trip against frozen `geometry.colmap_to_threejs` + `quat_to_mat`
(`test_keyframe_pose_matches_frozen_conversion`); beat snap puts every key on the grid, no
beats.json / corrupt beats.json → unsnapped (no block); no events / no poses → valid empty
path + note; confidence ties → earlier; lead clamps at 0; unregistered poses excluded; API
route + CLI dispatch real output — 14 tests in `test_director.py`. `director.js` wires 🎬:
POST → `camPath.fromJSON` → auto-play via the existing #btn-path flow; empty path shows
"no events yet" on the button.
- [x] **M12** export (code-complete; needs live browser, below): canvas captureStream(30) +
`transport.captureAudioStream()` tap → MediaRecorder vp9/opus webm (vp8/webm fallbacks),
first→last keyframe at forced rate 1, downloads `festival4d-cut.webm`, restores playhead/
rate/play/path/follow state, releases tap + tracks. Button disabled < 2 keyframes (chained
onto main.js's `camPath.onChange` — no frozen edits).
- [x] Suite on the merged tree (main incl. lane G merged into this branch):
`uv run pytest backend/tests -p no:warnings`**189 passed, 0 failed** (169 on main after
lane G + 21 lane-E 1 retired stub test). Pre-merge lane-only figure was 148 (127 + 21).
`cd frontend && npm run build` → clean (pre-existing chunk-size warning only).
**Change request filed:** **CR-5** (plan/CHANGE_REQUESTS.md) — two stub-era assertions in
`test_phase5_api.py` asserted lane E's *unimplemented* behavior (CLI exit 2; empty director
path) and fail by design once M10/M11 land. Applied the minimal test edit directly per the
**CR-1 precedent** — and, mid-lane, main moved under me with lane G's **coordinator-ratified
CR-4** doing the identical thing for `capsule`, which confirms the pattern. Merged main into
`lane/e-director2` and reconciled: the CLI stub loop is now empty (all three subcommands
real) so `test_cli_dispatches_stubs_gracefully` is retired with a pointer comment; the
director test is renamed `test_director_returns_valid_campath` asserting the frozen camPath
shape. api.py/cli.py byte-for-byte untouched. If the coordinator prefers the stricter
reading, revert `backend/tests/test_phase5_api.py` and re-apply at integration2; the suite
will then show those stub-era failures until adjudication.
**Coordinator must verify live in a FOCUSED browser** (this session had no browser; rAF/
captureStream need focus — pitfall #2):
1. M11: `python -m festival4d synthetic && ingest && features && serve` + vite dev → 🎬 visible;
click → path loads ( Key counter jumps), camera flies the cut, #btn-path shows ⏹.
With `data/work/beats.json` deleted, 🎬 still works (unsnapped). With events cleared,
button flashes "🎬 no events yet", no console error.
2. M12: with a path loaded, ⏺ → records the ride, auto-stops at the last keyframe, downloads
`festival4d-cut.webm`; `ffprobe` it: duration within ±5% of the path span (top-8 synthetic:
1.0→20.0 s ⇒ 19 s ±0.95), has an opus audio track; plays in Chrome. Then confirm prior
state restored (playhead back, rate back, path button reset). Clicking ⏺ mid-ride stops
early and still yields a valid file. Note: hidden/backgrounded pane records frozen frames.
3. M12 + 🎧: enable spatial audio before ⏺ — exported track carries the spatial mix.
**Blockers / questions:** none beyond CR-4 adjudication.
**Next:** coordinator merge + live checks above; integration2 owns the real-footage pass.

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.

41
plan/status/phase4.md Normal file
View File

@ -0,0 +1,41 @@
# Status — phase4 (polish + field test)
## Round 1 — 2026-07-16 — STATUS: polish ready_to_merge; field test AWAITING FOOTAGE
**Directives acknowledged:** round 3 + addendum of plan/DIRECTIVES.md (anchor supersede + delete,
docs/ideas.md, suite ≥101, field-test protocol + ISSUES.md; local `.env` is machine-local).
### Polish pass — DONE
- [x] **Anchor supersede (fixes the Round-3 wart).** One anchor per event: first annotation
creates it (single-view fallback); a later view that triangulates **supersedes it in place**
(same anchor id, response gains `superseded: true`); a later fallback links without moving.
Annotations with no event stay independent.
- Evidence: reproduced the old "2 anchors for 1 object" scenario → now **1 anchor**, both
annotations link to the same id, superseded position err **0.0000** from ground truth
(`tests/test_resolve.py::test_two_view_annotation_supersedes_to_single_anchor`).
- [x] **`DELETE /api/anchors/{id}`** (additive) — unlinks annotations (NULLs `resolved_anchor_id`)
then deletes; 404 on unknown. `db.get_anchor`/`update_anchor`/`delete_anchor` +
`get_annotations(event_id=)` added.
- Evidence: `tests/test_resolve.py::test_delete_anchor_unlinks_annotations_and_404`.
- [x] **Frontend delete affordance** — the correction panel now has a scrollable **anchors list**
(color dot + label + ✕). Delete → `DELETE` → splice `state.anchors``anchors-changed`
(scene3d refresh; overlays read state each frame). `_submit` upserts by id so a supersede
replaces (not duplicates) the anchor in state. `npm run build` clean (18 modules).
- [x] **`docs/ideas.md`** created (gaussian splatting, MP4 path export, realtime ingest,
multi-modal audio, object tracking, QoL).
- [x] **CR-3** filed + APPLIED in plan/CHANGE_REQUESTS.md (strictly additive).
- [x] **Suite: 101 → 103 passed** (`../.venv/bin/python -m pytest`), frontend build clean.
### Field test — AWAITING FOOTAGE (protocol ready)
Scaffolding in place: this file + `plan/ISSUES.md` (seeded with ISSUE-1, the gemini-2.5-flash
retirement, FIXED). When 24 real overlapping clips land in `data/raw/`, run
`ingest → sync → reconstruct → events → serve` and record here: per-pair sync confidence +
offsets, COLMAP registration rate, and which milestones survived real footage. File failures as
`plan/ISSUES.md` entries, don't rush fixes.
**Blockers / notes for coordinator:**
- Field test cannot start until the human provides real footage (carried item).
- Human focused-browser M4 sync eyeball still open (carried from Round 2/3).
- `.env` (Gemini + OpenRouter creds) is machine-local + gitignored; present on this machine.
**Next:** merge `phase4/polish-field-test``main`; run the field test when footage arrives.

View File

@ -14,6 +14,7 @@ dependencies = [
"sqlalchemy>=2.0",
"pydantic>=2.6",
"opencv-python-headless>=4.9",
"python-multipart>=0.0.9", # multipart uploads for live capture (backend/capture.py)
# classifier providers (lane D) — imported lazily, never at module load
"google-genai>=0.3",
"anthropic>=0.40",

40
scripts/splat_via_modelbeast.sh Executable file
View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Train a 3DGS splat of this project's scene on the MODELBEAST fleet, and drop
# it where the viewer looks for it (data/work/splat.ply).
#
# Uses the sampled SfM frames (data/work/frames) as input: uploads them to
# MODELBEAST, runs colmap_poses + brush_train on the render farm's queue, and
# downloads the trained splat back. Requires the `mb` CLI env:
# export MB_HOST=http://100.89.131.57:8777 # M3 primary
# export MB_TOKEN=... # from MODELBEAST Settings → Users
#
# (v1 re-runs COLMAP on the farm rather than shipping our local reconstruction —
# simpler and uses the farm's known-good chain; direct dataset handoff is a
# future optimization. See docs/modelbeast-crossover.md.)
set -euo pipefail
cd "$(dirname "$0")/.."
MB=${MB:-/Users/m3ultra/Documents/MODELBEAST/mb}
FRAMES_DIR=${FESTIVAL4D_DATA_DIR:-data}/work/frames
OUT=${FESTIVAL4D_DATA_DIR:-data}/work
[ -d "$FRAMES_DIR" ] || { echo "no frames at $FRAMES_DIR — run the reconstruct step first"; exit 1; }
command -v "$MB" >/dev/null || { echo "mb CLI not found (set MB=/path/to/mb)"; exit 1; }
echo "[splat] uploading frames..."
mapfile -t frames < <(find "$FRAMES_DIR" -name '*.jpg' -o -name '*.png' | sort)
echo "[splat] ${#frames[@]} frames"
ids=$("$MB" upload "${frames[@]}" | awk '{print $1}')
first=$(echo "$ids" | head -1)
echo "[splat] running colmap_poses on the farm..."
cj=$("$MB" run colmap_poses --asset "$first" $(echo "$ids" | tail -n +2 | sed 's/^/--asset /') | grep -oE '[0-9a-f]{10,}' | head -1)
"$MB" wait "$cj"
ds=$("$MB" assets | awk '/colmap_dataset/ {print $1; exit}')
echo "[splat] dataset: $ds — training splat (brush)..."
bj=$("$MB" run brush_train --asset "$ds" | grep -oE '[0-9a-f]{10,}' | head -1)
"$MB" wait "$bj" --download "$OUT/_splat_dl"
ply=$(find "$OUT/_splat_dl" -name '*.ply' | head -1)
[ -n "$ply" ] || { echo "no .ply came back"; exit 1; }
mv "$ply" "$OUT/splat.ply" && rm -rf "$OUT/_splat_dl"
echo "[splat] done → $OUT/splat.ply (restart/reload the app; the 3D view now renders the splat)"

11
uv.lock generated
View File

@ -441,6 +441,7 @@ dependencies = [
{ name = "openai" },
{ name = "opencv-python-headless" },
{ name = "pydantic" },
{ name = "python-multipart" },
{ name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "soundfile" },
@ -464,6 +465,7 @@ requires-dist = [
{ name = "opencv-python-headless", specifier = ">=4.9" },
{ name = "pydantic", specifier = ">=2.6" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "python-multipart", specifier = ">=0.0.9" },
{ name = "scipy", specifier = ">=1.11" },
{ name = "soundfile", specifier = ">=0.12" },
{ name = "sqlalchemy", specifier = ">=2.0" },
@ -1276,6 +1278,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"