feat(speed): switch Playback Speed to 0x–4x forward (drop reverse)
Real-browser eyeball: below-0x reverse jittered because the base loop clips aren't all-intra (the flagged risk). Narrow the slider to 0x–4x forward (still continuous, step=any); 0x still freezes; OS reduced-motion behavior unchanged. Removes the manual rAF reverse loop and the HEFScrub.reverseStep helper + its unit tests. Tests: node unit 10/10; a11y e2e 12/12 (Task 5 now 3 slider tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
# Playback Speed slider (replaces "Reduce motion") — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Approved (pending written-spec review)
|
||||
**Status:** Shipped, then revised
|
||||
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
|
||||
|
||||
> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a
|
||||
> real browser the below-0× reverse loop jittered — the base loop clips aren't
|
||||
> all-intra, so backward seeking can't stay smooth (exactly the risk flagged
|
||||
> below). The slider is now **0×–4× forward** (still continuous, `step="any"`).
|
||||
> `0×` still freezes; OS reduced-motion behavior is unchanged. The
|
||||
> `HEFScrub.reverseStep` helper and its tests were removed. Sections below
|
||||
> describing the −2…2 range and the manual reverse loop are superseded by this
|
||||
> note.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a continuous **Playback Speed** slider (−2× … 2×) that controls the resting
|
||||
|
||||
@@ -52,23 +52,25 @@ test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () =>
|
||||
.toBe(false); // experience running → loop plays
|
||||
}
|
||||
|
||||
test("the Reduce motion toggle is gone, replaced by a speed slider", async ({ page }) => {
|
||||
test("the Reduce motion toggle is gone, replaced by a 0–4x speed slider", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#reduce-motion")).toHaveCount(0);
|
||||
const slider = page.locator("#play-speed");
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveAttribute("min", "0");
|
||||
await expect(slider).toHaveAttribute("max", "4");
|
||||
});
|
||||
|
||||
test("forward speed sets the loop video's playbackRate", async ({ page }) => {
|
||||
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await startExperience(page);
|
||||
await page.locator("#play-speed").fill("2");
|
||||
await page.locator("#play-speed").fill("4");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
|
||||
.toBe(2);
|
||||
.toBe(4);
|
||||
});
|
||||
|
||||
test("0x freezes the loop video", async ({ page }) => {
|
||||
@@ -81,23 +83,6 @@ test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () =>
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("negative speed plays the loop in reverse (manual frame-stepping)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await startExperience(page);
|
||||
await page.locator("#play-speed").fill("-1.5");
|
||||
await page.waitForTimeout(150);
|
||||
const a = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.currentTime);
|
||||
const paused = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused);
|
||||
await page.waitForTimeout(250);
|
||||
const b = await page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.currentTime);
|
||||
// The reverse loop pauses the element and drives currentTime by hand — so the
|
||||
// element reads paused, yet currentTime keeps moving (≠ forward play, ≠ freeze).
|
||||
// (It may wrap loopStart→tail, so we assert movement, not a strict decrease.)
|
||||
expect(paused).toBe(true);
|
||||
expect(b).not.toBe(a);
|
||||
});
|
||||
|
||||
test("OS reduced-motion disables the slider and freezes the loop", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
|
||||
+14
-42
@@ -1127,65 +1127,38 @@ let devStatsTimer = null;
|
||||
// --- Playback speed + reduced motion -----------------------------------------
|
||||
// "Reduce motion" is no longer a visible toggle — its accessibility behavior is
|
||||
// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted
|
||||
// override). The visible control is the Playback Speed slider, which drives ONLY
|
||||
// the resting altitude loop video (#vid-loop); the morph/transition video (#vid)
|
||||
// is scrub-driven and is never speed-controlled. Below 0x the loop plays in
|
||||
// reverse via a manual rAF loop (native negative playbackRate is unreliable —
|
||||
// Chrome ignores it). When the OS asks for reduced motion the loop stays frozen
|
||||
// and the slider is disabled, so a vestibular/photosensitive visitor never gets
|
||||
// motion they did not ask for.
|
||||
const SPEED_EPS = 0.02; // |speed| below this = freeze (treat as 0)
|
||||
// override). The visible control is the Playback Speed slider (0x–4x forward),
|
||||
// which drives ONLY the resting altitude loop video (#vid-loop); the
|
||||
// morph/transition video (#vid) is scrub-driven and is never speed-controlled.
|
||||
// 0x freezes the loop on its current frame. (Reverse playback was dropped: base
|
||||
// loop clips aren't all-intra, so native/manual reverse seeking jitters.) When
|
||||
// the OS asks for reduced motion the loop stays frozen and the slider is
|
||||
// disabled, so a vestibular/photosensitive visitor never gets motion they did
|
||||
// not ask for.
|
||||
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
|
||||
let playSpeed = 1;
|
||||
let revRaf = 0; // rAF handle for the manual reverse loop
|
||||
let revLast = 0; // last rAF timestamp (ms) for dt
|
||||
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
|
||||
function isReduced() { return !!(reduceMq && reduceMq.matches); }
|
||||
|
||||
function stopReverse() {
|
||||
if (revRaf) { cancelAnimationFrame(revRaf); revRaf = 0; }
|
||||
}
|
||||
// One reverse-loop frame: step #vid-loop's currentTime backward via the pure
|
||||
// HEFScrub.reverseStep math, then schedule the next. dt is capped so a stalled
|
||||
// tab can't make a giant jump.
|
||||
function reverseTick(now) {
|
||||
if (!revRaf) return;
|
||||
const dt = revLast ? Math.min((now - revLast) / 1000, 0.1) : 0;
|
||||
revLast = now;
|
||||
const dur = loopVid.duration;
|
||||
if (Number.isFinite(dur) && dur > 0 && dt > 0) {
|
||||
const r = HEFScrub.reverseStep(loopVid.currentTime, Math.abs(playSpeed), dt, loopStartFrame(), dur);
|
||||
try { loopVid.currentTime = r.next; } catch (_) { /* not seekable yet */ }
|
||||
}
|
||||
revRaf = requestAnimationFrame(reverseTick);
|
||||
}
|
||||
function startReverse() {
|
||||
if (revRaf) return;
|
||||
loopVid.pause(); // the rAF owns the element while reversing
|
||||
revLast = 0;
|
||||
revRaf = requestAnimationFrame(reverseTick);
|
||||
}
|
||||
// Apply the chosen slider speed to the resting loop video. When reduced motion
|
||||
// holds or video output is off, the freeze/play decision belongs to
|
||||
// syncReducedMotion / playLoop — here we only make sure no reverse loop is left
|
||||
// running and the forward rate is sane for when it resumes.
|
||||
// syncReducedMotion / playLoop — here we only keep the forward rate sane for
|
||||
// when it resumes.
|
||||
function applyPlaySpeed() {
|
||||
stopReverse();
|
||||
if (isReduced() || !videoEverOn || !$("visual").checked) {
|
||||
loopVid.playbackRate = Math.max(0.0625, Math.abs(playSpeed) || 1);
|
||||
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
|
||||
return;
|
||||
}
|
||||
if (playSpeed > SPEED_EPS) {
|
||||
loopVid.playbackRate = playSpeed;
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
} else if (playSpeed < -SPEED_EPS) {
|
||||
startReverse();
|
||||
} else {
|
||||
loopVid.pause(); // ~0x → freeze on the current frame
|
||||
loopVid.pause(); // 0x → freeze on the current frame
|
||||
}
|
||||
}
|
||||
function updateSpeedLabel() {
|
||||
const el = $("play-speed-val");
|
||||
if (el) el.textContent = playSpeed.toFixed(2).replace("-", "−") + "×";
|
||||
if (el) el.textContent = playSpeed.toFixed(2) + "×";
|
||||
}
|
||||
function initPlaySpeed() {
|
||||
const slider = $("play-speed");
|
||||
@@ -1212,7 +1185,6 @@ function syncReducedMotion() {
|
||||
const reduced = isReduced();
|
||||
if (slider) slider.disabled = reduced;
|
||||
if (reduced) {
|
||||
stopReverse();
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
|
||||
@@ -55,17 +55,14 @@
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
|
||||
<input type="range" id="play-speed" min="-2" max="2" step="any" value="1"
|
||||
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="play-speed-val" />
|
||||
<span id="play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<datalist id="play-speed-ticks">
|
||||
<option value="-2"></option><option value="-1.75"></option><option value="-1.5"></option>
|
||||
<option value="-1.25"></option><option value="-1"></option><option value="-0.75"></option>
|
||||
<option value="-0.5"></option><option value="-0.25"></option><option value="0"></option>
|
||||
<option value="0.25"></option><option value="0.5"></option><option value="0.75"></option>
|
||||
<option value="1"></option><option value="1.25"></option><option value="1.5"></option>
|
||||
<option value="1.75"></option><option value="2"></option>
|
||||
<option value="0"></option><option value="0.5"></option><option value="1"></option>
|
||||
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
|
||||
<option value="3"></option><option value="3.5"></option><option value="4"></option>
|
||||
</datalist>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
|
||||
@@ -41,23 +41,6 @@
|
||||
return dir < 0 ? 0 : loopTailS;
|
||||
}
|
||||
|
||||
// One frame of REVERSE loop playback for the resting altitude video. Native
|
||||
// negative playbackRate is unreliable (Chrome ignores it), so app.js drives
|
||||
// currentTime by hand; this is the pure math. The visible loop region is
|
||||
// [loopStart, duration] (same frames the forward loop shows). Step back by
|
||||
// absSpeed*dt; if it crosses below loopStart, wrap around to the tail
|
||||
// (duration), carrying the overshoot modulo the region length so a big dt
|
||||
// can't escape the region. Returns the next currentTime and whether a
|
||||
// bottom-boundary wrap happened (so the caller can re-arm its loop state).
|
||||
function reverseStep(t, absSpeed, dt, loopStart, duration) {
|
||||
const L = duration - loopStart;
|
||||
if (!(L > 0)) return { next: duration, wrapped: false };
|
||||
const next = t - Math.abs(absSpeed) * dt;
|
||||
if (next >= loopStart) return { next, wrapped: false };
|
||||
const overshoot = (loopStart - next) % L;
|
||||
return { next: duration - overshoot, wrapped: true };
|
||||
}
|
||||
|
||||
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
|
||||
// exactly on an integer counts as crossing it (it commits that altitude).
|
||||
function integerCrossings(prevPos, pos) {
|
||||
@@ -70,5 +53,5 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, reverseStep, integerCrossings };
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
|
||||
});
|
||||
|
||||
@@ -59,31 +59,6 @@ test("integerCrossings: multiple crossings in travel order", () => {
|
||||
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
|
||||
});
|
||||
|
||||
test("reverseStep: decrements within the loop region without wrapping", () => {
|
||||
// region [loopStart=3, duration=8]; step back absSpeed*dt = 0.5
|
||||
assert.deepEqual(S.reverseStep(5, 1, 0.5, 3, 8), { next: 4.5, wrapped: false });
|
||||
// landing exactly on the bottom boundary is the region edge, not a wrap
|
||||
assert.deepEqual(S.reverseStep(3.5, 1, 0.5, 3, 8), { next: 3, wrapped: false });
|
||||
});
|
||||
|
||||
test("reverseStep: wraps past the bottom boundary back to the tail (duration)", () => {
|
||||
// 3.2 - 0.5 = 2.7 < 3 -> overshoot 0.3 -> 8 - 0.3 = 7.7
|
||||
const r = S.reverseStep(3.2, 1, 0.5, 3, 8);
|
||||
assert.equal(r.wrapped, true);
|
||||
assert.ok(Math.abs(r.next - 7.7) < 1e-9, `next=${r.next}`);
|
||||
});
|
||||
|
||||
test("reverseStep: overshoot larger than the region length wraps modulo", () => {
|
||||
// region length L = 5; 3 - 7 = -4 -> overshoot 7 -> 7 % 5 = 2 -> 8 - 2 = 6
|
||||
const r = S.reverseStep(3, 1, 7, 3, 8);
|
||||
assert.equal(r.wrapped, true);
|
||||
assert.ok(Math.abs(r.next - 6) < 1e-9, `next=${r.next}`);
|
||||
});
|
||||
|
||||
test("reverseStep: degenerate region (duration <= loopStart) holds at duration", () => {
|
||||
assert.deepEqual(S.reverseStep(3, 1, 0.5, 3, 3), { next: 3, wrapped: false });
|
||||
});
|
||||
|
||||
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
|
||||
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
|
||||
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
|
||||
|
||||
Reference in New Issue
Block a user