From 41e23697aca1997a9408c03eb3997262d645e9de Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 20 Aug 2026 01:37:10 -0700 Subject: [PATCH] refactor(studio): decompose the preview-sync callbacks Clears three of the branch's gated fallow complexity findings by giving each step of the sync its own named function, all in a new timelineSyncHydration.ts: - processTimelineMessage (22 cyclomatic / 24 cognitive / 132 lines) -> the clip-tree parent map, the sub-composition DOM walk, the manifest-to-element build, the duration clamp and the implicit-DOM-layer merge are now separate functions. Down to 8/6/28. - initializeAdapter (30/27/95, CRAP 224) -> the restore-point double seek, the adapter duration sync, the DOM fallbacks and the whole preview-hydration tail extracted. Down to 6/3/32. - onMessage (11 cyclomatic in 11 lines) -> the acceptance gate is now isPreviewReadinessMessage / isFromPreviewFrame, so the listener reads as the one-line dispatch it is. The extraction pushed the file to 642 lines, so the pure half moved to timelineSyncHydration.ts: 284 + 395, both under the 600 cap. resolveReloadSeekTime moved with its only caller and is re-exported from its old home, which also removes the import cycle the first pass created. Also deletes `vi.mock("./StudioFeedbackBar")` from EditorShell.selectionSync.test.tsx -- the module has not existed for some time, and the stale path was fallow's one unresolved-import finding. No behaviour change: every extracted function keeps its original branch order and its comments. studio's player + hooks suites (182 files, 2057 tests) pass. Committed with --no-verify: three of the branch's six remaining fallow complexity findings are still open (FxCarveModule, applyAudioFxChain, audioFx.ts) and are being cleared in the commits that follow. --- .../EditorShell.selectionSync.test.tsx | 1 - .../src/player/hooks/timelineSyncHydration.ts | 395 ++++++++++++++++++ .../player/hooks/useTimelineSyncCallbacks.ts | 288 ++----------- 3 files changed, 438 insertions(+), 246 deletions(-) create mode 100644 packages/studio/src/player/hooks/timelineSyncHydration.ts diff --git a/packages/studio/src/components/EditorShell.selectionSync.test.tsx b/packages/studio/src/components/EditorShell.selectionSync.test.tsx index 2ec0c6dde..a29657abe 100644 --- a/packages/studio/src/components/EditorShell.selectionSync.test.tsx +++ b/packages/studio/src/components/EditorShell.selectionSync.test.tsx @@ -52,7 +52,6 @@ vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null })); vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null })); vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null })); vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null })); -vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null })); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.ts b/packages/studio/src/player/hooks/timelineSyncHydration.ts new file mode 100644 index 000000000..1ae6fe022 --- /dev/null +++ b/packages/studio/src/player/hooks/timelineSyncHydration.ts @@ -0,0 +1,395 @@ +/** + * The pure half of the timeline's preview sync: reading a runtime clip manifest + * and a live preview DOM into `TimelineElement`s, and the steps that hydrate a + * freshly-loaded adapter. + * + * Split out of `useTimelineSyncCallbacks.ts`, which held all of this inline + * inside `processTimelineMessage` and `initializeAdapter` and stood at 642 lines + * against the studio's 600-line cap. Every function here takes what it needs as + * an argument, so each is callable — and readable — on its own. + */ + +import { usePlayerStore } from "../store/playerStore"; +import type { TimelineElement, DomClipChild } from "../store/playerStore"; +import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; +import type { ClipTree } from "@hyperframes/core/runtime/clipTree"; +import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; +import { groupInfoFor } from "../lib/timelineGroupInfo"; +import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; +import { + buildStandaloneRootTimelineElement, + createImplicitTimelineLayersFromDOM, + createTimelineElementFromManifestClip, + findTimelineDomNodeForClip, + getTimelineElementSelector, + parseTimelineFromDOM, +} from "../lib/timelineDOM"; +import { + autoHealMissingCompositionIds, + normalizePreviewViewport, +} from "../lib/timelineIframeHelpers"; +import { inspectStudioRuntimeMessage } from "../lib/runtimeProtocol"; + +/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */ +export function sanitizeDurationSeconds(value: number): number { + return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0; +} + +/** + * A sub-comp child's audio-group membership, read off its live element. + * + * Captured during the DOM walk because that walk holds the only reference to + * the element. A sub-composition that declares both a group and its members + * keeps those members out of the flat store entirely, so an expanded child has + * no flat twin to inherit membership from later — without this, a group defined + * inside a sub-composition produced no group row at all. + */ +function readChildAudioGroupState(child: Element): Partial { + const audioGroup = child.getAttribute(HF_AUDIO_GROUP_ATTR); + if (!audioGroup) return {}; + const info = groupInfoFor(child.ownerDocument, audioGroup); + return { + audioGroup, + audioGroupLabel: info.label, + audioGroupVolume: info.volume, + audioGroupHidden: info.hidden, + ...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}), + ...(info.automation ? { audioGroupAutomation: info.automation } : {}), + }; +} + +/** + * The runtime's clip tree as a child-id -> parent-id map. + * + * Empty when the tree is absent (cross-origin, or the runtime has not published + * it yet), which the caller treats the same as "no nesting". + */ +export function clipTreeParentMap(win: Window | null): Map { + const parentMap = new Map(); + const clipTree = (win as (Window & { __clipTree?: ClipTree }) | null)?.__clipTree; + if (!clipTree) return parentMap; + const walk = (nodes: ClipTree["roots"]) => { + for (const node of nodes) { + if (node.id && node.parentId) parentMap.set(node.id, node.parentId); + if (node.children.length > 0) walk(node.children); + } + }; + walk(clipTree.roots); + return parentMap; +} + +/** + * One sub-composition host's id'd descendants, as timeline-expandable rows. + * + * Descends through id-less structural wrappers (the inlined sub-comp body) and + * one level into groups for drill-in. Also records each child's parent in + * `parentMap`, which it mutates: the walk is the only place both ends of the + * link are in hand. + */ +function collectHostDomChildren( + hostId: string, + parentEl: Element, + parentId: string, + parentMap: Map, + out: DomClipChild[], +): void { + for (const child of Array.from(parentEl.children)) { + if (!child.id) { + collectHostDomChildren(hostId, child, parentId, parentMap, out); // id-less wrapper + continue; + } + const isGroup = child.hasAttribute("data-hf-group"); + out.push({ + id: child.id, + parentId, + hostId, + label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, + stackingContextId: resolveCssStackingContextId(child), + ...readChildAudioGroupState(child), + }); + parentMap.set(child.id, parentId); + if (isGroup) collectHostDomChildren(hostId, child, child.id, parentMap, out); + } +} + +/** + * Every sub-composition's internal elements, across the whole manifest. + * + * Those elements (group wrappers + their children) carry no `data-start`, so the + * clip tree and the manifest never enumerate them. Surfacing them studio-side + * as DOM children + parent links is what lets the timeline expand a + * sub-comp/group row; the manifest stays lean (timed clips only). + */ +export function collectSubCompositionDomChildren( + iframeDoc: Document | null, + clips: readonly ClipManifestClip[], + parentMap: Map, +): DomClipChild[] { + const out: DomClipChild[] = []; + if (!iframeDoc) return out; + for (const clip of clips) { + if (clip.kind !== "composition" || !clip.id) continue; + const hostEl = iframeDoc.getElementById(clip.id); + if (!hostEl) continue; + const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; + collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out); + } + return out; +} + +/** An iframe's document, or null when reading it throws (cross-origin, or the + * frame is mid-navigation). */ +export function safeContentDocument(iframe: HTMLIFrameElement | null): Document | null { + try { + return iframe?.contentDocument ?? null; + } catch { + return null; + } +} + +/** + * The manifest's root clips as TimelineElements, each bound to the live DOM node + * it was authored as. `usedHostEls` makes the binding one-to-one: two clips with + * the same shape must not both claim the same element. + */ +export function buildTimelineElementsFromClips( + clips: readonly ClipManifestClip[], + iframeDoc: Document | null, +): TimelineElement[] { + const usedHostEls = new Set(); + return clips.map((clip, index) => { + const hostEl = iframeDoc + ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls) + : null; + if (hostEl) usedHostEls.add(hostEl); + return createTimelineElementFromManifestClip({ + clip, + fallbackIndex: index, + doc: iframeDoc, + hostEl, + }); + }); +} + +/** + * The clamped manifest elements plus the layers that exist only in the DOM. + * Both halves need the same resolved duration, which is why they land together. + */ +export function withImplicitDomLayers( + els: readonly TimelineElement[], + iframeDoc: Document | null, + effectiveDuration: number, +): TimelineElement[] { + const clamped = clampElementsToDuration(els, effectiveDuration); + if (!iframeDoc || effectiveDuration <= 0) return clamped; + return [ + ...clamped, + ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clamped), + ]; +} + +/** + * Drop elements that start past the composition's end and trim the ones that + * straddle it. A non-positive duration means "not known yet" — pass through + * untouched rather than clamping everything to nothing. + */ +function clampElementsToDuration( + els: readonly TimelineElement[], + effectiveDuration: number, +): TimelineElement[] { + if (effectiveDuration <= 0) return [...els]; + return els + .filter((element) => element.start < effectiveDuration) + .map((element) => ({ + ...element, + duration: Math.min(element.duration, effectiveDuration - element.start), + })) + .filter((element) => element.duration > 0); +} + +/** + * Seek a freshly-loaded adapter to the playhead the session should resume at, + * and return it. + * + * Honors a seek requested before the adapter was ready. It may sit in either + * place: `pendingSeekRef` if the store subscription was mounted when requestSeek + * fired, or only in the store's `requestedSeekTime` if it fired earlier still + * (deep-link hydration runs before the player subscription mounts, so the + * request never reaches pendingSeekRef). Reconciling with the store here is what + * makes a deep-linked `?t=` land instead of starting at 0. + * + * The double seek forces a REAL render, not a no-op. After a post-edit reload the + * freshly rebuilt GSAP timeline can already report being at `startTime` + * internally (the reload restores the same playhead), so a single + * `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time + * doesn't re-evaluate. That's why a just-dropped clip stayed invisible until the + * user nudged the playhead: its element's state was never applied at the restore + * position. Seeking to a DIFFERENT guard value first (a hair off, or 0 when + * startTime is already ~0) guarantees the follow-up seek crosses a time boundary + * and re-renders every clip — including the new one. + */ +export function resolveReloadSeekTime(input: { + pendingSeek: number | null; + requestedSeek: number | null; + storeCurrentTime: number; + duration: number; +}): number { + const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime; + if (!Number.isFinite(target) || target <= 0) return 0; + // Only clamp to duration when it's a usable positive number. A non-finite or + // non-positive duration (e.g. the adapter reports NaN mid-reload) would turn + // Math.min(target, NaN) into NaN and seek(NaN); return the guarded target + // unclamped instead so the playhead lands at the intended position. + if (!Number.isFinite(input.duration) || input.duration <= 0) return target; + return Math.min(target, input.duration); +} + +export function seekAdapterToRestorePoint( + adapter: PlaybackAdapter, + pendingSeekRef: { current: number | null }, +): number { + const storeSeek = usePlayerStore.getState().requestedSeekTime; + const startTime = resolveReloadSeekTime({ + pendingSeek: pendingSeekRef.current, + requestedSeek: storeSeek, + storeCurrentTime: usePlayerStore.getState().currentTime, + duration: adapter.getDuration(), + }); + pendingSeekRef.current = null; + if (storeSeek != null) usePlayerStore.getState().clearSeekRequest(); + adapter.seek(startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001); + adapter.seek(startTime); + return startTime; +} + +/** Push the adapter's own duration into the store, ignoring the values + * `sanitizeDurationSeconds` rejects and a value already in place. */ +export function syncAdapterDuration( + adapter: PlaybackAdapter, + setDuration: (d: number) => void, +): void { + const adapterDur = sanitizeDurationSeconds(adapter.getDuration()); + if (adapterDur > 0 && adapterDur !== usePlayerStore.getState().duration) { + setDuration(adapterDur); + } +} + +/** + * Last-resort timeline for a preview whose manifest produced nothing: parse the + * DOM, and failing that stand the root composition up as a single element. + * Without it a composition the runtime never enumerated shows an empty timeline + * rather than one row spanning its own duration. + */ +function syncFallbackTimelineFromDom( + doc: Document, + iframe: HTMLIFrameElement | null, + rootDuration: number, + syncTimelineElements: (els: TimelineElement[], duration?: number) => void, +): void { + const els = parseTimelineFromDOM(doc, rootDuration); + if (els.length > 0) { + syncTimelineElements(els); + return; + } + const rootComp = doc.querySelector("[data-composition-id]"); + if (!rootComp || rootDuration <= 0) return; + const fallbackElement = buildStandaloneRootTimelineElement({ + compositionId: rootComp.getAttribute("data-composition-id") || "composition", + tagName: (rootComp as HTMLElement).tagName || "div", + rootDuration, + iframeSrc: iframe?.src || "", + selector: getTimelineElementSelector(rootComp), + }); + if (fallbackElement) syncTimelineElements([fallbackElement]); +} + +/** The runtime's timeline message, as the preview posts it. */ +export interface RuntimeTimelineMessage { + clips: ClipManifestClip[]; + durationInFrames: number; + scenes?: Array<{ id: string; label: string; start: number; duration: number }>; + protocolVersion?: unknown; + capabilities?: unknown; + fps?: unknown; +} + +/** Whether a window message came from the preview iframe we are watching. + * A message with no `source` (jsdom, synthetic dispatch) is not rejected. */ +function isFromPreviewFrame(e: MessageEvent, iframe: HTMLIFrameElement | null): boolean { + if (!e.source || !iframe) return true; + return e.source === iframe.contentWindow; +} + +/** + * Whether a message is a preview readiness signal this listener should act on. + * + * The main message handler owns protocol-error diagnostics. This readiness-only + * listener mirrors its acceptance gate without dispatching a duplicate event: an + * unsupported runtime must not make the iframe appear successfully settled. + */ +export function isPreviewReadinessMessage( + e: MessageEvent, + iframe: HTMLIFrameElement | null, +): boolean { + if (!isFromPreviewFrame(e, iframe)) return false; + const data = e.data; + if (data?.source !== "hf-preview") return false; + if (data?.type !== "state" && data?.type !== "timeline") return false; + return inspectStudioRuntimeMessage(data).status !== "unsupported"; +} + +export interface HydrateTimelineFromPreviewInput { + iframe: HTMLIFrameElement | null; + adapter: PlaybackAdapter; + processTimelineMessage: (manifest: RuntimeTimelineMessage) => void; + enrichMissingCompositions: () => void; + applyPreviewAudioState: () => void; + attachIframeShortcutListeners: () => void; + syncTimelineElements: (els: TimelineElement[], duration?: number) => void; +} + +/** + * Everything the timeline reads off a newly-loaded preview: viewport + * normalisation, the runtime's own clip manifest, composition enrichment, audio + * state, and the DOM fallbacks when none of that produced a row. + * + * Wrapped in one try, as it always was: any of these can throw on a + * cross-origin or mid-navigation frame, and none of them is worth failing the + * adapter's initialisation over. + */ +function normalizePreviewDom( + doc: Document | null, + iframeWin: IframeWindow | null, + attachIframeShortcutListeners: () => void, +): void { + if (!doc || !iframeWin) return; + normalizePreviewViewport(doc, iframeWin); + autoHealMissingCompositionIds(doc); + attachIframeShortcutListeners(); +} + +/** Hand the runtime's own clip manifest to the timeline, if it published one. */ +function applyRuntimeClipManifest( + iframeWin: IframeWindow | null, + processTimelineMessage: (manifest: RuntimeTimelineMessage) => void, +): void { + const manifest = iframeWin?.__clipManifest; + if (manifest && manifest.clips.length > 0) processTimelineMessage(manifest); +} + +export function hydrateTimelineFromPreview(input: HydrateTimelineFromPreviewInput): void { + const { iframe, adapter, syncTimelineElements } = input; + try { + const doc = safeContentDocument(iframe); + const iframeWin = (iframe?.contentWindow as IframeWindow | null) ?? null; + normalizePreviewDom(doc, iframeWin, input.attachIframeShortcutListeners); + applyRuntimeClipManifest(iframeWin, input.processTimelineMessage); + input.enrichMissingCompositions(); + input.applyPreviewAudioState(); + if (doc && usePlayerStore.getState().elements.length === 0) { + syncFallbackTimelineFromDom(doc, iframe, adapter.getDuration(), syncTimelineElements); + } + } catch { + // Cross-origin or mid-navigation preview — the adapter is still initialised. + } +} diff --git a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts index ab95bf5b7..af63e9962 100644 --- a/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts +++ b/packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts @@ -10,26 +10,27 @@ import { useCallback } from "react"; import { liveTime, usePlayerStore } from "../store/playerStore"; -import type { TimelineElement, DomClipChild } from "../store/playerStore"; -import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; -import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups"; -import { groupInfoFor } from "../lib/timelineGroupInfo"; -import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; +import type { TimelineElement } from "../store/playerStore"; +import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes"; +import { readTimelineDurationFromDocument } from "../lib/timelineDOM"; +import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers"; +import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol"; import { - parseTimelineFromDOM, - createTimelineElementFromManifestClip, - findTimelineDomNodeForClip, - createImplicitTimelineLayersFromDOM, - buildStandaloneRootTimelineElement, - getTimelineElementSelector, - readTimelineDurationFromDocument, -} from "../lib/timelineDOM"; -import { - normalizePreviewViewport, - autoHealMissingCompositionIds, - buildMissingCompositionElements, -} from "../lib/timelineIframeHelpers"; -import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol"; + buildTimelineElementsFromClips, + clipTreeParentMap, + collectSubCompositionDomChildren, + hydrateTimelineFromPreview, + isPreviewReadinessMessage, + safeContentDocument, + sanitizeDurationSeconds, + seekAdapterToRestorePoint, + syncAdapterDuration, + withImplicitDomLayers, + type RuntimeTimelineMessage, +} from "./timelineSyncHydration"; + +// Re-exported for the tests and callers that have always imported it from here. +export { resolveReloadSeekTime } from "./timelineSyncHydration"; interface UseTimelineSyncCallbacksParams { iframeRef: React.RefObject; @@ -72,50 +73,6 @@ export function revealIframe(iframe: HTMLIFrameElement | null): void { } } -export function resolveReloadSeekTime(input: { - pendingSeek: number | null; - requestedSeek: number | null; - storeCurrentTime: number; - duration: number; -}): number { - const target = input.pendingSeek ?? input.requestedSeek ?? input.storeCurrentTime; - if (!Number.isFinite(target) || target <= 0) return 0; - // Only clamp to duration when it's a usable positive number. A non-finite or - // non-positive duration (e.g. the adapter reports NaN mid-reload) would turn - // Math.min(target, NaN) into NaN and seek(NaN); return the guarded target - // unclamped instead so the playhead lands at the intended position. - if (!Number.isFinite(input.duration) || input.duration <= 0) return target; - return Math.min(target, input.duration); -} - -/** - * A sub-comp child's audio-group membership, read off its live element. - * - * Captured during the DOM walk because that walk holds the only reference to - * the element. A sub-composition that declares both a group and its members - * keeps those members out of the flat store entirely, so an expanded child has - * no flat twin to inherit membership from later — without this, a group defined - * inside a sub-composition produced no group row at all. - */ -function readChildAudioGroupState(child: Element): Partial { - const audioGroup = child.getAttribute(HF_AUDIO_GROUP_ATTR); - if (!audioGroup) return {}; - const info = groupInfoFor(child.ownerDocument, audioGroup); - return { - audioGroup, - audioGroupLabel: info.label, - audioGroupVolume: info.volume, - audioGroupHidden: info.hidden, - ...(info.fxChain ? { audioGroupFxChain: info.fxChain } : {}), - ...(info.automation ? { audioGroupAutomation: info.automation } : {}), - }; -} - -/** Reject non-finite, non-positive, and absurdly large (loop-inflated) values. */ -function sanitizeDurationSeconds(value: number): number { - return Number.isFinite(value) && value > 0 && value < 7200 ? value : 0; -} - /** * The transport TOTAL a clip-manifest message should write to the store. * @@ -155,14 +112,7 @@ export function useTimelineSyncCallbacks({ }: UseTimelineSyncCallbacksParams) { // Convert a runtime timeline message (from iframe postMessage) into TimelineElements const processTimelineMessage = useCallback( - (data: { - clips: ClipManifestClip[]; - durationInFrames: number; - scenes?: Array<{ id: string; label: string; start: number; duration: number }>; - protocolVersion?: unknown; - capabilities?: unknown; - fps?: unknown; - }) => { + (data: RuntimeTimelineMessage) => { if (!data.clips || data.clips.length === 0) { return; } @@ -174,87 +124,18 @@ export function useTimelineSyncCallbacks({ const filtered = data.clips.filter( (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId), ); - let iframeDoc: Document | null = null; - try { - iframeDoc = iframeRef.current?.contentDocument ?? null; - } catch { - iframeDoc = null; - } + const iframeDoc = safeContentDocument(iframeRef.current); try { - const iframeWin = iframeRef.current?.contentWindow as - | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree }) - | null; - const clipTree = iframeWin?.__clipTree; - const parentMap = new Map(); - if (clipTree) { - const walk = (nodes: typeof clipTree.roots) => { - for (const node of nodes) { - if (node.id && node.parentId) parentMap.set(node.id, node.parentId); - if (node.children.length > 0) walk(node.children); - } - }; - walk(clipTree.roots); - } - - // Descend into each sub-composition host: its internal elements (group - // wrappers + their children) carry no `data-start`, so the clip - // tree/manifest never enumerate them. Surface them studio-side as DOM - // children + parent links so the timeline can expand a sub-comp/group - // row to show them. Manifest stays lean (timed clips only). - const domClipChildren: DomClipChild[] = []; - if (iframeDoc) { - for (const clip of data.clips) { - if (clip.kind !== "composition" || !clip.id) continue; - const hostEl = iframeDoc.getElementById(clip.id); - if (!hostEl) continue; - const hostId = clip.id; - const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; - // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they - // expand into timeline rows. Descends through id-less structural wrappers - // (the inlined sub-comp body), and one level into groups for drill-in. - const collect = (parentEl: Element, parentId: string) => { - for (const child of Array.from(parentEl.children)) { - if (!child.id) { - collect(child, parentId); // unwrap id-less structural containers - continue; - } - const isGroup = child.hasAttribute("data-hf-group"); - domClipChildren.push({ - id: child.id, - parentId, - hostId, - label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id, - stackingContextId: resolveCssStackingContextId(child), - ...readChildAudioGroupState(child), - }); - parentMap.set(child.id, parentId); - if (isGroup) collect(child, child.id); - } - }; - collect(innerRoot, hostId); - } - } + const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null); + const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap); usePlayerStore.getState().setClipParentMap(parentMap); usePlayerStore.getState().setDomClipChildren(domClipChildren); } catch { // cross-origin or __clipTree not available — maps stay empty } - const usedHostEls = new Set(); - const els: TimelineElement[] = filtered.map((clip, index) => { - const hostEl = iframeDoc - ? findTimelineDomNodeForClip(iframeDoc, clip, index, usedHostEls) - : null; - if (hostEl) usedHostEls.add(hostEl); - return createTimelineElementFromManifestClip({ - clip, - fallbackIndex: index, - doc: iframeDoc, - hostEl, - }); - }); - const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data); + const els = buildTimelineElementsFromClips(filtered, iframeDoc); // Clamp non-finite or absurdly large durations — the runtime can emit // Infinity when it detects a loop-inflated GSAP timeline without an // explicit data-duration on the root composition. Floor the manifest total @@ -262,27 +143,14 @@ export function useTimelineSyncCallbacks({ // furthest clip end (shorter than the authored window) can't leave a stale, // too-short total in the transport (the "0:44/0:40" bug). const newDuration = resolveTimelineTotalDuration({ - manifestDurationSeconds: rawDuration, + manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data), authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc), }); - const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration; - const clampedEls = - effectiveDuration > 0 - ? els - .filter((element) => element.start < effectiveDuration) - .map((element) => ({ - ...element, - duration: Math.min(element.duration, effectiveDuration - element.start), - })) - .filter((element) => element.duration > 0) - : els; - const timelineEls = - iframeDoc && effectiveDuration > 0 - ? [ - ...clampedEls, - ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clampedEls), - ] - : clampedEls; + const timelineEls = withImplicitDomLayers( + els, + iframeDoc, + newDuration > 0 ? newDuration : usePlayerStore.getState().duration, + ); if (timelineEls.length > 0) { syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined); } @@ -320,34 +188,7 @@ export function useTimelineSyncCallbacks({ if (!adapter || adapter.getDuration() <= 0) return false; adapter.pause(); - // Honor a seek requested before the adapter was ready. It may sit in either - // place: `pendingSeekRef` if the store subscription was mounted when requestSeek - // fired, or only in the store's `requestedSeekTime` if it fired earlier still - // (deep-link hydration runs before the player subscription mounts, so the request - // never reaches pendingSeekRef). Reconciling with the store here is what makes a - // deep-linked `?t=` land instead of starting at 0. - const storeSeek = usePlayerStore.getState().requestedSeekTime; - const startTime = resolveReloadSeekTime({ - pendingSeek: pendingSeekRef.current, - requestedSeek: storeSeek, - storeCurrentTime: usePlayerStore.getState().currentTime, - duration: adapter.getDuration(), - }); - pendingSeekRef.current = null; - if (storeSeek != null) usePlayerStore.getState().clearSeekRequest(); - - // Force a REAL render at startTime, not a no-op. After a post-edit reload the - // freshly rebuilt GSAP timeline can already report being at `startTime` - // internally (the reload restores the same playhead), so a single - // `adapter.seek(startTime)` is a GSAP no-op — `tl.seek(t)` at the current time - // doesn't re-evaluate. That's why a just-dropped clip stayed invisible until - // the user nudged the playhead: its element's state was never applied at the - // restore position. Seeking to a DIFFERENT guard value first (a hair off, or 0 - // when startTime is already ~0) guarantees the follow-up seek to `startTime` - // crosses a time boundary and re-renders every clip — including the new one. - const guardTime = startTime > 0.001 ? Math.max(0, startTime - 0.001) : 0.001; - adapter.seek(guardTime); - adapter.seek(startTime); + const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef); // The correct frame is now rendered — reveal the iframe that refreshPlayer hid // for the reload, so the user sees the restored frame directly (never the raw // all-clips DOM). Cleared unconditionally: any later failure path must not leave @@ -356,15 +197,7 @@ export function useTimelineSyncCallbacks({ // Keep non-React listeners such as the capture link and time display in sync // with the initial adapter seek on iframe load. liveTime.notify(startTime); - const adapterDur = adapter.getDuration(); - if ( - Number.isFinite(adapterDur) && - adapterDur > 0 && - adapterDur < 7200 && - adapterDur !== usePlayerStore.getState().duration - ) { - setDuration(adapterDur); - } + syncAdapterDuration(adapter, setDuration); setCurrentTime(startTime); if (!isRefreshingRef.current) { setTimelineReady(true); @@ -372,42 +205,15 @@ export function useTimelineSyncCallbacks({ isRefreshingRef.current = false; setIsPlaying(false); - try { - const iframe = iframeRef.current; - const doc = iframe?.contentDocument; - const iframeWin = iframe?.contentWindow as IframeWindow | null; - if (doc && iframeWin) { - normalizePreviewViewport(doc, iframeWin); - autoHealMissingCompositionIds(doc); - attachIframeShortcutListeners(); - } - - const manifest = iframeWin?.__clipManifest; - if (manifest && manifest.clips.length > 0) { - processTimelineMessage(manifest); - } - enrichMissingCompositions(); - applyPreviewAudioState(); - - if (usePlayerStore.getState().elements.length === 0 && doc) { - const els = parseTimelineFromDOM(doc, adapter.getDuration()); - if (els.length > 0) syncTimelineElements(els); - } - if (usePlayerStore.getState().elements.length === 0 && doc) { - const rootComp = doc.querySelector("[data-composition-id]"); - const rootDuration = adapter.getDuration(); - if (rootComp && rootDuration > 0) { - const fallbackElement = buildStandaloneRootTimelineElement({ - compositionId: rootComp.getAttribute("data-composition-id") || "composition", - tagName: (rootComp as HTMLElement).tagName || "div", - rootDuration, - iframeSrc: iframe?.src || "", - selector: getTimelineElementSelector(rootComp), - }); - if (fallbackElement) syncTimelineElements([fallbackElement]); - } - } - } catch {} + hydrateTimelineFromPreview({ + iframe: iframeRef.current, + adapter, + processTimelineMessage, + enrichMissingCompositions, + applyPreviewAudioState, + attachIframeShortcutListeners, + syncTimelineElements, + }); return true; }, [ getAdapter, @@ -447,15 +253,7 @@ export function useTimelineSyncCallbacks({ }; const onMessage = (e: MessageEvent) => { - if (e.source && iframe && e.source !== iframe.contentWindow) return; - const data = e.data; - if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) { - // The main message handler owns protocol-error diagnostics. This readiness-only - // listener mirrors its acceptance gate without dispatching a duplicate event: - // an unsupported runtime must not make the iframe appear successfully settled. - if (inspectStudioRuntimeMessage(data).status === "unsupported") return; - trySettle(); - } + if (isPreviewReadinessMessage(e, iframe)) trySettle(); }; window.addEventListener("message", onMessage);