From a5c7654252fc4293b2d55bb3be9f4794608fd1c4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 17 Aug 2026 16:43:06 -0400 Subject: [PATCH] refactor(studio): split the two files over the 600-line cap The file size check has been failing on main. It is diff-scoped on a PR but full-scans packages/studio on push, so two files that crept over the cap were only ever caught after merge, and every release since has been red: TimelineAutomationLane.tsx 674 lines StudioRightPanel.tsx 609 lines Both are pure code moves. No behavior change. TimelineAutomationLane.tsx keeps the single-lane editor and gives up the track-level layer: ClipLaneRow, ClipAutomationLanes and TimelineAutomationLaneSlot move to TimelineAutomationLaneSlot.tsx, which is the name its test file already used. The dependency runs one way, slot -> lane, so there is no cycle. 674 -> 499. StudioRightPanel.tsx gives up its props interface to a sibling .types.ts. That block is the part that changes least, so moving it keeps the component's own diffs small; two in-flight branches touch this file and both are based on a 556-line copy of it, so keeping the cut away from the body matters. 609 -> 568. Verified by running the CI rule's full-scan branch over the 679 tracked packages/studio source files: no file over 600, exit 0. Studio suite green at 2790 passed across 226 files, and typecheck clean. --- .../src/components/StudioRightPanel.tsx | 58 +----- .../src/components/StudioRightPanel.types.ts | 55 ++++++ .../components/TimelineAutomationLane.tsx | 175 ---------------- .../TimelineAutomationLaneSlot.test.tsx | 2 +- .../components/TimelineAutomationLaneSlot.tsx | 187 ++++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 2 +- 6 files changed, 252 insertions(+), 227 deletions(-) create mode 100644 packages/studio/src/components/StudioRightPanel.types.ts create mode 100644 packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 31ac169dd0..a62bb9e1c8 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -1,19 +1,19 @@ -import { useCallback, useEffect, useRef, type MutableRefObject } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { StudioRightPanelProps } from "./StudioRightPanel.types"; + +export type { StudioRightPanelProps }; import { PropertyPanel } from "./editor/PropertyPanel"; import { LayersPanel } from "./editor/LayersPanel"; import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel"; import { BlockParamsPanel } from "./editor/BlockParamsPanel"; import { RenderQueue } from "./renders/RenderQueue"; import { SlideshowPanel } from "./panels/SlideshowPanel"; -import { VariablesPanel, type StudioEditPersistenceProps } from "./panels/VariablesPanel"; +import { VariablesPanel } from "./panels/VariablesPanel"; import { PanelTabButton } from "./PanelTabButton"; import { usePreviewVariablesStore } from "../hooks/previewVariablesStore"; import type { RenderJob } from "./renders/useRenderQueue"; -import type { BlockParam } from "@hyperframes/core/registry"; import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability"; -import type { Composition } from "@hyperframes/sdk"; -import type { EditHistoryKind } from "../utils/editHistory"; -import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; +import { useSlideshowPersist } from "../hooks/useSlideshowPersist"; import { useSlideshowTabState } from "../hooks/useSlideshowTabState"; import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; @@ -27,52 +27,10 @@ import { EMPTY_COLOR_GRADING_SCOPE_RESULT, type ColorGradingScope, } from "./studioColorGradingScope"; -import type { - AddMediaOverlayHandler, - BackgroundRemovalProgress, -} from "./editor/propertyPanelTypes"; -import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers"; +import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes"; +import { timelineKeysForSelections } from "../utils/studioHelpers"; import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize"; -export interface StudioRightPanelProps extends StudioEditPersistenceProps { - designPanelActive: boolean; - activeBlockParams?: { - blockName: string; - blockTitle: string; - params: BlockParam[]; - compositionPath: string; - } | null; - onCloseBlockParams?: () => void; - recordingState?: "idle" | "recording" | "preview"; - recordingDuration?: number; - onToggleRecording?: () => void; - /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ - sdkSession: Composition | null; - publishSdkSession: NonNullable; - /** - * Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider - * opens its own separate SDK session scoped to the selected element's own - * file (needed so promoting inside a sub-composition binds a variable there, - * not on the host) — for a top-level selection that's the SAME file this - * session already has open, so a write through that other session leaves - * this one holding stale in-memory content. The self-write-echo registry - * that normally suppresses redundant reloads is keyed by file path only, not - * by session instance, so it wrongly treats the sibling session's write as - * "our own echo" and never reloads on its own — this must be called - * explicitly after such a write. - */ - forceReloadSdkSession?: () => void; - reloadPreview: () => void; - domEditSaveTimestampRef: MutableRefObject; - recordEdit: (entry: { - label: string; - kind: EditHistoryKind; - files: Record; - }) => Promise; - onToggleElementHidden?: ToggleHiddenHandler; - onAddMediaOverlay?: AddMediaOverlayHandler; -} - // fallow-ignore-next-line complexity export function StudioRightPanel({ designPanelActive, diff --git a/packages/studio/src/components/StudioRightPanel.types.ts b/packages/studio/src/components/StudioRightPanel.types.ts new file mode 100644 index 0000000000..096fc69958 --- /dev/null +++ b/packages/studio/src/components/StudioRightPanel.types.ts @@ -0,0 +1,55 @@ +/** + * Props for StudioRightPanel. + * + * Kept beside the component rather than inside it: the panel is at the file-size + * cap, and this block is the part that changes least, so moving it keeps the + * component's own diffs small and readable. + */ + +import type { MutableRefObject } from "react"; +import type { StudioEditPersistenceProps } from "./panels/VariablesPanel"; +import type { BlockParam } from "@hyperframes/core/registry"; +import type { Composition } from "@hyperframes/sdk"; +import type { EditHistoryKind } from "../utils/editHistory"; +import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist"; +import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes"; +import type { ToggleHiddenHandler } from "../utils/studioHelpers"; + +export interface StudioRightPanelProps extends StudioEditPersistenceProps { + designPanelActive: boolean; + activeBlockParams?: { + blockName: string; + blockTitle: string; + params: BlockParam[]; + compositionPath: string; + } | null; + onCloseBlockParams?: () => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; + /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ + sdkSession: Composition | null; + publishSdkSession: NonNullable; + /** + * Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider + * opens its own separate SDK session scoped to the selected element's own + * file (needed so promoting inside a sub-composition binds a variable there, + * not on the host) — for a top-level selection that's the SAME file this + * session already has open, so a write through that other session leaves + * this one holding stale in-memory content. The self-write-echo registry + * that normally suppresses redundant reloads is keyed by file path only, not + * by session instance, so it wrongly treats the sibling session's write as + * "our own echo" and never reloads on its own — this must be called + * explicitly after such a write. + */ + forceReloadSdkSession?: () => void; + reloadPreview: () => void; + domEditSaveTimestampRef: MutableRefObject; + recordEdit: (entry: { + label: string; + kind: EditHistoryKind; + files: Record; + }) => Promise; + onToggleElementHidden?: ToggleHiddenHandler; + onAddMediaOverlay?: AddMediaOverlayHandler; +} diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 3e4f5dbf07..061fea8e4c 100644 --- a/packages/studio/src/player/components/TimelineAutomationLane.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLane.tsx @@ -27,7 +27,6 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { - resolveAutomationRange, sampleAutomationLane, type AutomationRange, type HfAutomation, @@ -42,13 +41,7 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { generateShape, type AutomationShapeId } from "./automationShapes"; import { simplifyPoints } from "./automationSimplify"; import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection"; -import { getTimelineLaneTop } from "./timelineLayout"; import { defaultTimelineTheme } from "./timelineTheme"; -import { groupAutomationLanes } from "./automationLaneData"; -import { isAudioTimelineElement } from "../../utils/timelineInspector"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import type { TimelineElement } from "../store/playerStore"; -import type { UseAutomationLanesResult } from "./useAutomationLanes"; /** * Drawn radius of a breakpoint. @@ -504,171 +497,3 @@ export function TimelineAutomationLane({ ); } - -/** Which shared rows one clip draws into, and with which of its lanes. */ -interface ClipLaneRow { - lane: HfAutomationLane; - rowIndex: number; -} - -/** - * One clip's envelopes, each in the shared row its property owns. - * - * Its own component because every clip on the row needs its own binding, its own - * gestures and its own selection box — a shared row is a shared lane track, not a - * shared envelope, and two clips' curves must never drag as one thing. Hooks - * cannot run in a loop, so the loop is over components. - */ -function ClipAutomationLanes({ - element, - rows, - isSelected, - lanes, - pps, - top, - accentColor, - currentTime, - beatTimes, -}: { - element: TimelineElement; - rows: readonly ClipLaneRow[]; - isSelected: boolean; - lanes: UseAutomationLanesResult; - pps: number; - /** y of the first automation row on this track. */ - top: number; - accentColor: string; - currentTime: number; - beatTimes?: readonly number[]; -}) { - // Beats inside this clip, in the clip's own frame — the lane's times are - // clip-local, and a beat outside the clip can never be snapped to anyway. - const snapTimes = useMemo( - () => - (beatTimes ?? []) - .filter((t) => t >= element.start && t <= element.start + element.duration) - .map((t) => t - element.start), - [beatTimes, element.start, element.duration], - ); - const bound = lanes.bind(element, isSelected); - // Stale-selection guard: the selected lane's target can vanish out from under - // it (e.g. its effect got deleted from the chain, dropping the lane), leaving - // a rectangle selecting nothing. Clear it rather than let it point at a - // target that no longer draws. Above the empty-rows return, because a clip - // that draws nothing is exactly when a selection goes stale. - useEffect(() => { - const target = bound.selection?.target; - if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) { - bound.onRangeClear(); - } - }, [bound]); - if (rows.length === 0) return null; - const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; - return ( - <> - {rows.map(({ lane, rowIndex }) => { - const range = resolveAutomationRange(lane.target, bound.chain ?? undefined); - // A lane whose target no longer resolves was already dropped upstream; - // this is belt and braces so a row can never draw on the wrong axis. - if (!range) return null; - return ( - bound.onRangeSelect(lane.target, t0, t1, v0, v1)} - onRangeClear={bound.onRangeClear} - /> - ); - })} - - ); -} - -export interface TimelineAutomationLaneSlotProps { - /** Every clip on the track, in row order — not just the selected one. */ - elements: readonly TimelineElement[]; - isSelected: (element: TimelineElement) => boolean; - lanes: UseAutomationLanesResult; - pps: number; - /** Keyframe lanes already stacked above, which automation sits under. */ - laneCount: number; - accentColor: string; - /** Composition-time playhead; the slot converts it to clip-local. */ - currentTime: number; - /** Composition-time beat grid; the slot converts it to clip-local too. */ - beatTimes?: readonly number[]; -} - -/** - * Every automated parameter on this TRACK, one lane per row — the way a DAW - * stacks them, so two envelopes can be read and edited without swapping a - * control to see either. - * - * Rows belong to the track, not to a clip: clips sharing a row share a row per - * property (see `groupAutomationLanes`), each drawing over its own span, and a - * clip that does not automate that property leaves its stretch empty. Binding one - * clip at a time is what made the visible envelopes change with the selection. - */ -export function TimelineAutomationLaneSlot({ - elements, - isSelected, - lanes, - pps, - laneCount, - accentColor, - currentTime, - beatTimes, -}: TimelineAutomationLaneSlotProps) { - const clips = elements.filter(isAudioTimelineElement); - const rowsByClip = new Map(); - groupAutomationLanes(clips).forEach((group, rowIndex) => { - for (const entry of group.entries) { - const key = getTimelineElementIdentity(entry.element); - const rows = rowsByClip.get(key); - if (rows) rows.push({ lane: entry.lane, rowIndex }); - else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]); - } - }); - const top = getTimelineLaneTop(laneCount); - return ( - <> - {clips.map((element) => ( - - ))} - - ); -} diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx index ebc6d95398..7249132f57 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.test.tsx @@ -2,7 +2,7 @@ import { act } from "react"; import { describe, expect, it, vi } from "vitest"; import { createRoot } from "react-dom/client"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { PAD_X } from "./automationLaneGeometry"; import { getTimelineLaneTop } from "./timelineLayout"; diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx new file mode 100644 index 0000000000..8c0c07ffba --- /dev/null +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx @@ -0,0 +1,187 @@ +/** + * Track-level automation: the stack of shared rows an audio track draws, and + * one clip's envelopes within them. + * + * Split from TimelineAutomationLane.tsx, which owns the single-lane editor this + * renders many of. The dependency runs one way, slot -> lane, so the editor + * stays readable on its own and this file keeps the row-layout concern. + */ + +import { useEffect, useMemo } from "react"; +import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core/audio-automation"; +import { TimelineAutomationLane } from "./TimelineAutomationLane"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { getTimelineLaneTop } from "./timelineLayout"; +import { groupAutomationLanes } from "./automationLaneData"; +import { isAudioTimelineElement } from "../../utils/timelineInspector"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import type { TimelineElement } from "../store/playerStore"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; + +/** Which shared rows one clip draws into, and with which of its lanes. */ +interface ClipLaneRow { + lane: HfAutomationLane; + rowIndex: number; +} + +/** + * One clip's envelopes, each in the shared row its property owns. + * + * Its own component because every clip on the row needs its own binding, its own + * gestures and its own selection box — a shared row is a shared lane track, not a + * shared envelope, and two clips' curves must never drag as one thing. Hooks + * cannot run in a loop, so the loop is over components. + */ +function ClipAutomationLanes({ + element, + rows, + isSelected, + lanes, + pps, + top, + accentColor, + currentTime, + beatTimes, +}: { + element: TimelineElement; + rows: readonly ClipLaneRow[]; + isSelected: boolean; + lanes: UseAutomationLanesResult; + pps: number; + /** y of the first automation row on this track. */ + top: number; + accentColor: string; + currentTime: number; + beatTimes?: readonly number[]; +}) { + // Beats inside this clip, in the clip's own frame — the lane's times are + // clip-local, and a beat outside the clip can never be snapped to anyway. + const snapTimes = useMemo( + () => + (beatTimes ?? []) + .filter((t) => t >= element.start && t <= element.start + element.duration) + .map((t) => t - element.start), + [beatTimes, element.start, element.duration], + ); + const bound = lanes.bind(element, isSelected); + // Stale-selection guard: the selected lane's target can vanish out from under + // it (e.g. its effect got deleted from the chain, dropping the lane), leaving + // a rectangle selecting nothing. Clear it rather than let it point at a + // target that no longer draws. Above the empty-rows return, because a clip + // that draws nothing is exactly when a selection goes stale. + useEffect(() => { + const target = bound.selection?.target; + if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) { + bound.onRangeClear(); + } + }, [bound]); + if (rows.length === 0) return null; + const inClip = currentTime >= element.start && currentTime <= element.start + element.duration; + return ( + <> + {rows.map(({ lane, rowIndex }) => { + const range = resolveAutomationRange(lane.target, bound.chain ?? undefined); + // A lane whose target no longer resolves was already dropped upstream; + // this is belt and braces so a row can never draw on the wrong axis. + if (!range) return null; + return ( + bound.onRangeSelect(lane.target, t0, t1, v0, v1)} + onRangeClear={bound.onRangeClear} + /> + ); + })} + + ); +} + +export interface TimelineAutomationLaneSlotProps { + /** Every clip on the track, in row order — not just the selected one. */ + elements: readonly TimelineElement[]; + isSelected: (element: TimelineElement) => boolean; + lanes: UseAutomationLanesResult; + pps: number; + /** Keyframe lanes already stacked above, which automation sits under. */ + laneCount: number; + accentColor: string; + /** Composition-time playhead; the slot converts it to clip-local. */ + currentTime: number; + /** Composition-time beat grid; the slot converts it to clip-local too. */ + beatTimes?: readonly number[]; +} + +/** + * Every automated parameter on this TRACK, one lane per row — the way a DAW + * stacks them, so two envelopes can be read and edited without swapping a + * control to see either. + * + * Rows belong to the track, not to a clip: clips sharing a row share a row per + * property (see `groupAutomationLanes`), each drawing over its own span, and a + * clip that does not automate that property leaves its stretch empty. Binding one + * clip at a time is what made the visible envelopes change with the selection. + */ +export function TimelineAutomationLaneSlot({ + elements, + isSelected, + lanes, + pps, + laneCount, + accentColor, + currentTime, + beatTimes, +}: TimelineAutomationLaneSlotProps) { + const clips = elements.filter(isAudioTimelineElement); + const rowsByClip = new Map(); + groupAutomationLanes(clips).forEach((group, rowIndex) => { + for (const entry of group.entries) { + const key = getTimelineElementIdentity(entry.element); + const rows = rowsByClip.get(key); + if (rows) rows.push({ lane: entry.lane, rowIndex }); + else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]); + } + }); + const top = getTimelineLaneTop(laneCount); + return ( + <> + {clips.map((element) => ( + + ))} + + ); +} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index d0993d81f0..752359f6ba 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -3,7 +3,7 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; import { useAutomationLanes } from "./useAutomationLanes"; import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader";