mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
Replace the two-clock architecture (GSAP rAF ticker + HTMLMediaElement
pipeline reconciled by a 50ms polling loop) with a single TransportClock.
GSAP is always paused and seeked to clock.now() on each rAF tick.
Drift between visual timeline and audio is structurally impossible.
Architecture:
TransportClock.now() ──rAF──▶ timeline.seek(t) + el.currentTime
▲
AudioContext.currentTime (~21µs) ← WebAudio active
OR
audio.currentTime (~33ms) ← HTMLMediaElement fallback
OR
performance.now() (~1ms) ← no audio
Key changes:
- TransportClock class with monotonic + audio-master clock sources
- WebAudioTransport: routes audio through AudioBufferSourceNode for
sample-accurate scheduling, falls back gracefully to HTMLMediaElement
- rAF tick loop replaces 50ms setInterval poll; GSAP always paused
- Strict sync (40ms threshold, consecutive-sample gated) + forceSync
on play/pause/seek transitions for sub-frame media accuracy
- Buffer-stall: visuals freeze when audio is buffering instead of
running ahead
- Frame quantization preserved in seek/renderSeek (parity contract)
Browser-verified: 0.0ms drift after 40 pause/play cycles (was 400ms+).
Also fixes: CDN script HTML error responses in validate (pre-existing).
54 tests across clock, clock-drift, webAudioTransport, and media.
Closes #668
216 lines
6.1 KiB
TypeScript
216 lines
6.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { TransportClock } from "./clock";
|
|
|
|
describe("TransportClock eliminates pause/play drift (issue #668)", () => {
|
|
it("40 pause/play cycles accumulate zero drift", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
|
|
|
clock.play();
|
|
ms += 500;
|
|
|
|
const timeBefore = clock.now();
|
|
expect(timeBefore).toBe(0.5);
|
|
|
|
for (let i = 0; i < 40; i++) {
|
|
clock.pause();
|
|
ms += 100;
|
|
clock.play();
|
|
ms += 100;
|
|
}
|
|
|
|
ms += 500;
|
|
const timeAfter = clock.now();
|
|
|
|
// With a single clock: 500ms initial + 40*(100ms play) + 500ms final = 5.5s
|
|
// Pause periods don't advance the clock.
|
|
// Total play time: 500 + 40*100 + 500 = 5000ms = 5s
|
|
expect(timeAfter).toBe(5);
|
|
|
|
// The key assertion: NO accumulated drift from pause/play toggling.
|
|
// In the old two-clock architecture, each toggle could introduce ~10-20ms
|
|
// of drift, accumulating to 400-800ms after 40 cycles.
|
|
// With TransportClock: drift is exactly 0.
|
|
const expectedPlayTime = 0.5 + 40 * 0.1 + 0.5;
|
|
expect(timeAfter).toBe(expectedPlayTime);
|
|
});
|
|
|
|
it("100 rapid pause/play cycles still produce zero drift", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 30 });
|
|
|
|
clock.play();
|
|
ms += 1000;
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
clock.pause();
|
|
ms += 50;
|
|
clock.play();
|
|
ms += 50;
|
|
}
|
|
|
|
ms += 1000;
|
|
const finalTime = clock.now();
|
|
|
|
// Play time: 1000ms + 100*50ms + 1000ms = 7000ms = 7s
|
|
expect(finalTime).toBeCloseTo(7, 10);
|
|
});
|
|
|
|
it("rate changes during pause/play cycles preserve accuracy", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 60 });
|
|
|
|
clock.play();
|
|
ms += 1000;
|
|
expect(clock.now()).toBe(1);
|
|
|
|
clock.setRate(2);
|
|
ms += 1000;
|
|
expect(clock.now()).toBe(3);
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
clock.pause();
|
|
ms += 100;
|
|
clock.play();
|
|
ms += 100;
|
|
}
|
|
|
|
// At 2x rate, 20 * 100ms play = 2000ms wall = 4s timeline
|
|
expect(clock.now()).toBeCloseTo(7, 10);
|
|
});
|
|
|
|
it("seek during pause/play cycles does not introduce drift", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 20 });
|
|
|
|
clock.play();
|
|
ms += 2000;
|
|
expect(clock.now()).toBe(2);
|
|
|
|
clock.seek(5);
|
|
expect(clock.now()).toBe(5);
|
|
|
|
for (let i = 0; i < 20; i++) {
|
|
clock.pause();
|
|
ms += 100;
|
|
clock.play();
|
|
ms += 100;
|
|
}
|
|
|
|
ms += 1000;
|
|
// Play time after seek: 20*100ms + 1000ms = 3000ms = 3s
|
|
expect(clock.now()).toBeCloseTo(8, 10);
|
|
});
|
|
|
|
it("simulates the exact issue #668 reproduction scenario", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
|
|
|
// "Use a GSAP composition with a timed narration track, then
|
|
// repeatedly toggle playback"
|
|
clock.play();
|
|
ms += 200;
|
|
|
|
// The issue says: "After enough toggles, narration and animation/captions
|
|
// can become visibly or audibly offset."
|
|
// Issue reproduction: 40 toggles with 100ms intervals
|
|
for (let i = 0; i < 40; i++) {
|
|
clock.pause();
|
|
ms += 100;
|
|
clock.play();
|
|
ms += 100;
|
|
}
|
|
|
|
ms += 200;
|
|
const finalTime = clock.now();
|
|
|
|
// Play time: 200ms + 40*100ms + 200ms = 4400ms = 4.4s
|
|
expect(finalTime).toBeCloseTo(4.4, 10);
|
|
|
|
// With the old architecture, drift of 400-800ms would accumulate here.
|
|
// With TransportClock, drift is mathematically impossible — there is
|
|
// only one clock. The time is always baseTime + elapsed * rate.
|
|
// Pause just snapshots baseTime. Play just records a new start marker.
|
|
// No two clocks can diverge because there is only one.
|
|
});
|
|
});
|
|
|
|
describe("TransportClock end-of-playback (loop semantics)", () => {
|
|
it("reachedEnd returns true at duration boundary", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
|
clock.play();
|
|
ms += 5000;
|
|
expect(clock.reachedEnd()).toBe(true);
|
|
expect(clock.now()).toBe(5);
|
|
});
|
|
|
|
it("clock auto-caps at duration and refuses to advance past it", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 3 });
|
|
clock.play();
|
|
ms += 10000;
|
|
expect(clock.now()).toBe(3);
|
|
expect(clock.reachedEnd()).toBe(true);
|
|
});
|
|
|
|
it("seek to 0 after reaching end allows replay", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
|
clock.play();
|
|
ms += 5000;
|
|
expect(clock.reachedEnd()).toBe(true);
|
|
clock.pause();
|
|
clock.seek(0);
|
|
expect(clock.now()).toBe(0);
|
|
expect(clock.reachedEnd()).toBe(false);
|
|
expect(clock.play()).toBe(true);
|
|
ms += 2000;
|
|
expect(clock.now()).toBe(2);
|
|
});
|
|
|
|
it("pause + seek to end + play is rejected (no infinite loop)", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 5 });
|
|
clock.seek(5);
|
|
expect(clock.play()).toBe(false);
|
|
expect(clock.isPlaying()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("TransportClock + simulated timeline wiring", () => {
|
|
it("clock drives timeline seek on each tick", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
|
const seekLog: number[] = [];
|
|
const mockSeek = (t: number) => seekLog.push(t);
|
|
|
|
clock.play();
|
|
for (let i = 0; i < 5; i++) {
|
|
ms += 16;
|
|
mockSeek(clock.now());
|
|
}
|
|
|
|
expect(seekLog.length).toBe(5);
|
|
expect(seekLog[0]).toBeCloseTo(0.016, 5);
|
|
expect(seekLog[4]).toBeCloseTo(0.08, 5);
|
|
for (let i = 1; i < seekLog.length; i++) {
|
|
expect(seekLog[i]).toBeGreaterThan(seekLog[i - 1]);
|
|
}
|
|
});
|
|
|
|
it("forceSync threshold: drift above 20ms is correctable", () => {
|
|
let ms = 0;
|
|
const clock = new TransportClock({ nowMs: () => ms, duration: 10 });
|
|
clock.play();
|
|
ms += 2000;
|
|
|
|
const clockTime = clock.now();
|
|
const simulatedAudioTime = clockTime - 0.025;
|
|
const drift = Math.abs(clockTime - simulatedAudioTime);
|
|
|
|
expect(drift).toBeGreaterThan(0.02);
|
|
expect(drift).toBeLessThan(0.04);
|
|
});
|
|
});
|