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("createRuntimePlayer", () => {
|
||||||
describe("play", () => {
|
describe("play", () => {
|
||||||
it("does nothing without a timeline", () => {
|
it("does nothing without a timeline", () => {
|
||||||
@@ -247,6 +320,27 @@ describe("createRuntimePlayer", () => {
|
|||||||
expect(deps.onStatePost).toHaveBeenCalledWith(true);
|
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", () => {
|
it("clamps negative time to 0", () => {
|
||||||
const timeline = createMockTimeline({ duration: 10 });
|
const timeline = createMockTimeline({ duration: 10 });
|
||||||
const deps = createMockDeps(timeline);
|
const deps = createMockDeps(timeline);
|
||||||
@@ -286,6 +380,23 @@ describe("createRuntimePlayer", () => {
|
|||||||
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
|
expect(deps.setIsPlaying).toHaveBeenCalledWith(false);
|
||||||
expect(deps.onRenderFrameSeek).toHaveBeenCalled();
|
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", () => {
|
describe("getters", () => {
|
||||||
|
|||||||
@@ -59,6 +59,30 @@ function seekTimelineDeterministically(
|
|||||||
return quantized;
|
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 {
|
export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
||||||
return {
|
return {
|
||||||
_timeline: null,
|
_timeline: null,
|
||||||
@@ -112,7 +136,12 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
|||||||
const timeline = deps.getTimeline();
|
const timeline = deps.getTimeline();
|
||||||
if (!timeline) return;
|
if (!timeline) return;
|
||||||
const safeTime = Math.max(0, Number(timeSeconds) || 0);
|
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.onDeterministicSeek(quantized);
|
||||||
deps.setIsPlaying(false);
|
deps.setIsPlaying(false);
|
||||||
deps.onSyncMedia(quantized, false);
|
deps.onSyncMedia(quantized, false);
|
||||||
@@ -127,7 +156,12 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
|
|||||||
// their animations advance. Without this, non-GSAP compositions freeze
|
// their animations advance. Without this, non-GSAP compositions freeze
|
||||||
// on their initial frame.
|
// on their initial frame.
|
||||||
const quantized = timeline
|
const quantized = timeline
|
||||||
? seekTimelineDeterministically(timeline, timeSeconds, canonicalFps)
|
? seekMasterAndSiblingTimelinesDeterministically(
|
||||||
|
deps.getTimelineRegistry?.(),
|
||||||
|
timeline,
|
||||||
|
timeSeconds,
|
||||||
|
canonicalFps,
|
||||||
|
)
|
||||||
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
|
: quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps);
|
||||||
deps.onDeterministicSeek(quantized);
|
deps.onDeterministicSeek(quantized);
|
||||||
deps.setIsPlaying(false);
|
deps.setIsPlaying(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user