From 89069d24c3f31a8f4c4a15c5e587e57bdb61a0da Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Sun, 23 Aug 2026 18:10:46 -0700 Subject: [PATCH] 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 --- .../studio/src/components/nle/NLEContext.tsx | 13 +- .../studio/src/components/nle/PreviewPane.tsx | 1 - .../nle/useTimelineEditCallbacks.ts | 6 +- .../src/contexts/DesignPanelInputContext.tsx | 11 +- .../studio/src/contexts/DomEditContext.tsx | 13 +- .../src/contexts/FileManagerContext.tsx | 5 +- .../src/contexts/PanelLayoutContext.tsx | 5 +- .../studio/src/contexts/StudioContext.tsx | 10 +- .../src/contexts/TimelineEditContext.tsx | 8 +- .../src/contexts/VariablePromoteContext.tsx | 8 +- .../studio/src/contexts/ViewModeContext.tsx | 4 +- .../studio/src/hooks/domEditDeleteMembers.ts | 34 ++ .../src/hooks/useDomEditAttributeCommits.ts | 13 + .../src/hooks/useDomEditSession.test.tsx | 59 ++- .../studio/src/hooks/useDomEditSession.ts | 47 +-- .../src/hooks/useEffectiveTimelineDuration.ts | 16 +- .../studio/src/hooks/useLivePlayheadTime.ts | 4 +- .../src/player/hooks/previewMessageRouter.ts | 15 +- .../src/player/hooks/timelineSyncHydration.ts | 395 ++++++++++++++++++ .../hooks/useExpandedTimelineElements.test.ts | 83 ++++ .../hooks/useExpandedTimelineElements.ts | 41 +- .../src/player/hooks/useTimelinePlayer.ts | 9 +- .../player/hooks/useTimelineSyncCallbacks.ts | 262 ++---------- packages/studio/src/utils/hmrStableContext.ts | 64 +++ 24 files changed, 821 insertions(+), 305 deletions(-) create mode 100644 packages/studio/src/hooks/domEditDeleteMembers.ts create mode 100644 packages/studio/src/player/hooks/timelineSyncHydration.ts create mode 100644 packages/studio/src/utils/hmrStableContext.ts diff --git a/packages/studio/src/components/nle/NLEContext.tsx b/packages/studio/src/components/nle/NLEContext.tsx index 5f5ae4293..d71d7ef52 100644 --- a/packages/studio/src/components/nle/NLEContext.tsx +++ b/packages/studio/src/components/nle/NLEContext.tsx @@ -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(null); +const NLEContext = createStableContext("NLEContext", null); export function useNLEContext(): NLEContextValue { const ctx = useContext(NLEContext); diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx index 42c6e742f..530f4ba95 100644 --- a/packages/studio/src/components/nle/PreviewPane.tsx +++ b/packages/studio/src/components/nle/PreviewPane.tsx @@ -156,7 +156,6 @@ export function PreviewPane({ disabled={timelineDisabled} isFullscreen={isFullscreen} onToggleFullscreen={toggleFullscreen} - previewIframeRef={iframeRef} /> diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 4f7d251a7..788b56282 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -47,7 +47,11 @@ export interface TimelineEditCallbackDeps { handleRazorSplit: (element: TimelineElement, splitTime: number) => Promise | void; handleRazorSplitAll: (splitTime: number) => Promise | void; /** C1's ungrouped-track FX pointer — same auto-grouping write B6's carve uses. */ - handleGroupClips?: (clipIds: readonly string[], groupId: string) => Promise; + handleGroupClips?: ( + clipIds: readonly string[], + groupId: string, + groupLabel?: string, + ) => Promise; /** C1's single-clip FX write, addressed by the clip itself. */ setElementFxAttribute?: { setLive: (element: TimelineElement, attr: string, value: string | null) => void; diff --git a/packages/studio/src/contexts/DesignPanelInputContext.tsx b/packages/studio/src/contexts/DesignPanelInputContext.tsx index a4b4223c0..25c2e68fc 100644 --- a/packages/studio/src/contexts/DesignPanelInputContext.tsx +++ b/packages/studio/src/contexts/DesignPanelInputContext.tsx @@ -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({ - ui: "classic", - section: "unknown", -}); +const DesignPanelInputContext = createStableContext( + "DesignPanelInputContext", + { ui: "classic", section: "unknown" }, +); export function DesignPanelInputProvider({ ui, diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 365504b8d..31210b3f7 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -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; @@ -93,8 +94,14 @@ export interface DomEditSelectionValue extends Pick< | "agentPromptSelectionContext" > {} -const DomEditActionsContext = createContext(null); -const DomEditSelectionContext = createContext(null); +const DomEditActionsContext = createStableContext( + "DomEditActionsContext", + null, +); +const DomEditSelectionContext = createStableContext( + "DomEditSelectionContext", + null, +); export function useDomEditActionsContext(): DomEditActionsValue { const ctx = useContext(DomEditActionsContext); diff --git a/packages/studio/src/contexts/FileManagerContext.tsx b/packages/studio/src/contexts/FileManagerContext.tsx index 73270943a..a491555e3 100644 --- a/packages/studio/src/contexts/FileManagerContext.tsx +++ b/packages/studio/src/contexts/FileManagerContext.tsx @@ -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; -const FileManagerContext = createContext(null); +const FileManagerContext = createStableContext("FileManagerContext", null); export function useFileManagerContext(): FileManagerValue { const ctx = useContext(FileManagerContext); diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx index 6666fccbc..c37ff8474 100644 --- a/packages/studio/src/contexts/PanelLayoutContext.tsx +++ b/packages/studio/src/contexts/PanelLayoutContext.tsx @@ -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; -const PanelLayoutContext = createContext(null); +const PanelLayoutContext = createStableContext("PanelLayoutContext", null); export function usePanelLayoutContext(): PanelLayoutValue { const ctx = useContext(PanelLayoutContext); diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 5de449ffa..97b8ac0be 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -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(null); -const StudioPlaybackContext = createContext(null); +const StudioShellContext = createStableContext("StudioShellContext", null); +const StudioPlaybackContext = createStableContext( + "StudioPlaybackContext", + null, +); export function useStudioShellContext(): StudioShellValue { const ctx = useContext(StudioShellContext); diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index 22c82cdf0..4bdedcfbb 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -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(null); +const TimelineEditContext = createStableContext( + "TimelineEditContext", + null, +); export function useTimelineEditContext(): TimelineEditCallbacks { const ctx = useContext(TimelineEditContext); diff --git a/packages/studio/src/contexts/VariablePromoteContext.tsx b/packages/studio/src/contexts/VariablePromoteContext.tsx index 18fac0cb5..1190cd1bf 100644 --- a/packages/studio/src/contexts/VariablePromoteContext.tsx +++ b/packages/studio/src/contexts/VariablePromoteContext.tsx @@ -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(null); +const VariablePromoteContext = createStableContext( + "VariablePromoteContext", + null, +); function readBinding(session: Composition, hfId: string, channel: PromoteChannel): string | null { const snapshot = session.getElement(hfId); diff --git a/packages/studio/src/contexts/ViewModeContext.tsx b/packages/studio/src/contexts/ViewModeContext.tsx index 9370b88a5..adb7b13c2 100644 --- a/packages/studio/src/contexts/ViewModeContext.tsx +++ b/packages/studio/src/contexts/ViewModeContext.tsx @@ -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(null); +const ViewModeContext = createStableContext("ViewModeContext", null); export function useViewMode(): ViewModeValue { const ctx = useContext(ViewModeContext); diff --git a/packages/studio/src/hooks/domEditDeleteMembers.ts b/packages/studio/src/hooks/domEditDeleteMembers.ts new file mode 100644 index 000000000..cc3a1fae0 --- /dev/null +++ b/packages/studio/src/hooks/domEditDeleteMembers.ts @@ -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; +} + +/** + * 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]; +} diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts index 7d72bd962..423e5da7e 100644 --- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts @@ -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 `` 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 `