diff --git a/packages/studio/src/player/components/TimelineAutomationLane.tsx b/packages/studio/src/player/components/TimelineAutomationLane.tsx index 20c15483f..061fea8e4 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, @@ -43,11 +42,6 @@ import { generateShape, type AutomationShapeId } from "./automationShapes"; import { simplifyPoints } from "./automationSimplify"; import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection"; import { defaultTimelineTheme } from "./timelineTheme"; -import { groupAutomationLanes, isCarveLane } 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. @@ -67,7 +61,6 @@ const LANE_BORDER = defaultTimelineTheme.rowBorder; /** Selection box on one lane. Value bounds included: a point at the right time * but the wrong value is not in it. */ type SelectionBox = { t0: number; t1: number; v0: number; v1: number }; -import { getTimelineLaneTop } from "./timelineLayout"; /** Is this breakpoint inside the selection box? The rule itself is shared with * Delete and with the group drag, so what is drawn as caught is exactly what @@ -504,180 +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; - /** Exact y for the first lane, overriding `laneCount`. A group's lanes sit - * directly under its header row rather than under a stack of keyframe - * lanes, so it cannot be said in `laneCount`. */ - topOffset?: 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, - topOffset, - 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 = topOffset ?? getTimelineLaneTop(laneCount); - return ( - <> - {clips.map((element) => ( - - ))} - - ); -} diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx index 8c0c07ffb..ed65d1b93 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx @@ -12,7 +12,7 @@ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core import { TimelineAutomationLane } from "./TimelineAutomationLane"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { getTimelineLaneTop } from "./timelineLayout"; -import { groupAutomationLanes } from "./automationLaneData"; +import { groupAutomationLanes, isCarveLane } from "./automationLaneData"; import { isAudioTimelineElement } from "../../utils/timelineInspector"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; import type { TimelineElement } from "../store/playerStore"; @@ -100,7 +100,11 @@ function ClipAutomationLanes({ onCommit={bound.onCommit} onSelect={bound.onSelect} snapTimes={snapTimes} - readOnly={bound.readOnly} + // The carve owns its own envelopes and rewrites them on every + // re-run, so a drag would be silently discarded — shown, but not + // editable. Per LANE, not per binding: a carved bed can carry the + // author's own volume curve beside the carve's bands. + readOnly={bound.readOnly || isCarveLane(lane.target, bound.chain)} rangeSelection={ bound.selection?.target === lane.target ? { @@ -128,6 +132,10 @@ export interface TimelineAutomationLaneSlotProps { pps: number; /** Keyframe lanes already stacked above, which automation sits under. */ laneCount: number; + /** Exact y for the first lane, overriding `laneCount`. A group's lanes sit + * directly under its header row rather than under a stack of keyframe + * lanes, so it cannot be said in `laneCount`. */ + topOffset?: number; accentColor: string; /** Composition-time playhead; the slot converts it to clip-local. */ currentTime: number; @@ -151,6 +159,7 @@ export function TimelineAutomationLaneSlot({ lanes, pps, laneCount, + topOffset, accentColor, currentTime, beatTimes, @@ -165,7 +174,7 @@ export function TimelineAutomationLaneSlot({ else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]); } }); - const top = getTimelineLaneTop(laneCount); + const top = topOffset ?? getTimelineLaneTop(laneCount); return ( <> {clips.map((element) => ( diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 1d27189da..4a5209622 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -10,7 +10,7 @@ import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineGroupHeader } from "./TimelineGroupHeader"; import { groupAutomationLanes } from "./automationLaneData"; import { groupAutomationElement } from "./groupAutomationElement"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; import { TRACK_H } from "./timelineLayout"; import type { UseAutomationLanesResult } from "./useAutomationLanes";