Files
hyperframes/packages/core/src/runtime/clock.test.ts
Miguel Ángel 840f657028 feat(player): play and loop a range of a film with range-start and range-end (#4688)
* feat(player): play and loop a range of the film with range-start and range-end

The runtime takes a set-play-range control and stops on the range end frame itself.
The player wraps to range-start with loop, fires ended without it, and reports a
range cut to the film with rangeclamped. Video and __timelines players check it per tick.

* fix(player): hold a range on its last frame and let the runtime own range jumps

A runtime with the play-range capability jumps to range-start itself when a new range leaves
the playhead outside, so a stale state no longer ends the film; older runtimes keep the
player fallback, guarded until a state lands inside. Every range stop shows the last frame
before range-end; a paused playhead re-parks only when it falls outside the new range.

* fix(player): stop a range a check early and enforce its end on a video in a background tab

* test(player): share the range video setup

* fix(player): a slow frame no longer cuts a range short; a video without a range is unchanged

* fix(player): a range still stops early where video time moves once per frame

* refactor(player): cut the range look-ahead comment to one line

* fix(player): a per-frame video time rounded down still stops a range early

* test(player): pin no rounding tolerance at a film's own end

* test(studio): expect the play-range capability on control messages
2026-09-28 23:38:32 +00:00

503 lines
17 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { TransportClock } from "./clock";
function createClock(opts?: ConstructorParameters<typeof TransportClock>[0]) {
let ms = 0;
const clock = new TransportClock({ nowMs: () => ms, ...opts });
const advance = (deltaMs: number) => {
ms += deltaMs;
};
return { clock, advance, getMs: () => ms };
}
/** Advances `totalMs` in sub-threshold steps, reading `now()` each time (see clock.ts's stall policy). */
function advancePolled(clock: TransportClock, advance: (deltaMs: number) => void, totalMs: number) {
for (let remaining = totalMs; remaining > 0; remaining -= 250) {
advance(Math.min(250, remaining));
clock.now();
}
}
describe("TransportClock", () => {
describe("initial state", () => {
it("starts paused at time 0", () => {
const { clock } = createClock();
expect(clock.now()).toBe(0);
expect(clock.isPlaying()).toBe(false);
});
it("respects initialTime", () => {
const { clock } = createClock({ initialTime: 5 });
expect(clock.now()).toBe(5);
});
it("respects initial rate", () => {
const { clock, advance } = createClock({ rate: 2 });
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("respects initial duration", () => {
const { clock } = createClock({ duration: 10 });
expect(clock.getDuration()).toBe(10);
});
});
describe("play/pause", () => {
it("advances time while playing", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
expect(clock.now()).toBe(1);
});
it("freezes time while paused", () => {
const { clock, advance } = createClock();
clock.play();
advance(500);
clock.pause();
const t = clock.now();
advance(500);
expect(clock.now()).toBe(t);
});
it("play returns true on first call, false if already playing", () => {
const { clock } = createClock();
expect(clock.play()).toBe(true);
expect(clock.play()).toBe(false);
});
it("pause returns true on first call, false if already paused", () => {
const { clock } = createClock();
expect(clock.pause()).toBe(false);
clock.play();
expect(clock.pause()).toBe(true);
expect(clock.pause()).toBe(false);
});
it("resumes from where it paused", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
clock.pause();
expect(clock.now()).toBe(1);
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("does not play past duration", () => {
const { clock, advance } = createClock({ duration: 5 });
clock.play();
advance(10000);
expect(clock.now()).toBe(5);
});
it("refuses to play when already at end", () => {
const { clock } = createClock({ duration: 5, initialTime: 5 });
expect(clock.play()).toBe(false);
expect(clock.isPlaying()).toBe(false);
});
});
describe("seek", () => {
it("seeks while paused", () => {
const { clock } = createClock();
clock.seek(5);
expect(clock.now()).toBe(5);
expect(clock.isPlaying()).toBe(false);
});
it("seeks while playing without interrupting playback", () => {
const { clock, advance } = createClock();
clock.play();
advance(1000);
clock.seek(10);
expect(clock.isPlaying()).toBe(true);
expect(clock.now()).toBe(10);
advancePolled(clock, advance, 1000);
expect(clock.now()).toBeCloseTo(11, 5);
});
it("clamps to 0", () => {
const { clock } = createClock();
clock.seek(-5);
expect(clock.now()).toBe(0);
});
it("clamps to duration", () => {
const { clock } = createClock({ duration: 10 });
clock.seek(20);
expect(clock.now()).toBe(10);
});
});
describe("rate", () => {
it("applies rate multiplier to elapsed time", () => {
const { clock, advance } = createClock({ rate: 2 });
clock.play();
advance(1000);
expect(clock.now()).toBe(2);
});
it("rate change mid-play preserves current position", () => {
const { clock, advance } = createClock();
clock.play();
advance(2000);
expect(clock.now()).toBe(2);
clock.setRate(2);
advance(1000);
expect(clock.now()).toBe(4);
});
it("rate change while paused is applied on next play", () => {
const { clock, advance } = createClock();
clock.setRate(3);
clock.play();
advance(1000);
expect(clock.now()).toBe(3);
});
it("clamps rate to [0.1, 5]", () => {
const { clock } = createClock();
clock.setRate(0.01);
expect(clock.getRate()).toBe(0.1);
clock.setRate(100);
expect(clock.getRate()).toBe(5);
});
it("defaults to 1 for invalid rate", () => {
const { clock } = createClock();
clock.setRate(NaN);
expect(clock.getRate()).toBe(1);
clock.setRate(-1);
expect(clock.getRate()).toBe(1);
});
});
describe("duration", () => {
it("auto-pauses at duration boundary", () => {
const { clock, advance } = createClock({ duration: 5 });
clock.play();
advance(5000);
expect(clock.now()).toBe(5);
expect(clock.reachedEnd()).toBe(true);
});
it("setDuration clamps existing baseTime", () => {
const { clock } = createClock({ initialTime: 10 });
clock.setDuration(5);
expect(clock.now()).toBe(5);
});
it("setDuration with 0 or negative becomes Infinity", () => {
const { clock } = createClock({ duration: 5 });
clock.setDuration(0);
expect(clock.getDuration()).toBe(Infinity);
clock.setDuration(-1);
expect(clock.getDuration()).toBe(Infinity);
});
it("plays to a range's end, stops on its last frame, and runs to the film end once cleared", () => {
const { clock, advance } = createClock({ duration: 6 });
clock.setPlayRange(2, 3, 30);
clock.seek(2);
clock.play();
advance(1500);
expect(clock.now()).toBe(3);
expect(clock.reachedEnd()).toBe(true);
expect(clock.getStopTime()).toBeCloseTo(89 / 30, 9);
clock.pause();
clock.seek(clock.getStopTime());
expect(clock.reachedEnd()).toBe(true);
expect(clock.getDuration()).toBe(6);
expect(clock.getPlayStart()).toBe(2);
clock.setPlayRange(0, null, 30);
expect(clock.reachedEnd()).toBe(false);
expect(clock.getStopTime()).toBe(6);
clock.play();
advance(5000);
expect(clock.now()).toBe(6);
});
it("reachedEnd returns false when no duration set", () => {
const { clock, advance } = createClock();
clock.play();
advance(999999000);
expect(clock.reachedEnd()).toBe(false);
});
});
describe("snapshot", () => {
it("returns current state", () => {
const { clock, advance } = createClock({ duration: 10, rate: 1.5 });
clock.play();
advance(2000);
const snap = clock.snapshot();
expect(snap.time).toBe(3);
expect(snap.playing).toBe(true);
expect(snap.rate).toBe(1.5);
expect(snap.duration).toBe(10);
});
});
describe("edge cases", () => {
it("time never goes negative", () => {
const { clock } = createClock({ initialTime: 0 });
clock.seek(-100);
expect(clock.now()).toBe(0);
});
it("multiple play/pause cycles accumulate correctly", () => {
const { clock, advance } = createClock();
for (let i = 0; i < 100; i++) {
clock.play();
advance(10);
clock.pause();
}
expect(clock.now()).toBeCloseTo(1, 5);
});
it("seek to 0 while playing restarts from beginning", () => {
const { clock, advance } = createClock();
clock.play();
advance(5000);
clock.seek(0);
expect(clock.now()).toBe(0);
advancePolled(clock, advance, 1000);
expect(clock.now()).toBeCloseTo(1, 5);
});
});
describe("audio-master clock", () => {
function createMockAudioEl(currentTime: number, paused: boolean) {
return { currentTime, paused } as HTMLMediaElement;
}
it("reads time from audio element when attached and playing", () => {
const { clock } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(3.5, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(3.5);
expect(clock.getSource()).toBe("audio");
});
it("falls back to monotonic when audio is paused", () => {
const { clock, advance } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(3.5, true);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
advance(1000);
expect(clock.now()).toBe(1);
expect(clock.getSource()).toBe("monotonic");
});
it("maps audio position back to composition time through a rate lane", () => {
const { clock } = createClock({ duration: 20 });
const rate = {
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
};
const audioEl = createMockAudioEl(4, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 1, mediaStart: 0, rate });
expect(clock.now()).toBeCloseTo(1 + 2 + (4 - 3.641) / 3, 2);
});
it("accounts for compositionStart offset", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(2.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 5, mediaStart: 0 });
expect(clock.now()).toBe(7);
});
it("accounts for mediaStart offset", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(5.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 2 });
expect(clock.now()).toBe(3);
});
it("detaching preserves current time and falls back to monotonic", () => {
const { clock, advance } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(5.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(5);
clock.detachAudioSource();
expect(clock.now()).toBeCloseTo(5, 1);
advancePolled(clock, advance, 1000);
expect(clock.now()).toBeCloseTo(6, 1);
expect(clock.getSource()).toBe("monotonic");
});
it("holds, never rewinds, the playhead for a clip that started late, then follows it", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 6300);
const sfx = createMockAudioEl(0, false); // at its start, 50 ms behind the playhead
clock.attachAudioSource({ el: sfx, compositionStart: 6.25, mediaStart: 0 });
expect(clock.now()).toBeCloseTo(6.3, 5);
advance(100);
sfx.currentTime = 0.02;
expect(clock.now()).toBeCloseTo(6.3, 5);
advance(100);
sfx.currentTime = 0.12;
expect(clock.now()).toBeCloseTo(6.37, 5);
});
it("holds the playhead while its element seeks, even to a time ahead", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 2000);
const voice = Object.assign(createMockAudioEl(3, false), { seeking: true });
clock.attachAudioSource({ el: voice, compositionStart: 2, mediaStart: 0 });
advance(150);
expect(clock.now()).toBeCloseTo(2, 5);
});
it("holds the playhead on an element that is buffering behind it", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 6300);
const voice = Object.assign(createMockAudioEl(0, false), { readyState: 1 });
clock.attachAudioSource({ el: voice, compositionStart: 5, mediaStart: 0 });
advance(500);
expect(clock.now()).toBeCloseTo(6.3, 5);
});
it("does not follow a jump back past a hard sync, such as a native loop wrap", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
const bed = createMockAudioEl(5, false);
clock.attachAudioSource({ el: bed, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(5);
advance(16);
bed.currentTime = 1; // wrapped to its loop start
expect(clock.now()).toBeCloseTo(5.016, 5);
});
it("does not freeze on a short native loop that wraps", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
const bed = Object.assign(createMockAudioEl(0.3, false), { loop: true, duration: 0.32 });
clock.attachAudioSource({ el: bed, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBeCloseTo(0.3, 5);
advance(16);
bed.currentTime = 0.02; // wrapped to its loop start
expect(clock.now()).toBeCloseTo(0.316, 5);
});
it("holds the playhead for a looping bed that starts late", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 2000);
const bed = Object.assign(createMockAudioEl(0, false), { loop: true, duration: 8 });
clock.attachAudioSource({ el: bed, compositionStart: 1.9, mediaStart: 0 });
advance(16);
expect(clock.now()).toBeCloseTo(2, 5);
});
it("continues from the last audio time when the element drops out", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
advancePolled(clock, advance, 4000);
const voice = createMockAudioEl(5, false);
clock.attachAudioSource({ el: voice, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(5);
Object.assign(voice, { paused: true });
advance(16);
expect(clock.now()).toBeCloseTo(5.016, 5);
});
it("lets a seek move the playhead back past a held time", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
const voice = createMockAudioEl(5, false);
clock.attachAudioSource({ el: voice, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(5);
clock.detachAudioSource();
clock.seek(2);
voice.currentTime = 2;
clock.attachAudioSource({ el: voice, compositionStart: 0, mediaStart: 0 });
advance(16);
expect(clock.now()).toBe(2);
});
it("clamps audio-derived time to duration", () => {
const { clock } = createClock({ duration: 10 });
const audioEl = createMockAudioEl(15.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(10);
});
it("hasAudioSource returns correct state", () => {
const { clock } = createClock();
expect(clock.hasAudioSource()).toBe(false);
clock.attachAudioSource({
el: createMockAudioEl(0, false),
compositionStart: 0,
mediaStart: 0,
});
expect(clock.hasAudioSource()).toBe(true);
clock.detachAudioSource();
expect(clock.hasAudioSource()).toBe(false);
});
it("audio stall freezes visual time (desired behavior for narration)", () => {
const { clock } = createClock({ duration: 20 });
const audioEl = createMockAudioEl(3.0, false);
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(3);
// Audio buffers — currentTime doesn't advance
expect(clock.now()).toBe(3);
expect(clock.now()).toBe(3);
// Audio resumes
audioEl.currentTime = 3.5;
expect(clock.now()).toBe(3.5);
});
it("at 2x rate, audio-derived time advances at 2x (not 1x)", () => {
// el.playbackRate=2 means el.currentTime advances at 2x wall-clock speed.
// With rate=2, composition time should also advance at 2x — so
// composition_time = (el.currentTime - mediaStart) + compositionStart.
// The old bug divided by rate instead, yielding 1x speed.
const { clock } = createClock({ rate: 2, duration: 20 });
const audioEl = { currentTime: 4, paused: false, playbackRate: 2 } as HTMLMediaElement;
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
// After 1 wall-clock second at 2x: el.currentTime=4, composition should be at 4.
expect(clock.now()).toBe(4);
});
it("reads the element's place in its file, whatever playbackRate it has been given", () => {
const { clock } = createClock({ rate: 2, duration: 20 });
const audioEl = { currentTime: 1, paused: false, playbackRate: 1 } as HTMLMediaElement;
clock.play();
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
expect(clock.now()).toBe(1);
});
it("keeps its place when the speed changes under a clip with its own rate", () => {
const { clock, advance } = createClock({ duration: 30 });
clock.play();
const voice = { currentTime: 12.5, paused: false, playbackRate: 1.25 } as HTMLMediaElement;
clock.attachAudioSource({ el: voice, compositionStart: 0, mediaStart: 0, rate: 1.25 });
expect(clock.now()).toBe(10);
clock.setRate(1.5);
voice.playbackRate = 1.5; // the transport rate lands on the element before media sync restores 1.875
advance(16);
expect(clock.now()).toBe(10);
});
});
});