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>
This commit is contained in:
commit
bdb931def9
@ -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
|
// A collapsible panel (pre-wired container #anchor-panel, toggle #btn-anchors) listing every
|
||||||
// container in index.html, this module's import + init() call in main.js, the hooks
|
// anchor in state.anchors: color dot (recolor), label (rename), jump-to (scene3d.focusOn),
|
||||||
// `scene3d.focusOn(x, y, z)` (jump-to) and `PATCH ${state.apiBase}/api/anchors/{id}`
|
// delete. Mutations go through PATCH/DELETE ${state.apiBase}/api/anchors/{id}; after any
|
||||||
// {label?, color?} (rename/recolor), plus DELETE /api/anchors/{id} from phase 4.
|
// 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
|
// + Tag (friend tags): drives the FROZEN M8 annotation flow WITHOUT an event.
|
||||||
// (color dot, label, jump-to, rename, recolor, delete) from state.anchors; after any
|
// annotate.js was written null-event safe on purpose: startAnnotateMode() only needs the
|
||||||
// mutation update state.anchors and emit("anchors-changed") so scene3d + overlays refresh.
|
// registered cells, and _submit POSTs /api/annotations with event_id: null, which the backend
|
||||||
// The + Tag flow reuses the M8 bbox-annotation flow WITHOUT an event (annotate.js is
|
// resolves to an INDEPENDENT anchor ("Annotations with no event stay independent").
|
||||||
// frozen — drive it, don't edit it) and names the resulting anchor.
|
// Flow: prompt for the friend's name -> annotator.close() (clears any currentEvent) ->
|
||||||
// Capsule mode: give every MUTATING control the `write-ui` class — body.capsule hides that
|
// annotator.startAnnotateMode() -> the user drags a bbox over the friend on any video ->
|
||||||
// class globally (contract #5); the read-only list + jump-to stay available.
|
// 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 = {
|
export const anchorPanel = {
|
||||||
ready: false,
|
ready: false,
|
||||||
init() {}, // ponytail: stub — lane F replaces the body
|
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>`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
// main.js calls fx.update(tGlobal) every animation frame (after transport.tick(), before
|
||||||
// import + init() call in main.js, and a per-frame `fx.update(tGlobal)` call from the master
|
// scene3d.update()). While the ✨ toggle is on we watch the playhead cross event markers
|
||||||
// loop (after transport.tick(), before scene3d.update()).
|
// (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.
|
||||||
//
|
//
|
||||||
// When implementing: unhide the button, set `ready = true`. update() watches the playhead
|
// Scene access is ONLY through scene3d.addObject/removeObject (plus read-only looks at
|
||||||
// crossing event markers (state.events) — detect crossings in play AND scrub (track the
|
// scene3d.points/.splat for the pulse). Hot-path discipline: burst slots + their typed
|
||||||
// previous tGlobal; a seek emits "seek" if you need to reset). On `pyro`/`confetti` fire a
|
// arrays are allocated once, spawns recycle slots, update() allocates nothing, and the whole
|
||||||
// short particle burst at the event's resolved anchor, falling back to the stage centroid;
|
// body is guarded — a throw here would kill the app's master loop, so on error FX disables
|
||||||
// on `bass_drop` pulse the point-cloud/splat scale for ~a beat (beats via GET /api/beats
|
// itself instead of ever throwing.
|
||||||
// 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.
|
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 = {
|
export const fx = {
|
||||||
ready: false,
|
ready: false,
|
||||||
init() {}, // ponytail: stub — lane F replaces the body
|
enabled: false,
|
||||||
update(tGlobal) {}, // called every animation frame by main.js
|
_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 */
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
// 📷 / "P": pause the transport if playing, hide every helper (grid/axes/frusta/gizmos/anchor
|
||||||
// import + init() call in main.js, and the hook `scene3d.setHelpersVisible(false)` which
|
// labels — scene3d.setHelpersVisible(false), one call), render ONE frame at 3840 px wide
|
||||||
// hides grid/axes/frusta/path-lines/keyframe-gizmos/anchor-labels in one call (restore with
|
// (preserving the pane's aspect) by temporarily resizing scene3d.renderer with pixelRatio 1,
|
||||||
// true).
|
// 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
|
// Splat note (pitfall #5): the DropInViewer is a plain child of scene3d.scene and renders with
|
||||||
// pause the transport, setHelpersVisible(false), render ONE frame at 3840-wide (preserve
|
// the same renderer.render(scene, camera) call, so no splat-specific branch exists here; live
|
||||||
// aspect) to an offscreen WebGLRenderTarget via scene3d.renderer/scene/camera, download the
|
// verification on a trained splat is deferred to the coordinator.
|
||||||
// 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 —
|
// No write-ui class: photo mode is read-only and stays available in capsule bundles.
|
||||||
// pitfall #5).
|
|
||||||
|
import { state } from "./state.js";
|
||||||
|
import { transport } from "./transport.js";
|
||||||
|
import { scene3d } from "./scene3d.js";
|
||||||
|
|
||||||
|
const PHOTO_WIDTH = 3840;
|
||||||
|
|
||||||
export const photoMode = {
|
export const photoMode = {
|
||||||
ready: false,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
34
plan/status/lane-F.md
Normal file
34
plan/status/lane-F.md
Normal 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).
|
||||||
Loading…
Reference in New Issue
Block a user