fix(shader-transitions): a runtime seek during the prewarm is where the preview lands (#4523)

* fix(shader-transitions): a runtime seek during the prewarm is where the preview lands

The runtime seeks the timeline through totalTime, which HyperShader did not
wrap. A seek that arrived while the transition prewarm ran moved the
timeline under the capture and was never recorded, so the prewarm restored
the playhead it had before the seek and the film's tweens showed that time
while the player showed the new one. totalTime is now wrapped like time and
seek: while the prewarm runs it records the playhead, and the prewarm
restores it. HyperShader's own play, pause, time and seek calls, which GSAP
routes through totalTime, pass straight through.

* fix(shader-transitions): a speed change during the prewarm does not move the playhead

GSAP re-renders in place through totalTime from timeScale, paused and
reversed, so those now pass through as HyperShader's own seeks instead of
recording the capture frame as the playhead to restore.

* fix(shader-transitions): an outside seek during the prewarm is applied, not dropped

Dropping outside totalTime calls while the prewarm ran also dropped the
runtime's bind-time render, so a warm reload at 0 never drew tl.set states
at 0, and it starved the runtime's volume probe. An outside totalTime call
is now applied as before and recorded as the playhead the prewarm restores;
reading totalTime during the prewarm still returns that playhead.

* fix(shader-transitions): the prewarm captures its sample frame even after an outside seek

Outside seeks now apply during the prewarm, so one that lands while a
capture frame paints (the runtime's volume probe restoring the playhead,
a transport tick, a restored playhead) could move the timeline before the
capture and cache the wrong frame. The capture re-seeks to its sample, with
events suppressed, right after the paint wait.
This commit is contained in:
Miguel Ángel
2026-09-26 08:21:14 +00:00
committed by GitHub
parent 81d8a03d2e
commit 0bb7b2f61a
2 changed files with 207 additions and 8 deletions
@@ -10,10 +10,14 @@ function stubGsap() {
let paused = true;
const calls: { fn: () => void; at: number }[] = [];
const tl = {
paused: () => paused,
paused: (value?: boolean) =>
value === undefined ? paused : ((paused = value), tl.totalTime(now, true), tl),
play: () => ((paused = false), tl),
pause: () => ((paused = true), tl),
time: (t?: number, suppressEvents = false) => {
// Like GSAP, time() and seek() move the playhead through totalTime().
time: (t?: number, suppressEvents = false) =>
t === undefined ? now : tl.totalTime(t, suppressEvents),
totalTime: (t?: number, suppressEvents = false) => {
if (t === undefined) return now;
const from = now;
now = t;
@@ -26,6 +30,7 @@ function stubGsap() {
return tl;
},
seek: (t: number) => tl.time(t),
timeScale: (scale?: number) => (scale === undefined ? 1 : (tl.totalTime(now, true), tl)),
call: (fn: () => void, _args: null, at: number) => (calls.push({ fn, at }), tl),
set: () => tl,
to: () => tl,
@@ -33,6 +38,7 @@ function stubGsap() {
};
const timeline = (opts: { onUpdate?: () => void }) => ((onUpdate = opts.onUpdate), tl);
vi.stubGlobal("gsap", { timeline, set: () => {}, to: () => {}, fromTo: () => {} });
return { now: () => now };
}
function stubWebGl() {
@@ -155,3 +161,157 @@ describe("preview outside a transition", () => {
);
});
});
type SpeedTimeline = ShaderTimeline & {
timeScale: (scale?: number) => unknown;
totalTime: (t?: number) => unknown;
};
describe("a runtime seek while the prewarm runs", () => {
it("is where the prewarm leaves the playhead", async () => {
stubGsap();
stubWebGl();
vi.spyOn(console, "warn").mockImplementation(() => {});
mountScenes(["s4", "s5"]);
const tl = init({
bgColor: "#000",
scenes: ["s4", "s5"],
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
}) as ShaderTimeline & { totalTime: (t?: number) => unknown };
let crossings = 0;
tl.call(() => (crossings += 1), null, 0.5);
await Promise.resolve();
tl.totalTime(1);
await prewarmDone();
crossings = 0;
expect(tl.totalTime()).toBe(1);
tl.time(0.2);
expect(crossings).toBe(1);
});
it("is not moved by a speed change while the prewarm sits on a capture frame", async () => {
stubGsap();
stubWebGl();
let tl: SpeedTimeline | undefined;
let changedSpeed = false;
vi.spyOn(console, "warn").mockImplementation((message: unknown) => {
if (!changedSpeed && String(message).includes("Transition capture failed")) {
changedSpeed = true;
tl?.timeScale(2);
}
});
mountScenes(["s4", "s5"]);
tl = init({
bgColor: "#000",
scenes: ["s4", "s5"],
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
}) as unknown as SpeedTimeline;
await prewarmDone();
expect(changedSpeed).toBe(true);
expect(tl?.totalTime()).toBe(0);
});
function initDuringCapture(onCaptureFrame: (tl: SpeedTimeline) => void): SpeedTimeline {
stubGsap();
stubWebGl();
let tl: SpeedTimeline | undefined;
let fired = false;
vi.spyOn(console, "warn").mockImplementation((message: unknown) => {
if (!fired && tl && String(message).includes("Transition capture failed")) {
fired = true;
onCaptureFrame(tl);
}
});
mountScenes(["s4", "s5"]);
tl = init({
bgColor: "#000",
scenes: ["s4", "s5"],
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
}) as unknown as SpeedTimeline;
return tl;
}
it("applies it, reads back the recorded playhead, and restores to it", async () => {
let readBeforeSeek: unknown;
let crossedTwo = 0;
let appliedAtOnce = false;
const tl = initDuringCapture((timeline) => {
readBeforeSeek = timeline.totalTime();
crossedTwo = 0;
timeline.totalTime(1);
appliedAtOnce = crossedTwo === 1;
});
tl.call(() => (crossedTwo += 1), null, 2);
await prewarmDone();
expect(readBeforeSeek).toBe(0);
expect(appliedAtOnce).toBe(true);
expect(tl.totalTime()).toBe(1);
});
it("is not moved by paused(false) while the prewarm sits on a capture frame", async () => {
const tl = initDuringCapture((timeline) => {
(timeline as unknown as { paused: (value: boolean) => unknown }).paused(false);
});
await prewarmDone();
expect(tl.totalTime()).toBe(0);
});
it("passes a seek after the prewarm straight through", async () => {
const tl = initDuringCapture(() => {});
let crossedThree = 0;
tl.call(() => (crossedThree += 1), null, 3);
await prewarmDone();
crossedThree = 0;
tl.totalTime(3.5);
expect(crossedThree).toBe(1);
});
it("does not reach the capture when it lands while the capture frame paints", async () => {
const clock = stubGsap();
stubWebGl();
let tl: SpeedTimeline | undefined;
let capturedAt: number | undefined;
vi.spyOn(console, "warn").mockImplementation((message: unknown) => {
if (capturedAt === undefined && String(message).includes("Transition capture failed")) {
capturedAt = clock.now();
}
});
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
tl?.totalTime(0.3);
return setTimeout(() => callback(performance.now()), 0) as unknown as number;
});
mountScenes(["s4", "s5"]);
tl = init({
bgColor: "#000",
scenes: ["s4", "s5"],
transitions: [{ time: 4.4, duration: 0.8, shader: "domain-warp" }],
}) as unknown as SpeedTimeline;
await prewarmDone();
expect(capturedAt).toBe(4.4);
});
it("still records outside seeks after a callback throws inside a capture seek", async () => {
let thrown = false;
const tl = initDuringCapture((timeline) => timeline.totalTime(1));
tl.call(
() => {
if (!thrown) {
thrown = true;
throw new Error("author callback");
}
},
null,
2,
);
await prewarmDone();
expect(thrown).toBe(true);
expect(tl.totalTime()).toBe(1);
});
});
@@ -35,6 +35,10 @@ interface GsapTimeline {
(value: number, suppressEvents?: boolean): GsapTimeline;
};
seek?: (position: number | string, suppressEvents?: boolean) => GsapTimeline;
totalTime?: {
(): number;
(value: number, suppressEvents?: boolean): GsapTimeline;
};
call: (fn: () => void, args: null, position: number) => GsapTimeline;
to: (
target: Record<string, unknown>,
@@ -1219,13 +1223,36 @@ export function init(config: HyperShaderConfig): GsapTimeline {
tl = gsap.timeline({ paused: true, onUpdate: tickShader });
}
const originalPlay = tl.play.bind(tl) as (...args: unknown[]) => GsapTimeline;
const originalPause = tl.pause.bind(tl) as (...args: unknown[]) => GsapTimeline;
const originalTime = tl.time.bind(tl) as (...args: unknown[]) => GsapTimeline | number;
let ownSeekDepth = 0;
const asOwnSeek =
<R>(fn: (...args: unknown[]) => R) =>
(...args: unknown[]): R => {
ownSeekDepth += 1;
try {
return fn(...args);
} finally {
ownSeekDepth -= 1;
}
};
const originalPlay = asOwnSeek(tl.play.bind(tl) as (...args: unknown[]) => GsapTimeline);
const originalPause = asOwnSeek(tl.pause.bind(tl) as (...args: unknown[]) => GsapTimeline);
const originalTime = asOwnSeek(tl.time.bind(tl) as (...args: unknown[]) => GsapTimeline | number);
const originalSeek =
typeof tl.seek === "function"
? (tl.seek.bind(tl) as (...args: unknown[]) => GsapTimeline)
? asOwnSeek(tl.seek.bind(tl) as (...args: unknown[]) => GsapTimeline)
: null;
const originalTotalTime =
typeof tl.totalTime === "function"
? (tl.totalTime.bind(tl) as (...args: unknown[]) => GsapTimeline | number)
: null;
// GSAP re-renders in place through totalTime from these; that is not a seek to record.
const reRendersInPlace = tl as unknown as Record<string, unknown>;
for (const method of ["timeScale", "paused", "reversed"]) {
const original = reRendersInPlace[method];
if (typeof original === "function") {
reRendersInPlace[method] = asOwnSeek(original.bind(tl) as (...args: unknown[]) => unknown);
}
}
const readActualTimelineTime = (): number => {
const value = originalTime();
return typeof value === "number" && Number.isFinite(value) ? value : 0;
@@ -1320,6 +1347,15 @@ export function init(config: HyperShaderConfig): GsapTimeline {
}) as NonNullable<GsapTimeline["seek"]>;
}
if (originalTotalTime) {
tl.totalTime = ((...args: unknown[]) => {
if (ownSeekDepth > 0) return originalTotalTime(...args);
if (args.length === 0) return prewarming ? publicTimelineTime : originalTotalTime();
updatePublicTimelineTime(args[0]);
return originalTotalTime(...args);
}) as NonNullable<GsapTimeline["totalTime"]>;
}
initCapture();
glCanvas.style.display = "none";
@@ -1904,11 +1940,14 @@ export function init(config: HyperShaderConfig): GsapTimeline {
disposeCachedTransition(cache);
let allPersisted = true;
for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) {
const progress = sampleIndex / (sampleCount - 1);
const sampleTime = cache.time + (cache.duration * sampleIndex) / (sampleCount - 1);
suppressSceneMutationTracking(() => {
originalTime(cache.time + cache.duration * progress, false);
originalTime(sampleTime, false);
});
await waitForPaint();
suppressSceneMutationTracking(() => {
originalTime(sampleTime, true);
});
const fromScene = document.getElementById(cache.fromId);
const toScene = document.getElementById(cache.toId);