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.
This commit is contained in:
Vance Ingalls
2026-08-20 16:40:30 -07:00
parent 939efa3f91
commit b81f494117
3 changed files with 438 additions and 246 deletions
@@ -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 });
@@ -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<DomClipChild> {
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<string, string> {
const parentMap = new Map<string, string>();
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<string, string>,
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<string, string>,
): 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<Element>();
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.
}
}
@@ -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<HTMLIFrameElement | null>;
@@ -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<DomClipChild> {
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<string, string>();
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<Element>();
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);