From 43f0a2b2bb1e430b96090591fedb7c17ea30dde2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 16:32:03 -0400 Subject: [PATCH 1/7] fix(studio): seek slider respects effective timeline duration with appended blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seek slider read duration from the player store, which was set from the iframe adapter's getDuration() — only aware of the root composition's authored data-duration. Appended sub-compositions (via Blocks panel) extend the timeline but the slider stayed capped at the original duration. Sync effectiveTimelineDuration (which accounts for all timeline elements) into the player store, and prevent adapter callbacks from overwriting a larger effective duration back down to the authored value. --- packages/studio/src/App.tsx | 8 ++++---- packages/studio/src/player/hooks/useTimelinePlayer.ts | 2 +- .../studio/src/player/hooks/useTimelineSyncCallbacks.ts | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1cf24b2c5..ced474638 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -84,6 +84,10 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); + useEffect(() => { + if (effectiveTimelineDuration !== usePlayerStore.getState().duration) + usePlayerStore.getState().setDuration(effectiveTimelineDuration); + }, [effectiveTimelineDuration]); const refreshPreviewDocumentVersion = useCallback(() => { setPreviewDocumentVersion((v) => v + 1); window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80); @@ -235,11 +239,9 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), }); - domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; - useCaptionDetection({ projectId, activeCompPath, @@ -271,10 +273,8 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); - const [globalDragOver, setGlobalDragOver] = useState(false); const dragCounterRef = useRef(0); - const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys; const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index cbd6a164a..7b6579afe 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -99,7 +99,7 @@ export function useTimelinePlayer() { if ( Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0 && - nextDuration !== state.duration + (nextDuration ?? 0) > state.duration ) { setDuration(nextDuration ?? 0); } diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 54c28732f..7a55ec01d 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -163,11 +163,12 @@ export function useTimelineSyncCallbacks({ // with the initial adapter seek on iframe load. liveTime.notify(startTime); const adapterDur = adapter.getDuration(); + const storeDur = usePlayerStore.getState().duration; if ( Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200 && - adapterDur !== usePlayerStore.getState().duration + adapterDur > storeDur ) { setDuration(adapterDur); } From f66c47c812cf4f95218925a9e1fb6f5c6371a2c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 16:35:59 -0400 Subject: [PATCH 2/7] fix(studio): derive effective duration in PlayerControls instead of useEffect sync Replace the useEffect that pushed effectiveTimelineDuration into the player store with an inline derived selector in PlayerControls. The selector computes Math.max(duration, maxElementEnd) directly from store state, avoiding the effect-based sync anti-pattern entirely. --- packages/studio/src/App.tsx | 4 ---- packages/studio/src/player/components/PlayerControls.tsx | 6 +++++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ced474638..1c6fd8e79 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -84,10 +84,6 @@ export function StudioApp() { : 0; return Math.max(timelineDuration, maxEnd); }, [timelineDuration, timelineElements]); - useEffect(() => { - if (effectiveTimelineDuration !== usePlayerStore.getState().duration) - usePlayerStore.getState().setDuration(effectiveTimelineDuration); - }, [effectiveTimelineDuration]); const refreshPreviewDocumentVersion = useCallback(() => { setPreviewDocumentVersion((v) => v + 1); window.setTimeout(() => setPreviewDocumentVersion((v) => v + 1), 80); diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index c528cffb8..ef57ca457 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -55,7 +55,11 @@ export const PlayerControls = memo(function PlayerControls({ }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); - const duration = usePlayerStore((s) => s.duration); + const duration = usePlayerStore((s) => { + if (s.elements.length === 0) return s.duration; + const maxEnd = Math.max(...s.elements.map((el) => el.start + el.duration)); + return Math.max(s.duration, maxEnd); + }); const timelineReady = usePlayerStore((s) => s.timelineReady); const playbackRate = usePlayerStore((s) => s.playbackRate); const audioMuted = usePlayerStore((s) => s.audioMuted); From 1a4e534d0fdbe128f7d9b81cfc24540f189a3090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 17:28:45 -0400 Subject: [PATCH 3/7] fix(studio): uncap seek clamp to include timeline element range The seek function clamped to adapter.getDuration() which only knows the root composition's authored duration. Appended timeline elements extend beyond this range. Compute the effective max from both the adapter duration and the store's element boundaries so scrubbing reaches the full timeline. --- packages/studio/src/player/hooks/useTimelinePlayer.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 7b6579afe..cecd98458 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -357,7 +357,13 @@ export function useTimelinePlayer() { pendingSeekRef.current = Math.max(0, time); return false; } - const duration = Math.max(0, adapter.getDuration()); + const adapterDur = adapter.getDuration(); + const state = usePlayerStore.getState(); + const maxEnd = + state.elements.length > 0 + ? Math.max(...state.elements.map((el) => el.start + el.duration)) + : 0; + const duration = Math.max(0, adapterDur, maxEnd); const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time); adapter.seek(nextTime, options); liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render From ca6069763da088995d58f60916e02d37dd913680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 18:10:43 -0400 Subject: [PATCH 4/7] fix(studio): patch runtime clock when document duration exceeds player duration When the runtime player's clock duration is smaller than the effective timeline (computed from data-start + data-hf-authored-duration on sub-composition elements), the seek is clamped too early and sub-compositions beyond the clock duration are invisible. Detect this mismatch in getAdapter() and pad the root GSAP timeline to the document duration, then force a timeline rebind so the clock updates. --- .../src/player/hooks/useTimelinePlayer.ts | 25 +++++++++++++++++++ .../studio/src/player/lib/playbackTypes.ts | 1 + 2 files changed, 26 insertions(+) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index cecd98458..ecd923389 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -43,6 +43,27 @@ import { shouldMutePreviewAudio, } from "../lib/timelineIframeHelpers"; +function patchRuntimeClockDuration(win: IframeWindow, targetDuration: number): void { + try { + const rootEl = win.document?.querySelector("[data-composition-id]"); + const rootId = rootEl?.getAttribute("data-composition-id"); + const tl = rootId && win.__timelines?.[rootId]; + if (tl && typeof tl.duration === "function" && tl.duration() < targetDuration) { + const tlWithTo = tl as typeof tl & { + to?: (t: object, v: { duration: number }, p: number) => void; + }; + if (typeof tlWithTo.to === "function") { + tlWithTo.to({}, { duration: 0 }, targetDuration); + } + } + if (typeof win.__hfForceTimelineRebind === "function") { + win.__hfForceTimelineRebind(); + } + } catch { + // cross-origin or missing runtime — non-fatal + } +} + // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -119,6 +140,10 @@ export function useTimelinePlayer() { const playerAdapter = win.__player && typeof win.__player.play === "function" ? win.__player : null; if (getAdapterDuration(playerAdapter) > 0) { + const docDur = readTimelineDurationFromDocument(iframe?.contentDocument); + if (docDur > 0 && docDur > playerAdapter!.getDuration()) { + patchRuntimeClockDuration(win, docDur); + } return playerAdapter; } diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index e86b67667..540933465 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -57,4 +57,5 @@ export type IframeWindow = Window & { __timeline?: TimelineLike; __timelines?: Record; __clipManifest?: ClipManifest; + __hfForceTimelineRebind?: () => void; }; From b32a62d4d1548edffd87023103fbcfd187ea29a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 18:15:33 -0400 Subject: [PATCH 5/7] fix(studio): seek slider respects full document timeline duration getAdapter() returned the runtime player or GSAP timeline adapter directly when its duration was > 0, even when the document's timeline (computed from sub-composition data-start + data-hf-authored-duration attributes) extended beyond that duration. This capped the seek slider, seek clamping, and sub-composition visibility at the adapter's shorter value. Now each adapter path checks whether the document duration exceeds the adapter's own duration. When it does, the adapter falls through to createStaticSeekPlaybackAdapter which wraps the runtime player with the correct effective duration, allowing seeking and preview across the full timeline range. --- packages/studio/src/App.tsx | 4 ++ .../src/player/components/PlayerControls.tsx | 6 +- .../src/player/hooks/useTimelinePlayer.ts | 72 ++++++------------- .../player/hooks/useTimelineSyncCallbacks.ts | 3 +- .../studio/src/player/lib/playbackTypes.ts | 1 - 5 files changed, 28 insertions(+), 58 deletions(-) diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 1c6fd8e79..1cf24b2c5 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -235,9 +235,11 @@ export function StudioApp() { openSourceForSelection: fileManager.openSourceForSelection, selectSidebarTab: (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), }); + domEditSelectionBridgeRef.current = domEditSession.domEditSelection; clearDomSelectionRef.current = domEditSession.clearDomSelection; handleDomEditElementDeleteRef.current = domEditSession.handleDomEditElementDelete; + useCaptionDetection({ projectId, activeCompPath, @@ -269,8 +271,10 @@ export function StudioApp() { setConsoleErrors, resetErrors: resetConsoleErrors, } = useConsoleErrorCapture(previewIframe); + const [globalDragOver, setGlobalDragOver] = useState(false); const dragCounterRef = useRef(0); + const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys; const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { diff --git a/packages/studio/src/player/components/PlayerControls.tsx b/packages/studio/src/player/components/PlayerControls.tsx index ef57ca457..c528cffb8 100644 --- a/packages/studio/src/player/components/PlayerControls.tsx +++ b/packages/studio/src/player/components/PlayerControls.tsx @@ -55,11 +55,7 @@ export const PlayerControls = memo(function PlayerControls({ }: PlayerControlsProps) { // Subscribe to only the fields we render — each selector prevents cascading re-renders const isPlaying = usePlayerStore((s) => s.isPlaying); - const duration = usePlayerStore((s) => { - if (s.elements.length === 0) return s.duration; - const maxEnd = Math.max(...s.elements.map((el) => el.start + el.duration)); - return Math.max(s.duration, maxEnd); - }); + const duration = usePlayerStore((s) => s.duration); const timelineReady = usePlayerStore((s) => s.timelineReady); const playbackRate = usePlayerStore((s) => s.playbackRate); const audioMuted = usePlayerStore((s) => s.audioMuted); diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index ecd923389..5ab8b87e7 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -43,27 +43,6 @@ import { shouldMutePreviewAudio, } from "../lib/timelineIframeHelpers"; -function patchRuntimeClockDuration(win: IframeWindow, targetDuration: number): void { - try { - const rootEl = win.document?.querySelector("[data-composition-id]"); - const rootId = rootEl?.getAttribute("data-composition-id"); - const tl = rootId && win.__timelines?.[rootId]; - if (tl && typeof tl.duration === "function" && tl.duration() < targetDuration) { - const tlWithTo = tl as typeof tl & { - to?: (t: object, v: { duration: number }, p: number) => void; - }; - if (typeof tlWithTo.to === "function") { - tlWithTo.to({}, { duration: 0 }, targetDuration); - } - } - if (typeof win.__hfForceTimelineRebind === "function") { - win.__hfForceTimelineRebind(); - } - } catch { - // cross-origin or missing runtime — non-fatal - } -} - // --------------------------------------------------------------------------- // Hook // --------------------------------------------------------------------------- @@ -120,7 +99,7 @@ export function useTimelinePlayer() { if ( Number.isFinite(nextDuration) && (nextDuration ?? 0) > 0 && - (nextDuration ?? 0) > state.duration + nextDuration !== state.duration ) { setDuration(nextDuration ?? 0); } @@ -139,58 +118,57 @@ export function useTimelinePlayer() { const playerAdapter = win.__player && typeof win.__player.play === "function" ? win.__player : null; - if (getAdapterDuration(playerAdapter) > 0) { - const docDur = readTimelineDurationFromDocument(iframe?.contentDocument); - if (docDur > 0 && docDur > playerAdapter!.getDuration()) { - patchRuntimeClockDuration(win, docDur); - } + const docDuration = readTimelineDurationFromDocument(iframe.contentDocument); + const adapterDur = getAdapterDuration(playerAdapter); + + if (adapterDur > 0 && docDuration <= adapterDur) { return playerAdapter; } if (win.__timeline) { const adapter = wrapTimeline(win.__timeline); - if (getAdapterDuration(adapter) > 0) return adapter; + const dur = getAdapterDuration(adapter); + if (dur > 0 && docDuration <= dur) return adapter; } if (win.__timelines) { const keys = Object.keys(win.__timelines); if (keys.length > 0) { - // Resolve the root composition id from the DOM — the outermost - // `[data-composition-id]` element is the master. Without this, - // Object.keys() order would let a sub-composition's timeline - // hijack play/pause/seek and the duration readout. const rootId = iframe?.contentDocument ?.querySelector("[data-composition-id]") ?.getAttribute("data-composition-id"); const key = rootId && rootId in win.__timelines ? rootId : keys[keys.length - 1]; const adapter = wrapTimeline(win.__timelines[key]); - if (getAdapterDuration(adapter) > 0) return adapter; + const dur = getAdapterDuration(adapter); + if (dur > 0 && docDuration <= dur) return adapter; } } - const fallbackDuration = Math.max( + const effectiveDuration = Math.max( usePlayerStore.getState().duration, - readTimelineDurationFromDocument(iframe.contentDocument), + docDuration, + adapterDur, ); + const baseAdapter = playerAdapter; if ( - playerAdapter && - fallbackDuration > 0 && - (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function") + baseAdapter && + effectiveDuration > 0 && + (typeof baseAdapter.renderSeek === "function" || typeof baseAdapter.seek === "function") ) { const cached = staticSeekAdapterRef.current; - if (cached?.player === playerAdapter && cached.duration === fallbackDuration) { + if (cached?.player === baseAdapter && cached.duration === effectiveDuration) { return cached.adapter; } cached?.adapter.pause(); const adapter = createStaticSeekPlaybackAdapter( - playerAdapter, - fallbackDuration, + baseAdapter, + effectiveDuration, getDefaultStaticSeekPlaybackClock(win), () => usePlayerStore.getState().playbackRate, ); staticSeekAdapterRef.current = { - player: playerAdapter, - duration: fallbackDuration, + player: baseAdapter, + duration: effectiveDuration, adapter, }; return adapter; @@ -382,13 +360,7 @@ export function useTimelinePlayer() { pendingSeekRef.current = Math.max(0, time); return false; } - const adapterDur = adapter.getDuration(); - const state = usePlayerStore.getState(); - const maxEnd = - state.elements.length > 0 - ? Math.max(...state.elements.map((el) => el.start + el.duration)) - : 0; - const duration = Math.max(0, adapterDur, maxEnd); + const duration = Math.max(0, adapter.getDuration()); const nextTime = Math.max(0, duration > 0 ? Math.min(duration, time) : time); adapter.seek(nextTime, options); liveTime.notify(nextTime); // Direct DOM updates (playhead, timecode, progress) — no re-render diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index 7a55ec01d..54c28732f 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -163,12 +163,11 @@ export function useTimelineSyncCallbacks({ // with the initial adapter seek on iframe load. liveTime.notify(startTime); const adapterDur = adapter.getDuration(); - const storeDur = usePlayerStore.getState().duration; if ( Number.isFinite(adapterDur) && adapterDur > 0 && adapterDur < 7200 && - adapterDur > storeDur + adapterDur !== usePlayerStore.getState().duration ) { setDuration(adapterDur); } diff --git a/packages/studio/src/player/lib/playbackTypes.ts b/packages/studio/src/player/lib/playbackTypes.ts index 540933465..e86b67667 100644 --- a/packages/studio/src/player/lib/playbackTypes.ts +++ b/packages/studio/src/player/lib/playbackTypes.ts @@ -57,5 +57,4 @@ export type IframeWindow = Window & { __timeline?: TimelineLike; __timelines?: Record; __clipManifest?: ClipManifest; - __hfForceTimelineRebind?: () => void; }; From 10af9655f207b15028734c831290c4684571ac5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 18:20:34 -0400 Subject: [PATCH 6/7] fix(studio): restore root-id resolution comment in __timelines path --- packages/studio/src/player/hooks/useTimelinePlayer.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 5ab8b87e7..4614b3936 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -134,6 +134,10 @@ export function useTimelinePlayer() { if (win.__timelines) { const keys = Object.keys(win.__timelines); if (keys.length > 0) { + // Resolve the root composition id from the DOM — the outermost + // `[data-composition-id]` element is the master. Without this, + // Object.keys() order would let a sub-composition's timeline + // hijack play/pause/seek and the duration readout. const rootId = iframe?.contentDocument ?.querySelector("[data-composition-id]") ?.getAttribute("data-composition-id"); From 1a63a945e012f74965ca1f38245075c491417351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 18 May 2026 18:28:34 -0400 Subject: [PATCH 7/7] =?UTF-8?q?fix(studio):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20GSAP-only=20fallback,=20drop=20dead=20alias,=20add=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [blocker] When playerAdapter is null (GSAP-only runtimes with no win.__player), the fallback path now uses the best available timeline adapter instead of returning null. Track timelineAdapter across the __timeline and __timelines paths, then use it as the base for createStaticSeekPlaybackAdapter. - [nit] Remove dead baseAdapter alias — use bestAdapter directly. - [tests] Add 4 tests: readTimelineDurationFromDocument with data-hf-authored-duration fallback, createStaticSeekPlaybackAdapter with seek-only adapter (no renderSeek), and pause lifecycle. --- .filesize-allowlist | 1 + .../player/hooks/useTimelinePlayer.test.ts | 55 +++++++++++++++++++ .../src/player/hooks/useTimelinePlayer.ts | 22 +++++--- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.filesize-allowlist b/.filesize-allowlist index b4dbc51b4..c4845eb51 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -2,4 +2,5 @@ packages/studio/src/player/hooks/useTimelinePlayer.ts packages/studio/src/hooks/useManifestPersistence.ts packages/studio/src/player/components/PlayerControls.tsx packages/studio/src/components/editor/manualEdits.test.ts +packages/studio/src/player/hooks/useTimelinePlayer.test.ts packages/studio/src/components/editor/manualEditsDom.ts diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts index 672bc576a..f83ea6ef4 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.test.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.test.ts @@ -106,6 +106,28 @@ describe("readTimelineDurationFromDocument", () => { expect(readTimelineDurationFromDocument(doc)).toBe(5.5); }); + + it("reads data-hf-authored-duration when data-duration is stripped", () => { + const doc = createDocument(` +
+
+
+
+ `); + + expect(readTimelineDurationFromDocument(doc)).toBe(70); + }); + + it("picks the larger of data-duration and data-hf-authored-duration children", () => { + const doc = createDocument(` +
+
+
+
+ `); + + expect(readTimelineDurationFromDocument(doc)).toBe(82); + }); }); describe("createStaticSeekPlaybackAdapter", () => { @@ -153,6 +175,39 @@ describe("createStaticSeekPlaybackAdapter", () => { expect(renderedTimes).toEqual([2]); expect(adapter.getTime()).toBe(2); }); + + it("works with a seek-only adapter (no renderSeek)", () => { + const clock = createManualAnimationClock(); + const seekedTimes: number[] = []; + const adapter = createStaticSeekPlaybackAdapter( + { + getTime: () => 0, + seek: (time: number) => { + seekedTimes.push(time); + }, + }, + 82, + clock, + ); + + adapter.seek(77); + expect(seekedTimes).toEqual([77]); + expect(adapter.getTime()).toBe(77); + expect(adapter.getDuration()).toBe(82); + }); + + it("pauses old adapter before replacing with new duration", () => { + const clock = createManualAnimationClock(); + const adapter = createStaticSeekPlaybackAdapter( + { getTime: () => 0, renderSeek: () => {} }, + 10, + clock, + ); + adapter.play(); + expect(adapter.isPlaying()).toBe(true); + adapter.pause(); + expect(adapter.isPlaying()).toBe(false); + }); }); describe("buildStandaloneRootTimelineElement", () => { diff --git a/packages/studio/src/player/hooks/useTimelinePlayer.ts b/packages/studio/src/player/hooks/useTimelinePlayer.ts index 4614b3936..3c9db97d6 100644 --- a/packages/studio/src/player/hooks/useTimelinePlayer.ts +++ b/packages/studio/src/player/hooks/useTimelinePlayer.ts @@ -59,7 +59,7 @@ export function useTimelinePlayer() { const iframeShortcutCleanupRef = useRef<(() => void) | null>(null); const lastTimelineMessageRef = useRef(0); const staticSeekAdapterRef = useRef<{ - player: RuntimePlaybackAdapter; + player: RuntimePlaybackAdapter | PlaybackAdapter; duration: number; adapter: PlaybackAdapter; } | null>(null); @@ -125,10 +125,12 @@ export function useTimelinePlayer() { return playerAdapter; } + let timelineAdapter: PlaybackAdapter | null = null; if (win.__timeline) { const adapter = wrapTimeline(win.__timeline); const dur = getAdapterDuration(adapter); if (dur > 0 && docDuration <= dur) return adapter; + if (dur > 0) timelineAdapter ??= adapter; } if (win.__timelines) { @@ -145,40 +147,44 @@ export function useTimelinePlayer() { const adapter = wrapTimeline(win.__timelines[key]); const dur = getAdapterDuration(adapter); if (dur > 0 && docDuration <= dur) return adapter; + if (dur > 0) timelineAdapter ??= adapter; } } + // The document timeline extends past every native adapter's duration. + // Wrap the best available adapter with the effective duration so the + // seek slider, seek clamping, and duration display cover the full range. + const bestAdapter = playerAdapter ?? timelineAdapter; const effectiveDuration = Math.max( usePlayerStore.getState().duration, docDuration, adapterDur, ); - const baseAdapter = playerAdapter; if ( - baseAdapter && + bestAdapter && effectiveDuration > 0 && - (typeof baseAdapter.renderSeek === "function" || typeof baseAdapter.seek === "function") + ("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function") ) { const cached = staticSeekAdapterRef.current; - if (cached?.player === baseAdapter && cached.duration === effectiveDuration) { + if (cached?.player === bestAdapter && cached.duration === effectiveDuration) { return cached.adapter; } cached?.adapter.pause(); const adapter = createStaticSeekPlaybackAdapter( - baseAdapter, + bestAdapter, effectiveDuration, getDefaultStaticSeekPlaybackClock(win), () => usePlayerStore.getState().playbackRate, ); staticSeekAdapterRef.current = { - player: baseAdapter, + player: bestAdapter, duration: effectiveDuration, adapter, }; return adapter; } - return playerAdapter; + return bestAdapter; } catch (err) { console.warn("[useTimelinePlayer] Could not get playback adapter (cross-origin)", err); return null;