compare: pad-to-match time mapping + fix stalled play at small du

- applyU now maps u to a shared global frame index uGlobal in [0, maxT-1];
  each panel clamps to its own (T-1), so a shorter timeline pads its last
  frame while the longer one finishes — both advance at the same wall-clock
  tempo instead of rescaling their timelines.
- tick() keeps u as a float closure variable; reading it back from the
  integer-step scrubber was quantizing du to 0 at slow tempo + high T
  (1600ms/frame, T=24: du ≈ 4e-4 → round to 0 on scrub), stalling playback
  after one frame.
This commit is contained in:
Michael Pilosov 2026-04-22 14:44:08 -06:00
parent 89401e3aee
commit d3f5088233
2 changed files with 29 additions and 18 deletions

View File

@ -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; });

View File

@ -111,6 +111,6 @@
</section>
<script src="/static/theme.js?v=11"></script>
<script type="module" src="/static/compare.js?v=5"></script>
<script type="module" src="/static/compare.js?v=7"></script>
</body>
</html>