diff --git a/.filesize-allowlist b/.filesize-allowlist index 895df1d18..11dad5076 100644 --- a/.filesize-allowlist +++ b/.filesize-allowlist @@ -2,5 +2,6 @@ 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 packages/studio/src/utils/sourcePatcher.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 cbd6a164a..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); @@ -118,13 +118,19 @@ export function useTimelinePlayer() { const playerAdapter = win.__player && typeof win.__player.play === "function" ? win.__player : null; - if (getAdapterDuration(playerAdapter) > 0) { + const docDuration = readTimelineDurationFromDocument(iframe.contentDocument); + const adapterDur = getAdapterDuration(playerAdapter); + + if (adapterDur > 0 && docDuration <= adapterDur) { return playerAdapter; } + let timelineAdapter: PlaybackAdapter | null = null; 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 (dur > 0) timelineAdapter ??= adapter; } if (win.__timelines) { @@ -139,39 +145,46 @@ export function useTimelinePlayer() { ?.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; + if (dur > 0) timelineAdapter ??= adapter; } } - const fallbackDuration = Math.max( + // 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, - readTimelineDurationFromDocument(iframe.contentDocument), + docDuration, + adapterDur, ); if ( - playerAdapter && - fallbackDuration > 0 && - (typeof playerAdapter.renderSeek === "function" || typeof playerAdapter.seek === "function") + bestAdapter && + effectiveDuration > 0 && + ("renderSeek" in bestAdapter || typeof bestAdapter.seek === "function") ) { const cached = staticSeekAdapterRef.current; - if (cached?.player === playerAdapter && cached.duration === fallbackDuration) { + if (cached?.player === bestAdapter && cached.duration === effectiveDuration) { return cached.adapter; } cached?.adapter.pause(); const adapter = createStaticSeekPlaybackAdapter( - playerAdapter, - fallbackDuration, + bestAdapter, + effectiveDuration, getDefaultStaticSeekPlaybackClock(win), () => usePlayerStore.getState().playbackRate, ); staticSeekAdapterRef.current = { - player: playerAdapter, - duration: fallbackDuration, + 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;