From 13b7424c8374b8f0fa737d46b34f7d2e60f8c542 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:54:11 +1000 Subject: [PATCH 1/3] M13: anchor manager & friend tags (lane F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/anchorPanel.js | 339 ++++++++++++++++++++++++++++++++++-- 1 file changed, 326 insertions(+), 13 deletions(-) diff --git a/frontend/src/anchorPanel.js b/frontend/src/anchorPanel.js index 84db2ed..1b8cb7b 100644 --- a/frontend/src/anchorPanel.js +++ b/frontend/src/anchorPanel.js @@ -1,19 +1,332 @@ -// Anchor manager & friend tags (spec M13, lane F). STUB — lane F fills THIS FILE ONLY. +// Anchor manager & friend tags (spec M13, lane F). // -// Pre-wired by foundation2: the hidden ⚓ toggle (#btn-anchors) and the empty #anchor-panel -// container in index.html, this module's import + init() call in main.js, the hooks -// `scene3d.focusOn(x, y, z)` (jump-to) and `PATCH ${state.apiBase}/api/anchors/{id}` -// {label?, color?} (rename/recolor), plus DELETE /api/anchors/{id} from phase 4. +// 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). // -// When implementing: unhide the button, set `ready = true`; build the collapsible list -// (color dot, label, jump-to, rename, recolor, delete) from state.anchors; after any -// mutation update state.anchors and emit("anchors-changed") so scene3d + overlays refresh. -// The + Tag flow reuses the M8 bbox-annotation flow WITHOUT an event (annotate.js is -// frozen — drive it, don't edit it) and names the resulting anchor. -// Capsule mode: give every MUTATING control the `write-ui` class — body.capsule hides that -// class globally (contract #5); the read-only list + jump-to stay available. +// + 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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, - init() {}, // ponytail: stub — lane F replaces the body + open: false, + _panel: null, + _btn: null, + _pendingTag: null, // { name, color, knownIds:Set } 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 = ` +
+ ⚓ Anchors + + +
+
+
`; + + 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(`tag mode failed: ${esc(err.message)}`); + return; + } + this._tagButton(true); + this._hint(`drag a box over ${esc(name)} 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 ${esc(tag.name)} — visible in 3D and on every video`); + } catch (err) { + this._hint(`naming the tag failed: ${esc(err.message)}`); + } + }, + + // --- 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 = + `
no anchors yet — + Tag a friend,` + + ` or annotate an event on the timeline
`; + return; + } + list.innerHTML = state.anchors + .map((a) => { + const color = a.color || DEFAULT_COLOR; + return ( + `
` + + `` + + `` + + `${esc(a.label) || "(unnamed)"}` + + `` + + `` + + `
` + ); + }) + .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(`rename failed: ${esc(err.message)}`); + } + }, + + async _recolor(id, color) { + if (!this._find(id)) return; + try { + upsertIntoState(await patchAnchor(id, { color })); + } catch (err) { + this._hint(`recolor failed: ${esc(err.message)}`); + 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(`delete failed: ${esc(err.message)}`); + } + }, }; From 1b970861455736d98b24baa5c1302a6de1306214 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:54:11 +1000 Subject: [PATCH 2/3] M14: photo mode (lane F) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📷 / 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 --- frontend/src/photoMode.js | 123 ++++++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 12 deletions(-) diff --git a/frontend/src/photoMode.js b/frontend/src/photoMode.js index 36ff2e1..1688c91 100644 --- a/frontend/src/photoMode.js +++ b/frontend/src/photoMode.js @@ -1,18 +1,117 @@ -// Photo mode (spec M14, lane F). STUB — lane F fills THIS FILE ONLY. +// Photo mode (spec M14, lane F). // -// Pre-wired by foundation2: the hidden 📷 button (#btn-photo in index.html), this module's -// import + init() call in main.js, and the hook `scene3d.setHelpersVisible(false)` which -// hides grid/axes/frusta/path-lines/keyframe-gizmos/anchor-labels in one call (restore with -// true). +// 📷 / "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. // -// When implementing: unhide the button, set `ready = true`, bind key "P" too; on trigger -// pause the transport, setHelpersVisible(false), render ONE frame at 3840-wide (preserve -// aspect) to an offscreen WebGLRenderTarget via scene3d.renderer/scene/camera, download the -// PNG, then restore everything (helpers, renderer size, play state). Works in free-roam and -// follow-cam; with a splat present it renders the splat (no splat -> the point cloud — -// pitfall #5). +// 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, - init() {}, // ponytail: stub — lane F replaces the body + _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); + } + } + }, }; From 175ab0f77267ce754ae132b06aa69c17a6047283 Mon Sep 17 00:00:00 2001 From: type-two Date: Fri, 17 Jul 2026 17:54:11 +1000 Subject: [PATCH 3/3] 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).