mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(core): restore nested seek scrubbing (#404)
This commit is contained in:
@@ -54,6 +54,79 @@ function createMockDeps(timeline?: RuntimeTimelineLike | null) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNestedTimelineHarness() {
|
||||
const createScene = (start: number, duration: number) => {
|
||||
const state = { time: 0, paused: false };
|
||||
const timeline = {
|
||||
play: vi.fn(() => {
|
||||
state.paused = false;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
state.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
state.time = t;
|
||||
}),
|
||||
time: vi.fn(() => state.time),
|
||||
duration: vi.fn(() => duration),
|
||||
add: vi.fn(),
|
||||
paused: vi.fn((p?: boolean) => {
|
||||
if (p !== undefined) state.paused = p;
|
||||
}),
|
||||
timeScale: vi.fn(),
|
||||
set: vi.fn(),
|
||||
} satisfies RuntimeTimelineLike;
|
||||
return { timeline, state, start, duration };
|
||||
};
|
||||
|
||||
const scene1 = createScene(0, 1.5);
|
||||
const scene2 = createScene(1.5, 10);
|
||||
const scene5 = createScene(12, 3);
|
||||
const children = [scene1, scene2, scene5];
|
||||
|
||||
const masterState = { time: 0, paused: false };
|
||||
const master = {
|
||||
play: vi.fn(() => {
|
||||
masterState.paused = false;
|
||||
}),
|
||||
pause: vi.fn(() => {
|
||||
masterState.paused = true;
|
||||
}),
|
||||
seek: vi.fn((t: number) => {
|
||||
masterState.time = t;
|
||||
for (const child of children) {
|
||||
if (child.state.paused) continue;
|
||||
child.state.time = Math.max(0, Math.min(t - child.start, child.duration));
|
||||
}
|
||||
}),
|
||||
totalTime: vi.fn((t: number) => {
|
||||
masterState.time = t;
|
||||
for (const child of children) {
|
||||
if (child.state.paused) continue;
|
||||
child.state.time = Math.max(0, Math.min(t - child.start, child.duration));
|
||||
}
|
||||
}),
|
||||
time: vi.fn(() => masterState.time),
|
||||
duration: vi.fn(() => 20),
|
||||
add: vi.fn(),
|
||||
paused: vi.fn((p?: boolean) => {
|
||||
if (p !== undefined) masterState.paused = p;
|
||||
}),
|
||||
timeScale: vi.fn(),
|
||||
set: vi.fn(),
|
||||
} satisfies RuntimeTimelineLike;
|
||||
|
||||
return {
|
||||
master,
|
||||
scene1: scene1.timeline,
|
||||
scene2: scene2.timeline,
|
||||
scene5: scene5.timeline,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createRuntimePlayer", () => {
|
||||
describe("play", () => {
|
||||
it("does nothing without a timeline", () => {
|
||||
@@ -247,6 +320,27 @@ describe("createRuntimePlayer", () => {
|
||||
expect(deps.onStatePost).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("rearms paused sibling timelines so master seek updates their local offsets", () => {
|
||||
const { master, scene1, scene2, scene5 } = createNestedTimelineHarness();
|
||||
const deps = createMockDeps(master);
|
||||
const player = createRuntimePlayer({
|
||||
...deps,
|
||||
getTimelineRegistry: () => ({ main: master, scene1, scene2, scene5 }),
|
||||
});
|
||||
player.pause();
|
||||
player.seek(3);
|
||||
expect(scene1.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene2.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene5.play).toHaveBeenCalledTimes(1);
|
||||
expect(master.totalTime).toHaveBeenCalledWith(3, false);
|
||||
expect(scene1.time()).toBe(1.5);
|
||||
expect(scene2.time()).toBe(1.5);
|
||||
expect(scene5.time()).toBe(0);
|
||||
expect(scene1.pause).toHaveBeenCalledTimes(2);
|
||||
expect(scene2.pause).toHaveBeenCalledTimes(2);
|
||||
expect(scene5.pause).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("clamps negative time to 0", () => {
|
||||
const timeline = createMockTimeline({ duration: 10 });
|
||||
const deps = createMockDeps(timeline);
|
||||
@@ -286,6 +380,23 @@ describe("createRuntimePlayer", () => {
|
||||
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
|
||||
expect(deps.onRenderFrameSeek).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renderSeek rearms paused siblings before seeking the master timeline", () => {
|
||||
const { master, scene1, scene2, scene5 } = createNestedTimelineHarness();
|
||||
const deps = createMockDeps(master);
|
||||
const player = createRuntimePlayer({
|
||||
...deps,
|
||||
getTimelineRegistry: () => ({ main: master, scene1, scene2, scene5 }),
|
||||
});
|
||||
player.pause();
|
||||
player.renderSeek(5);
|
||||
expect(scene1.time()).toBe(1.5);
|
||||
expect(scene2.time()).toBe(3.5);
|
||||
expect(scene5.time()).toBe(0);
|
||||
expect(scene1.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene2.play).toHaveBeenCalledTimes(1);
|
||||
expect(scene5.play).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getters", () => {
|
||||
|
||||
@@ -59,6 +59,30 @@ function seekTimelineDeterministically(
|
||||
return quantized;
|
||||
}
|
||||
|
||||
function seekMasterAndSiblingTimelinesDeterministically(
|
||||
registry: Record<string, RuntimeTimelineLike | undefined> | undefined | null,
|
||||
master: RuntimeTimelineLike,
|
||||
timeSeconds: number,
|
||||
canonicalFps: number,
|
||||
): number {
|
||||
const rearmedSiblings: RuntimeTimelineLike[] = [];
|
||||
forEachSiblingTimeline(registry, master, (tl) => {
|
||||
tl.play();
|
||||
rearmedSiblings.push(tl);
|
||||
});
|
||||
try {
|
||||
return seekTimelineDeterministically(master, timeSeconds, canonicalFps);
|
||||
} finally {
|
||||
for (const tl of rearmedSiblings) {
|
||||
try {
|
||||
tl.pause();
|
||||
} catch {
|
||||
// ignore sibling failures — one broken timeline shouldn't poison seek
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
return {
|
||||
_timeline: null,
|
||||
@@ -112,7 +136,12 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
const timeline = deps.getTimeline();
|
||||
if (!timeline) return;
|
||||
const safeTime = Math.max(0, Number(timeSeconds) || 0);
|
||||
const quantized = seekTimelineDeterministically(timeline, safeTime, deps.getCanonicalFps());
|
||||
const quantized = seekMasterAndSiblingTimelinesDeterministically(
|
||||
deps.getTimelineRegistry?.(),
|
||||
timeline,
|
||||
safeTime,
|
||||
deps.getCanonicalFps(),
|
||||
);
|
||||
deps.onDeterministicSeek(quantized);
|
||||
deps.setIsPlaying(false);
|
||||
deps.onSyncMedia(quantized, false);
|
||||
@@ -127,7 +156,12 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||
// their animations advance. Without this, non-GSAP compositions freeze
|
||||
// on their initial frame.
|
||||
const quantized = timeline
|
||||
? seekTimelineDeterministically(timeline, timeSeconds, canonicalFps)
|
||||
? seekMasterAndSiblingTimelinesDeterministically(
|
||||
deps.getTimelineRegistry?.(),
|
||||
timeline,
|
||||
timeSeconds,
|
||||
canonicalFps,
|
||||
)
|
||||
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
|
||||
deps.onDeterministicSeek(quantized);
|
||||
deps.setIsPlaying(false);
|
||||
|
||||
Reference in New Issue
Block a user