mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +00:00
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:
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
||||
import { useTimelinePlayer, usePlayerStore } from "../../player";
|
||||
import type { TimelineElement } from "../../player";
|
||||
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
||||
@@ -16,6 +8,7 @@ import { setCompositionSourceMap } from "../editor/domEditingDom";
|
||||
import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
|
||||
import { createStableContext } from "../../utils/hmrStableContext";
|
||||
|
||||
// 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.
|
||||
@@ -55,7 +48,7 @@ export interface NLEContextValue {
|
||||
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 {
|
||||
const ctx = useContext(NLEContext);
|
||||
|
||||
@@ -156,7 +156,6 @@ export function PreviewPane({
|
||||
disabled={timelineDisabled}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
previewIframeRef={iframeRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,11 @@ export interface TimelineEditCallbackDeps {
|
||||
handleRazorSplit: (element: TimelineElement, 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. */
|
||||
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. */
|
||||
setElementFxAttribute?: {
|
||||
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";
|
||||
|
||||
// Carries which inspector UI and which section the currently-rendered design-panel
|
||||
@@ -11,10 +12,10 @@ interface DesignPanelInputContextValue {
|
||||
section: string;
|
||||
}
|
||||
|
||||
const DesignPanelInputContext = createContext<DesignPanelInputContextValue>({
|
||||
ui: "classic",
|
||||
section: "unknown",
|
||||
});
|
||||
const DesignPanelInputContext = createStableContext<DesignPanelInputContextValue>(
|
||||
"DesignPanelInputContext",
|
||||
{ ui: "classic", section: "unknown" },
|
||||
);
|
||||
|
||||
export function DesignPanelInputProvider({
|
||||
ui,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
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>;
|
||||
|
||||
@@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick<
|
||||
| "agentPromptSelectionContext"
|
||||
> {}
|
||||
|
||||
const DomEditActionsContext = createContext<DomEditActionsValue | null>(null);
|
||||
const DomEditSelectionContext = createContext<DomEditSelectionValue | null>(null);
|
||||
const DomEditActionsContext = createStableContext<DomEditActionsValue | null>(
|
||||
"DomEditActionsContext",
|
||||
null,
|
||||
);
|
||||
const DomEditSelectionContext = createStableContext<DomEditSelectionValue | null>(
|
||||
"DomEditSelectionContext",
|
||||
null,
|
||||
);
|
||||
|
||||
export function useDomEditActionsContext(): DomEditActionsValue {
|
||||
const ctx = useContext(DomEditActionsContext);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { useFileManager } from "../hooks/useFileManager";
|
||||
import { useContext, useMemo, type ReactNode } from "react";
|
||||
import { createStableContext } from "../utils/hmrStableContext";
|
||||
|
||||
type FileManagerValue = ReturnType<typeof useFileManager>;
|
||||
|
||||
const FileManagerContext = createContext<FileManagerValue | null>(null);
|
||||
const FileManagerContext = createStableContext<FileManagerValue | null>("FileManagerContext", null);
|
||||
|
||||
export function useFileManagerContext(): FileManagerValue {
|
||||
const ctx = useContext(FileManagerContext);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { usePanelLayout } from "../hooks/usePanelLayout";
|
||||
import { useContext, useMemo, type ReactNode } from "react";
|
||||
import { createStableContext } from "../utils/hmrStableContext";
|
||||
|
||||
type PanelLayoutValue = ReturnType<typeof usePanelLayout>;
|
||||
|
||||
const PanelLayoutContext = createContext<PanelLayoutValue | null>(null);
|
||||
const PanelLayoutContext = createStableContext<PanelLayoutValue | null>("PanelLayoutContext", null);
|
||||
|
||||
export function usePanelLayoutContext(): PanelLayoutValue {
|
||||
const ctx = useContext(PanelLayoutContext);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import type { TimelineElement } from "../player";
|
||||
import type { CompositionDimensions } from "../components/renders/RenderQueue";
|
||||
import type { FfmpegStatus } from "../components/renders/useFfmpegStatus";
|
||||
import { useContext, useMemo, type ReactNode } from "react";
|
||||
import { createStableContext } from "../utils/hmrStableContext";
|
||||
|
||||
export interface StudioShellValue {
|
||||
projectId: string;
|
||||
@@ -52,8 +53,11 @@ export interface StudioPlaybackValue {
|
||||
|
||||
export type StudioContextValue = StudioShellValue & StudioPlaybackValue;
|
||||
|
||||
const StudioShellContext = createContext<StudioShellValue | null>(null);
|
||||
const StudioPlaybackContext = createContext<StudioPlaybackValue | null>(null);
|
||||
const StudioShellContext = createStableContext<StudioShellValue | null>("StudioShellContext", null);
|
||||
const StudioPlaybackContext = createStableContext<StudioPlaybackValue | null>(
|
||||
"StudioPlaybackContext",
|
||||
null,
|
||||
);
|
||||
|
||||
export function useStudioShellContext(): StudioShellValue {
|
||||
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";
|
||||
|
||||
const TimelineEditContext = createContext<TimelineEditCallbacks | null>(null);
|
||||
const TimelineEditContext = createStableContext<TimelineEditCallbacks | null>(
|
||||
"TimelineEditContext",
|
||||
null,
|
||||
);
|
||||
|
||||
export function useTimelineEditContext(): TimelineEditCallbacks {
|
||||
const ctx = useContext(TimelineEditContext);
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* 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 { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
@@ -22,6 +21,8 @@ import {
|
||||
uniqueId,
|
||||
type PromoteChannel,
|
||||
} from "./variablePromoteHelpers";
|
||||
import { useContext, useEffect, useMemo, useState } from "react";
|
||||
import { createStableContext } from "../utils/hmrStableContext";
|
||||
|
||||
export type { PromoteChannel };
|
||||
|
||||
@@ -47,7 +48,10 @@ interface VariablePromoteContextValue {
|
||||
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 {
|
||||
const snapshot = session.getElement(hfId);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createStableContext } from "../utils/hmrStableContext";
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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 { bumpDomEditCommitMapVersion, runDomEditCommit } from "./domEditCommitRunner";
|
||||
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 ──
|
||||
|
||||
@@ -63,6 +65,17 @@ function setOrRemovePreviewAttribute(
|
||||
} else {
|
||||
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(
|
||||
|
||||
@@ -58,6 +58,8 @@ const capturedOnReorderShadow: { fn: ((targets: string[]) => void) | undefined }
|
||||
fn: undefined,
|
||||
};
|
||||
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() });
|
||||
|
||||
function createSessionParams(
|
||||
@@ -131,7 +133,7 @@ vi.mock("./useDomSelection", () => ({
|
||||
domEditHoverSelection: null,
|
||||
activeGroupElement: null,
|
||||
domEditSelectionRef,
|
||||
domEditGroupSelectionsRef: { current: [] },
|
||||
domEditGroupSelectionsRef,
|
||||
setActiveGroupElement: vi.fn(),
|
||||
applyDomSelection: vi.fn(),
|
||||
clearDomSelection: vi.fn(),
|
||||
@@ -190,7 +192,7 @@ vi.mock("./useGsapScriptCommits", () => ({
|
||||
}));
|
||||
vi.mock("./useGroupCommits", () => ({
|
||||
useGroupCommits: () => ({
|
||||
groupSelection: vi.fn(),
|
||||
groupSelection: (...args: unknown[]) => groupSelectionSpy(...args),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { isAudioDomElement } from "../utils/timelineInspector";
|
||||
import type { SelectElementOptions, TimelineElement } from "../player";
|
||||
import type { ImportedFontAsset } from "../components/editor/fontAssets";
|
||||
import type { EditHistoryKind } from "../utils/editHistory";
|
||||
import type { RightPanelTab } from "../utils/studioHelpers";
|
||||
import type { PatchTarget } from "../utils/sourcePatcher";
|
||||
import type { SidebarTab } from "../components/sidebar/LeftSidebar";
|
||||
@@ -21,13 +21,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing";
|
||||
import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher";
|
||||
import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
|
||||
interface RecordEditInput {
|
||||
label: string;
|
||||
kind: EditHistoryKind;
|
||||
coalesceKey?: string;
|
||||
files: Record<string, { before: string; after: string }>;
|
||||
}
|
||||
import { membersForDelete } from "./domEditDeleteMembers";
|
||||
import type { RecordEditInput } from "./domEditDeleteMembers";
|
||||
// Re-exported: the delete rule lives in its own module now, and callers (and its
|
||||
// own test) have always imported it from here.
|
||||
export { membersForDelete };
|
||||
|
||||
export interface UseDomEditSessionParams {
|
||||
projectId: string | null;
|
||||
@@ -73,22 +71,6 @@ export interface UseDomEditSessionParams {
|
||||
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({
|
||||
projectId,
|
||||
activeCompPath,
|
||||
@@ -374,6 +356,23 @@ export function useDomEditSession({
|
||||
showToast("Select at least 2 elements to group", "info");
|
||||
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 });
|
||||
void groupSelection(members);
|
||||
}, [domEditGroupSelectionsRef, domEditSelectionRef, groupSelection, showToast]);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
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
|
||||
@@ -10,11 +11,12 @@ export function useEffectiveTimelineDuration(
|
||||
timelineDuration: number,
|
||||
timelineElements: readonly TimelineElement[],
|
||||
): number {
|
||||
return useMemo(() => {
|
||||
const maxEnd =
|
||||
timelineElements.length > 0
|
||||
? Math.max(...timelineElements.map((el) => el.start + el.duration))
|
||||
: 0;
|
||||
return Math.max(timelineDuration, maxEnd);
|
||||
}, [timelineDuration, timelineElements]);
|
||||
// Delegates to `getEffectiveTimelineDuration` rather than restating the
|
||||
// arithmetic: that one guards a non-finite stored duration and a non-finite
|
||||
// result (an element with NaN timing), which this copy did not — it would
|
||||
// return NaN and every downstream width became NaN with it.
|
||||
return useMemo(
|
||||
() => getEffectiveTimelineDuration(timelineDuration, timelineElements),
|
||||
[timelineDuration, timelineElements],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* dragged as well as while it is playing.
|
||||
*/
|
||||
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. */
|
||||
const THROTTLE_MS = 33;
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
*
|
||||
* 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
|
||||
* admitting the same thing. Nothing here is new logic — it is the same four
|
||||
* branches (accept-gate, group levels, state, timeline) against the same refs,
|
||||
* with the suppression retired rather than moved.
|
||||
* admitting the same thing. Nothing here is new logic — it is the same three
|
||||
* branches (accept-gate, state, timeline) against the same refs, with the
|
||||
* 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 type { ClipManifestClip, IframeWindow, PlaybackAdapter } from "../lib/playbackTypes";
|
||||
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
||||
import { groupLevels, parseGroupLevelsMessage } from "../store/groupLevels";
|
||||
import { parseTimelineFromDOM } from "../lib/timelineDOM";
|
||||
|
||||
/** What `processTimelineMessage` accepts — the clip-manifest postMessage. */
|
||||
@@ -60,12 +60,6 @@ function acceptedPreviewMessage(
|
||||
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
|
||||
* 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 data = acceptedPreviewMessage(e, iframe);
|
||||
if (!data) return;
|
||||
if (data.type === "group-levels") return handleGroupLevelsMessage(data);
|
||||
if (data.type === "state") return handleStateMessage(deps, iframe);
|
||||
if (data.type === "timeline" && Array.isArray(data.clips)) {
|
||||
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.
|
||||
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
|
||||
* 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> {
|
||||
if (!flat) return {};
|
||||
return {
|
||||
@@ -169,6 +195,7 @@ function buildChildElements(
|
||||
editBasis: { start: number; sourceFile: string | undefined },
|
||||
expandedHostKey: string,
|
||||
elements: readonly TimelineElement[],
|
||||
domChildrenById: ReadonlyMap<string, DomClipChild>,
|
||||
): TimelineElement[] {
|
||||
const result: TimelineElement[] = [];
|
||||
for (const child of siblings) {
|
||||
@@ -197,6 +224,10 @@ function buildChildElements(
|
||||
result.push({
|
||||
...base,
|
||||
...hostElementState(elements.find((element) => element.key === key)),
|
||||
...childGroupState(
|
||||
elements.find((element) => element.key === key),
|
||||
domId ? domChildrenById.get(domId) : undefined,
|
||||
),
|
||||
key,
|
||||
start: clamped.start,
|
||||
duration: clamped.duration,
|
||||
@@ -215,7 +246,13 @@ function buildChildElements(
|
||||
// clips. Fractions strictly between the host's lane and the next integer
|
||||
// can never equal a normalized (integer) lane, while still rendering the
|
||||
// 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,
|
||||
stackingContextId: base.stackingContextId,
|
||||
expandedParentStart: editBasis.start,
|
||||
@@ -299,6 +336,7 @@ export function buildExpandedElements(
|
||||
};
|
||||
|
||||
const parentKey = topLevelElement.key ?? topLevelElement.id;
|
||||
const domChildrenById = new Map(domClipChildren.map((child) => [child.id, child]));
|
||||
const expanded = buildChildElements(
|
||||
siblings,
|
||||
{
|
||||
@@ -309,6 +347,7 @@ export function buildExpandedElements(
|
||||
editBasis,
|
||||
parentKey,
|
||||
elements,
|
||||
domChildrenById,
|
||||
);
|
||||
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
|
||||
@@ -37,11 +37,7 @@ import {
|
||||
mergeTimelineElementsPreservingDowngrades,
|
||||
} from "../lib/timelineDOM";
|
||||
import { normalizeToZones } from "../components/timelineZones";
|
||||
import {
|
||||
setPreviewMediaMuted,
|
||||
setPreviewMediaVolume,
|
||||
setPreviewPlaybackRate,
|
||||
} from "../lib/timelineIframeHelpers";
|
||||
import { applyPreviewAudioFlags, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
||||
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
||||
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
||||
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
||||
@@ -235,8 +231,7 @@ export function useTimelinePlayer() {
|
||||
}, []);
|
||||
const applyPreviewAudioState = useCallback(() => {
|
||||
const { audioMuted, audioVolume } = usePlayerStore.getState();
|
||||
setPreviewMediaMuted(iframeRef.current, audioMuted);
|
||||
setPreviewMediaVolume(iframeRef.current, audioVolume);
|
||||
applyPreviewAudioFlags(iframeRef.current, audioMuted, audioVolume);
|
||||
}, []);
|
||||
const play = useCallback(() => {
|
||||
stopRAFLoop();
|
||||
|
||||
@@ -10,24 +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 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>;
|
||||
@@ -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.
|
||||
*
|
||||
@@ -130,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;
|
||||
}
|
||||
@@ -149,86 +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),
|
||||
});
|
||||
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
|
||||
@@ -236,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);
|
||||
}
|
||||
@@ -294,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
|
||||
@@ -330,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);
|
||||
@@ -346,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,
|
||||
@@ -421,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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user