test(core): cover the group-bus routing, and clear the last five oversized files

Two loose ends from the rebase.

**The routing had no test.** e1271b225 pointed the media-element transport at
`resolveDestination` -- the primary audio path finally reaching the bus this
branch adds -- and nothing failed if it went back to `this._masterGain`. Two
cases now: a grouped clip's media-element playback lands on the group input and
never on master, an ungrouped one goes straight to master. Verified they FAIL on
a revert of that one line. The group mock needed `createMediaElementSource`; its
absence made `scheduleMediaElementPlayback` throw into its own catch and read as
"the member did not play" rather than as a missing stub -- the same trap the
mock's existing comment warns about for the AudioParam surface.

**Five studio files were over the 600-line cap.** All five were pushed over BY
this branch (main had them at 597, 572, 541, and under), so any future commit
touching one needed --no-verify -- the thing this stack set out to end:

- TimelineLanes.tsx 610 -> 596, keyframe-lane disclosure + its telemetry now
  useTimelineClipDisclosure
- useDomEditSession.ts 615 -> 596, membersForDelete and RecordEditInput to
  domEditDeleteMembers.ts (re-exported, its test imports from the old home)
- useTimelineEditing.ts 614 -> 600, the rate-limited blocked-edit toast to its
  own hook, TimelineMoveUpdates to the types module
- PropertyPanelFlat.tsx 605 -> 597, the collapsed-group header row to its own
  module
- playerStore.ts 604 -> 594, the dev-build console handle to its own module

Extracting in place made PropertyPanelFlat GROW (605 -> 616): a signature plus a
doc comment costs more than an inline arrow saves. Only a move to a sibling
module actually removes lines.

Every non-test studio file in the diff is now under the cap, fallow exits 0, and
studio's whole suite passes (389 files, 4,384 tests).
This commit is contained in:
Vance Ingalls
2026-08-20 02:56:46 -07:00
parent f914d71029
commit 6c961e0134
12 changed files with 211 additions and 85 deletions
@@ -633,6 +633,7 @@ describe("WebAudioTransport", () => {
getFloatTimeDomainData: ReturnType<typeof vi.fn>;
}[] = [];
const masterGain = { gain: { value: 1 }, connect: vi.fn(), disconnect: vi.fn() };
const mediaElementSource = { connect: vi.fn(), disconnect: vi.fn() };
const ctx = {
currentTime,
state: "running",
@@ -678,10 +679,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) {
@@ -734,6 +739,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();
@@ -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) => (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
const renderClosedGroup = (group: FlatGroupDescriptor) =>
closedGroupHeader(group, toggleOpen, justToggledIds);
return (
<DesignPanelInputProvider ui="flat">
@@ -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 (
<DesignPanelInputProvider key={group.id} section={slugifyDesignInput(group.title)}>
<FlatGroupHeader
title={group.title}
isOpen={false}
onToggleOpen={() => toggleOpen(group.id)}
summary={group.summary}
animateEntrance={justToggledIds.includes(group.id)}
/>
</DesignPanelInputProvider>
);
}
@@ -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];
}
@@ -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],
);
}
+5 -24
View File
@@ -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<string, { before: string; after: string }>;
}
import { membersForDelete } from "./domEditDeleteMembers";
import type { RecordEditInput } from "./domEditDeleteMembers";
// Re-exported: the delete rule lives in its own module now, and callers (and its
// own test) have always imported it from here.
export { membersForDelete };
export interface UseDomEditSessionParams {
projectId: string | null;
@@ -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,
@@ -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<TimelineElement, "start" | "track"> & {
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,
@@ -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<void>;
/** 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<TimelineElement, "start" | "track"> & {
stackingReorder?: TimelineStackingReorderIntent | null;
};
@@ -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,
@@ -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);
},
};
}
@@ -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<PlayerState>((set, get) => ({
activeKeyframePct: null,
motionPathArmed: false,
focusedEaseSegment: null,
revealedAudioFxTarget: null,
revealedAudioFxTarget: null,
}
: { selectedElementId: id, selectedElementIds };
}),
@@ -590,15 +591,4 @@ export const usePlayerStore = create<PlayerState>((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);
@@ -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;
}