mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user