fix(core): restore nested seek scrubbing (#404)

This commit is contained in:
Miguel Ángel
2026-04-22 06:13:09 +02:00
committed by GitHub
parent e0749ab768
commit d38a4f19fd
2 changed files with 147 additions and 2 deletions
+111
View File
@@ -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", () => {
+36 -2
View File
@@ -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);