Files
feedBack/static/v3/venue-scene-3d.js
T
e779c72396 feat(venue): reactive crowd video layer behind the 3D highway (career mode 1/3) (#905)
* feat(venue): reactive crowd video layer behind the 3D highway (career mode PR1)

Two crossfading video backdrop planes in the highway_3d venue background
style, driven by a new venue-crowd.js state machine that maps
v3:live-performance-state to crowd states (bored/neutral/engaged/ecstatic)
with 3s stability + 8s dwell hysteresis, plus one-shot reaction stingers
on streak milestones and end-of-song accuracy. Inert without a venue pack
manifest (career plugin, PR2) or the feedBack-venue-crowd-dev flag — the
static bg plate behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): retry renderer binding + preserve mid-stinger transitions

Codex preflight P2s: (1) videos created before highway_3d registered its
globals never reached the backdrop planes — binding is now idempotent and
retried from start/perf-event/re-activation paths; (2) a crowd-state
switch committing while a stinger played was dropped because the machine
had already advanced — it is now deferred and played when the stinger ends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): per-video load tokens + unbind renderer on stop

Codex preflight round 2: (1) the global load token let a stinger cancel a
committed loop load on the other layer — tokens are now per-element, and a
stinger preempting an in-flight loop on its own layer requeues that loop
for when the stinger ends; (2) setManifest(null)/deactivate left the last
crowd frame bound and visible over the static plate — stop() now unbinds
both layers from the renderer and zeroes the mix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): flush deferred loop on stinger failure, source accuracy from perf events

Codex preflight round 3: (1) a failed/timed-out stinger left a deferred
loop switch queued forever; the failure path now flushes it. (2)
stats:recorded only carries {filename, arrangement}, so the end-of-song
reaction now uses the accuracyPct from the song's last
v3:live-performance-state event (a real percentage) instead of a field
that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): requeue mid-crossfade loops preempted by stingers; hard-stop on manifest swap

Codex preflight round 4: (1) idleLayer() still points at the fading-in
layer during a crossfade, so a stinger firing mid-fade overwrote the new
loop with nothing requeued — the fading loop is now tracked and requeued
like an in-flight load; (2) swapping venue packs while active now goes
through stop() so _stopGen invalidates the old manifest's in-flight loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): generation-gate stinger handlers; recrop on video size change

Codex preflight round 5: (1) an ended/timeout handler orphaned by stop()
could fire into a later stinger's lifecycle on the reused element — handlers
now detach unconditionally and carry a generation token; (2) the renderer
only re-applied cover-crop on camera aspect changes, so a src swap with a
different intrinsic size kept stale repeat/offset — it now recrops when
videoWidth/Height change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): bail loop-fade completion when a stinger preempted the layer

Codex preflight round 6: the loop crossfade's completion callback could
still run between a stinger's start and its canplaythrough, promoting the
stinger's layer to active and pausing the real loop — it now bails when
the fading loop was preempted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): keep rear video layer opaque during crossfades

Two half-transparent layers let the static bg plate bleed through (~25%
at mid-fade) — visible as a flash of the old still image on every state
transition. The crossfade is now always the front layer fading over an
opaque rear layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): reset active layer with mix on stop

Codex preflight: stop() zeroed the mix but left _activeLayer at 1, so a
restart flashed layer 0's stale frame until the new loop loaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): reset crowd mood to neutral on song load

Codex preflight: a song ending in ecstatic/bored left the next song's
crowd stuck in that mood until the hysteresis window passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): cancel in-flight fade when a stinger preempts it

Codex preflight: the orphaned ramp kept pushing the mix toward the layer
whose src the stinger had just replaced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): don't let null accuracy resets wipe the end-of-song value

Codex preflight: Number(null) is 0, so idle HUD resets overwrote
_lastAccuracyPct before stats:recorded consumed it, suppressing the
end-of-song stinger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): abort stale stinger state on song load

Codex preflight: a stinger straddling a song change could fade back into
the previous song's layer or flush its pending loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): always detach load listeners, gate only the callback

