From 175ab0f77267ce754ae132b06aa69c17a6047283 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:54:11 +1000 Subject: [PATCH] M15: moment FX (lane F) + status file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✨ 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 --- frontend/src/fx.js | 288 ++++++++++++++++++++++++++++++++++++++++-- plan/status/lane-F.md | 34 +++++ 2 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 plan/status/lane-F.md diff --git a/frontend/src/fx.js b/frontend/src/fx.js index 4c451db..1bbced9 100644 --- a/frontend/src/fx.js +++ b/frontend/src/fx.js @@ -1,19 +1,281 @@ -// Moment FX (spec M15, lane F). STUB — lane F fills THIS FILE ONLY. +// Moment FX (spec M15, lane F). // -// Pre-wired by foundation2: the hidden ✨ toggle (#btn-fx in index.html), this module's -// import + init() call in main.js, and a per-frame `fx.update(tGlobal)` call from the master -// loop (after transport.tick(), before scene3d.update()). +// 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 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. // -// When implementing: unhide the button, set `ready = true`. update() watches the playhead -// crossing event markers (state.events) — detect crossings in play AND scrub (track the -// previous tGlobal; a seek emits "seek" if you need to reset). On `pyro`/`confetti` fire a -// short particle burst at the event's resolved anchor, falling back to the stage centroid; -// on `bass_drop` pulse the point-cloud/splat scale for ~a beat (beats via GET /api/beats -// when present — no beats file, use ~0.5 s; pitfall #5). Scene access ONLY through -// scene3d.addObject/removeObject. Keep >= 30 fps on the synthetic scene. +// 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, - init() {}, // ponytail: stub — lane F replaces the body - update(tGlobal) {}, // called every animation frame by main.js + 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 */ + } + }, }; diff --git a/plan/status/lane-F.md b/plan/status/lane-F.md new file mode 100644 index 0000000..a024186 --- /dev/null +++ b/plan/status/lane-F.md @@ -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).