mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 15:27:14 +00:00
* 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
503 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|