diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 3c45b6b22..411e36314 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -642,6 +642,7 @@ describe("WebAudioTransport", () => { getFloatTimeDomainData: ReturnType; }[] = []; const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() }; + const mediaElementSource = { connect: vi.fn(), disconnect: vi.fn() }; const ctx = { currentTime, state: "running", @@ -687,10 +688,14 @@ describe("WebAudioTransport", () => { analysers.push(node); return node; }), + // The media-element route needs this as much as the decoded one: without + // it `scheduleMediaElementPlayback` throws and its catch returns null, + // which reads as "the member did not play" rather than a missing stub. + createMediaElementSource: vi.fn(() => mediaElementSource), destination: {}, close: vi.fn(), }; - return { ctx, gainNodes, analysers, masterGain }; + return { ctx, gainNodes, analysers, masterGain, mediaElementSource }; } function setupGroupTransport(currentTime = 100) { @@ -743,6 +748,30 @@ describe("WebAudioTransport", () => { expect(clipGain!.connect).toHaveBeenCalledWith(mock.masterGain); }); + // The media-element transport is the PRIMARY path for audio — the runtime + // tries it first and only falls back to a decoded buffer. It has to reach + // the same bus, or grouping silently applies to nothing that actually plays. + it("routes a grouped clip's MEDIA-ELEMENT playback to the group bus, not master", async () => { + const { transport, mock, gen } = setupGroupTransport(); + const el = groupedAudioEl("vo-1", "vo"); + + await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1); + + const clipGain = mock.gainNodes[0]!; + expect(clipGain.connect).toHaveBeenCalledWith(firstGroupInput(mock)); + expect(clipGain.connect).not.toHaveBeenCalledWith(mock.masterGain); + }); + + it("routes an UNGROUPED clip's media-element playback straight to master", async () => { + const { transport, mock, gen } = setupGroupTransport(); + const el = groupedAudioEl("lone"); + + await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1); + + expect(mock.gainNodes).toHaveLength(1); + expect(mock.gainNodes[0]!.connect).toHaveBeenCalledWith(mock.masterGain); + }); + it("two members of the same group land on ONE shared group gain, not master directly", async () => { const { transport, mock, gen } = setupGroupTransport(); diff --git a/packages/studio/src/components/editor/PropertyPanelFlat.tsx b/packages/studio/src/components/editor/PropertyPanelFlat.tsx index 9af2af372..b48d7d189 100644 --- a/packages/studio/src/components/editor/PropertyPanelFlat.tsx +++ b/packages/studio/src/components/editor/PropertyPanelFlat.tsx @@ -10,6 +10,7 @@ import { audioFxSummary } from "./audioFxSummary"; import { HF_AUDIO_GROUP_TAG, resolveAudioGroups } from "@hyperframes/core/audio-groups"; import { PropertyPanelFlatHeader } from "./PropertyPanelFlatHeader"; import { PropertyPanelFlatFooter } from "./PropertyPanelFlatFooter"; +import { closedGroupHeader } from "./propertyPanelFlatClosedGroup"; import { FlatGroupHeader } from "./propertyPanelFlatPrimitives"; import { FlatTextSection } from "./propertyPanelFlatTextSection"; import { FlatStyleSection } from "./propertyPanelFlatStyleSections"; @@ -524,17 +525,8 @@ export function PropertyPanelFlat({ const beforeOpen = openIndex === -1 ? groups : groups.slice(0, openIndex); const openGroup = openIndex === -1 ? null : groups[openIndex]; const afterOpen = openIndex === -1 ? [] : groups.slice(openIndex + 1); - const renderClosedGroup = (group: FlatGroupDescriptor) => ( - - toggleOpen(group.id)} - summary={group.summary} - animateEntrance={justToggledIds.includes(group.id)} - /> - - ); + const renderClosedGroup = (group: FlatGroupDescriptor) => + closedGroupHeader(group, toggleOpen, justToggledIds); return ( diff --git a/packages/studio/src/components/editor/propertyPanelFlatClosedGroup.tsx b/packages/studio/src/components/editor/propertyPanelFlatClosedGroup.tsx new file mode 100644 index 000000000..f0fdd04e0 --- /dev/null +++ b/packages/studio/src/components/editor/propertyPanelFlatClosedGroup.tsx @@ -0,0 +1,31 @@ +/** + * A collapsed group's header row in the flat inspector. + * + * Its own module so `PropertyPanelFlat.tsx` stays under the studio's 600-line + * cap; it reads only its arguments, which is what makes it separable. + */ + +import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext"; +import { slugifyDesignInput } from "../../utils/designInputTracking"; +import { FlatGroupHeader } from "./propertyPanelFlatPrimitives"; +import type { FlatGroupDescriptor } from "./propertyPanelFlatDescriptors"; + +/** Its title, its one-line summary, and the entrance animation only the group + * just toggled gets. */ +export function closedGroupHeader( + group: FlatGroupDescriptor, + toggleOpen: (id: string) => void, + justToggledIds: readonly string[], +) { + return ( + + toggleOpen(group.id)} + summary={group.summary} + animateEntrance={justToggledIds.includes(group.id)} + /> + + ); +} 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/useBlockedTimelineEditToast.ts b/packages/studio/src/hooks/useBlockedTimelineEditToast.ts new file mode 100644 index 000000000..0ad69388d --- /dev/null +++ b/packages/studio/src/hooks/useBlockedTimelineEditToast.ts @@ -0,0 +1,27 @@ +/** + * The "can't be moved from the timeline yet" toast, rate-limited. + * + * Its own hook so `useTimelineEditing.ts` stays under the studio's 600-line cap. + * The 1.5s gate matters: a blocked drag fires this per pointermove, and without + * it one gesture stacked dozens of identical toasts. + */ + +import { useCallback, useRef } from "react"; +import type { TimelineElement } from "../player"; + +const BLOCKED_TOAST_INTERVAL_MS = 1500; + +export function useBlockedTimelineEditToast( + showToast: (message: string, tone?: "info" | "error") => void, +): (element: TimelineElement) => void { + const lastAtRef = useRef(0); + return useCallback( + (_element: TimelineElement) => { + const now = Date.now(); + if (now - lastAtRef.current < BLOCKED_TOAST_INTERVAL_MS) return; + lastAtRef.current = now; + showToast("This clip can't be moved or resized from the timeline yet.", "info"); + }, + [showToast], + ); +} diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 3ce9d273b..b88babf4a 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -3,7 +3,6 @@ import { trackStudioEvent } from "../utils/studioTelemetry"; import { isAudioDomElement } from "../utils/timelineInspector"; import type { SelectElementOptions, TimelineElement } from "../player"; import type { ImportedFontAsset } from "../components/editor/fontAssets"; -import type { EditHistoryKind } from "../utils/editHistory"; import type { RightPanelTab } from "../utils/studioHelpers"; import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; @@ -22,13 +21,11 @@ import { useGsapAwareEditing } from "./useGsapAwareEditing"; import { useStudioSelectionPublisher } from "./useStudioSelectionPublisher"; import { useKeyframeEaseCommits } from "./useKeyframeEaseCommits"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; - -interface RecordEditInput { - label: string; - kind: EditHistoryKind; - coalesceKey?: string; - files: Record; -} +import { membersForDelete } from "./domEditDeleteMembers"; +import type { RecordEditInput } from "./domEditDeleteMembers"; +// Re-exported: the delete rule lives in its own module now, and callers (and its +// own test) have always imported it from here. +export { membersForDelete }; export interface UseDomEditSessionParams { projectId: string | null; @@ -74,22 +71,6 @@ export interface UseDomEditSessionParams { forceReloadSdkSession?: () => void; } -/** - * Which elements a delete acts on. `expandGroup` widens the primary to the - * whole marquee group, which is what the Delete key means. - * - * The caller chooses rather than the delete deciding for everyone: Cut copies - * the primary alone, so expanding for it put one element on the clipboard and - * removed every other member of the group with it. - */ -export function membersForDelete( - selection: DomEditSelection, - group: DomEditSelection[], - options?: { expandGroup?: boolean }, -): DomEditSelection[] { - return options?.expandGroup && group.length > 0 ? group : [selection]; -} - export function useDomEditSession({ projectId, activeCompPath, diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 6ddc1ccb6..731ff9d78 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -26,7 +26,6 @@ import { syncPreviewContentDuration, } from "./timelineTimingSync"; import type { PersistTimelineEditInput } from "./timelineEditingHelpers"; -import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; import { useSetAudioGroupAttribute } from "./timelineAudioGroupVolume"; import { useSetElementAttribute } from "./timelineElementFxAttribute"; import { useAudioGroupCarveAssignment } from "./timelineAudioGroupCreate"; @@ -35,16 +34,13 @@ import { useTimelineTrackVisibilityEditing, } from "./timelineTrackVisibility"; import { useTimelineGroupEditing } from "./useTimelineGroupEditing"; +import { useBlockedTimelineEditToast } from "./useBlockedTimelineEditToast"; import { serializeZLaneGesture } from "../components/nle/zLaneGesture"; import { cutoverCommittedOrThrow, sdkTimingPersist } from "../utils/sdkCutover"; -import type { UseTimelineEditingOptions } from "./useTimelineEditingTypes"; +import type { TimelineMoveUpdates, UseTimelineEditingOptions } from "./useTimelineEditingTypes"; import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics"; import { studioWriteHeaders } from "../utils/studioFileVersion"; -type TimelineMoveUpdates = Pick & { - stackingReorder?: TimelineStackingReorderIntent | null; -}; - export function useTimelineEditing({ projectId, activeCompPath, @@ -67,9 +63,7 @@ export function useTimelineEditing({ }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; - const editQueueRef = useRef(Promise.resolve()); - const lastBlockedTimelineToastAtRef = useRef(0); const enqueueEdit = useCallback( ( @@ -569,15 +563,7 @@ export function useTimelineEditing({ observeProjectFileVersion, }); - const handleBlockedTimelineEdit = useCallback( - (_element: TimelineElement) => { - const now = Date.now(); - if (now - lastBlockedTimelineToastAtRef.current < 1500) return; - lastBlockedTimelineToastAtRef.current = now; - showToast("This clip can't be moved or resized from the timeline yet.", "info"); - }, - [showToast], - ); + const handleBlockedTimelineEdit = useBlockedTimelineEditToast(showToast); const { handleRazorSplit, handleRazorSplitAll } = useRazorSplit({ projectId, diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index bd3df209d..2770d4df6 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -1,6 +1,7 @@ import type { MutableRefObject, RefObject } from "react"; import type { Composition } from "@hyperframes/sdk"; import type { TimelineElement } from "../player"; +import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing"; import type { EditHistoryKind } from "../utils/editHistory"; import type { PublishSdkSession } from "../utils/sdkCutover"; @@ -55,3 +56,9 @@ export type TimelineFileDropHandler = ( files: File[], placement?: { start: number; track: number }, ) => Promise; + +/** What a timeline move commits: the new start and track, plus the z-index + * reorder a vertical drag resolves to (absent for a pure horizontal move). */ +export type TimelineMoveUpdates = Pick & { + stackingReorder?: TimelineStackingReorderIntent | null; +}; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 6e1cc2895..2243a7967 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -9,6 +9,7 @@ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelecti import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; +import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure"; import { isTrackRowExpanded, resolveTrackKeyframeClip, @@ -22,7 +23,6 @@ import { usePlayerStore } from "../store/playerStore"; import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview"; import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows"; import type { TimelineLanesProps } from "./timelineLaneProps"; -import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; import { createClipGestureHandlers } from "./timelineClipGestureHandlers"; import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren"; @@ -107,9 +107,6 @@ export function TimelineLanes({ // synthetic lane element spans the whole composition rather than a clip. const compositionDuration = usePlayerStore((s) => s.duration); useAutomationSelectionKeyboard({ lanes: automationLanes }); - const expandClips = usePlayerStore((s) => s.expandClips); - const setClipExpanded = usePlayerStore((s) => s.setClipExpanded); - const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups); // Which tracks are group MEMBERS, so their headers can render the level-2 // nesting their `aria-level` already reports. @@ -117,21 +114,10 @@ export function TimelineLanes({ () => new Set(groups.flatMap((group) => group.memberTracks)), [groups], ); - // The caret belongs to the ROW, so it opens and closes every clip on it at - // once. Toggling only the active clip left the row's state depending on which - // sibling happened to be selected: expand one, click another, and the row - // collapsed under a caret that still pointed down. - const toggleRowExpandedTracked = (keys: readonly string[]) => { - const willExpand = !keys.some((key) => expandedClipIds.has(key)); - trackStudioKeyframeLaneExpand({ expanded: willExpand }); - if (willExpand) expandClips(keys); - else for (const key of keys) setClipExpanded(key, false); - }; - const toggleClipExpandedTracked = (key: string) => { - const willExpand = !expandedClipIds.has(key); - trackStudioKeyframeLaneExpand({ expanded: willExpand }); - toggleClipExpanded(key); - }; + const { + toggleRowExpanded: toggleRowExpandedTracked, + toggleClipExpanded: toggleClipExpandedTracked, + } = useTimelineClipDisclosure(); const actorWindows = useTimelineMultiDragActorWindows( multiDragPreview, rowsVirtualized, diff --git a/packages/studio/src/player/components/useTimelineClipDisclosure.ts b/packages/studio/src/player/components/useTimelineClipDisclosure.ts new file mode 100644 index 000000000..fe856be17 --- /dev/null +++ b/packages/studio/src/player/components/useTimelineClipDisclosure.ts @@ -0,0 +1,41 @@ +/** + * Opening and closing a track's keyframe property lanes, with the telemetry that + * goes with it. + * + * Split out of `TimelineLanes.tsx` to keep that file under the studio's 600-line + * cap. Both callbacks were already the only place the disclosure state and its + * `keyframe_lane_expand` event were written together, which is what makes them a + * seam rather than a shuffle. + */ + +import { usePlayerStore } from "../store/playerStore"; +import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; + +export interface TimelineClipDisclosure { + /** The caret belongs to the ROW, so it opens and closes every clip on it at + * once. Toggling only the active clip left the row's state depending on which + * sibling happened to be selected: expand one, click another, and the row + * collapsed under a caret that still pointed down. */ + toggleRowExpanded: (keys: readonly string[]) => void; + toggleClipExpanded: (key: string) => void; +} + +export function useTimelineClipDisclosure(): TimelineClipDisclosure { + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const expandClips = usePlayerStore((s) => s.expandClips); + const setClipExpanded = usePlayerStore((s) => s.setClipExpanded); + const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); + + return { + toggleRowExpanded: (keys) => { + const willExpand = !keys.some((key) => expandedClipIds.has(key)); + trackStudioKeyframeLaneExpand({ expanded: willExpand }); + if (willExpand) expandClips(keys); + else for (const key of keys) setClipExpanded(key, false); + }, + toggleClipExpanded: (key) => { + trackStudioKeyframeLaneExpand({ expanded: !expandedClipIds.has(key) }); + toggleClipExpanded(key); + }, + }; +} diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 6dd727ad9..354621db2 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { attachPlayerStoreDevHandle } from "./playerStoreDevHandle"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { BeatEditState } from "../../utils/beatEditing"; @@ -548,7 +549,7 @@ export const usePlayerStore = create((set, get) => ({ activeKeyframePct: null, motionPathArmed: false, focusedEaseSegment: null, - revealedAudioFxTarget: null, + revealedAudioFxTarget: null, } : { selectedElementId: id, selectedElementIds }; }), @@ -590,15 +591,4 @@ export const usePlayerStore = create((set, get) => ({ reset: () => set(createTimelineResetState()), })); -function isDevBuild(): boolean { - try { - return import.meta.env.DEV === true; - } catch { - // Turbopack and other non-Vite bundlers may not provide import.meta.env. - return false; - } -} -if (isDevBuild() && typeof window !== "undefined") { - // Console handle for dumping live Studio state during bug-bash reproduction. - (window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore; -} +attachPlayerStoreDevHandle(usePlayerStore); diff --git a/packages/studio/src/player/store/playerStoreDevHandle.ts b/packages/studio/src/player/store/playerStoreDevHandle.ts new file mode 100644 index 000000000..ee2c6fde9 --- /dev/null +++ b/packages/studio/src/player/store/playerStoreDevHandle.ts @@ -0,0 +1,22 @@ +/** + * A console handle on the live store, dev builds only. + * + * Split out of `playerStore.ts` to keep it under the studio's 600-line cap. The + * dev check is its own function because `import.meta.env` is absent under + * Turbopack and other non-Vite bundlers, where reading it throws. + */ + +function isDevBuild(): boolean { + try { + return import.meta.env.DEV === true; + } catch { + return false; + } +} + +/** Expose `store` as `window.__playerStore` for dumping live Studio state + * during bug-bash reproduction. No-op outside a dev build or a browser. */ +export function attachPlayerStoreDevHandle(store: unknown): void { + if (!isDevBuild() || typeof window === "undefined") return; + (window as unknown as { __playerStore?: unknown }).__playerStore = store; +}