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>
This commit is contained in:
byrongamatos
2026-07-12 01:33:12 +02:00
co-authored by Claude Fable 5
parent de10e81259
commit 9178959dbd
2 changed files with 19 additions and 2 deletions
+10
View File
@@ -3489,6 +3489,16 @@
// videoWidth === 0 until metadata lands — showing the // videoWidth === 0 until metadata lands — showing the
// plane before that paints a black flash over the plate. // plane before that paints a black flash over the plate.
const ready = !!el && el.videoWidth > 0; const ready = !!el && el.videoWidth > 0;
// venue-crowd.js swaps src on the same element (loop ↔
// stinger); a new intrinsic size needs a fresh
// cover-crop, which _bgFitBackdropPlane only reapplies
// on camera aspect changes.
if (ready && (layer.lastVidW !== el.videoWidth ||
layer.lastVidH !== el.videoHeight)) {
layer.lastVidW = el.videoWidth;
layer.lastVidH = el.videoHeight;
layer.applyCoverCrop();
}
const opacity = i === 0 ? 1 - _venueCrowdMix : _venueCrowdMix; const opacity = i === 0 ? 1 - _venueCrowdMix : _venueCrowdMix;
layer.mat.opacity = opacity; layer.mat.opacity = opacity;
layer.mesh.visible = ready && opacity > 0.01; layer.mesh.visible = ready && opacity > 0.01;
+9 -2
View File
@@ -110,6 +110,7 @@
let _loadingLoop = null; // loop currently waiting on canplaythrough let _loadingLoop = null; // loop currently waiting on canplaythrough
let _fadingLoop = null; // loop currently crossfading in (not yet active) let _fadingLoop = null; // loop currently crossfading in (not yet active)
let _stingerUntilEnded = false; let _stingerUntilEnded = false;
let _stingerGen = 0; // identity for ended/timeout handlers
let _lastStingerAt = -Infinity; let _lastStingerAt = -Infinity;
let _prevStreak = 0; let _prevStreak = 0;
let _lastAccuracyPct = null; // from perf events; stats:recorded carries none let _lastAccuracyPct = null; // from perf events; stats:recorded carries none
@@ -276,10 +277,15 @@
_pendingLoop = null; _pendingLoop = null;
showLoop(pending, FADE_MS); showLoop(pending, FADE_MS);
}; };
const myGen = ++_stingerGen;
const back = () => { const back = () => {
if (!_stingerUntilEnded) return; // Always detach: a handler left behind by a stop()/manifest swap
_stingerUntilEnded = false; // must not fire into a LATER stinger's lifecycle on this reused
// element (the gen check below guards that; the boolean alone
// would pass once a new stinger is active).
video.removeEventListener('ended', back); video.removeEventListener('ended', back);
if (_stingerGen !== myGen || !_stingerUntilEnded) return;
_stingerUntilEnded = false;
// Fade back to the loop layer (which kept playing underneath). // Fade back to the loop layer (which kept playing underneath).
fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS); fadeMixTo(_activeLayer === 1 ? 1 : 0, STINGER_FADE_MS);
flushPending(); flushPending();
@@ -344,6 +350,7 @@
function stop() { function stop() {
cancelFade(); cancelFade();
_stopGen++; _stopGen++;
_stingerGen++;
_stingerUntilEnded = false; _stingerUntilEnded = false;
_pendingLoop = null; _pendingLoop = null;
_loadingLoop = null; _loadingLoop = null;