fix(sim): loop base from morph-tail offset so landed morphs don't jitter

Operator eyeball feedback: landing on an altitude jump-cut the loop back to frame 0.
Each morph reproduces the destination clip's first 3s (pipeline trim=0:3) and ends on
dst@~3s, so the steady-state loop now starts at LOOP_TAIL_S=3.0 and loops within
[3.0, duration] — the morph's last frame hands straight off to the loop, and the
morph-covered intro never re-shows at rest (removes the overlapping 'altitude video
that is part of the morph'). E2E asserts a landed clip loops from ~3s, not 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-27 16:29:22 -07:00
parent 70fc9a46ab
commit 614411b5e4
2 changed files with 59 additions and 1 deletions
+27 -1
View File
@@ -214,17 +214,42 @@ function preloadChip() {
// Load the active scale's BASE footage into the video (the painterly canvas reads
// its frames live). The Right knob no longer swaps the source — it only drives the
// deterministic dream restyle — so media reloads only when the SCALE changes.
// Each baked morph reproduces the first LOOP_TAIL_S seconds of the destination clip
// (pipeline `trim=0:3` in build_pool_manifest._make_transition), ending on that
// clip's frame at ~LOOP_TAIL_S. So the STEADY-STATE loop must begin there, not at 0
// — otherwise a landed morph (dst@~3s) jump-cuts back to dst@0 (the jitter). We loop
// the base within [LOOP_TAIL_S, duration]: the morph's last frame hands straight off
// to the loop's first frame, and the morph-covered intro never re-shows at rest.
const LOOP_TAIL_S = 3.0;
function seekToLoopStart() {
try { vid.currentTime = LOOP_TAIL_S; } catch (e) { /* not seekable yet */ }
}
function ensureClipMedia() {
const clip = activeClip();
if (!clip || clip.id === currentClipId) return;
currentClipId = clip.id;
delete vid.dataset.morph; // this is a base loop, not a morph
vid.dataset.loopTail = "1"; // arm the [LOOP_TAIL_S, duration] loop handler
vid.src = mediaUrl(clip.base_file);
vid.loop = true;
vid.loop = false; // native loop restarts at 0; we loop from the tail instead
vid.muted = true;
if (vid.readyState >= 1) seekToLoopStart();
else vid.addEventListener("loadedmetadata", seekToLoopStart, { once: true });
vid.play().catch(() => {});
vid.style.opacity = "1";
}
// The custom loop: when a base loop reaches the end, jump back to the tail offset
// (not 0). Inert during a morph (loopTail cleared in rebuildSegment), where the
// scrub drives currentTime directly.
vid.addEventListener("timeupdate", () => {
if (vid.dataset.loopTail === "1" && vid.duration && vid.currentTime >= vid.duration - 0.08) {
seekToLoopStart();
}
});
// Compose the video look. The Right DREAM is now a painterly WebGL restyle of the
// live frames (see the render loop below) — so here we only stash its intensity for
// that loop, and build the Dark/Light mood grade as a CSS filter. The grade rides
@@ -730,6 +755,7 @@ function rebuildSegment(lo, enteredFrom) {
tint.style.opacity = "0";
vid.style.filter = "none";
vid.loop = false;
vid.dataset.loopTail = "0"; // morph scrub drives currentTime; disarm the base loop handler
vid.style.opacity = "1";
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;