mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 22:08:03 +00:00
* feat(core): speed ramps as a rate lane in data-automation A clip can carry a rate lane in data-automation. sourceTimeAt integrates it to source time and is the one mapping the runtime uses for media seeking, playback rate, natural duration, child timelines and the audio-master clock. Rate is clamped to 0.1-10 in one place, pitch is preserved unless data-preserve-pitch is false, and ramped clips play through the media element. Lint warns on a rate lane outside video and audio. * fix(core): format the speed-ramp files and expect the unified rate clamp in the engine tests * test(engine): read the playback-rate clamp from core instead of a literal
379 lines
12 KiB
TypeScript
379 lines
12 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("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("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("composition time is correct when el.playbackRate differs from clock rate", () => {
|
|
// General formula: wall_elapsed = (el.currentTime - mediaStart) / el.playbackRate
|
|
// composition_time = compositionStart + wall_elapsed * clockRate
|
|
const { clock } = createClock({ rate: 2, duration: 20 });
|
|
// Audio at 1x, clock at 2x: after 1s wall, el.currentTime=1, comp should be 2.
|
|
const audioEl = { currentTime: 1, paused: false, playbackRate: 1 } as HTMLMediaElement;
|
|
clock.play();
|
|
clock.attachAudioSource({ el: audioEl, compositionStart: 0, mediaStart: 0 });
|
|
expect(clock.now()).toBe(2);
|
|
});
|
|
});
|
|
});
|