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.
This commit is contained in:
Miguel Ángel
2026-05-18 18:15:33 -04:00
parent ca6069763d
commit b32a62d4d1
5 changed files with 28 additions and 58 deletions
+4
View File
@@ -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) => {
@@ -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);
@@ -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
@@ -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);
}
@@ -57,5 +57,4 @@ export type IframeWindow = Window & {
__timeline?: TimelineLike;
__timelines?: Record<string, TimelineLike>;
__clipManifest?: ClipManifest;
__hfForceTimelineRebind?: () => void;
};