From 4e6fcdb3a9f310a9c483622d6a4ca5354a63fb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 16 Oct 2026 10:54:31 +0000 Subject: [PATCH] fix(core): a caption line shows again after the playhead jumps back over it (#5690) * fix(core): a caption line shows again after the playhead jumps back over it The runtime hides an off-screen clip with an inline visibility:hidden that its children inherit. A seek that jumps over a clip renders that clip's GSAP tweens for the first time while it is hidden, so a set or autoAlpha tween records the inherited "hidden" as the value to restore. Seeking back then writes it back, and the element stays hidden for the rest of the session. Interactive seeks now render GSAP with hidden clips shown, so first reads see the authored style. Play ticks and render captures move forward and are unchanged. * fix(core): keep the files' comment share where it was * fix(core): show hidden clips for any seek that jumps past what has rendered Clicks and scrubs were covered, but render seeks jump too (hover frames, thumbnails, stills at several times). The decision now lives in the seek itself: a seek more than a frame and a half from the last one, to a time the timelines have not rendered through yet, renders GSAP with hidden clips shown. Frame-by-frame renders, play ticks and scrubs over already-rendered time pay nothing. --- packages/core/src/runtime/init.test.ts | 19 +++++++++++++++++++ packages/core/src/runtime/init.ts | 10 ++++++++++ packages/core/src/runtime/timedClipHide.ts | 8 ++++++++ 3 files changed, 37 insertions(+) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index f67fbaa534..2a56463e91 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -513,6 +513,25 @@ describe("initSandboxRuntimeModular", () => { } }); + it.each(["seek", "renderSeek"] as const)( + "shows a clip's child again after a %s skipped past the GSAP set that hides it at the clip's end", + (seek) => { + document.body.innerHTML = + `
` + + `
` + + `
`; + const tl = gsap.timeline({ paused: true }); + tl.set("#words", { visibility: "hidden" }, 3.9); + window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; + initSandboxRuntimeModular(); + const words = document.getElementById("words")!; + // The set first renders on this jump, while its clip is off screen. + window.__player?.[seek](6); + window.__player?.[seek](3); + expect(getComputedStyle(words).visibility).toBe("visible"); + }, + ); + it("keeps a hidden scene's contents hidden when a rewound GSAP set marks one visible inline", () => { hideTimedClipsUntilFirstPass(); try { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index f856b13a99..e3d109a141 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -10,6 +10,7 @@ import { CLIP_HIDDEN_ATTR, revealTimedClipsAfterFirstPass, SKIPPED_CLIP, + showHiddenClips, skipsHiddenImages, } from "./timedClipHide"; import { @@ -4235,6 +4236,7 @@ export function initSandboxRuntimeModular(): void { // Reuse a completed seek only for the exact requested time and captured timeline. // Paused players and host ticks can share the frame without suppressing a newer time. let lastTransportSeekTime = Number.NaN; + let tweensRenderedThrough = { revision: Number.NaN, time: Number.NEGATIVE_INFINITY }; let lastTransportSeekTimeline: RuntimeTimelineLike | null = null; let pausedSeekDeferredByManualGesture = false; // Set while the transport is parked (see scheduleNextTransportFrame). @@ -4394,6 +4396,12 @@ export function initSandboxRuntimeModular(): void { opts?: { activateChildren?: boolean; suppressEvents?: boolean }, ): () => Animation[] { const suppressEvents = opts?.suppressEvents === true; + const revision = readCompositionTimingRevision(); + if (revision !== tweensRenderedThrough.revision) + tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY }; + const jumped = !(Math.abs(t - lastTransportSeekTime) <= 1.5 / state.canonicalFps); + const firstRenders = jumped && t > tweensRenderedThrough.time; + const hideClipsAgain = firstRenders ? showHiddenClips() : null; if (tl) { // #10: when data-duration exceeds the timeline's intrinsic length the // engine requests frames past the last tween. Seeking a paused GSAP @@ -4442,6 +4450,8 @@ export function initSandboxRuntimeModular(): void { // the deterministic adapters, syncTimedElementVisibility, the hf-timelines-built // handler and __hfReseekGpu. It only ever moved the state the seek left behind. seekStandaloneRegisteredTimelines(t, opts); + hideClipsAgain?.(); + tweensRenderedThrough.time = Math.max(tweensRenderedThrough.time, t); const pageAnimations = pageAnimationsForOnePass(); for (const adapter of state.deterministicAdapters) { if (adapter.name === "gsap" && tl) continue; diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index af55f3e8c8..5e141066f0 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -19,6 +19,14 @@ export const CLIP_HIDDEN_ATTR = "data-hf-clip-hidden"; const CONTENTS_ATTR = "data-hf-hidden-clip-contents"; const HIDE_HIDDEN_CLIP_CONTENTS = `[${CLIP_HIDDEN_ATTR}][style*="visibility: hidden"] * { visibility: hidden !important; }`; +export function showHiddenClips(): () => void { + const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)); + for (const el of hidden) el.style.visibility = "visible"; + return () => { + for (const el of hidden) el.style.visibility = "hidden"; + }; +} + type FirstPassWindow = Window & { __hfFirstPassHidden?: boolean; __hyperframeRuntimeBootstrapped?: boolean;