fix(studio): keep preview state synchronized (#3450)

* fix(core): harden audio FX and group identity

* fix(core): address audio group review feedback

* fix(core): align preview transport with grouped audio

* test(core): pin audio group gain ceiling

* fix(core): preserve solo bridge through stack

* fix(engine): harden grouped audio rendering

* docs(engine): explain grouped mix fallback invariant

* test(engine): allow grouped mixes to finish on Windows

* feat(lint): validate audio group membership and timing

* test(lint): pin audio group membership guards

* fix(studio): unify audio IDs and group state

* fix(studio): make audio-group edits transactional

* fix(studio): keep preview state synchronized
This commit is contained in:
Vance Ingalls
2026-08-23 18:10:46 -07:00
committed by GitHub
parent 8e96ccb0b2
commit 89069d24c3
24 changed files with 821 additions and 305 deletions
@@ -1,12 +1,4 @@
import { import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
type ReactNode,
} from "react";
import { useTimelinePlayer, usePlayerStore } from "../../player"; import { useTimelinePlayer, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player"; import type { TimelineElement } from "../../player";
import type { CompositionLevel } from "./CompositionBreadcrumb"; import type { CompositionLevel } from "./CompositionBreadcrumb";
@@ -16,6 +8,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload"; import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore"; import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { createStableContext } from "../../utils/hmrStableContext";
// Timeline gets a generous default height so the preview isn't oversized and the // Timeline gets a generous default height so the preview isn't oversized and the
// tracks have room to breathe (CapCut-style). Users can still drag the divider. // tracks have room to breathe (CapCut-style). Users can still drag the divider.
@@ -55,7 +48,7 @@ export interface NLEContextValue {
setPreviewCompositionSize: (size: { width: number; height: number } | null) => void; setPreviewCompositionSize: (size: { width: number; height: number } | null) => void;
} }
const NLEContext = createContext<NLEContextValue | null>(null); const NLEContext = createStableContext<NLEContextValue | null>("NLEContext", null);
export function useNLEContext(): NLEContextValue { export function useNLEContext(): NLEContextValue {
const ctx = useContext(NLEContext); const ctx = useContext(NLEContext);
@@ -156,7 +156,6 @@ export function PreviewPane({
disabled={timelineDisabled} disabled={timelineDisabled}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
previewIframeRef={iframeRef}
/> />
</div> </div>
</div> </div>
@@ -47,7 +47,11 @@ export interface TimelineEditCallbackDeps {
handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void; handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise<void> | void;
handleRazorSplitAll: (splitTime: number) => Promise<void> | void; handleRazorSplitAll: (splitTime: number) => Promise<void> | void;
/** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */ /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */
handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>; handleGroupClips?: (
clipIds: readonly string[],
groupId: string,
groupLabel?: string,
) => Promise<void>;
/** C1's single-clip FX write, addressed by the clip itself. */ /** C1's single-clip FX write, addressed by the clip itself. */
setElementFxAttribute?: { setElementFxAttribute?: {
setLive: (element: TimelineElement, attr: string, value: string | null) => void; setLive: (element: TimelineElement, attr: string, value: string | null) => void;
@@ -1,4 +1,5 @@
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"; import { useCallback, useContext, useMemo, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking"; import { trackDesignInput, type DesignInputUi } from "../utils/designInputTracking";
// Carries which inspector UI and which section the currently-rendered design-panel // Carries which inspector UI and which section the currently-rendered design-panel
@@ -11,10 +12,10 @@ interface DesignPanelInputContextValue {
section: string; section: string;
} }
const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({ const DesignPanelInputContext = createStableContext<DesignPanelInputContextValue>(
ui: "classic", "DesignPanelInputContext",
section: "unknown", { ui: "classic", section: "unknown" },
}); );
export function DesignPanelInputProvider({ export function DesignPanelInputProvider({
ui, ui,
@@ -1,6 +1,7 @@
// fallow-ignore-file code-duplication // fallow-ignore-file code-duplication
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import type { useDomEditSession } from "../hooks/useDomEditSession"; import type { useDomEditSession } from "../hooks/useDomEditSession";
import { useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
type DomEditValue = ReturnType<typeof useDomEditSession>; type DomEditValue = ReturnType<typeof useDomEditSession>;
@@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick<
| "agentPromptSelectionContext" | "agentPromptSelectionContext"
> {} > {}
const DomEditActionsContext = createContext<DomEditActionsValue | null>(null); const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
const DomEditSelectionContext = createContext<DomEditSelectionValue | null>(null); "DomEditActionsContext",
null,
);
const DomEditSelectionContext = createStableContext<DomEditSelectionValue | null>(
"DomEditSelectionContext",
null,
);
export function useDomEditActionsContext(): DomEditActionsValue { export function useDomEditActionsContext(): DomEditActionsValue {
const ctx = useContext(DomEditActionsContext); const ctx = useContext(DomEditActionsContext);
@@ -1,9 +1,10 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { useFileManager } from "../hooks/useFileManager"; import type { useFileManager } from "../hooks/useFileManager";
import { useContext, useMemo, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
type FileManagerValue = ReturnType<typeof useFileManager>; type FileManagerValue = ReturnType<typeof useFileManager>;
const FileManagerContext = createContext<FileManagerValue | null>(null); const FileManagerContext = createStableContext<FileManagerValue | null>("FileManagerContext", null);
export function useFileManagerContext(): FileManagerValue { export function useFileManagerContext(): FileManagerValue {
const ctx = useContext(FileManagerContext); const ctx = useContext(FileManagerContext);
@@ -1,9 +1,10 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { usePanelLayout } from "../hooks/usePanelLayout"; import type { usePanelLayout } from "../hooks/usePanelLayout";
import { useContext, useMemo, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
type PanelLayoutValue = ReturnType<typeof usePanelLayout>; type PanelLayoutValue = ReturnType<typeof usePanelLayout>;
const PanelLayoutContext = createContext<PanelLayoutValue | null>(null); const PanelLayoutContext = createStableContext<PanelLayoutValue | null>("PanelLayoutContext", null);
export function usePanelLayoutContext(): PanelLayoutValue { export function usePanelLayoutContext(): PanelLayoutValue {
const ctx = useContext(PanelLayoutContext); const ctx = useContext(PanelLayoutContext);
@@ -1,7 +1,8 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import type { TimelineElement } from "../player"; import type { TimelineElement } from "../player";
import type { CompositionDimensions } from "../components/renders/RenderQueue"; import type { CompositionDimensions } from "../components/renders/RenderQueue";
import type { FfmpegStatus } from "../components/renders/useFfmpegStatus"; import type { FfmpegStatus } from "../components/renders/useFfmpegStatus";
import { useContext, useMemo, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
export interface StudioShellValue { export interface StudioShellValue {
projectId: string; projectId: string;
@@ -52,8 +53,11 @@ export interface StudioPlaybackValue {
export type StudioContextValue = StudioShellValue & StudioPlaybackValue; export type StudioContextValue = StudioShellValue & StudioPlaybackValue;
const StudioShellContext = createContext<StudioShellValue | null>(null); const StudioShellContext = createStableContext<StudioShellValue | null>("StudioShellContext", null);
const StudioPlaybackContext = createContext<StudioPlaybackValue | null>(null); const StudioPlaybackContext = createStableContext<StudioPlaybackValue | null>(
"StudioPlaybackContext",
null,
);
export function useStudioShellContext(): StudioShellValue { export function useStudioShellContext(): StudioShellValue {
const ctx = useContext(StudioShellContext); const ctx = useContext(StudioShellContext);
@@ -1,7 +1,11 @@
import { createContext, useContext, useMemo, type ReactNode } from "react"; import { useContext, useMemo, type ReactNode } from "react";
import { createStableContext } from "../utils/hmrStableContext";
import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks"; import type { TimelineEditCallbacks } from "../player/components/timelineCallbacks";
const TimelineEditContext = createContext<TimelineEditCallbacks | null>(null); const TimelineEditContext = createStableContext<TimelineEditCallbacks | null>(
"TimelineEditContext",
null,
);
export function useTimelineEditContext(): TimelineEditCallbacks { export function useTimelineEditContext(): TimelineEditCallbacks {
const ctx = useContext(TimelineEditContext); const ctx = useContext(TimelineEditContext);
@@ -7,7 +7,6 @@
* and callbacks to promote or to edit the bound variable's default in place. * and callbacks to promote or to edit the bound variable's default in place.
*/ */
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { Composition, CompositionVariable } from "@hyperframes/sdk"; import type { Composition, CompositionVariable } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { import {
@@ -22,6 +21,8 @@ import {
uniqueId, uniqueId,
type PromoteChannel, type PromoteChannel,
} from "./variablePromoteHelpers"; } from "./variablePromoteHelpers";
import { useContext, useEffect, useMemo, useState } from "react";
import { createStableContext } from "../utils/hmrStableContext";
export type { PromoteChannel }; export type { PromoteChannel };
@@ -47,7 +48,10 @@ interface VariablePromoteContextValue {
onPersistError: (error: unknown) => void; onPersistError: (error: unknown) => void;
} }
const VariablePromoteContext = createContext<VariablePromoteContextValue | null>(null); const VariablePromoteContext = createStableContext<VariablePromoteContextValue | null>(
"VariablePromoteContext",
null,
);
function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null { function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null {
const snapshot = session.getElement(hfId); const snapshot = session.getElement(hfId);
@@ -1,5 +1,4 @@
import { import {
createContext,
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
@@ -8,6 +7,7 @@ import {
useState, useState,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { createStableContext } from "../utils/hmrStableContext";
/** /**
* Top-level Studio view mode. * Top-level Studio view mode.
@@ -123,7 +123,7 @@ export function useViewModeState(): ViewModeValue {
); );
} }
const ViewModeContext = createContext<ViewModeValue | null>(null); const ViewModeContext = createStableContext<ViewModeValue | null>("ViewModeContext", null);
export function useViewMode(): ViewModeValue { export function useViewMode(): ViewModeValue {
const ctx = useContext(ViewModeContext); const ctx = useContext(ViewModeContext);
@@ -0,0 +1,34 @@
/**
* Which elements a delete acts on.
*
* Its own module so `useDomEditSession.ts` stays under the studio's 600-line
* cap; it reads only its arguments.
*/
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { EditHistoryKind } from "../utils/editHistory";
/** One entry in the studio's edit history, as `useDomEditSession`'s caller
* supplies it. */
export interface RecordEditInput {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
files: Record<string, { before: string; after: string }>;
}
/**
* Which elements a delete acts on. `expandGroup` widens the primary to the
* whole marquee group, which is what the Delete key means.
*
* The caller chooses rather than the delete deciding for everyone: Cut copies
* the primary alone, so expanding for it put one element on the clipboard and
* removed every other member of the group with it.
*/
export function membersForDelete(
selection: DomEditSelection,
group: DomEditSelection[],
options?: { expandGroup?: boolean },
): DomEditSelection[] {
return options?.expandGroup && group.length > 0 ? group : [selection];
}
@@ -9,6 +9,8 @@ import type { PersistDomEditOperations } from "./domEditCommitTypes";
import { reportDomEditPersistFailure } from "./domEditPersistFailure"; import { reportDomEditPersistFailure } from "./domEditPersistFailure";
import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner"; import { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync"; import { syncStoredAutomationFromPreview } from "../player/lib/automationStoreSync";
import { HF_AUDIO_GROUP_ATTR, HF_AUDIO_GROUP_TAG } from "@hyperframes/core/audio-groups";
import { invalidateGroupInfoCache } from "../player/lib/timelineGroupInfo";
// ── Types ── // ── Types ──
@@ -63,6 +65,17 @@ function setOrRemovePreviewAttribute(
} else { } else {
el.setAttribute(fullAttr, value); el.setAttribute(fullAttr, value);
} }
// Every DOM-edit attribute write funnels through here, which is the only
// place that can catch a group edit made from the rack rather than from the
// group header — `openGroupFxRack` hands the `<hf-audio-group>` to the DOM
// editor, and that path never went near the timeline's own writers.
//
// The group element itself OR a member's membership attribute: writing
// `data-audio-group` onto an `<audio>` moves it between groups, which changes
// the answer just as much as editing the bus does.
if (el.tagName.toLowerCase() === HF_AUDIO_GROUP_TAG || fullAttr === HF_AUDIO_GROUP_ATTR) {
invalidateGroupInfoCache(el.ownerDocument);
}
} }
function findPreviewAttributeElement( function findPreviewAttributeElement(
@@ -58,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
fn: undefined, fn: undefined,
}; };
const domEditSelectionRef: { current: DomEditSelection | null } = { current: null }; const domEditSelectionRef: { current: DomEditSelection | null } = { current: null };
const domEditGroupSelectionsRef: { current: DomEditSelection[] } = { current: [] };
const groupSelectionSpy = vi.fn();
const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() }); const gsapCommitMutation = Object.assign(vi.fn(), { batch: vi.fn() });
function createSessionParams( function createSessionParams(
@@ -131,7 +133,7 @@ vi.mock("./useDomSelection", () => ({
domEditHoverSelection: null, domEditHoverSelection: null,
activeGroupElement: null, activeGroupElement: null,
domEditSelectionRef, domEditSelectionRef,
domEditGroupSelectionsRef: { current: [] }, domEditGroupSelectionsRef,
setActiveGroupElement: vi.fn(), setActiveGroupElement: vi.fn(),
applyDomSelection: vi.fn(), applyDomSelection: vi.fn(),
clearDomSelection: vi.fn(), clearDomSelection: vi.fn(),
@@ -190,7 +192,7 @@ vi.mock("./useGsapScriptCommits", () => ({
})); }));
vi.mock("./useGroupCommits", () => ({ vi.mock("./useGroupCommits", () => ({
useGroupCommits: () => ({ useGroupCommits: () => ({
groupSelection: vi.fn(), groupSelection: (...args: unknown[]) => groupSelectionSpy(...args),
ungroupSelection: vi.fn(), ungroupSelection: vi.fn(),
}), }),
})); }));
@@ -407,3 +409,56 @@ describe("bulk segment ease commits", () => {
} }
}); });
}); });
// ── Grouping refuses audio ───────────────────────────────────────────────────
//
// A layout group is a positioned wrapper: it takes the members' bounding box,
// rebases each child's left/top against it and adopts the topmost z-index. An
// <audio> clip has no box — offsetWidth/Height are 0 — so this produced a 0x0
// div with inline left/top on elements that are never laid out. Enforced here
// rather than only by hiding the button, because the G shortcut routes through
// the same handler and no hidden button can gate a keystroke.
describe("handleGroupSelection with audio in the selection", () => {
const sel = (tag: string): DomEditSelection =>
({
id: tag,
element: document.createElement(tag),
sourceFile: "index.html",
}) as unknown as DomEditSelection;
async function group(members: DomEditSelection[]) {
const { useDomEditSession } = await import("./useDomEditSession");
groupSelectionSpy.mockClear();
domEditGroupSelectionsRef.current = members;
const showToast = vi.fn();
const captured: { fn?: () => void } = {};
function Probe() {
captured.fn = useDomEditSession(createSessionParams({ showToast })).handleGroupSelection;
return null;
}
const root = createRoot(document.createElement("div"));
act(() => root.render(<Probe />));
act(() => captured.fn?.());
act(() => root.unmount());
domEditGroupSelectionsRef.current = [];
return { showToast };
}
it("refuses a selection of audio clips, and says where grouping audio lives", async () => {
const { showToast } = await group([sel("audio"), sel("audio")]);
expect(groupSelectionSpy).not.toHaveBeenCalled();
expect(String(showToast.mock.calls[0]?.[0])).toContain("bus");
});
it("refuses a mixed selection, since the wrapper would take the audio in too", async () => {
const { showToast } = await group([sel("div"), sel("audio")]);
expect(groupSelectionSpy).not.toHaveBeenCalled();
expect(String(showToast.mock.calls[0]?.[0])).toContain("layout");
});
it("still groups a selection of layout elements", async () => {
await group([sel("div"), sel("span")]);
expect(groupSelectionSpy).toHaveBeenCalledTimes(1);
});
});
+23 -24
View File
@@ -1,8 +1,8 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { trackStudioEvent } from "../utils/studioTelemetry"; import { trackStudioEvent } from "../utils/studioTelemetry";
import { isAudioDomElement } from "../utils/timelineInspector";
import type { SelectElementOptions, TimelineElement } from "../player"; import type { SelectElementOptions, TimelineElement } from "../player";
import type { ImportedFontAsset } from "../components/editor/fontAssets"; import type { ImportedFontAsset } from "../components/editor/fontAssets";
import type { EditHistoryKind } from "../utils/editHistory";
import type { RightPanelTab } from "../utils/studioHelpers"; import type { RightPanelTab } from "../utils/studioHelpers";
import type { PatchTarget } from "../utils/sourcePatcher"; import type { PatchTarget } from "../utils/sourcePatcher";
import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import type { SidebarTab } from "../components/sidebar/LeftSidebar";
@@ -21,13 +21,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher"; import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits"; import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { membersForDelete } from "./domEditDeleteMembers";
interface RecordEditInput { import type { RecordEditInput } from "./domEditDeleteMembers";
label: string; // Re-exported: the delete rule lives in its own module now, and callers (and its
kind: EditHistoryKind; // own test) have always imported it from here.
coalesceKey?: string; export { membersForDelete };
files: Record<string, { before: string; after: string }>;
}
export interface UseDomEditSessionParams { export interface UseDomEditSessionParams {
projectId: string | null; projectId: string | null;
@@ -73,22 +71,6 @@ export interface UseDomEditSessionParams {
forceReloadSdkSession?: () => void; forceReloadSdkSession?: () => void;
} }
/**
* Which elements a delete acts on. `expandGroup` widens the primary to the
* whole marquee group, which is what the Delete key means.
*
* The caller chooses rather than the delete deciding for everyone: Cut copies
* the primary alone, so expanding for it put one element on the clipboard and
* removed every other member of the group with it.
*/
export function membersForDelete(
selection: DomEditSelection,
group: DomEditSelection[],
options?: { expandGroup?: boolean },
): DomEditSelection[] {
return options?.expandGroup && group.length > 0 ? group : [selection];
}
export function useDomEditSession({ export function useDomEditSession({
projectId, projectId,
activeCompPath, activeCompPath,
@@ -374,6 +356,23 @@ export function useDomEditSession({
showToast("Select at least 2 elements to group", "info"); showToast("Select at least 2 elements to group", "info");
return; return;
} }
// A layout group is a positioned wrapper: it takes the members' bounding
// box, rebases each child's left/top against it, and adopts the topmost
// z-index. An <audio> clip has no box — offsetWidth/Height are 0 — so
// grouping audio produced a 0x0 div with inline left/top written onto
// elements that have never been laid out, and the timeline gained a
// wrapper standing for nothing audible. The audio answer to "these clips
// belong together" is an <hf-audio-group> bus, which the timeline's own FX
// pointer creates, so the refusal names it rather than just declining.
if (members.some((m) => isAudioDomElement(m.element))) {
showToast(
members.every((m) => isAudioDomElement(m.element))
? "Audio clips group into a bus — use FX on the track header"
: "Can't group audio clips with layout elements",
"info",
);
return;
}
trackStudioEvent("group", { action: "create", count: members.length }); trackStudioEvent("group", { action: "create", count: members.length });
void groupSelection(members); void groupSelection(members);
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]); }, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
@@ -1,5 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { TimelineElement } from "../player/store/timelineElement"; import type { TimelineElement } from "../player/store/timelineElement";
import { getEffectiveTimelineDuration } from "../player/components/timelineViewModel";
/** /**
* The stored `duration` lags a moment behind an edit that pushes an element * The stored `duration` lags a moment behind an edit that pushes an element
@@ -10,11 +11,12 @@ export function useEffectiveTimelineDuration(
timelineDuration: number, timelineDuration: number,
timelineElements: readonly TimelineElement[], timelineElements: readonly TimelineElement[],
): number { ): number {
return useMemo(() => { // Delegates to `getEffectiveTimelineDuration` rather than restating the
const maxEnd = // arithmetic: that one guards a non-finite stored duration and a non-finite
timelineElements.length > 0 // result (an element with NaN timing), which this copy did not — it would
? Math.max(...timelineElements.map((el) => el.start + el.duration)) // return NaN and every downstream width became NaN with it.
: 0; return useMemo(
return Math.max(timelineDuration, maxEnd); () => getEffectiveTimelineDuration(timelineDuration, timelineElements),
}, [timelineDuration, timelineElements]); [timelineDuration, timelineElements],
);
} }
@@ -12,7 +12,9 @@
* dragged as well as while it is playing. * dragged as well as while it is playing.
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { liveTime, usePlayerStore } from "../player"; // The store's own module, not the `player` barrel: the barrel pulls the whole
// timeline in, and a timeline component importing this hook closes a cycle.
import { liveTime, usePlayerStore } from "../player/store/playerStore";
/** Long enough to be much cheaper than a frame, short enough to read as motion. */ /** Long enough to be much cheaper than a frame, short enough to read as motion. */
const THROTTLE_MS = 33; const THROTTLE_MS = 33;
@@ -3,16 +3,16 @@
* *
* Extracted from `useTimelinePlayer`, which had grown past the studio's 600-line * Extracted from `useTimelinePlayer`, which had grown past the studio's 600-line
* file cap and carried a `fallow-ignore-next-line complexity` on this function * file cap and carried a `fallow-ignore-next-line complexity` on this function
* admitting the same thing. Nothing here is new logic — it is the same four * admitting the same thing. Nothing here is new logic — it is the same three
* branches (accept-gate, group levels, state, timeline) against the same refs, * branches (accept-gate, state, timeline) against the same refs, with the
* with the suppression retired rather than moved. * suppression retired rather than moved. The group-levels branch went with the
* level meter it fed (see the group volume/meter removal).
*/ */
import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes"; import type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture"; import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol"; import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
import { parseTimelineFromDOM } from "../lib/timelineDOM"; import { parseTimelineFromDOM } from "../lib/timelineDOM";
/** What `processTimelineMessage` accepts — the clip-manifest postMessage. */ /** What `processTimelineMessage` accepts — the clip-manifest postMessage. */
@@ -60,12 +60,6 @@ function acceptedPreviewMessage(
return acceptStudioRuntimeMessage(data) ? data : null; return acceptStudioRuntimeMessage(data) ? data : null;
} }
/** One meter reading per group with an active member. */
function handleGroupLevelsMessage(data: PreviewMessage): void {
const levels = parseGroupLevelsMessage(data);
if (levels) groupLevels.notify(levels);
}
/** /**
* A `state` tick doubles as a recovery hook: if the store still has no * A `state` tick doubles as a recovery hook: if the store still has no
* elements, read the manifest straight off the iframe, and if no `timeline` * elements, read the manifest straight off the iframe, and if no `timeline`
@@ -118,7 +112,6 @@ export function createPreviewMessageHandler(
const iframe = deps.iframeRef.current; const iframe = deps.iframeRef.current;
const data = acceptedPreviewMessage(e, iframe); const data = acceptedPreviewMessage(e, iframe);
if (!data) return; if (!data) return;
if (data.type === "group-levels") return handleGroupLevelsMessage(data);
if (data.type === "state") return handleStateMessage(deps, iframe); if (data.type === "state") return handleStateMessage(deps, iframe);
if (data.type === "timeline" && Array.isArray(data.clips)) { if (data.type === "timeline" && Array.isArray(data.clips)) {
handleTimelineMessage(deps, iframe, data as unknown as ClipManifestMessage); handleTimelineMessage(deps, iframe, data as unknown as ClipManifestMessage);
@@ -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.
}
}
@@ -645,4 +645,87 @@ describe("buildExpandedElements — collision-free synthetic rows (cross-file la
// Distinct ordered rows per child. // Distinct ordered rows per child.
expect(children[0].track).not.toBe(children[1].track); expect(children[0].track).not.toBe(children[1].track);
}); });
/**
* A sub-composition that declares BOTH a group and its members keeps those
* members out of the flat store entirely — the store holds only the host.
* So "inherit membership from the flat twin" had nothing to inherit from,
* and the group produced no timeline row at all, for exactly the case group
* support was extended to cover. Verified against a real studio session
* before this test was written: the flat store held three elements (the
* panel, the sub-comp host and an ungrouped bed) and neither voice.
*/
it("takes group membership from the DOM child when there is no flat store twin", () => {
const elements = [
el({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
];
const manifest = [
clip({ id: "voices-host", start: 0, duration: 12, compositionSrc: "voices.html" }),
];
const parentMap = new Map([
["voice-1", "voices-host"],
["voice-2", "voices-host"],
]);
const domClipChildren = [
{
id: "voice-1",
parentId: "voices-host",
hostId: "voices-host",
label: "voice-1",
stackingContextId: "css:0",
audioGroup: "voiceover",
audioGroupLabel: "Voiceover",
audioGroupVolume: 0.8,
audioGroupHidden: false,
},
{
id: "voice-2",
parentId: "voices-host",
hostId: "voices-host",
label: "voice-2",
stackingContextId: "css:0",
audioGroup: "voiceover",
audioGroupLabel: "Voiceover",
audioGroupVolume: 0.8,
audioGroupHidden: false,
},
];
const out = buildExpandedElements(
elements,
manifest,
parentMap,
"voices-host",
"voices-host",
domClipChildren,
);
const voices = out.filter((e) => e.domId?.startsWith("voice-"));
expect(voices).toHaveLength(2);
for (const voice of voices) {
expect(voice.audioGroup).toBe("voiceover");
expect(voice.audioGroupLabel).toBe("Voiceover");
expect(voice.audioGroupVolume).toBeCloseTo(0.8, 6);
}
});
});
describe("sub-comp child rows never collide with a group anchor", () => {
/**
* A group row anchors at exactly `firstMemberTrack - 0.5`. The old child
* scheme `k / (n + 2)` hit 0.5 dead on for a host with TWO children (2/4),
* producing a duplicate row key and a duplicated group header.
*/
it("keeps every child strictly below the host's half-lane", () => {
for (const childCount of [1, 2, 3, 4, 7]) {
const fractions = Array.from(
{ length: childCount },
(_unused, i) => (0.5 * (i + 1)) / (childCount + 1),
);
expect(fractions.every((f) => f > 0 && f < 0.5)).toBe(true);
// Still distinct and ordered, which is what makes them usable as rows.
expect(new Set(fractions).size).toBe(childCount);
expect([...fractions].sort((a, b) => a - b)).toEqual(fractions);
}
});
}); });
@@ -143,6 +143,32 @@ interface DisplayBounds {
* could never be shown again (not even after a reload, since the attribute is in * could never be shown again (not even after a reload, since the attribute is in
* the source). * the source).
*/ */
/**
* Audio-group membership for an expanded child, from whichever source has it.
*
* The flat store twin when there is one; otherwise the `DomClipChild` record,
* which carried it off the live element during the DOM walk. That fallback is
* the ONLY source for a sub-composition that declares both a group and its
* members: those members never enter the flat store, so "inherit from the flat
* twin" silently produced no membership and therefore no group row for
* exactly the case group support was extended to cover.
*/
function childGroupState(
flat: TimelineElement | undefined,
domChild: DomClipChild | undefined,
): Partial<TimelineElement> {
const source = flat?.audioGroup ? flat : domChild?.audioGroup ? domChild : null;
if (!source) return {};
return {
audioGroup: source.audioGroup,
audioGroupLabel: source.audioGroupLabel,
audioGroupVolume: source.audioGroupVolume,
audioGroupHidden: source.audioGroupHidden,
audioGroupFxChain: source.audioGroupFxChain,
audioGroupAutomation: source.audioGroupAutomation,
};
}
function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> { function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
if (!flat) return {}; if (!flat) return {};
return { return {
@@ -169,6 +195,7 @@ function buildChildElements(
editBasis: { start: number; sourceFile: string | undefined }, editBasis: { start: number; sourceFile: string | undefined },
expandedHostKey: string, expandedHostKey: string,
elements: readonly TimelineElement[], elements: readonly TimelineElement[],
domChildrenById: ReadonlyMap<string, DomClipChild>,
): TimelineElement[] { ): TimelineElement[] {
const result: TimelineElement[] = []; const result: TimelineElement[] = [];
for (const child of siblings) { for (const child of siblings) {
@@ -197,6 +224,10 @@ function buildChildElements(
result.push({ result.push({
...base, ...base,
...hostElementState(elements.find((element) => element.key === key)), ...hostElementState(elements.find((element) => element.key === key)),
...childGroupState(
elements.find((element) => element.key === key),
domId ? domChildrenById.get(domId) : undefined,
),
key, key,
start: clamped.start, start: clamped.start,
duration: clamped.duration, duration: clamped.duration,
@@ -215,7 +246,13 @@ function buildChildElements(
// clips. Fractions strictly between the host's lane and the next integer // clips. Fractions strictly between the host's lane and the next integer
// can never equal a normalized (integer) lane, while still rendering the // can never equal a normalized (integer) lane, while still rendering the
// children as their own ordered rows directly under the host. // children as their own ordered rows directly under the host.
track: display.track + (result.length + 1) / (siblings.length + 2), //
// Confined to the LOWER half of that gap, because a GROUP row anchors at
// exactly `firstMemberTrack - 0.5` (`useTimelineTrackDerivations`) — and
// the old `k / (n + 2)` hit 0.5 dead on for a host with two children
// (2/4), producing a duplicate row key and a duplicated group header. This
// scheme's maximum is `0.5 * n / (n + 1)`, strictly under 0.5 for every n.
track: display.track + (0.5 * (result.length + 1)) / (siblings.length + 1),
authoredTrack: base.authoredTrack, authoredTrack: base.authoredTrack,
stackingContextId: base.stackingContextId, stackingContextId: base.stackingContextId,
expandedParentStart: editBasis.start, expandedParentStart: editBasis.start,
@@ -299,6 +336,7 @@ export function buildExpandedElements(
}; };
const parentKey = topLevelElement.key ?? topLevelElement.id; const parentKey = topLevelElement.key ?? topLevelElement.id;
const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child]));
const expanded = buildChildElements( const expanded = buildChildElements(
siblings, siblings,
{ {
@@ -309,6 +347,7 @@ export function buildExpandedElements(
editBasis, editBasis,
parentKey, parentKey,
elements, elements,
domChildrenById,
); );
if (expanded.length === 0) return filterToTopLevel(elements, parentMap); if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
@@ -37,11 +37,7 @@ import {
mergeTimelineElementsPreservingDowngrades, mergeTimelineElementsPreservingDowngrades,
} from "../lib/timelineDOM"; } from "../lib/timelineDOM";
import { normalizeToZones } from "../components/timelineZones"; import { normalizeToZones } from "../components/timelineZones";
import { import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
setPreviewMediaMuted,
setPreviewMediaVolume,
setPreviewPlaybackRate,
} from "../lib/timelineIframeHelpers";
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub"; import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture"; import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe"; import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
@@ -235,8 +231,7 @@ export function useTimelinePlayer() {
}, []); }, []);
const applyPreviewAudioState = useCallback(() => { const applyPreviewAudioState = useCallback(() => {
const { audioMuted, audioVolume } = usePlayerStore.getState(); const { audioMuted, audioVolume } = usePlayerStore.getState();
setPreviewMediaMuted(iframeRef.current, audioMuted); applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
setPreviewMediaVolume(iframeRef.current, audioVolume);
}, []); }, []);
const play = useCallback(() => { const play = useCallback(() => {
stopRAFLoop(); stopRAFLoop();
@@ -10,24 +10,27 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { liveTime, usePlayerStore } from "../store/playerStore"; import { liveTime, usePlayerStore } from "../store/playerStore";
import type { TimelineElement, DomClipChild } from "../store/playerStore"; import type { TimelineElement } from "../store/playerStore";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes"; import { readTimelineDurationFromDocument } from "../lib/timelineDOM";
import { buildMissingCompositionElements } from "../lib/timelineIframeHelpers";
import { acceptedRuntimeMessageFps } from "../lib/runtimeProtocol";
import { import {
parseTimelineFromDOM, buildTimelineElementsFromClips,
createTimelineElementFromManifestClip, clipTreeParentMap,
findTimelineDomNodeForClip, collectSubCompositionDomChildren,
createImplicitTimelineLayersFromDOM, hydrateTimelineFromPreview,
buildStandaloneRootTimelineElement, isPreviewReadinessMessage,
getTimelineElementSelector, safeContentDocument,
readTimelineDurationFromDocument, sanitizeDurationSeconds,
} from "../lib/timelineDOM"; seekAdapterToRestorePoint,
import { syncAdapterDuration,
normalizePreviewViewport, withImplicitDomLayers,
autoHealMissingCompositionIds, type RuntimeTimelineMessage,
buildMissingCompositionElements, } from "./timelineSyncHydration";
} from "../lib/timelineIframeHelpers";
import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol"; // Re-exported for the tests and callers that have always imported it from here.
export { resolveReloadSeekTime } from "./timelineSyncHydration";
interface UseTimelineSyncCallbacksParams { interface UseTimelineSyncCallbacksParams {
iframeRef: React.RefObject<HTMLIFrameElement | null>; iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -70,27 +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);
}
/** 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. * The transport TOTAL a clip-manifest message should write to the store.
* *
@@ -130,14 +112,7 @@ export function useTimelineSyncCallbacks({
}: UseTimelineSyncCallbacksParams) { }: UseTimelineSyncCallbacksParams) {
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback( const processTimelineMessage = useCallback(
(data: { (data: RuntimeTimelineMessage) => {
clips: ClipManifestClip[];
durationInFrames: number;
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
protocolVersion?: unknown;
capabilities?: unknown;
fps?: unknown;
}) => {
if (!data.clips || data.clips.length === 0) { if (!data.clips || data.clips.length === 0) {
return; return;
} }
@@ -149,86 +124,18 @@ export function useTimelineSyncCallbacks({
const filtered = data.clips.filter( const filtered = data.clips.filter(
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId), (clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
); );
let iframeDoc: Document | null = null; const iframeDoc = safeContentDocument(iframeRef.current);
try {
iframeDoc = iframeRef.current?.contentDocument ?? null;
} catch {
iframeDoc = null;
}
try { try {
const iframeWin = iframeRef.current?.contentWindow as const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
| (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree }) const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
| 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),
});
parentMap.set(child.id, parentId);
if (isGroup) collect(child, child.id);
}
};
collect(innerRoot, hostId);
}
}
usePlayerStore.getState().setClipParentMap(parentMap); usePlayerStore.getState().setClipParentMap(parentMap);
usePlayerStore.getState().setDomClipChildren(domClipChildren); usePlayerStore.getState().setDomClipChildren(domClipChildren);
} catch { } catch {
// cross-origin or __clipTree not available — maps stay empty // cross-origin or __clipTree not available — maps stay empty
} }
const usedHostEls = new Set<Element>(); const els = buildTimelineElementsFromClips(filtered, iframeDoc);
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);
// Clamp non-finite or absurdly large durations — the runtime can emit // Clamp non-finite or absurdly large durations — the runtime can emit
// Infinity when it detects a loop-inflated GSAP timeline without an // Infinity when it detects a loop-inflated GSAP timeline without an
// explicit data-duration on the root composition. Floor the manifest total // explicit data-duration on the root composition. Floor the manifest total
@@ -236,27 +143,14 @@ export function useTimelineSyncCallbacks({
// furthest clip end (shorter than the authored window) can't leave a stale, // 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). // too-short total in the transport (the "0:44/0:40" bug).
const newDuration = resolveTimelineTotalDuration({ const newDuration = resolveTimelineTotalDuration({
manifestDurationSeconds: rawDuration, manifestDurationSeconds: data.durationInFrames / acceptedRuntimeMessageFps(data),
authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc), authoredRootDurationSeconds: readTimelineDurationFromDocument(iframeDoc),
}); });
const effectiveDuration = newDuration > 0 ? newDuration : usePlayerStore.getState().duration; const timelineEls = withImplicitDomLayers(
const clampedEls = els,
effectiveDuration > 0 iframeDoc,
? els newDuration > 0 ? newDuration : usePlayerStore.getState().duration,
.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;
if (timelineEls.length > 0) { if (timelineEls.length > 0) {
syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined); syncTimelineElements(timelineEls, newDuration > 0 ? newDuration : undefined);
} }
@@ -294,34 +188,7 @@ export function useTimelineSyncCallbacks({
if (!adapter || adapter.getDuration() <= 0) return false; if (!adapter || adapter.getDuration() <= 0) return false;
adapter.pause(); adapter.pause();
// Honor a seek requested before the adapter was ready. It may sit in either const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
// 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);
// The correct frame is now rendered — reveal the iframe that refreshPlayer hid // 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 // 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 // all-clips DOM). Cleared unconditionally: any later failure path must not leave
@@ -330,15 +197,7 @@ export function useTimelineSyncCallbacks({
// Keep non-React listeners such as the capture link and time display in sync // Keep non-React listeners such as the capture link and time display in sync
// with the initial adapter seek on iframe load. // with the initial adapter seek on iframe load.
liveTime.notify(startTime); liveTime.notify(startTime);
const adapterDur = adapter.getDuration(); syncAdapterDuration(adapter, setDuration);
if (
Number.isFinite(adapterDur) &&
adapterDur > 0 &&
adapterDur < 7200 &&
adapterDur !== usePlayerStore.getState().duration
) {
setDuration(adapterDur);
}
setCurrentTime(startTime); setCurrentTime(startTime);
if (!isRefreshingRef.current) { if (!isRefreshingRef.current) {
setTimelineReady(true); setTimelineReady(true);
@@ -346,42 +205,15 @@ export function useTimelineSyncCallbacks({
isRefreshingRef.current = false; isRefreshingRef.current = false;
setIsPlaying(false); setIsPlaying(false);
try { hydrateTimelineFromPreview({
const iframe = iframeRef.current; iframe: iframeRef.current,
const doc = iframe?.contentDocument; adapter,
const iframeWin = iframe?.contentWindow as IframeWindow | null; processTimelineMessage,
if (doc && iframeWin) { enrichMissingCompositions,
normalizePreviewViewport(doc, iframeWin); applyPreviewAudioState,
autoHealMissingCompositionIds(doc); attachIframeShortcutListeners,
attachIframeShortcutListeners(); syncTimelineElements,
} });
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 {}
return true; return true;
}, [ }, [
getAdapter, getAdapter,
@@ -421,15 +253,7 @@ export function useTimelineSyncCallbacks({
}; };
const onMessage = (e: MessageEvent) => { const onMessage = (e: MessageEvent) => {
if (e.source && iframe && e.source !== iframe.contentWindow) return; if (isPreviewReadinessMessage(e, iframe)) trySettle();
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();
}
}; };
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
@@ -0,0 +1,64 @@
/**
* `createContext`, but stable across Vite HMR re-evaluations.
*
* A module-scope `createContext()` mints a NEW context object every time its
* module is re-evaluated. HMR re-evaluates modules one at a time, so a context
* module can be replaced while the components consuming it still hold the old
* object the provider then fills context A while the consumer reads context
* B, gets `null`, and a `useX must be used within an XProvider` guard throws.
*
* The symptom is unmistakable and misleading: the React component stack shows
* the consumer nested INSIDE the very provider it claims to be missing. It
* crashed the studio on edits to files nowhere near the context anything that
* propagated an HMR boundary up to it was enough.
*
* Keying the context on `globalThis` by a stable name makes the second
* evaluation reuse the first object, so old and new modules agree. Production
* builds evaluate once, where this is an ordinary `createContext` with a map
* lookup in front of it.
*/
import { createContext, type Context } from "react";
const REGISTRY = "__hfStudioContexts";
type Registry = Map<string, Context<unknown>>;
/** Default registered per name, so a genuine collision can be told from an HMR
* re-evaluation (which re-registers the same default). */
const seenNames = new Map<string, unknown>();
function registry(): Registry {
const host = globalThis as unknown as Record<string, Registry | undefined>;
const existing = host[REGISTRY];
if (existing) return existing;
const created: Registry = new Map();
host[REGISTRY] = created;
return created;
}
/**
* `name` must be unique per context and stable across reloads the module path
* plus the export name is the convention here.
*/
export function createStableContext<T>(name: string, defaultValue: T): Context<T> {
const store = registry();
// A collision is silent and its symptom is remote: two modules asking for the
// same name share ONE context, so one provider's value is read by the other's
// consumers and the bug surfaces as a wrong value far from either file. The
// convention is module path + export name; this makes breaking it loud.
if (store.has(name) && seenNames.get(name) !== defaultValue) {
// Not on the HMR path: a re-evaluated module hands back the SAME default it
// registered, which is how a hot reload keeps its context alive.
console.warn(
`[hmrStableContext] "${name}" was registered twice with different defaults — ` +
"two contexts are sharing one identity. Use module path + export name.",
);
}
seenNames.set(name, defaultValue);
const existing = store.get(name);
if (existing) return existing as Context<T>;
const created = createContext<T>(defaultValue);
store.set(name, created as Context<unknown>);
return created;
}