diff --git a/app/web/static/compare.js b/app/web/static/compare.js index c59cfa0..f86af7d 100644 --- a/app/web/static/compare.js +++ b/app/web/static/compare.js @@ -459,27 +459,34 @@ async function main() { } // ---- time mapping ----------------------------------------------------- - // Scrubber is 0..1000. Each panel picks round(u * (T-1)) as its frame idx. + // Scrubber is 0..1000, mapped to a shared global frame index uGlobal in + // [0, maxT-1]. Each panel clamps uGlobal to its own (T-1) — so a shorter + // timeline pads its last frame until the longer timeline finishes, and + // both panels advance at the same wall-clock tempo. const SCRUB_MAX = 1000; function framesOf(p) { return p ? p.data.frames.length : 0; } - function timeLabelFor(p, u) { + function timeLabelForAtLocal(p, uLocal) { if (!p) return '—'; const T = framesOf(p); if (T <= 0) return '—'; - const idx = Math.max(0, Math.min(T - 1, Math.round(u * (T - 1)))); + const idx = Math.max(0, Math.min(T - 1, Math.round(uLocal))); return p.data.times?.[idx] ?? String(idx); } function applyU(u) { u = Math.max(0, Math.min(1, u)); const smooth = motionSel.value === 'smooth'; - for (const p of Object.values(panels)) { + const tMax = maxT(); + const uGlobal = u * (tMax - 1); + let uLocalA = 0, uLocalB = 0; + for (const [slot, p] of Object.entries(panels)) { if (!p) continue; const T = framesOf(p); if (T <= 0) continue; - const uLocal = u * (T - 1); + const uLocal = Math.min(uGlobal, T - 1); + if (slot === 'a') uLocalA = uLocal; else uLocalB = uLocal; if (smooth) { p.setFrameInterpolated(uLocal); } else { @@ -487,8 +494,8 @@ async function main() { p.setFrame(idx); } } - timeAEl.textContent = timeLabelFor(panels.a, u); - timeBEl.textContent = timeLabelFor(panels.b, u); + timeAEl.textContent = timeLabelForAtLocal(panels.a, uLocalA); + timeBEl.textContent = timeLabelForAtLocal(panels.b, uLocalB); } // ---- axes sync mode --------------------------------------------------- @@ -523,22 +530,25 @@ async function main() { } function baseMsPerFrame() { return 1600 / parseFloat(speedSel.value || '1'); } + // u is the authoritative playhead (float in [0, 1]). The scrubber mirrors + // it for display; reading u back from the scrubber would quantize to the + // scrubber's integer step and stall the loop at small du. + let u = 0; + function tick(ts) { requestAnimationFrame(tick); for (const p of Object.values(panels)) p?.render(); if (!playing) { lastTs = ts; return; } - if (!lastTs) lastTs = ts; + if (!lastTs) { lastTs = ts; return; } const dt = ts - lastTs; + lastTs = ts; + if (dt <= 0) return; const perFrame = baseMsPerFrame(); const T = maxT(); - const du = dt / (perFrame * (T - 1)); - if (du > 0) { - let u = parseFloat(scrub.value) / SCRUB_MAX + du; - if (u >= 1) u -= Math.floor(u); // wrap 0..1 - scrub.value = String(Math.round(u * SCRUB_MAX)); - applyU(u); - lastTs = ts; - } + u += dt / (perFrame * (T - 1)); + if (u >= 1) u -= Math.floor(u); // wrap 0..1 + scrub.value = String(Math.round(u * SCRUB_MAX)); + applyU(u); } requestAnimationFrame(tick); @@ -551,7 +561,8 @@ async function main() { playBtn.addEventListener('click', () => setPlaying(!playing)); scrub.addEventListener('input', () => { - applyU(parseFloat(scrub.value) / SCRUB_MAX); + u = parseFloat(scrub.value) / SCRUB_MAX; + applyU(u); }); speedSel.addEventListener('change', () => { lastTs = 0; }); diff --git a/app/web/templates/compare.html b/app/web/templates/compare.html index e9cae6f..3c098b9 100644 --- a/app/web/templates/compare.html +++ b/app/web/templates/compare.html @@ -111,6 +111,6 @@ - +