refactor(studio): split the two files over the 600-line cap (#3313)

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.
This commit is contained in:
Miguel Ángel
2026-08-17 16:46:22 -04:00
committed by GitHub
parent 0285a711e9
commit 35eb6d2906
6 changed files with 252 additions and 227 deletions
@@ -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<UseSlideshowPersistParams["publishSdkSession"]>;
/**
* 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<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: ToggleHiddenHandler;
onAddMediaOverlay?: AddMediaOverlayHandler;
}
// fallow-ignore-next-line complexity
export function StudioRightPanel({
designPanelActive,
@@ -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<UseSlideshowPersistParams["publishSdkSession"]>;
/**
* 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<number>;
recordEdit: (entry: {
label: string;
kind: EditHistoryKind;
files: Record<string, { before: string; after: string }>;
}) => Promise<void>;
onToggleElementHidden?: ToggleHiddenHandler;
onAddMediaOverlay?: AddMediaOverlayHandler;
}
@@ -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({
</div>
);
}
/** 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 (
<TimelineAutomationLane
key={lane.target}
duration={element.duration}
widthPx={Math.max(element.duration * pps, 4)}
leftPx={element.start * pps}
topPx={top + rowIndex * AUTOMATION_LANE_H}
automation={bound.automation}
target={lane.target}
range={range}
accentColor={accentColor}
playheadSec={inClip ? currentTime - element.start : null}
onPreview={bound.onPreview}
onCommit={bound.onCommit}
onSelect={bound.onSelect}
snapTimes={snapTimes}
readOnly={bound.readOnly}
rangeSelection={
bound.selection?.target === lane.target
? {
t0: bound.selection.t0,
t1: bound.selection.t1,
v0: bound.selection.v0,
v1: bound.selection.v1,
}
: null
}
onRangeSelect={(t0, t1, v0, v1) => 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<string, ClipLaneRow[]>();
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) => (
<ClipAutomationLanes
key={getTimelineElementIdentity(element)}
element={element}
rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
isSelected={isSelected(element)}
lanes={lanes}
pps={pps}
top={top}
accentColor={accentColor}
currentTime={currentTime}
beatTimes={beatTimes}
/>
))}
</>
);
}
@@ -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";
@@ -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 (
<TimelineAutomationLane
key={lane.target}
duration={element.duration}
widthPx={Math.max(element.duration * pps, 4)}
leftPx={element.start * pps}
topPx={top + rowIndex * AUTOMATION_LANE_H}
automation={bound.automation}
target={lane.target}
range={range}
accentColor={accentColor}
playheadSec={inClip ? currentTime - element.start : null}
onPreview={bound.onPreview}
onCommit={bound.onCommit}
onSelect={bound.onSelect}
snapTimes={snapTimes}
readOnly={bound.readOnly}
rangeSelection={
bound.selection?.target === lane.target
? {
t0: bound.selection.t0,
t1: bound.selection.t1,
v0: bound.selection.v0,
v1: bound.selection.v1,
}
: null
}
onRangeSelect={(t0, t1, v0, v1) => 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<string, ClipLaneRow[]>();
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) => (
<ClipAutomationLanes
key={getTimelineElementIdentity(element)}
element={element}
rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
isSelected={isSelected(element)}
lanes={lanes}
pps={pps}
top={top}
accentColor={accentColor}
currentTime={currentTime}
beatTimes={beatTimes}
/>
))}
</>
);
}
@@ -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";