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 @@
-
+