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