Codex preflight: superseded loads left canplaythrough/error listeners
attached to the persistent video elements — unbounded growth over a
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(venue-crowd): flyover intro with crowd-ambience ducking

On song:loaded, an optional pack intro plays once: a camera flyover video
(idle layer, one-shot) with bar-crowd ambience audio that ducks out on
song:play, near the flyover's landing, or at handoff — whichever first.
Machine commits and stingers defer during the intro; stop()/song-change
abort it. Packs without an intro behave as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(venue-crowd): fall back to the loop when the intro fails to load

Codex preflight: a failed/timed-out intro left the song with no crowd
loop at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 22:32:04 +02:00

267 lines
8.6 KiB
JavaScript

/*
* fee[dB]ack v0.3.0 — Venue 3D scene bridge.
*
* Activates the highway_3d `venue` background style when Visualization = Venue.
* Reacts to v3:live-performance-state for lighting mood (read-only).
*/
(function (root) {
'use strict';
const THEME_ID = 'small-club';
const ASSET_BASE = '/static/assets/venue/themes/small-club/';
const BG_PLATE = 'bg-plate.png';
const BG_PLATE_WEBP = 'bg-plate.webp';
let _active = false;
let _assetsLoaded = false;
let _loadFailed = false;
let _lastMood = 'idle';
let _bound = false;
function isVenueViz() {
if (root && root.v3VenueViz && typeof root.v3VenueViz.isVenueVisualization === 'function') {
const sel = root.v3VenueViz.getSelectedVizId
? root.v3VenueViz.getSelectedVizId()
: root.v3VenueViz.readVizSelection();
return root.v3VenueViz.isVenueVisualization(sel);
}
try {
const sel = document.getElementById('viz-picker');
if (sel && sel.value) return String(sel.value) === 'venue';
return localStorage.getItem('vizSelection') === 'venue';
} catch (_) {
return false;
}
}
function h3dApi(name) {
return root && typeof root[name] === 'function' ? root[name] : null;
}
function setH3dActive(on) {
const fn = h3dApi('h3dVenueSceneSetActive');
if (fn) fn(!!on);
}
function setH3dMood(state) {
const fn = h3dApi('h3dVenueSceneSetMood');
if (fn) fn(state);
}
function readH3dState() {
const fn = h3dApi('h3dVenueSceneGetState');
return fn ? fn() : null;
}
function syncPlaceholderVisibility() {
try {
if (root && root.v3VenueViz && typeof root.v3VenueViz.syncPlayerVizClass === 'function') {
const id = root.v3VenueViz.getSelectedVizId
? root.v3VenueViz.getSelectedVizId()
: root.v3VenueViz.readVizSelection();
root.v3VenueViz.syncPlayerVizClass(id);
}
} catch (_) { /* visual-only */ }
try {
if (root && root.v3VenueMoodFx && typeof root.v3VenueMoodFx.onVenueVisualizationSelected === 'function' &&
isVenueViz()) {
root.v3VenueMoodFx.onVenueVisualizationSelected();
}
} catch (_) { /* visual-only */ }
}
function readArrangementSignal() {
// Intentional karaoke/vocals signal: active arrangement name from the
// highway WS (user selected Vocals in #arr-select). Do NOT use
// window.highway.getLyricsVisible() — lyrics overlay stays on during normal
// guitar practice and must not force vocals POV.
try {
const si = root.highway && typeof root.highway.getSongInfo === 'function'
? root.highway.getSongInfo()
: null;
if (si && si.arrangement) return si.arrangement;
const cs = root.feedBack && root.feedBack.currentSong;
if (cs && cs.arrangement) return cs.arrangement;
if (cs && cs.arrangementSmartName) return cs.arrangementSmartName;
} catch (_) { /* visual-only */ }
return '';
}
function syncInstrumentPov() {
const fn = h3dApi('h3dVenueSceneSetInstrumentPov');
if (fn) fn(readArrangementSignal());
}
function activate() {
if (_active) {
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
return;
}
_active = true;
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(true);
setH3dMood(_lastMood);
syncInstrumentPov();
syncVenueMotion();
syncCrowd(true);
}
function syncCrowd(on) {
// Reactive crowd video layer (career mode) — inert without a pack.
try {
if (root && root.v3VenueCrowd &&
typeof root.v3VenueCrowd.setVenueActive === 'function') {
root.v3VenueCrowd.setVenueActive(!!on);
}
} catch (_) { /* visual-only */ }
}
function syncVenueMotion() {
const motionApi = root && root.v3VenueMoodFx;
const mode = motionApi && typeof motionApi.getMotion === 'function'
? motionApi.getMotion()
: 'subtle';
const fn = h3dApi('h3dVenueSceneSetMotionMode');
if (fn) fn(mode);
else if (motionApi && typeof motionApi.syncMotionToRenderer === 'function') {
motionApi.syncMotionToRenderer(mode);
}
}
function deactivate() {
if (!_active) {
setH3dActive(false);
return;
}
_active = false;
_assetsLoaded = false;
_loadFailed = false;
setH3dActive(false);
syncCrowd(false);
syncPlaceholderVisibility();
}
function syncViz(vizId) {
const id = String(vizId || '');
if (id === 'venue') {
activate();
} else {
deactivate();
}
}
function onPerformanceState(e) {
if (!_active) return;
const d = (e && e.detail) || {};
const state = String(d.state || 'idle').toLowerCase();
// v3:live-performance-state fires per note hit/miss; skip the renderer
// push when the mood is unchanged (e.g. a run of hits all in 'fire').
if (state === _lastMood) return;
_lastMood = state;
setH3dMood(state);
}
function onAssetsLoaded() {
_assetsLoaded = true;
_loadFailed = false;
syncPlaceholderVisibility();
}
function onAssetsFailed() {
_loadFailed = true;
_assetsLoaded = false;
syncPlaceholderVisibility();
}
function bindRuntime() {
if (_bound) return;
_bound = true;
const sm = root && root.feedBack;
if (sm && typeof sm.on === 'function') {
sm.on('v3:live-performance-state', onPerformanceState);
sm.on('song:loaded', () => {
if (_active) syncInstrumentPov();
});
sm.on('arrangement:changed', () => {
if (_active) syncInstrumentPov();
});
sm.on('song:arrangement-changed', () => {
if (_active) syncInstrumentPov();
});
sm.on('viz:renderer:ready', () => {
if (isVenueViz()) activate();
else deactivate();
});
sm.on('viz:reverted', () => deactivate());
}
if (isVenueViz()) activate();
}
function getState() {
const h3d = readH3dState();
const povApi = root && root.v3VenueInstrumentPov;
const arrangement = readArrangementSignal();
const instrumentPov = povApi && typeof povApi.resolveVenueInstrumentPov === 'function'
? povApi.resolveVenueInstrumentPov(arrangement)
: 'guitar';
return {
active: _active,
themeId: THEME_ID,
assetBase: ASSET_BASE,
arrangement,
instrumentPov,
assetsLoaded: _assetsLoaded || !!(h3d && h3d.assetsLoaded),
loadFailed: _loadFailed || !!(h3d && h3d.loadFailed),
mood: _lastMood,
h3dVenueState: h3d,
isVenueViz: isVenueViz(),
};
}
function shouldShowDomPlaceholder() {
// V2: no on-screen construction badge during Venue playback.
return false;
}
const api = {
THEME_ID,
ASSET_BASE,
BG_PLATE,
BG_PLATE_WEBP,
activate,
deactivate,
syncViz,
onAssetsLoaded,
onAssetsFailed,
onPerformanceState,
bindRuntime,
getState,
syncInstrumentPov,
syncVenueMotion,
readArrangementSignal,
shouldShowDomPlaceholder,
isSceneLoaded: () => {
if (_assetsLoaded) return true;
const h3d = readH3dState();
return !!(h3d && h3d.assetsLoaded);
},
};
if (root) root.v3VenueScene3d = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document !== 'undefined') {
const boot = () => bindRuntime();
// `defer` runs this at readyState 'interactive' — later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
}
}(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : null)));