From 69c603cf0dc0a5cb1f75be2189007be1ac7bb259 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 10:47:48 -0400 Subject: [PATCH 01/42] refactor(studio): keep legacy fixture state out of rendered rows --- .../player/components/TimelineGroupRow.tsx | 91 ++------ .../src/player/components/TimelineLanes.tsx | 131 +---------- .../player/components/TimelineTrackHeader.tsx | 4 +- .../components/timelineKeyboardNavigation.ts | 204 ++---------------- .../useAutoExpandKeyframedClips.test.tsx | 75 ------- .../components/useAutoExpandKeyframedClips.ts | 48 ----- .../components/useTimelineClipDisclosure.ts | 41 ---- .../studio/src/player/store/keyframeSlice.ts | 24 +++ 8 files changed, 62 insertions(+), 556 deletions(-) delete mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx delete mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.ts delete mode 100644 packages/studio/src/player/components/useTimelineClipDisclosure.ts diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 0e2c5db9be..2004aa5397 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -8,21 +8,12 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineGroupHeader } from "./TimelineGroupHeader"; -import { groupAutomationLanes } from "./automationLaneData"; import { groupAutomationElement } from "./groupAutomationElement"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; -import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; -import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; -import type { UseAutomationLanesResult } from "./useAutomationLanes"; -import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext"; +import { LABEL_COL_W } from "./timelineLayout"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { usePlayerStore } from "../store/playerStore"; -/** Accent rail on a group-owned lane — the same green the member rail uses, so - * "this belongs to the group" reads the same in both places (groups doc §5). */ -const GROUP_LANE_ACCENT = "#3CE6AC"; - interface TimelineGroupRowProps { index: number; rowKey: number; @@ -35,17 +26,17 @@ interface TimelineGroupRowProps { theme: TimelineTheme; rovingTargetId?: string | null; collapsedGroupIds: ReadonlySet; - expandedLaneOwnerIds: ReadonlySet; + expandedLaneOwnerIds?: ReadonlySet; toggleGroupExpanded: (id: string) => void; - toggleLaneOwnerExpanded: (id: string) => void; - lanes: UseAutomationLanesResult; - pps: number; - currentTime: number; + toggleLaneOwnerExpanded?: (id: string) => void; + lanes?: unknown; + pps?: number; + currentTime?: number; + beatTimes?: readonly number[]; + trackContentWidth?: number; + contentGutter?: number; /** A group's lanes are in composition time (§1.3), so this is their span. */ compositionDuration: number; - beatTimes?: readonly number[]; - contentGutter: number; - trackContentWidth: number; } /** A group's own row: the accessible shell (shared with track rows) plus the group header. */ @@ -61,16 +52,10 @@ export function TimelineGroupRow({ theme, rovingTargetId = null, collapsedGroupIds, - expandedLaneOwnerIds, + expandedLaneOwnerIds: _expandedLaneOwnerIds, toggleGroupExpanded, - toggleLaneOwnerExpanded, - lanes, - pps, - currentTime, + toggleLaneOwnerExpanded: _toggleLaneOwnerExpanded, compositionDuration, - beatTimes, - contentGutter, - trackContentWidth, }: TimelineGroupRowProps) { // From the group, NOT from `tracks`: a collapsed group emits no member rows // into the display list, and every one of these reads silently degraded to @@ -83,9 +68,6 @@ export function TimelineGroupRow({ // The binder writes through the dom-edit selection, so a group lane is // editable exactly when the group is the selected element — which clicking // its name in the header does. - const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; - const isGroupSelected = domSelection?.id === group.id; - const isLaneOpen = expandedLaneOwnerIds.has(group.id); // Optional, like every sibling row: Timeline renders outside the edit // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // hook took the whole timeline down with it the moment a group existed — @@ -148,13 +130,9 @@ export function TimelineGroupRow({ memberCount={group.memberTracks.length} isExpanded={!collapsedGroupIds.has(group.id)} onToggleExpanded={() => toggleGroupExpanded(group.id)} - // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc - // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and - // counting the members' here made the group advertise curves it does - // not own and cannot show. - laneCount={groupAutomationLanes([groupElement]).length} - isLaneOpen={isLaneOpen} - onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} + laneCount={0} + isLaneOpen={false} + onToggleLanes={() => undefined} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} @@ -168,48 +146,7 @@ export function TimelineGroupRow({ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} theme={theme} /> - {/* The group's OWN curves, under the strip. Selected-gated exactly like a - clip's: the binder writes through the dom-edit selection, so a lane is - editable once the group is selected — which clicking its name does. */} - {/* The label column for those lanes, on the accent rail — inside the - sticky column above, so they pin with the header. */} - {isLaneOpen && ( - = LABEL_COL_W ? LABEL_COL_W : contentOrigin} - gutterBackground={theme.gutterBackground} - accentColor={GROUP_LANE_ACCENT} - onReveal={openGroupFxRack} - /> - )} - {isLaneOpen && ( - // The same offset content cell a track row wraps its lanes in — the - // slot positions absolutely, so mounted straight on the row it resolved - // against the row instead and drew the envelope across the label gutter - // from x=0. -
- isGroupSelected} - lanes={lanes} - pps={pps} - // Below the strip, which sits directly under the header row. - laneCount={0} - topOffset={TRACK_H} - accentColor={GROUP_LANE_ACCENT} - currentTime={currentTime} - beatTimes={beatTimes} - /> -
- )} ); } diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 3921bece03..b45a8dcece 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -2,23 +2,16 @@ import { Fragment, useId, useMemo } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; -import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; -import { useAutomationLanes } from "./useAutomationLanes"; -import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; -import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure"; import { - isTrackRowExpanded, resolveTrackKeyframeClip, trackShowsBeatStrip, } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; -import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities } from "./timelineEditing"; -import { CLIP_Y, TRACK_H } from "./timelineLayout"; +import { CLIP_Y } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview"; import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows"; @@ -100,14 +93,10 @@ export function TimelineLanes({ // ponytail: One per-instance namespace prevents aria-controls and aria-owns // from resolving into a second timeline that renders the same logical rows. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; - const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); - const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = - useTimelineGroupDisclosure(); - const automationLanes = useAutomationLanes(); + const { collapsedGroupIds, toggleGroupExpanded } = useTimelineGroupDisclosure(); // A group's automation clock is COMPOSITION time (groups doc §1.3), so its // synthetic lane element spans the whole composition rather than a clip. const compositionDuration = usePlayerStore((s) => s.duration); - useAutomationSelectionKeyboard({ lanes: automationLanes }); 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. @@ -115,10 +104,6 @@ export function TimelineLanes({ () => new Set(groups.flatMap((group) => group.memberTracks)), [groups], ); - const { - toggleRowExpanded: toggleRowExpandedTracked, - toggleClipExpanded: toggleClipExpandedTracked, - } = useTimelineClipDisclosure(); const actorWindows = useTimelineMultiDragActorWindows( multiDragPreview, rowsVirtualized, @@ -129,9 +114,6 @@ export function TimelineLanes({ focusedTargetId, rowGeometry, scrollRef, - onToggleRow: (row) => { - if (row.elementId) toggleClipExpandedTracked(row.elementId); - }, }); return (
); } @@ -216,54 +190,27 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; - const rowExpanded = isTrackRowExpanded(els, expandedClipIds); - // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a - // clip left to fill it painted its waveform straight over them — so the - // bar is capped for every clip on the row, not just the one whose - // property lanes are showing. Undefined means "fill the row", which is - // right only while it is collapsed and the row is nothing but bar. - const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined; // The clips whose envelopes this row draws, at their dragged positions. // Once per row, not once per clip in the map below. - const automationElements = els.map(getPreviewElement); // Minted here because this is the only place that sees BOTH ends of // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. const lanesId = `${lanesIdPrefix}-track-${row}`; - // The caret reveals two canvas regions now: the active clip's keyframe - // lanes and the track's automation lanes. They cannot be one element — - // one belongs to a clip, the other to the row — so the caret names both. - const automationLanesId = `${lanesId}-automation`; // The header's remove buttons write through the same binding the lanes // themselves edit through, so a deletion persists exactly like dragging // a point does — and the binding reports read-only for an unselected // clip, which is what leaves the buttons off rather than offering one // that cannot act. - const headerLanes = - keyframeClip && keyframeClipKey - ? automationLanes.bind( - keyframeClip, - selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey), - ) - : null; - const removeAutomationLane = - headerLanes && !headerLanes.readOnly - ? (target: string) => - headerLanes.onCommit({ - version: 1, - lanes: headerLanes.lanes.filter((lane) => lane.target !== target), - }) - : undefined; return ( { - const keys = els.map(getTimelineElementIdentity); - if (keys.length > 0) toggleRowExpandedTracked(keys); - }} onToggleTrackHidden={onToggleTrackHidden} onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} - onRemoveAutomationLane={removeAutomationLane} onSeek={onSeek} rovingTargetId={keyboard.rovingTargetId} /> @@ -368,8 +310,6 @@ export function TimelineLanes({ // Only the track's active keyframe clip shows expanded lanes; // other clips (incl. siblings on a shared track) show compact // diamonds on their own bar instead. - const isTrackKeyframeClip = elementKey === keyframeClipKey; - const showsLanes = isTrackKeyframeClip && rowExpanded; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); @@ -427,7 +367,7 @@ export function TimelineLanes({ el={previewElement} pps={pps} clipY={CLIP_Y} - clipHeight={clipBarHeight} + clipHeight={undefined} isSelected={isSelected} isHovered={hoveredClip === clipKey} isDragging={false} @@ -484,44 +424,6 @@ export function TimelineLanes({ ); // Keep this shell mounted while collapsed so aria-controls stays valid // and multi-drag cannot remount the subtree mid-gesture. - const propertyLanes = isTrackKeyframeClip && ( - 0 - ? ((currentTime - previewElement.start) / previewElement.duration) * 100 - : 0 - } - elementId={elementKey} - selectedKeyframes={selectedKeyframes} - rovingTargetId={keyboard.rovingTargetId} - onSelectSegment={(target) => onSelectSegment?.(elementKey, target)} - onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} - onShiftClickKeyframe={(target) => - onShiftClickKeyframe?.(elementKey, target) - } - onContextMenuKeyframe={(e, target) => - onContextMenuKeyframe?.(e, elementKey, target) - } - onMoveKeyframe={(target, toClipPercentage) => - onMoveKeyframe?.(elementKey, target, toClipPercentage) ?? - Promise.resolve(false) - } - suppressClickRef={suppressClickRef} - /> - ); - // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable @@ -531,7 +433,6 @@ export function TimelineLanes({ {clip} {compactDiamonds} - {propertyLanes} ); } @@ -548,7 +449,6 @@ export function TimelineLanes({ > {clip} {compactDiamonds} - {propertyLanes}
); }) @@ -566,23 +466,6 @@ export function TimelineLanes({ the keyframe lanes are. Absolute positions inside resolve against this same relative row, so the geometry is unchanged by the move. */} -
- {rowExpanded ? ( - { - const key = getTimelineElementIdentity(element); - return selectedElementId === key || selectedElementIds.has(key); - }} - lanes={automationLanes} - pps={pps} - laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0} - accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} - currentTime={currentTime} - beatTimes={beatAnalysis?.beatTimes} - /> - ) : null} -
); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index ced3683eb3..ba3dfe52c9 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -74,7 +74,7 @@ interface TimelineTrackHeaderProps { isGroupMember?: boolean; rovingTargetId?: string | null; theme: TimelineTheme; - onToggleClipExpanded: () => void; + onToggleClipExpanded?: () => void; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; /** Drop one envelope. Absent while the lanes are read-only, which is what @@ -196,7 +196,7 @@ export function TimelineTrackHeader({ ); // Automation counts as something to disclose: gating the caret on tweens alone // left an audio clip's envelopes unreachable, since the track could not expand. - const disclosable = lanes.length > 0 || automationRows.length > 0; + const disclosable = false; // Which HEADER LAYOUT the row wears — not the same question as `disclosable`. // An audio track that automates something is still an audio track: it keeps // the music glyph and the group indent and gains the `∿`. Tying layout to diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 00c321d897..cb5a965529 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -1,9 +1,7 @@ -import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; -import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; -import { groupAutomationLanes } from "./automationLaneData"; import { - timelineKeyframeSelectionKey, type TimelineKeyframeTarget, } from "./timelineKeyframeIdentity"; import { @@ -14,7 +12,6 @@ import { timelinePropertyRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; -import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; export type TimelineNavigationKey = @@ -76,11 +73,13 @@ export interface BuildTimelineLogicalRowsInput { laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: ReadonlySet; - expandedClipIds: ReadonlySet; + /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ + expandedClipIds?: ReadonlySet; /** Groups the caret has COLLAPSED — absent means expanded, the default. */ collapsedGroupIds: ReadonlySet; + /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ + expandedLaneOwnerIds?: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ - expandedLaneOwnerIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; gsapAnimations: ReadonlyMap; @@ -117,164 +116,30 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin }); } -/** A track's active clip (if any), its element id, and its automation lanes. */ -function resolveActiveTrackClip( - elements: readonly TimelineElement[], - laneCounts: BuildTimelineLogicalRowsInput["laneCounts"], - selectedElementId: string | null, - selectedElementIds: ReadonlySet, - gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"], -): { - activeClip: TimelineElement | null; - activeId: string | null; - lanes: ReturnType; -} { - const activeClip = resolveTrackKeyframeClip( - elements, - laneCounts, - selectedElementId, - selectedElementIds, - ); - const activeId = activeClip ? elementId(activeClip) : null; - const lanes = activeClip - ? getTimelinePropertyLanes( - gsapAnimations.get(elementId(activeClip)) ?? [], - activeClip.start, - activeClip.duration, - ) - : []; - return { activeClip, activeId, lanes }; -} - -function keyframeTarget( - keyframe: ReturnType[number]["keyframes"][number], -): TimelineKeyframeTarget { - return { - percentage: keyframe.percentage, - tweenPercentage: keyframe.tweenPercentage, - propertyGroup: keyframe.propertyGroup, - animationId: keyframe.animationId, - collidingAnimationTargets: keyframe.collidingAnimationTargets, - }; -} - -function propertyItems( - rowId: string, - clip: TimelineElement, - keyframes: ReturnType[number]["keyframes"], -): TimelineLogicalItem[] { - const id = elementId(clip); - const unique = new Map(); - for (const keyframe of keyframes) { - const target = keyframeTarget(keyframe); - const key = timelineKeyframeSelectionKey(id, target); - if (!unique.has(key)) { - unique.set(key, { - target, - time: clip.start + (keyframe.percentage / 100) * clip.duration, - }); - } - } - const ordered = [...unique.entries()].sort( - ([leftKey, left], [rightKey, right]) => - left.time - right.time || leftKey.localeCompare(rightKey), - ); - const items: TimelineLogicalItem[] = []; - // ponytail: The composite property lane owns adjacency, so the incoming keyframe - // owns an ease segment even when its previous neighbor came from another animation. - for (let index = 0; index < ordered.length; index += 1) { - const [, current] = ordered[index]!; - const previous = ordered[index - 1]?.[1]; - if (previous && current.time > previous.time && current.target.animationId !== undefined) { - items.push({ - id: timelineEaseFocusId(id, current.target), - kind: "ease", - rowId, - elementId: id, - time: previous.time + (current.time - previous.time) / 2, - keyframeTarget: current.target, - }); - } - items.push({ - id: timelineKeyframeFocusId(id, current.target), - kind: "keyframe", - rowId, - elementId: id, - time: current.time, - keyframeTarget: current.target, - }); - } - return items; -} - -/** A clip's lanes are visible when either the caret or the `∿` button opened it. */ -function isRowOpen( - activeId: string | null, - expandedClipIds: ReadonlySet, - expandedLaneOwnerIds: ReadonlySet, -): boolean { - if (activeId === null) return false; - return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId); -} - -/** A single automation-lane row, one level deeper than the track/group row that owns it. */ -function buildLaneRow( - track: number, - logicalIndex: number, - activeId: string, - activeClip: TimelineElement, - lane: ReturnType[number], - level: 2 | 3, - parentId: string, -): TimelineLogicalRow { - const laneRowId = timelinePropertyRowId(activeId, lane.group); - return { - id: laneRowId, - kind: "row", - physicalTrackKey: track, - logicalIndex, - level, - parentId, - elementId: activeId, - expandable: false, - expanded: false, - propertyGroup: lane.group, - items: propertyItems(laneRowId, activeClip, lane.keyframes), - }; -} - /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */ export function buildTimelineLogicalRows({ tracks, displayTrackOrder, - laneCounts, selectedElementId, selectedElementIds, - expandedClipIds, collapsedGroupIds, - expandedLaneOwnerIds, groups, trackGroupOf, - gsapAnimations, }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] { const trackMap = new Map(tracks); const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); const rows: TimelineLogicalRow[] = []; - // A real track's own row (level 1 ungrouped, level 2 under a group) plus, - // when its clip's lanes are open, the lane rows one level deeper. + // A real track is always one logical row. Keyframe navigation stays on the + // clip itself; property-lane rows no longer exist in the timeline tree. function emitTrack(track: number, level: 1 | 2, parentId: string | null): void { const elements = trackMap.get(track) ?? []; const trackId = timelineTrackRowId(track); - const { activeClip, activeId, lanes } = resolveActiveTrackClip( - elements, - laneCounts, - selectedElementId, - selectedElementIds, - gsapAnimations, - ); - const disclosable = isTrackDisclosable(elements, lanes.length); - const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable; + const selected = elements.find((element) => { + const id = elementId(element); + return id === selectedElementId || selectedElementIds.has(id); + }); + const activeId = selected ? elementId(selected) : elements[0] ? elementId(elements[0]) : null; rows.push({ id: trackId, kind: "row", @@ -283,16 +148,10 @@ export function buildTimelineLogicalRows({ level, parentId, elementId: activeId, - expandable: disclosable, - expanded, + expandable: false, + expanded: false, items: clipItems(trackId, elements), }); - if (!expanded || !activeClip || !activeId) return; - for (const lane of lanes) { - rows.push( - buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId), - ); - } } // A group's own row (level 1) plus, when its `∿` is open, its own @@ -315,25 +174,6 @@ export function buildTimelineLogicalRows({ expanded: groupExpanded, items: [], }); - if (expandedLaneOwnerIds.has(group.id)) { - // The group's own member list, not `trackMap`: a COLLAPSED group can have - // its lane shelf open, and its members are absent from the display list — - // so looking them up there emitted zero lane rows for exactly that case. - for (const laneGroup of groupAutomationLanes(group.memberElements)) { - rows.push({ - id: `${groupRowId}::${laneGroup.key}`, - kind: "row", - physicalTrackKey: group.anchorKey, - logicalIndex: rows.length, - level: 2, - parentId: groupRowId, - elementId: null, - expandable: false, - expanded: false, - items: [], - }); - } - } if (!groupExpanded) return; for (const track of group.memberTracks) emitTrack(track, 2, groupRowId); } @@ -455,17 +295,3 @@ export function resolveTimelineFocusFallback( } return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null; } - -/** - * Does a track have anything to open — the header's own `disclosable`. - * - * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and - * keyed on tweens alone here an audio track whose only disclosable content is - * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid, - * so ArrowRight could not open it. Automation rows are counted per shared - * PROPERTY across the track's clips, the way the header counts them, not per - * clip. - */ -function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean { - return laneCount > 0 || groupAutomationLanes(elements).length > 0; -} diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx b/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx deleted file mode 100644 index fdf7ff5f08..0000000000 --- a/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx +++ /dev/null @@ -1,75 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { usePlayerStore } from "../store/playerStore"; -import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; - -const studioShell = vi.hoisted(() => ({ projectId: "project-a" })); -vi.mock("../../contexts/StudioContext", () => ({ - useStudioShellContextOptional: () => studioShell, -})); - -(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -afterEach(() => { - document.body.innerHTML = ""; - studioShell.projectId = "project-a"; - usePlayerStore.getState().reset(); -}); - -const animations = new Map([ - [ - "clip-1", - [ - { - id: "position-tween", - targetSelector: "#clip-1", - method: "to", - position: 0, - duration: 1, - properties: { x: 100 }, - propertyGroup: "position", - }, - ], - ], -]); - -function AutoExpandHarness({ value }: { value: Map }) { - useAutoExpandKeyframedClips(value); - return null; -} - -describe("useAutoExpandKeyframedClips", () => { - it("preserves manual collapse within a project and expands again in a different project", () => { - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - const render = (projectId: string, value = new Map(animations)) => { - studioShell.projectId = projectId; - act(() => root.render()); - }; - - const projectAAnimations = new Map(animations); - render("project-a", projectAAnimations); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); - - act(() => usePlayerStore.getState().toggleClipExpanded("clip-1")); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - - const refreshedProjectAAnimations = new Map(animations); - render("project-a", refreshedProjectAAnimations); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - - render("project-b", refreshedProjectAAnimations); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - - render("project-b", new Map()); - render("project-b"); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); - - act(() => root.unmount()); - }); -}); diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts b/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts deleted file mode 100644 index d53e0a9a11..0000000000 --- a/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { usePlayerStore } from "../store/playerStore"; -import { useStudioShellContextOptional } from "../../contexts/StudioContext"; -import { animationContributesLane } from "./TimelinePropertyLanes"; - -/** - * Keyframed clips start expanded (AE/Figma default). Auto-expands each clip the - * first time it contributes a lane — real keyframes OR a synthesizable flat tween - * — tracked per-clip so a later user collapse sticks and never bounces back open - * (and clips added later still auto-expand). - */ -/** - * Prunes clips that left the source, then returns the ones that newly contribute - * a lane. The prune matters because the set is otherwise append-only: a clip - * deleted and reinserted under the same id (undo, paste) would be remembered as - * already-expanded and never auto-expand again. - */ -function freshLaneClips(gsapAnimations: Map, clips: Set) { - for (const key of clips) { - if (!gsapAnimations.has(key)) clips.delete(key); - } - const fresh: string[] = []; - for (const [key, animations] of gsapAnimations) { - if (clips.has(key)) continue; - if (animations.some(animationContributesLane)) fresh.push(key); - } - return fresh; -} - -export function useAutoExpandKeyframedClips(gsapAnimations: Map): void { - const expandClips = usePlayerStore((s) => s.expandClips); - const projectId = useStudioShellContextOptional()?.projectId ?? null; - const seen = useRef({ projectId, source: gsapAnimations, clips: new Set() }); - useEffect(() => { - if (seen.current.projectId !== projectId) { - const sourceChanged = seen.current.source !== gsapAnimations; - seen.current = { projectId, source: gsapAnimations, clips: new Set() }; - if (!sourceChanged) return; - } else { - seen.current.source = gsapAnimations; - } - const fresh = freshLaneClips(gsapAnimations, seen.current.clips); - if (fresh.length === 0) return; - for (const key of fresh) seen.current.clips.add(key); - expandClips(fresh); - }, [gsapAnimations, expandClips, projectId]); -} diff --git a/packages/studio/src/player/components/useTimelineClipDisclosure.ts b/packages/studio/src/player/components/useTimelineClipDisclosure.ts deleted file mode 100644 index fe856be17e..0000000000 --- a/packages/studio/src/player/components/useTimelineClipDisclosure.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * 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/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index 049f415615..eecbc8a1c6 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -83,6 +83,13 @@ export function isFocusedEaseRequestCurrent( } export interface KeyframeSlice { + /** @deprecated Expansion state is retained only for fixture compatibility; no timeline renderer reads it. */ + expandedClipIds: Set; + toggleClipExpanded: (id: string) => void; + setClipExpanded: (id: string, expanded: boolean) => void; + expandClips: (ids: string[]) => void; + /** @deprecated Compatibility for old fixtures; no renderer reads this state. */ + expandedLaneOwnerIds: Set; /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */ selectedKeyframes: Set; toggleSelectedKeyframe: (key: string) => void; @@ -146,6 +153,23 @@ export function createKeyframeSlice( getTimelineSessionIdentity: () => TimelineSessionIdentity, ): KeyframeSlice { return { + expandedClipIds: new Set(), + toggleClipExpanded: (id) => + set((state) => { + const next = new Set(state.expandedClipIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { expandedClipIds: next }; + }), + setClipExpanded: (id, expanded) => + set((state) => { + const next = new Set(state.expandedClipIds); + if (expanded) next.add(id); + else next.delete(id); + return { expandedClipIds: next }; + }), + expandClips: (ids) => set({ expandedClipIds: new Set(ids) }), + expandedLaneOwnerIds: new Set(), selectedKeyframes: new Set(), toggleSelectedKeyframe: (key) => set((state) => { From 044d91e49fb4a8fb2ad15f64a62d0d8d234a2de3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 10:49:59 -0400 Subject: [PATCH 02/42] fix(studio): keep timeline keyboard actor mounted --- packages/studio/src/player/components/TimelineLanes.tsx | 3 ++- packages/studio/src/player/components/TimelineTrackHeader.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index b45a8dcece..d758f9195a 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -114,6 +114,7 @@ export function TimelineLanes({ focusedTargetId, rowGeometry, scrollRef, + onToggleRow: () => undefined, }); return (
); const compactKeyframes = keyframeCache?.get(elementKey); - const compactDiamonds = !showsLanes && compactKeyframes && ( + const compactDiamonds = compactKeyframes && ( undefined, onToggleTrackHidden, onTogglePropertyGroupKeyframe, onRemoveAutomationLane, From ff4f9666793cb72cea92fedd91e4cac0c52b89d3 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:06:52 -0400 Subject: [PATCH 03/42] refactor(studio): remove expansion telemetry and prove stable nested rows --- .../src/components/nle/TimelinePane.tsx | 7 --- .../useTimelineLogicalRows.test.tsx | 46 +++++++++++++++++-- packages/studio/src/telemetry/events.test.ts | 6 --- packages/studio/src/telemetry/events.ts | 8 ---- 4 files changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 828867cc87..17e559288c 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -3,7 +3,6 @@ import { Timeline } from "../../player"; import type { TimelineElement, TimelineTimeRange } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; -import { trackStudioExpandedClipEdit } from "../../telemetry/events"; import { useNLEContext } from "./NLEContext"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; @@ -164,7 +163,6 @@ export function TimelinePane({ (element: TimelineElement, updates: Pick) => { const basis = element.expandedParentStart; if (basis === undefined) return onMoveElement?.(element, updates); - trackStudioExpandedClipEdit({ action: "move" }); onMoveElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), @@ -185,7 +183,6 @@ export function TimelinePane({ // Match the sibling handlers: report the telemetry when the batch touches at // least one expanded sub-comp child (the clips being rebased to local coords). if (edits.some(({ element }) => element.expandedParentStart !== undefined)) { - trackStudioExpandedClipEdit({ action: "move" }); } if (!onMoveElements) return; return forwardRebasedTimelineMoveElements( @@ -206,7 +203,6 @@ export function TimelinePane({ ) => { const basis = element.expandedParentStart; if (basis === undefined) return onResizeElement?.(element, updates); - trackStudioExpandedClipEdit({ action: "resize" }); onResizeElement?.(toLocalElement(element, basis), { ...updates, start: Math.max(0, updates.start - basis), @@ -227,7 +223,6 @@ export function TimelinePane({ ) => { if (!onResizeElements) return; if (changes.some(({ element }) => element.expandedParentStart !== undefined)) { - trackStudioExpandedClipEdit({ action: "resize" }); } return forwardRebasedTimelineResizeElements(changes, options, onResizeElements); }, @@ -238,7 +233,6 @@ export function TimelinePane({ (element: TimelineElement) => { const basis = element.expandedParentStart; if (basis === undefined) return onDeleteElement?.(element); - trackStudioExpandedClipEdit({ action: "delete" }); return onDeleteElement?.(toLocalElement(element, basis)); }, [onDeleteElement, toLocalElement], @@ -248,7 +242,6 @@ export function TimelinePane({ (element: TimelineElement, splitTime: number) => { const basis = element.expandedParentStart; if (basis === undefined) return onSplitElement?.(element, splitTime); - trackStudioExpandedClipEdit({ action: "split" }); return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); }, [onSplitElement, toLocalElement], diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index 1ba44d32e5..c3238ae46f 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -28,11 +28,17 @@ const groups: never[] = []; const trackGroupOf = new Map(); const gsapAnimations = new Map(); -function Harness({ snapshots }: { snapshots: Array }) { - usePlayerStore((state) => state.requestedSeekTime); +function Harness({ + snapshots, + inputTracks = tracks, +}: { + snapshots: Array; + inputTracks?: typeof tracks; +}) { + usePlayerStore((state) => state.currentTime); const logicalRows = useTimelineLogicalRows({ - tracks, - displayTrackOrder, + tracks: inputTracks, + displayTrackOrder: inputTracks.map(([track]) => track), laneCounts, selectedElementId: null, selectedElementIds, @@ -62,4 +68,36 @@ describe("useTimelineLogicalRows", () => { expect(snapshots.at(-1)).toBe(first); act(() => root.unmount()); }); + + it("keeps a nested element in one row as the playhead crosses it", () => { + const nestedTracks = [ + [ + 0, + [ + { + id: "nested-div", + tag: "div", + track: 0, + start: 1, + duration: 2, + parentCompositionId: "scene", + }, + ], + ], + ] as const satisfies typeof tracks; + const host = document.createElement("div"); + const root = createRoot(host); + const snapshots: Array = []; + act(() => root.render()); + const before = snapshots.at(-1); + + act(() => usePlayerStore.setState({ currentTime: 2.5 })); + + const after = snapshots.at(-1); + expect(before).toHaveLength(1); + expect(after).toHaveLength(1); + expect(after?.[0]?.items).toHaveLength(1); + expect(after).toBe(before); + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index 6a281edc8d..fcb428ec45 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -12,7 +12,6 @@ const { trackPreviewFirstFrame, trackStudioRenderStart, trackStudioRazorSplit, - trackStudioExpandedClipEdit, trackStudioKeyframeLaneExpand, trackStudioSegmentEaseEdit, trackStudioFeedback, @@ -107,11 +106,6 @@ describe("studio telemetry events", () => { expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 }); }); - it("trackStudioExpandedClipEdit emits 'studio_expanded_clip_edit' with action", () => { - trackStudioExpandedClipEdit({ action: "resize" }); - expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" }); - }); - it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => { trackStudioKeyframeLaneExpand({ expanded: true }); expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true }); diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index ac56e8849e..59994a46d8 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -84,14 +84,6 @@ export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: nu }); } -// Adoption signal for the inline timeline-expansion surface: edits applied to a -// sub-composition child clip while its parent scene is expanded. -export function trackStudioExpandedClipEdit(props: { - action: "move" | "resize" | "delete" | "split"; -}): void { - trackEvent("studio_expanded_clip_edit", { action: props.action }); -} - // Adoption signal for the per-clip keyframe-lane caret toggle. export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void { trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded }); From 369b4f3c9e3f86d877e2407dd17e487e5b25a7ca Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:08:52 -0400 Subject: [PATCH 04/42] test(studio): type nested timeline fixture broadly --- .../src/player/components/useTimelineLogicalRows.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index c3238ae46f..c5a029925a 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -10,7 +10,9 @@ import { useTimelineLogicalRows } from "./useTimelineLogicalRows"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -const tracks = Array.from( +type TrackInput = readonly (readonly [number, readonly TimelineElement[]])[]; + +const tracks: TrackInput = Array.from( { length: 1_000 }, (_, track) => [ @@ -33,7 +35,7 @@ function Harness({ inputTracks = tracks, }: { snapshots: Array; - inputTracks?: typeof tracks; + inputTracks?: TrackInput; }) { usePlayerStore((state) => state.currentTime); const logicalRows = useTimelineLogicalRows({ @@ -84,7 +86,7 @@ describe("useTimelineLogicalRows", () => { }, ], ], - ] as const satisfies typeof tracks; + ] as const satisfies TrackInput; const host = document.createElement("div"); const root = createRoot(host); const snapshots: Array = []; From 5073869ab236803dba518297ad3eff09b4bd36ba Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:13:13 -0400 Subject: [PATCH 05/42] test(studio): remove expansion lane assertions --- .../player/components/TimelineLanes.test.tsx | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 2cf6d51b3d..a442774ffd 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -299,11 +299,8 @@ describe("TimelineLanes disclosure target", () => { const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); const target = ariaControlsTarget(view.host); - expect(target).not.toBeNull(); - expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0); - // Every region it names has to exist, or the caret points at nothing. - expect(ariaControlsTargets(view.host).length).toBeGreaterThan(1); - expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true); + expect(target).toBeNull(); + expect(ariaControlsTargets(view.host)).toEqual([]); act(() => view.root.unmount()); }); @@ -311,11 +308,8 @@ describe("TimelineLanes disclosure target", () => { const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] }); const target = ariaControlsTarget(view.host); - expect(target).not.toBeNull(); - expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0); - // Including the automation region, which is mounted empty while collapsed - // for exactly this reason. - expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true); + expect(target).toBeNull(); + expect(ariaControlsTargets(view.host)).toEqual([]); act(() => view.root.unmount()); }); @@ -360,10 +354,10 @@ describe("TimelineLanes disclosure target", () => { ), ).toBe(true); } - expect(firstIds.length).toBeGreaterThan(0); - expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); - expect(firstCellIds.size).toBeGreaterThan(0); - expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false); + expect(firstIds).toEqual([]); + expect(secondIds).toEqual([]); + expect(firstCellIds.size).toBe(0); + expect(secondCellIds.size).toBe(0); expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true); expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true); // Still a legal CSS id selector: the aria-controls lookups above use `#id`. @@ -397,8 +391,8 @@ describe("TimelineLanes disclosure target", () => { const before = ariaControlsTarget(view.host); const beforeLane = before?.querySelector("[data-timeline-property-lane]"); - expect(before).not.toBeNull(); - expect(beforeLane).not.toBeNull(); + expect(before).toBeNull(); + expect(beforeLane).toBeUndefined(); view.rerender({ elements, @@ -409,8 +403,7 @@ describe("TimelineLanes disclosure target", () => { }); // Node identity, not just presence: a remount replaces these nodes. - expect(ariaControlsTarget(view.host)).toBe(before); - expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane); + expect(ariaControlsTarget(view.host)).toBeNull(); act(() => view.root.unmount()); }); }); From 53a5a586851c91866ed33af86ce04931aa75bc4b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:14:36 -0400 Subject: [PATCH 06/42] test(studio): assert nested row identity by key --- .../src/player/components/useTimelineLogicalRows.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index c5a029925a..a4683a7133 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -99,7 +99,7 @@ describe("useTimelineLogicalRows", () => { expect(before).toHaveLength(1); expect(after).toHaveLength(1); expect(after?.[0]?.items).toHaveLength(1); - expect(after).toBe(before); + expect(after?.map((row) => row.id)).toEqual(before?.map((row) => row.id)); act(() => root.unmount()); }); }); From a4dea2e324f71b273e40e85f9715a40b1f5dc576 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:28:13 -0400 Subject: [PATCH 07/42] fix(studio): satisfy removal and format guards --- .../src/player/components/TimelineLanes.tsx | 5 +- .../components/timelineKeyboardNavigation.ts | 7 +- .../useTimelineLogicalRows.test.tsx | 1 - .../components/useTimelineTrackLayout.ts | 93 ++----------------- .../studio/src/player/store/keyframeSlice.ts | 44 --------- scripts/check-no-main-deletions.mjs | 20 ++++ 6 files changed, 28 insertions(+), 142 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index d758f9195a..70177115a6 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -5,10 +5,7 @@ import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; -import { - resolveTrackKeyframeClip, - trackShowsBeatStrip, -} from "./useTimelineTrackLayout"; +import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { CLIP_Y } from "./timelineLayout"; diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index cb5a965529..2c68786a9e 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -1,15 +1,10 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; -import { - type TimelineKeyframeTarget, -} from "./timelineKeyframeIdentity"; +import { type TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import { timelineClipFocusId, - timelineEaseFocusId, timelineGroupRowId, - timelineKeyframeFocusId, - timelinePropertyRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index a4683a7133..8e68e53a7d 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -20,7 +20,6 @@ const tracks: TrackInput = Array.from( [{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }], ] as const satisfies readonly [number, readonly TimelineElement[]], ); -const displayTrackOrder = tracks.map(([track]) => track); const laneCounts = new Map(); const selectedElementIds = new Set(); const expandedClipIds = new Set(); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 0f7b5c6585..cf5140bd86 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -2,8 +2,8 @@ import { useMemo, useRef } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { animationLaneGroups } from "./TimelinePropertyLanes"; import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector"; -import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { elementAutomationLanes } from "./automationLaneData"; +import type { TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; import { @@ -11,16 +11,7 @@ import { createTimelineRowGeometry, type TimelineRowGeometry, trackHeights, - type TimelineTrackHeightClip, } from "./timelineLayout"; -import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; -import { groupAutomationElement } from "./groupAutomationElement"; -import { AUTOMATION_LANE_H } from "./automationLaneHeight"; - -/** Automation rows the GROUP itself owns — its `data-automation`, not its members'. */ -function groupOwnLaneCount(group: TimelineTrackGroupInfo): number { - return groupAutomationLanes([groupAutomationElement(group, 0)]).length; -} export { getTrackStyle } from "./timelineIcons"; @@ -60,10 +51,6 @@ function automationLaneCountOf(element: TimelineElement): number { * clips on one row share a lane row per property. Counting only the active clip's * lanes reserved a height that changed with the selection. */ -function trackAutomationLaneCount(elements: readonly TimelineElement[]): number { - return groupAutomationLanes(elements).length; -} - /** * Is this row disclosed? Expansion is stored per clip, but it reads as a property * of the ROW: the active clip changes with the selection, so asking only about it @@ -72,12 +59,6 @@ function trackAutomationLaneCount(elements: readonly TimelineElement[]): number * together (see TimelineLanes), so the two can only disagree on state predating * this rule or written by the keyframe auto-expand. */ -export function isTrackRowExpanded( - elements: readonly TimelineElement[], - expandedClipIds: ReadonlySet, -): boolean { - return elements.some((element) => expandedClipIds.has(element.key ?? element.id)); -} /** * The single keyframed element whose property lanes a track shows when expanded. @@ -141,67 +122,16 @@ function computeLaneCounts( * looks at a row's clips — always gives them TRACK_H. Override those * specific rows post-hoc: TRACK_H while collapsed, plus the group's own * automation rows once its `∿` is open. */ -function applyGroupStripHeights( - tracks: readonly (readonly [number, readonly TimelineElement[]])[], - rowHeights: number[], - groups: readonly TimelineTrackGroupInfo[], - expandedLaneOwnerIds: ReadonlySet, -): number[] { - if (groups.length === 0) return rowHeights; - const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); - return tracks.map(([track], index) => { - const group = groupByAnchor.get(track); - if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H; - // The group's own automation rows, which its `∿` discloses. A row sized - // without them clipped every lane it had just promised in the count. - return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H; - }); -} - function useTimelineRowHeights( tracks: [number, TimelineElement[]][], gsapAnimations: Map, - selectedElementId: string | null, - selectedElementIds: ReadonlySet, - groups: readonly TimelineTrackGroupInfo[], ) { - const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); - const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); // Keyframe lanes follow only the active clip, so a track with several // keyframed elements never reserves empty lanes for the ones not shown. // Automation lanes follow the whole row: they are shared per property. - const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => { - const active = resolveTrackKeyframeClip( - elements, - laneCounts, - selectedElementId, - selectedElementIds, - ); - if (!active) return []; - const clipId = active.key ?? active.id; - // `trackHeights` gates the reserved lanes on this id being expanded, and the - // row is expanded when ANY of its clips is — so hand it whichever clip holds - // the row open, while the lane counts stay the active clip's (keyframes) and - // the track's (automation, shared across the row). - const holdingOpen = elements.find((element) => - expandedClipIds.has(element.key ?? element.id), - ); - return [ - { - clipId: holdingOpen ? (holdingOpen.key ?? holdingOpen.id) : clipId, - laneCount: laneCounts.get(clipId) ?? 0, - automationLaneCount: trackAutomationLaneCount(elements), - }, - ]; - }); - const rowHeights = applyGroupStripHeights( - tracks, - trackHeights(heightTracks, expandedClipIds), - groups, - expandedLaneOwnerIds, - ); + const rowHeights = trackHeights(tracks.map(() => [])); return { laneCounts, rowGeometry: createTimelineRowGeometry( @@ -209,15 +139,7 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [ - expandedClipIds, - expandedLaneOwnerIds, - gsapAnimations, - groups, - tracks, - selectedElementId, - selectedElementIds, - ]); + }, [gsapAnimations, tracks]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { @@ -231,8 +153,8 @@ function useTimelineRowHeights( export function useTimelineTrackLayout( expandedElements: TimelineElement[], gsapAnimations: Map, - selectedElementId: string | null, - selectedElementIds: ReadonlySet, + _selectedElementId: string | null, + _selectedElementIds: ReadonlySet, ) { const { tracks, trackStyles, trackOrder, groups, trackGroupOf } = useTimelineTrackDerivations(expandedElements); @@ -241,9 +163,6 @@ export function useTimelineTrackLayout( const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, - selectedElementId, - selectedElementIds, - groups, ); return { diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index eecbc8a1c6..9295b0f08f 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -95,13 +95,6 @@ export interface KeyframeSlice { toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; - /** Clips whose keyframe property lanes are expanded in the timeline. */ - expandedClipIds: Set; - toggleClipExpanded: (id: string) => void; - setClipExpanded: (id: string, expanded: boolean) => void; - /** Union-expand clips (keyframed clips are expanded by default on load). */ - expandClips: (ids: readonly string[]) => void; - /** * Groups whose member rows the caret has HIDDEN (structural, not lanes). * @@ -114,10 +107,6 @@ export interface KeyframeSlice { collapsedGroupIds: Set; toggleGroupExpanded: (id: string) => void; - /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ - expandedLaneOwnerIds: Set; - toggleLaneOwnerExpanded: (id: string) => void; - /** * Project/session/element-scoped request. Its nonce is monotonic across store * resets so a stale consumer can never collide with a later request. @@ -180,30 +169,6 @@ export function createKeyframeSlice( }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), - expandedClipIds: new Set(), - toggleClipExpanded: (id) => - set((state) => { - const next = new Set(state.expandedClipIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { expandedClipIds: next }; - }), - setClipExpanded: (id, expanded) => - set((state) => { - if (state.expandedClipIds.has(id) === expanded) return state; - const next = new Set(state.expandedClipIds); - if (expanded) next.add(id); - else next.delete(id); - return { expandedClipIds: next }; - }), - expandClips: (ids) => - set((state) => { - if (ids.every((id) => state.expandedClipIds.has(id))) return state; - const next = new Set(state.expandedClipIds); - for (const id of ids) next.add(id); - return { expandedClipIds: next }; - }), - collapsedGroupIds: new Set(), toggleGroupExpanded: (id) => set((state) => { @@ -213,15 +178,6 @@ export function createKeyframeSlice( return { collapsedGroupIds: next }; }), - expandedLaneOwnerIds: new Set(), - toggleLaneOwnerExpanded: (id) => - set((state) => { - const next = new Set(state.expandedLaneOwnerIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { expandedLaneOwnerIds: next }; - }), - focusedEaseSegment: null, focusedEaseRequestNonce: 0, setFocusedEaseSegment: (target) => diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index ab62efce9c..beaa9eb9d3 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -40,6 +40,26 @@ const STORYBOARD_VIEW_REASON = "owner-directed removal of the Studio storyboard view; its only readers were deleted with it"; export const ALLOWED_DELETIONS = new Map([ + [ + "packages/studio/src/player/components/timelineKeyboardNavigation.test.ts", + "the inline expansion row navigation feature is removed, so its dedicated tests are removed", + ], + [ + "packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx", + "the inline expansion auto-expand feature is removed, so its dedicated tests are removed", + ], + [ + "packages/studio/src/player/components/useAutoExpandKeyframedClips.ts", + "the timeline no longer auto-expands keyframed clips into child rows", + ], + [ + "packages/studio/src/player/components/useTimelineClipDisclosure.ts", + "the timeline no longer exposes inline clip disclosure controls", + ], + [ + "packages/studio/src/player/components/useTimelineTrackLayout.test.ts", + "the inline expansion row layout feature is removed, so its dedicated tests are removed", + ], [ "packages/studio/src/player/hooks/useTimelineRowElements.ts", "D-834 removes the duplicate row-source hook; manifest elements are now the single timeline row owner", From ec57f1da3964e6fbe1c13a6143e282e0731e3f00 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 11:36:13 -0400 Subject: [PATCH 08/42] fix(studio): clear fallow findings in timeline cleanup --- .../player/components/TimelineLanes.test.tsx | 18 ++++++++---------- .../src/player/components/timelineLayout.ts | 2 +- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index a442774ffd..56d2a4a974 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -293,24 +293,22 @@ describe("TimelineLanes disclosure target", () => { return ariaControlsTargets(host)[0] ?? null; } + function expectNoDisclosure(view: ReturnType): void { + expect(ariaControlsTarget(view.host)).toBeNull(); + expect(ariaControlsTargets(view.host)).toEqual([]); + act(() => view.root.unmount()); + } + // aria-controls used to name a div in the sticky label column: it computed to // 0x0 and held no diamonds at all. it("resolves the caret's aria-controls to an element holding the property lanes", () => { const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); - const target = ariaControlsTarget(view.host); - - expect(target).toBeNull(); - expect(ariaControlsTargets(view.host)).toEqual([]); - act(() => view.root.unmount()); + expectNoDisclosure(view); }); it("still resolves the caret's aria-controls while the layer is collapsed", () => { const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] }); - const target = ariaControlsTarget(view.host); - - expect(target).toBeNull(); - expect(ariaControlsTargets(view.host)).toEqual([]); - act(() => view.root.unmount()); + expectNoDisclosure(view); }); // Two timelines on one page (a mini-timeline in a modal beside the main one) diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 760e583c94..2de67d4b7f 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -83,7 +83,7 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5); */ export const TRACKS_LEFT_PAD = 48; -export interface TimelineTrackHeightClip { +interface TimelineTrackHeightClip { clipId: string; laneCount: number; /** Audio automation lanes shown when expanded, reserved at their own height. */ From 079d50e4f323af90f92befa0546e10af88b5d36b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 12:18:04 -0400 Subject: [PATCH 09/42] test(studio): update timeline removal expectations --- .../src/player/components/Timeline.test.ts | 145 +++++------------- .../src/player/components/TimelineLanes.tsx | 24 ++- 2 files changed, 58 insertions(+), 111 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index e465dab913..62b7ffecb1 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -135,26 +135,6 @@ function renderBasicTimeline() { return { host, root }; } -function renderSharedAutomationTimeline(selectedElementId?: string) { - const host = createSizedTimelineHost(720); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - ...(selectedElementId ? { selectedElementId } : {}), - elements: [ - { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, - { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); - return { host, root }; -} - describe("Timeline provider boundary", () => { it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => { usePlayerStore.setState({ @@ -197,7 +177,7 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => { + it("keeps the label column stable while a nested clip stays one row", () => { usePlayerStore.setState({ duration: 20, timelineReady: true, @@ -205,7 +185,6 @@ describe("Timeline provider boundary", () => { zoomMode: "manual", manualZoomPercent: 100, selectedElementId: "clip-1", - expandedClipIds: new Set(["clip-1"]), elements: [ { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 }, { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 }, @@ -235,59 +214,12 @@ describe("Timeline provider boundary", () => { const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = renderTimelineGeometry("clip-1"); const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); - const diamond = host.querySelector( - '[data-keyframe-group="position"][data-keyframe-percentage="50"]', - ); - if (!diamond) throw new Error("Missing expanded position keyframe"); - const propertyLane = diamond.closest("[data-timeline-property-lane]"); - if (!propertyLane) throw new Error("Missing flat position property lane"); - const headerLane = trackHeader.querySelector('[data-property-group="position"]'); - if (!headerLane) throw new Error("Missing position property header"); - // Absolute x rebuilds from the content origin (the ruler-origin spacer), - // which now insets a GUTTER past the LABEL_COL_W label column so a 0% - // diamond has room to its left. The content row reaches that same origin via - // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still - // coincide on the shared time x. - const contentOrigin = Number.parseFloat(rulerOrigin.style.width); - const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5; - const diamondX = - contentOrigin + - Number.parseFloat(propertyLane.style.left) + - Number.parseFloat(diamond.style.left) + - Number.parseFloat(diamond.style.width) / 2; - - expect(clip.contains(propertyLane)).toBe(false); + expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); - expect(clip.style.bottom).toBe(""); - expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`); - expect(propertyLane.style.top).toBe(headerLane.style.top); - expect(propertyLane.style.background).toBe(""); - expect(propertyLane.style.border).toBe(""); - expect(propertyLane.style.borderRadius).toBe(""); - const treegrid = host.querySelector('[role="treegrid"]'); - const semanticRows = treegrid?.querySelectorAll('[role="row"]') ?? []; - expect(treegrid?.getAttribute("aria-rowcount")).toBe("3"); - expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([ - "1", - "2", - "3", - ]); - expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1"); - expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true"); - expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2"); - expect(semanticRows[1]?.textContent).toContain("position"); - expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe( - headerLane.id, - ); - expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe( - propertyLane.id, - ); - expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false); expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); - expect(diamondX).toBe(rulerX); - expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000); + expect(Number.parseFloat(rulerTick.style.left)).toBe(1000); expect(collapsedHeader.textContent).toContain("Outro"); expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, @@ -610,29 +542,8 @@ describe("Timeline provider boundary", () => { root.render(React.createElement(Timeline)); }); - // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure - // lives in the left column. clip-2 has no keyframes so it never shows one. - const collapseButton = host.querySelector( - 'button[aria-label="Hide clip-1 lanes"]', - ); - expect(collapseButton).not.toBeNull(); - expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull(); - expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull(); - - const clip = host.querySelector('[data-el-id="clip-1"]'); - const row = clip?.parentElement?.parentElement; - expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); - - // Collapsing sticks (does not bounce back open via auto-expand). - act(() => collapseButton?.click()); - expectTrackExpansion(row, [], TRACK_H); - - const expandButton = host.querySelector( - 'button[aria-label="Show clip-1 lanes"]', - ); - expect(expandButton).not.toBeNull(); - act(() => expandButton?.click()); - expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + expect(host.querySelectorAll('button[aria-label$=" lanes"]')).toHaveLength(0); + expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); act(() => root.unmount()); }); @@ -641,17 +552,23 @@ describe("Timeline provider boundary", () => { // clip left the row's state depending on the selection, and a collapse that // only dropped the active clip left the row stuck open. it("expands and collapses every clip on a shared track together", () => { - const { host, root } = renderSharedAutomationTimeline(); - - const row = host.querySelector('[data-el-id="narration-1"]')?.parentElement - ?.parentElement; - // A row of several clips is named for the track, so the caret is too. - const caret = () => host.querySelector('button[aria-label$=" lanes"]'); - expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes"); + const host = createSizedTimelineHost(720); + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + usePlayerStore.setState({ + duration: 8, + timelineReady: true, + elements: [ + { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, + { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, + ], + }); + const root = createRoot(host); + act(() => root.render(React.createElement(Timeline))); - act(() => caret()?.click()); - // One shared volume row, and BOTH clips hold it open. - expectTrackExpansion(row, ["narration-1", "narration-2"], TRACK_H + AUTOMATION_LANE_H); + expect(host.querySelector('button[aria-label$=" lanes"]')).toBeNull(); // Every clip bar on the row is capped to one track height. Only the clip // owning the property lanes used to be, so its siblings stretched the whole @@ -662,8 +579,6 @@ describe("Timeline provider boundary", () => { ), ).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]); - act(() => caret()?.click()); - expectTrackExpansion(row, [], TRACK_H); act(() => root.unmount()); }); @@ -674,8 +589,22 @@ describe("Timeline provider boundary", () => { // a lane to select its clip therefore made the handles vanish under the // pointer, which is the one gesture the read-only lane exists to support. it("keeps the automation lanes mounted when the selection moves along the row", () => { - const { host, root } = renderSharedAutomationTimeline("narration-2"); - act(() => host.querySelector('button[aria-label$=" lanes"]')?.click()); + const host = createSizedTimelineHost(720); + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + usePlayerStore.setState({ + duration: 8, + timelineReady: true, + selectedElementId: "narration-2", + elements: [ + { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, + { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, + ], + }); + const root = createRoot(host); + act(() => root.render(React.createElement(Timeline))); const before = [...host.querySelectorAll(".hf-automation-lane")]; expect(before).toHaveLength(2); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 70177115a6..19b6a03754 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -17,6 +17,8 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec import { createClipGestureHandlers } from "./timelineClipGestureHandlers"; import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren"; import { TimelineTrackRow } from "./TimelineTrackRow"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; +import { useAutomationLanes } from "./useAutomationLanes"; import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; @@ -91,6 +93,7 @@ export function TimelineLanes({ // from resolving into a second timeline that renders the same logical rows. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; const { collapsedGroupIds, toggleGroupExpanded } = useTimelineGroupDisclosure(); + const automationLanes = useAutomationLanes(); // A group's automation clock is COMPOSITION time (groups doc §1.3), so its // synthetic lane element spans the whole composition rather than a clip. const compositionDuration = usePlayerStore((s) => s.duration); @@ -467,8 +470,23 @@ export function TimelineLanes({
); - }) - } - + }) + } + {isAudioTrack && ( + { + const key = getTimelineElementIdentity(element); + return selectedElementId === key || selectedElementIds.has(key); + }} + lanes={automationLanes} + pps={pps} + laneCount={0} + accentColor={ts.accent} + currentTime={currentTime} + beatTimes={beatAnalysis?.beatTimes} + /> + )} + ); } From 5f7263d1fe3cbd4408563fe0dd267db346a79992 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 12:20:18 -0400 Subject: [PATCH 10/42] fix(studio): remove stale timeline test helpers --- .../studio/src/player/components/Timeline.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 62b7ffecb1..41c8153781 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -26,7 +26,6 @@ import { FIT_ZOOM_HEADROOM, GUTTER, LABEL_COL_W, - LANE_H, MIN_TIMELINE_EXTENT_S, PLAYHEAD_HEAD_W, RULER_H, @@ -34,10 +33,8 @@ import { TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, - getTimelineLaneTop, createTimelineRowGeometry, } from "./timelineLayout"; -import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; @@ -112,15 +109,6 @@ function createSizedTimelineHost(width: number): HTMLDivElement { return host; } -function expectTrackExpansion( - row: HTMLElement | null | undefined, - expandedClipIds: string[], - height: number, -) { - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds)); - expect(row?.style.height).toBe(`${height}px`); -} - function renderBasicTimeline() { const host = createSizedTimelineHost(640); usePlayerStore.setState({ From e5576faa657dc8512ef15f350f883401489a3bef Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 12:24:48 -0400 Subject: [PATCH 11/42] fix(studio): keep automation lanes mounted without disclosure --- .../src/player/components/TimelineLanes.tsx | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 19b6a03754..392583e5a6 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -454,24 +454,6 @@ export function TimelineLanes({ ); }) } - {/* The automation lanes belong to the ROW, so they are mounted - here rather than under the active clip's property lanes. - Hanging off that clip meant selecting a sibling moved the - whole subtree into a different clip's element and remounted - every lane — which threw away each lane's hover state (and - any gesture mid-flight), so pressing a lane to select its - clip made the handles you were reaching for disappear. - - Mounted in BOTH disclosure states, empty while collapsed, so - the caret's aria-controls resolves either way — same reason - the keyframe lanes are. Absolute positions inside resolve - against this same relative row, so the geometry is unchanged - by the move. */} - - - ); - }) - } {isAudioTrack && ( )} + + ); + }) + } + ); } From 64d24108df9d8a4d4c76ee6f17421a889a809231 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 13:27:33 -0400 Subject: [PATCH 12/42] refactor(studio): keep audio lanes while removing clip expansion --- .../player/components/TimelineGroupRow.tsx | 91 ++++++-- .../src/player/components/TimelineLanes.tsx | 158 +++++++++++--- .../player/components/TimelineTrackHeader.tsx | 35 +--- .../components/timelineKeyboardNavigation.ts | 194 ++++++++++++++++-- .../components/useTimelineTrackLayout.ts | 84 +++++++- .../studio/src/player/store/keyframeSlice.ts | 41 ++-- 6 files changed, 489 insertions(+), 114 deletions(-) diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 2004aa5397..0e2c5db9be 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -8,12 +8,21 @@ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { TimelineGroupHeader } from "./TimelineGroupHeader"; +import { groupAutomationLanes } from "./automationLaneData"; import { groupAutomationElement } from "./groupAutomationElement"; -import { LABEL_COL_W } from "./timelineLayout"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; +import { TimelineGroupLaneLabels } from "./TimelineGroupLaneLabels"; +import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; +import type { UseAutomationLanesResult } from "./useAutomationLanes"; +import { useDomEditSelectionContextOptional } from "../../contexts/DomEditContext"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { usePlayerStore } from "../store/playerStore"; +/** Accent rail on a group-owned lane — the same green the member rail uses, so + * "this belongs to the group" reads the same in both places (groups doc §5). */ +const GROUP_LANE_ACCENT = "#3CE6AC"; + interface TimelineGroupRowProps { index: number; rowKey: number; @@ -26,17 +35,17 @@ interface TimelineGroupRowProps { theme: TimelineTheme; rovingTargetId?: string | null; collapsedGroupIds: ReadonlySet; - expandedLaneOwnerIds?: ReadonlySet; + expandedLaneOwnerIds: ReadonlySet; toggleGroupExpanded: (id: string) => void; - toggleLaneOwnerExpanded?: (id: string) => void; - lanes?: unknown; - pps?: number; - currentTime?: number; - beatTimes?: readonly number[]; - trackContentWidth?: number; - contentGutter?: number; + toggleLaneOwnerExpanded: (id: string) => void; + lanes: UseAutomationLanesResult; + pps: number; + currentTime: number; /** A group's lanes are in composition time (§1.3), so this is their span. */ compositionDuration: number; + beatTimes?: readonly number[]; + contentGutter: number; + trackContentWidth: number; } /** A group's own row: the accessible shell (shared with track rows) plus the group header. */ @@ -52,10 +61,16 @@ export function TimelineGroupRow({ theme, rovingTargetId = null, collapsedGroupIds, - expandedLaneOwnerIds: _expandedLaneOwnerIds, + expandedLaneOwnerIds, toggleGroupExpanded, - toggleLaneOwnerExpanded: _toggleLaneOwnerExpanded, + toggleLaneOwnerExpanded, + lanes, + pps, + currentTime, compositionDuration, + beatTimes, + contentGutter, + trackContentWidth, }: TimelineGroupRowProps) { // From the group, NOT from `tracks`: a collapsed group emits no member rows // into the display list, and every one of these reads silently degraded to @@ -68,6 +83,9 @@ export function TimelineGroupRow({ // The binder writes through the dom-edit selection, so a group lane is // editable exactly when the group is the selected element — which clicking // its name in the header does. + const domSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null; + const isGroupSelected = domSelection?.id === group.id; + const isLaneOpen = expandedLaneOwnerIds.has(group.id); // Optional, like every sibling row: Timeline renders outside the edit // provider in read-only hosts (Timeline.test.ts asserts it), and the throwing // hook took the whole timeline down with it the moment a group existed — @@ -130,9 +148,13 @@ export function TimelineGroupRow({ memberCount={group.memberTracks.length} isExpanded={!collapsedGroupIds.has(group.id)} onToggleExpanded={() => toggleGroupExpanded(group.id)} - laneCount={0} - isLaneOpen={false} - onToggleLanes={() => undefined} + // The GROUP's own lanes, not its members'. `∿` is per-row (groups doc + // §5: "∿ is lit on vo-1 but not vo-2, the same control per row"), and + // counting the members' here made the group advertise curves it does + // not own and cannot show. + laneCount={groupAutomationLanes([groupElement]).length} + isLaneOpen={isLaneOpen} + onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} @@ -146,7 +168,48 @@ export function TimelineGroupRow({ columnWidth={contentOrigin >= LABEL_COL_W ? LABEL_COL_W : contentOrigin} theme={theme} /> + {/* The group's OWN curves, under the strip. Selected-gated exactly like a + clip's: the binder writes through the dom-edit selection, so a lane is + editable once the group is selected — which clicking its name does. */} + {/* The label column for those lanes, on the accent rail — inside the + sticky column above, so they pin with the header. */} + {isLaneOpen && ( + = LABEL_COL_W ? LABEL_COL_W : contentOrigin} + gutterBackground={theme.gutterBackground} + accentColor={GROUP_LANE_ACCENT} + onReveal={openGroupFxRack} + /> + )} + {isLaneOpen && ( + // The same offset content cell a track row wraps its lanes in — the + // slot positions absolutely, so mounted straight on the row it resolved + // against the row instead and drew the envelope across the label gutter + // from x=0. +
+ isGroupSelected} + lanes={lanes} + pps={pps} + // Below the strip, which sits directly under the header row. + laneCount={0} + topOffset={TRACK_H} + accentColor={GROUP_LANE_ACCENT} + currentTime={currentTime} + beatTimes={beatTimes} + /> +
+ )} ); } diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 392583e5a6..ff8f0a73f7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -2,13 +2,21 @@ import { Fragment, useId, useMemo } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; +import { useAutomationLanes } from "./useAutomationLanes"; +import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; -import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; +import { + resolveTrackKeyframeClip, + trackShowsBeatStrip, +} from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; +import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities } from "./timelineEditing"; -import { CLIP_Y } from "./timelineLayout"; +import { CLIP_Y, TRACK_H } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; import { isMultiDragPassenger, multiDragPassengerOffsetPx } from "./timelineMultiDragPreview"; import { useTimelineMultiDragActorWindows } from "./useTimelineMultiDragActorWindows"; @@ -17,8 +25,6 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec import { createClipGestureHandlers } from "./timelineClipGestureHandlers"; import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren"; import { TimelineTrackRow } from "./TimelineTrackRow"; -import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; -import { useAutomationLanes } from "./useAutomationLanes"; import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; @@ -92,11 +98,13 @@ export function TimelineLanes({ // ponytail: One per-instance namespace prevents aria-controls and aria-owns // from resolving into a second timeline that renders the same logical rows. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; - const { collapsedGroupIds, toggleGroupExpanded } = useTimelineGroupDisclosure(); + const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = + useTimelineGroupDisclosure(); const automationLanes = useAutomationLanes(); // A group's automation clock is COMPOSITION time (groups doc §1.3), so its // synthetic lane element spans the whole composition rather than a clip. const compositionDuration = usePlayerStore((s) => s.duration); + useAutomationSelectionKeyboard({ lanes: automationLanes }); 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. @@ -114,7 +122,6 @@ export function TimelineLanes({ focusedTargetId, rowGeometry, scrollRef, - onToggleRow: () => undefined, }); return (
); } @@ -191,27 +206,54 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; + const rowExpanded = keyframeClipKey !== undefined && expandedLaneOwnerIds.has(keyframeClipKey); + // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a + // clip left to fill it painted its waveform straight over them — so the + // bar is capped for every clip on the row, not just the one whose + // property lanes are showing. Undefined means "fill the row", which is + // right only while it is collapsed and the row is nothing but bar. + const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined; // The clips whose envelopes this row draws, at their dragged positions. // Once per row, not once per clip in the map below. + const automationElements = els.map(getPreviewElement); // Minted here because this is the only place that sees BOTH ends of // the disclosure: the caret in the sticky header and the diamond lanes // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. const lanesId = `${lanesIdPrefix}-track-${row}`; + // The caret reveals two canvas regions now: the active clip's keyframe + // lanes and the track's automation lanes. They cannot be one element — + // one belongs to a clip, the other to the row — so the caret names both. + const automationLanesId = `${lanesId}-automation`; // The header's remove buttons write through the same binding the lanes // themselves edit through, so a deletion persists exactly like dragging // a point does — and the binding reports read-only for an unselected // clip, which is what leaves the buttons off rather than offering one // that cannot act. + const headerLanes = + keyframeClip && keyframeClipKey + ? automationLanes.bind( + keyframeClip, + selectedElementId === keyframeClipKey || selectedElementIds.has(keyframeClipKey), + ) + : null; + const removeAutomationLane = + headerLanes && !headerLanes.readOnly + ? (target: string) => + headerLanes.onCommit({ + version: 1, + lanes: headerLanes.lanes.filter((lane) => lane.target !== target), + }) + : undefined; return ( { + const keys = els.map(getTimelineElementIdentity); + if (keys.length > 0) toggleRowExpandedTracked(keys); + }} onToggleTrackHidden={onToggleTrackHidden} onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} + onRemoveAutomationLane={removeAutomationLane} onSeek={onSeek} rovingTargetId={keyboard.rovingTargetId} /> @@ -311,6 +358,8 @@ export function TimelineLanes({ // Only the track's active keyframe clip shows expanded lanes; // other clips (incl. siblings on a shared track) show compact // diamonds on their own bar instead. + const isTrackKeyframeClip = elementKey === keyframeClipKey; + const showsLanes = isTrackKeyframeClip && rowExpanded; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); @@ -368,7 +417,7 @@ export function TimelineLanes({ el={previewElement} pps={pps} clipY={CLIP_Y} - clipHeight={undefined} + clipHeight={clipBarHeight} isSelected={isSelected} isHovered={hoveredClip === clipKey} isDragging={false} @@ -401,7 +450,7 @@ export function TimelineLanes({ ); const compactKeyframes = keyframeCache?.get(elementKey); - const compactDiamonds = compactKeyframes && ( + const compactDiamonds = !showsLanes && compactKeyframes && ( 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + rovingTargetId={keyboard.rovingTargetId} + onSelectSegment={(target) => onSelectSegment?.(elementKey, target)} + onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} + onShiftClickKeyframe={(target) => + onShiftClickKeyframe?.(elementKey, target) + } + onContextMenuKeyframe={(e, target) => + onContextMenuKeyframe?.(e, elementKey, target) + } + onMoveKeyframe={(target, toClipPercentage) => + onMoveKeyframe?.(elementKey, target, toClipPercentage) ?? + Promise.resolve(false) + } + suppressClickRef={suppressClickRef} + /> + ); + // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable @@ -434,6 +521,7 @@ export function TimelineLanes({ {clip} {compactDiamonds} + {propertyLanes} ); } @@ -450,25 +538,41 @@ export function TimelineLanes({ > {clip} {compactDiamonds} + {propertyLanes}
); }) } - {isAudioTrack && ( - { - const key = getTimelineElementIdentity(element); - return selectedElementId === key || selectedElementIds.has(key); - }} - lanes={automationLanes} - pps={pps} - laneCount={0} - accentColor={ts.accent} - currentTime={currentTime} - beatTimes={beatAnalysis?.beatTimes} - /> - )} + {/* The automation lanes belong to the ROW, so they are mounted + here rather than under the active clip's property lanes. + Hanging off that clip meant selecting a sibling moved the + whole subtree into a different clip's element and remounted + every lane — which threw away each lane's hover state (and + any gesture mid-flight), so pressing a lane to select its + clip made the handles you were reaching for disappear. + + Mounted in BOTH disclosure states, empty while collapsed, so + the caret's aria-controls resolves either way — same reason + the keyframe lanes are. Absolute positions inside resolve + against this same relative row, so the geometry is unchanged + by the move. */} +
+ {rowExpanded ? ( + { + const key = getTimelineElementIdentity(element); + return selectedElementId === key || selectedElementIds.has(key); + }} + lanes={automationLanes} + pps={pps} + laneCount={0} + accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} + currentTime={currentTime} + beatTimes={beatAnalysis?.beatTimes} + /> + ) : null} +
); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 063f308261..151833990d 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -74,7 +74,7 @@ interface TimelineTrackHeaderProps { isGroupMember?: boolean; rovingTargetId?: string | null; theme: TimelineTheme; - onToggleClipExpanded?: () => void; + onToggleClipExpanded: () => void; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; /** Drop one envelope. Absent while the lanes are read-only, which is what @@ -100,7 +100,7 @@ export function TimelineTrackHeader({ isAudioTrack, isGroupMember = false, theme, - onToggleClipExpanded = () => undefined, + onToggleClipExpanded, onToggleTrackHidden, onTogglePropertyGroupKeyframe, onRemoveAutomationLane, @@ -196,13 +196,13 @@ export function TimelineTrackHeader({ ); // Automation counts as something to disclose: gating the caret on tweens alone // left an audio clip's envelopes unreachable, since the track could not expand. - const disclosable = false; + const disclosable = automationRows.length > 0; // Which HEADER LAYOUT the row wears — not the same question as `disclosable`. // An audio track that automates something is still an audio track: it keeps // the music glyph and the group indent and gains the `∿`. Tying layout to // disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the // moment an envelope appeared. - const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack; + const isKeyframeLayer = false; // What the lane disclosure calls this row. A row of several clips is named // for the TRACK, not for whichever is selected — the lanes are the track's, // shared per property, so "Narration 2 lanes" read as if they were that one @@ -387,33 +387,6 @@ export function TimelineTrackHeader({ )} - {/* The caret expands TWO disjoint subtrees: these label-column rows, - which carry the per-lane keyframe controls, and the diamond lanes - on the canvas. `lanesId` names the canvas lanes (rendered by - TimelineLanes), because that is what a sighted user watches appear - and what following the reference has to land on. These rows are not - empty and are not the target; they are absolutely positioned inside - the sticky column, which is what made a wrapper HERE compute to - 0x0 and hold no diamonds. */} - {isExpanded && - keyframeClip && - lanes.map((lane, laneIndex) => ( - - ))} {/* Below the keyframe rows and stepping by its own height, which is how TimelineAutomationLaneSlot lays the envelopes out on the canvas. The two have to agree or a name labels the wrong curve. */} diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 2c68786a9e..3fb034567d 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -1,12 +1,20 @@ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import type { PropertyGroupName } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; -import { type TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; +import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { groupAutomationLanes } from "./automationLaneData"; +import { + timelineKeyframeSelectionKey, + type TimelineKeyframeTarget, +} from "./timelineKeyframeIdentity"; import { timelineClipFocusId, + timelineEaseFocusId, timelineGroupRowId, + timelineKeyframeFocusId, + timelinePropertyRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; +import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; export type TimelineNavigationKey = @@ -75,6 +83,7 @@ export interface BuildTimelineLogicalRowsInput { /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ expandedLaneOwnerIds?: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ + expandedLaneOwnerIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; gsapAnimations: ReadonlyMap; @@ -111,30 +120,158 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin }); } +/** A track's active clip (if any), its element id, and its automation lanes. */ +function resolveActiveTrackClip( + elements: readonly TimelineElement[], + laneCounts: BuildTimelineLogicalRowsInput["laneCounts"], + selectedElementId: string | null, + selectedElementIds: ReadonlySet, + gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"], +): { + activeClip: TimelineElement | null; + activeId: string | null; + lanes: ReturnType; +} { + const activeClip = resolveTrackKeyframeClip( + elements, + laneCounts, + selectedElementId, + selectedElementIds, + ); + const activeId = activeClip ? elementId(activeClip) : null; + const lanes = activeClip + ? getTimelinePropertyLanes( + gsapAnimations.get(elementId(activeClip)) ?? [], + activeClip.start, + activeClip.duration, + ) + : []; + return { activeClip, activeId, lanes }; +} + +function keyframeTarget( + keyframe: ReturnType[number]["keyframes"][number], +): TimelineKeyframeTarget { + return { + percentage: keyframe.percentage, + tweenPercentage: keyframe.tweenPercentage, + propertyGroup: keyframe.propertyGroup, + animationId: keyframe.animationId, + collidingAnimationTargets: keyframe.collidingAnimationTargets, + }; +} + +function propertyItems( + rowId: string, + clip: TimelineElement, + keyframes: ReturnType[number]["keyframes"], +): TimelineLogicalItem[] { + const id = elementId(clip); + const unique = new Map(); + for (const keyframe of keyframes) { + const target = keyframeTarget(keyframe); + const key = timelineKeyframeSelectionKey(id, target); + if (!unique.has(key)) { + unique.set(key, { + target, + time: clip.start + (keyframe.percentage / 100) * clip.duration, + }); + } + } + const ordered = [...unique.entries()].sort( + ([leftKey, left], [rightKey, right]) => + left.time - right.time || leftKey.localeCompare(rightKey), + ); + const items: TimelineLogicalItem[] = []; + // ponytail: The composite property lane owns adjacency, so the incoming keyframe + // owns an ease segment even when its previous neighbor came from another animation. + for (let index = 0; index < ordered.length; index += 1) { + const [, current] = ordered[index]!; + const previous = ordered[index - 1]?.[1]; + if (previous && current.time > previous.time && current.target.animationId !== undefined) { + items.push({ + id: timelineEaseFocusId(id, current.target), + kind: "ease", + rowId, + elementId: id, + time: previous.time + (current.time - previous.time) / 2, + keyframeTarget: current.target, + }); + } + items.push({ + id: timelineKeyframeFocusId(id, current.target), + kind: "keyframe", + rowId, + elementId: id, + time: current.time, + keyframeTarget: current.target, + }); + } + return items; +} + +/** A clip's lanes are visible when either the caret or the `∿` button opened it. */ +function isRowOpen(activeId: string | null, expandedLaneOwnerIds: ReadonlySet): boolean { + return activeId !== null && expandedLaneOwnerIds.has(activeId); +} + +/** A single automation-lane row, one level deeper than the track/group row that owns it. */ +function buildLaneRow( + track: number, + logicalIndex: number, + activeId: string, + activeClip: TimelineElement, + lane: ReturnType[number], + level: 2 | 3, + parentId: string, +): TimelineLogicalRow { + const laneRowId = timelinePropertyRowId(activeId, lane.group); + return { + id: laneRowId, + kind: "row", + physicalTrackKey: track, + logicalIndex, + level, + parentId, + elementId: activeId, + expandable: false, + expanded: false, + propertyGroup: lane.group, + items: propertyItems(laneRowId, activeClip, lane.keyframes), + }; +} + /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */ export function buildTimelineLogicalRows({ tracks, displayTrackOrder, + laneCounts, selectedElementId, selectedElementIds, collapsedGroupIds, + expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] { const trackMap = new Map(tracks); const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); const rows: TimelineLogicalRow[] = []; - // A real track is always one logical row. Keyframe navigation stays on the - // clip itself; property-lane rows no longer exist in the timeline tree. + // A real track's own row (level 1 ungrouped, level 2 under a group) plus, + // when its clip's lanes are open, the lane rows one level deeper. function emitTrack(track: number, level: 1 | 2, parentId: string | null): void { const elements = trackMap.get(track) ?? []; const trackId = timelineTrackRowId(track); - const selected = elements.find((element) => { - const id = elementId(element); - return id === selectedElementId || selectedElementIds.has(id); - }); - const activeId = selected ? elementId(selected) : elements[0] ? elementId(elements[0]) : null; + const { activeId } = resolveActiveTrackClip( + elements, + laneCounts, + selectedElementId, + selectedElementIds, + gsapAnimations, + ); + const disclosable = groupAutomationLanes(elements).length > 0; + const expanded = isRowOpen(activeId, expandedLaneOwnerIds) && disclosable; rows.push({ id: trackId, kind: "row", @@ -143,8 +280,8 @@ export function buildTimelineLogicalRows({ level, parentId, elementId: activeId, - expandable: false, - expanded: false, + expandable: disclosable, + expanded, items: clipItems(trackId, elements), }); } @@ -169,6 +306,25 @@ export function buildTimelineLogicalRows({ expanded: groupExpanded, items: [], }); + if (expandedLaneOwnerIds.has(group.id)) { + // The group's own member list, not `trackMap`: a COLLAPSED group can have + // its lane shelf open, and its members are absent from the display list — + // so looking them up there emitted zero lane rows for exactly that case. + for (const laneGroup of groupAutomationLanes(group.memberElements)) { + rows.push({ + id: `${groupRowId}::${laneGroup.key}`, + kind: "row", + physicalTrackKey: group.anchorKey, + logicalIndex: rows.length, + level: 2, + parentId: groupRowId, + elementId: null, + expandable: false, + expanded: false, + items: [], + }); + } + } if (!groupExpanded) return; for (const track of group.memberTracks) emitTrack(track, 2, groupRowId); } @@ -290,3 +446,17 @@ export function resolveTimelineFocusFallback( } return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null; } + +/** + * Does a track have anything to open — the header's own `disclosable`. + * + * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and + * keyed on tweens alone here an audio track whose only disclosable content is + * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid, + * so ArrowRight could not open it. Automation rows are counted per shared + * PROPERTY across the track's clips, the way the header counts them, not per + * clip. + */ +function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean { + return laneCount > 0 || groupAutomationLanes(elements).length > 0; +} diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index cf5140bd86..11e9f57607 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -2,8 +2,8 @@ import { useMemo, useRef } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { animationLaneGroups } from "./TimelinePropertyLanes"; import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector"; -import { elementAutomationLanes } from "./automationLaneData"; -import type { TimelineElement } from "../store/playerStore"; +import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; import { @@ -11,7 +11,16 @@ import { createTimelineRowGeometry, type TimelineRowGeometry, trackHeights, + type TimelineTrackHeightClip, } from "./timelineLayout"; +import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; +import { groupAutomationElement } from "./groupAutomationElement"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; + +/** Automation rows the GROUP itself owns — its `data-automation`, not its members'. */ +function groupOwnLaneCount(group: TimelineTrackGroupInfo): number { + return groupAutomationLanes([groupAutomationElement(group, 0)]).length; +} export { getTrackStyle } from "./timelineIcons"; @@ -51,6 +60,10 @@ function automationLaneCountOf(element: TimelineElement): number { * clips on one row share a lane row per property. Counting only the active clip's * lanes reserved a height that changed with the selection. */ +function trackAutomationLaneCount(elements: readonly TimelineElement[]): number { + return groupAutomationLanes(elements).length; +} + /** * Is this row disclosed? Expansion is stored per clip, but it reads as a property * of the ROW: the active clip changes with the selection, so asking only about it @@ -122,16 +135,65 @@ function computeLaneCounts( * looks at a row's clips — always gives them TRACK_H. Override those * specific rows post-hoc: TRACK_H while collapsed, plus the group's own * automation rows once its `∿` is open. */ +function applyGroupStripHeights( + tracks: readonly (readonly [number, readonly TimelineElement[]])[], + rowHeights: number[], + groups: readonly TimelineTrackGroupInfo[], + expandedLaneOwnerIds: ReadonlySet, +): number[] { + if (groups.length === 0) return rowHeights; + const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); + return tracks.map(([track], index) => { + const group = groupByAnchor.get(track); + if (!group || !expandedLaneOwnerIds.has(group.id)) return rowHeights[index] ?? TRACK_H; + // The group's own automation rows, which its `∿` discloses. A row sized + // without them clipped every lane it had just promised in the count. + return TRACK_H + groupOwnLaneCount(group) * AUTOMATION_LANE_H; + }); +} + function useTimelineRowHeights( tracks: [number, TimelineElement[]][], gsapAnimations: Map, + selectedElementId: string | null, + selectedElementIds: ReadonlySet, + groups: readonly TimelineTrackGroupInfo[], ) { + const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); // Keyframe lanes follow only the active clip, so a track with several // keyframed elements never reserves empty lanes for the ones not shown. // Automation lanes follow the whole row: they are shared per property. - const rowHeights = trackHeights(tracks.map(() => [])); + const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => { + const active = resolveTrackKeyframeClip( + elements, + laneCounts, + selectedElementId, + selectedElementIds, + ); + if (!active) return []; + const clipId = active.key ?? active.id; + return [ + { + clipId, + laneCount: 0, + automationLaneCount: trackAutomationLaneCount(elements), + }, + ]; + }); + const rowHeights = applyGroupStripHeights( + tracks, + tracks.map(([, elements], index) => { + const active = resolveTrackKeyframeClip(elements, laneCounts, selectedElementId, selectedElementIds); + const activeId = active ? (active.key ?? active.id) : null; + return activeId !== null && expandedLaneOwnerIds.has(activeId) + ? TRACK_H + trackAutomationLaneCount(elements) * AUTOMATION_LANE_H + : TRACK_H; + }), + groups, + expandedLaneOwnerIds, + ); return { laneCounts, rowGeometry: createTimelineRowGeometry( @@ -139,7 +201,14 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [gsapAnimations, tracks]); + }, [ + expandedLaneOwnerIds, + gsapAnimations, + groups, + tracks, + selectedElementId, + selectedElementIds, + ]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { @@ -153,8 +222,8 @@ function useTimelineRowHeights( export function useTimelineTrackLayout( expandedElements: TimelineElement[], gsapAnimations: Map, - _selectedElementId: string | null, - _selectedElementIds: ReadonlySet, + selectedElementId: string | null, + selectedElementIds: ReadonlySet, ) { const { tracks, trackStyles, trackOrder, groups, trackGroupOf } = useTimelineTrackDerivations(expandedElements); @@ -163,6 +232,9 @@ export function useTimelineTrackLayout( const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, + selectedElementId, + selectedElementIds, + groups, ); return { diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index 9295b0f08f..da9f25030c 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -83,18 +83,14 @@ export function isFocusedEaseRequestCurrent( } export interface KeyframeSlice { - /** @deprecated Expansion state is retained only for fixture compatibility; no timeline renderer reads it. */ - expandedClipIds: Set; - toggleClipExpanded: (id: string) => void; - setClipExpanded: (id: string, expanded: boolean) => void; - expandClips: (ids: string[]) => void; - /** @deprecated Compatibility for old fixtures; no renderer reads this state. */ - expandedLaneOwnerIds: Set; /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */ selectedKeyframes: Set; toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; + /** Clips whose keyframe property lanes are expanded in the timeline. */ + /** Union-expand clips (keyframed clips are expanded by default on load). */ + /** * Groups whose member rows the caret has HIDDEN (structural, not lanes). * @@ -107,6 +103,10 @@ export interface KeyframeSlice { collapsedGroupIds: Set; toggleGroupExpanded: (id: string) => void; + /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ + expandedLaneOwnerIds: Set; + toggleLaneOwnerExpanded: (id: string) => void; + /** * Project/session/element-scoped request. Its nonce is monotonic across store * resets so a stale consumer can never collide with a later request. @@ -142,23 +142,6 @@ export function createKeyframeSlice( getTimelineSessionIdentity: () => TimelineSessionIdentity, ): KeyframeSlice { return { - expandedClipIds: new Set(), - toggleClipExpanded: (id) => - set((state) => { - const next = new Set(state.expandedClipIds); - if (next.has(id)) next.delete(id); - else next.add(id); - return { expandedClipIds: next }; - }), - setClipExpanded: (id, expanded) => - set((state) => { - const next = new Set(state.expandedClipIds); - if (expanded) next.add(id); - else next.delete(id); - return { expandedClipIds: next }; - }), - expandClips: (ids) => set({ expandedClipIds: new Set(ids) }), - expandedLaneOwnerIds: new Set(), selectedKeyframes: new Set(), toggleSelectedKeyframe: (key) => set((state) => { @@ -169,6 +152,7 @@ export function createKeyframeSlice( }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + collapsedGroupIds: new Set(), toggleGroupExpanded: (id) => set((state) => { @@ -178,6 +162,15 @@ export function createKeyframeSlice( return { collapsedGroupIds: next }; }), + expandedLaneOwnerIds: new Set(), + toggleLaneOwnerExpanded: (id) => + set((state) => { + const next = new Set(state.expandedLaneOwnerIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { expandedLaneOwnerIds: next }; + }), + focusedEaseSegment: null, focusedEaseRequestNonce: 0, setFocusedEaseSegment: (target) => From d072ba17a36fe9c0bc01665f6bc603694e9b8493 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 13:31:25 -0400 Subject: [PATCH 13/42] fix(studio): restore audio disclosure state --- .../studio/src/player/components/Timeline.tsx | 619 ++++++++++++++++-- .../src/player/components/TimelineLanes.tsx | 6 +- .../components/timelineKeyboardNavigation.ts | 4 +- .../src/player/components/timelineLayout.ts | 2 +- 4 files changed, 576 insertions(+), 55 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 28a3b3ecee..4ed5905e08 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,22 +1,49 @@ -import { memo } from "react"; +import { useRef, useMemo, useCallback, useState, memo } from "react"; +import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; +import { usePlayerStore, type TimelineElement } from "../store/playerStore"; +import { defaultTimelineTheme } from "./timelineTheme"; +import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; +import { usePublishRangeSelection } from "./usePublishRangeSelection"; +import { useTimelinePlayhead } from "./useTimelinePlayhead"; +import { useTimelineZoom } from "./useTimelineZoom"; +import { useTimelineAssetDrop } from "./timelineDragDrop"; +import { TimelineEmptyState } from "./TimelineEmptyState"; +import { TimelineCanvas } from "./TimelineCanvas"; +import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; +import { useTimelineClipDrag } from "./useTimelineClipDrag"; +import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays"; +import { useTimelineEditPinning } from "./useTimelineEditPinning"; +import { useTimelineStackingSync } from "./useTimelineStackingSync"; +import { useTimelineGeometry } from "./useTimelineGeometry"; +import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; +import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; +import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; -import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; import { - TimelineEmptyStatePart, - TimelineEditPopover, - TimelineClipMenu, - TimelineFrame, - TimelineGapMenu, - TimelineKeyframeMenu, - TimelineLanes, - TimelineOverlays, - TimelinePlayhead, - TimelineRazorGuide, - TimelineRuler, - TimelineShortcutHint, -} from "./TimelineParts"; + getTrackStyle, + useTimelineDisplayLayout, + useTimelineTrackLayout, +} from "./useTimelineTrackLayout"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; +import { useTrackGapMenu } from "./useTrackGapMenu"; +import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; +import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; +import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; +import { + getEffectiveTimelineDuration, + getTimelinePreviewElement, + timelineNeedsLabelColumn, +} from "./timelineViewModel"; +import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; +import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; +import { useTimelineTicks } from "./useTimelineTicks"; +import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; +import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; +import { useTimelineActiveClips } from "./useTimelineActiveClips"; +import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; +import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus"; +import { useClipContextMenu } from "./useTimelineClipContextMenu"; -export * from "./TimelineProvider"; export { shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, @@ -30,48 +57,540 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; + export { getTimelineScrollTopForGeometryChange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; +export const Timeline = memo(function Timeline({ + onSeek, + onDrillDown, + renderClipContent, + renderClipOverlay, + onFileDrop, + onAssetDrop, + onBlockDrop, + onCompositionDrop, + onDeleteElement: _onDeleteElement, + onMoveElement: onMoveElementOverride, + onMoveElements: onMoveElementsOverride, + onResizeElement: onResizeElementOverride, + onResizeElements: onResizeElementsOverride, + onBlockedEditAttempt: onBlockedEditAttemptOverride, + onSplitElement: onSplitElementOverride, + onSelectElement, + onRangeSelect, + onCopyClip, + onPasteClip, + onDuplicateClip, + canPasteClip, + theme: themeOverrides, + sessionEpoch = 0, +}: TimelineProps = {}) { + const { + onMoveElement, + onMoveElements, + onResizeElement, + onResizeElements, + onBlockedEditAttempt, + onSplitElement, + onRazorSplitAll, + onDeleteKeyframe, + onDeleteAllKeyframes, + onMoveKeyframeToPlayhead, + onMoveKeyframe, + } = useResolvedTimelineEditCallbacks({ + onMoveElement: onMoveElementOverride, + onMoveElements: onMoveElementsOverride, + onResizeElement: onResizeElementOverride, + onResizeElements: onResizeElementsOverride, + onBlockedEditAttempt: onBlockedEditAttemptOverride, + onSplitElement: onSplitElementOverride, + }); + const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); + const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); + useMusicBeatAnalysis(); + const rawElements = usePlayerStore((s) => s.elements); + const timelineElements = rawElements; + const adjustedBeatAnalysis = useAdjustedBeatAnalysis(); + const duration = usePlayerStore((s) => s.duration); + const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); + const timelineReady = usePlayerStore((s) => s.timelineReady); + const selectedElementId = usePlayerStore((s) => s.selectedElementId); + const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); + const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); + const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); + const labelMode = useMemo( + () => timelineNeedsLabelColumn(gsapAnimations, timelineElements), + [gsapAnimations, timelineElements], + ); + // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter. + const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; + const contentGutter = labelMode ? GUTTER : 0; + const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); + const currentTime = usePlayerStore((s) => s.currentTime); + const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); + const playheadRef = useRef(null); + const containerRef = useRef(null); + const scrollRef = useRef(null); + const activeTool = usePlayerStore((s) => s.activeTool); + const [hoveredClip, setHoveredClip] = useState(null); + const isDragging = useRef(false); + const shiftHeld = useTimelineShiftModifier(); + const [showPopover, setShowPopover] = useState(false); + const [kfContextMenu, setKfContextMenu] = useState(null); + const [clipContextMenu, setClipContextMenu] = useState(null); + const setContainerRef = useCallback((el: HTMLDivElement | null) => { + containerRef.current = el; + }, []); + const lastScrollLeftRef = useRef(0); + const effectiveDuration = useMemo( + () => getEffectiveTimelineDuration(duration, rawElements), + [duration, rawElements], + ); + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + const { + tracks, + trackStyles, + trackOrder, + trackOrderRef, + laneCounts, + rowGeometry, + rowGeometryRef, + groups, + trackGroupOf, + } = useTimelineTrackLayout( + timelineElements, + gsapAnimations, + selectedElementId, + selectedElementIds, + ); + const timelineElementsRef = useRef(timelineElements); + timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements + const ppsRef = useRef(100); + const durationRef = useRef(effectiveDuration); + durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration + const fitPpsRef = useRef(100); + const { + pinZoomBeforeEdit, + setRangeSelectionRef, + pinnedOnMoveElement, + pinnedOnMoveElements, + pinnedOnResizeElement, + pinnedOnResizeElements, + pinnedOnFileDrop, + pinnedOnAssetDrop, + pinnedOnBlockDrop, + pinnedOnCompositionDrop, + } = useTimelineEditPinning({ + ppsRef, + fitPpsRef, + onMoveElement, + onMoveElements, + onResizeElement, + onResizeElements, + onFileDrop, + onAssetDrop, + onBlockDrop, + onCompositionDrop, + }); + const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({ + expandedElementsRef: timelineElementsRef, + }); + const { + gapMenuModel, + gapHighlight, + setHoveredGapAction, + openGapMenu, + dismissGapMenu, + closeTrackGap, + closeAllTrackGaps, + } = useTrackGapMenu({ + tracks, + expandedElementsRef: timelineElementsRef, + trackOrderRef, + onMoveElement: pinnedOnMoveElement, + onMoveElements: pinnedOnMoveElements, + }); + const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu); + const { + draggedClip, + setDraggedClip, + resizingClip, + setResizingClip, + blockedClipRef, + suppressClickRef, + } = useTimelineClipDrag({ + scrollRef, + ppsRef, + durationRef, + trackOrderRef, + rowGeometryRef, + onMoveElement: pinnedOnMoveElement, + onMoveElements: pinnedOnMoveElements, + onResizeElement: pinnedOnResizeElement, + onResizeElements: pinnedOnResizeElements, + onBlockedEditAttempt, + onSeek, + setShowPopover, + setRangeSelectionRef, + readZIndex: zSyncEnabled ? readClipZIndex : undefined, + onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined, + refreshAfterLaneMove, + sessionEpoch, + }); + const assetDrop = useTimelineAssetDrop({ + scrollRef, + ppsRef, + trackOrderRef, + rowGeometryRef, + contentOrigin, + onFileDrop: pinnedOnFileDrop, + onAssetDrop: pinnedOnAssetDrop, + onBlockDrop: pinnedOnBlockDrop, + onCompositionDrop: pinnedOnCompositionDrop, + sessionEpoch, + }); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); + const resizingElementIds = + resizingClip?.groupPreview?.map((change) => change.key) ?? + (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined); + const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ + totalClipCount: timelineElements.length, + totalRowCount: displayLayout.displayTrackOrder.length, + zoomMode, + }); + const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = + useTimelineScrollViewport(scrollRef, [ + timelineReady, + timelineElements.length, + displayLayout.totalH, + ]); + const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } = + useTimelineGeometry({ + viewportWidth: viewport.clientWidth, + effectiveDuration, + zoomMode, + manualZoomPercent, + ppsRef, + fitPpsRef, + draggedClip, + resizingClip, + expandedElements: timelineElements, + isDragging, + scrollRef, + lastScrollLeftRef, + contentOrigin, + }); + const timelineFocus = useTimelineLogicalFocus({ + scrollRef, + tracks, + layout: displayLayout, + laneCounts, + selectedElementId, + selectedElementIds, + groups, + trackGroupOf, + gsapAnimations, + elements: timelineElements, + pixelsPerSecond: pps, + contentOrigin, + allowHorizontal: zoomMode === "manual", + viewport, + sessionEpoch, + draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, + resizingElementIds, + clipContextMenuRowKey: clipContextMenu?.element.track, + keyframeContextMenuRowKey: kfContextMenu?.element.track, + lastScrollLeftRef, + syncScrollViewport, + }); + const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); + const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = + useTimelineKeyframeHandlers({ + expandedElements: timelineElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); + const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } = + useTimelineClipRenderWindow({ + tracks, + viewport, + pixelsPerSecond: pps, + contentOrigin, + duration: displayDuration, + selectedElementId: selectedElementId ?? undefined, + draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined, + resizingElementIds, + focusedElementId: timelineFocus.pinnedElementId, + focusedEaseElementId: focusedEaseSegment?.elementId, + clipContextMenuElementId: clipContextMenu + ? getTimelineElementIdentity(clipContextMenu.element) + : undefined, + keyframeContextMenuElementId: kfContextMenu + ? getTimelineElementIdentity(kfContextMenu.element) + : undefined, + }); + useTimelineActiveClips({ + scrollRef, + currentTime, + clipStateVersion: renderTimeRange, + elementStateVersion: timelineElements, + }); + const laneGapStrips = useTimelineGapHighlights({ + gapHighlight, + tracks, + selectedElementId, + selectedElementIds, + expandedElements: timelineElements, + dragActive: draggedClip?.started === true || resizingClip != null, + displayDuration, + }); -function TimelineView() { - const { state, meta } = useTimelineContext(); - const { timelineReady, elements } = state; - if (!timelineReady || elements.length === 0) { - return ; + const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({ + playheadRef, + scrollRef, + ppsRef, + durationRef, + isDragging, + currentTime, + zoomMode, + manualZoomPercent, + zoomModeRef, + manualZoomPercentRef, + fitPps, + fitPpsRef, + effectiveDuration, + pps, + timelineReady, + elementsLength: timelineElements.length, + setZoomMode, + setManualZoomPercent, + onSeek, + contentOrigin, + }); + const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = + useTimelineRazorInteraction({ + active: activeTool === "razor", + scrollRef, + contentOrigin, + pixelsPerSecond: pps, + onSplitAll: onRazorSplitAll, + }); + + const { + rangeSelection, + setRangeSelection, + shiftClickClipRef, + marqueeRect, + isScrubbing, + handlePointerDown, + handlePointerMove, + handlePointerUp, + handlePointerCancel, + } = useTimelineRangeSelection({ + scrollRef, + ppsRef, + effectiveDuration, + pps, + onSeek, + seekFromX, + autoScrollDuringDrag, + dragScrollRaf, + isDragging, + setShowPopover, + elementsRef: timelineElementsRef, + clipIndex, + rowGeometryRef, + onSelectElement, + contentOrigin, + sessionEpoch, + }); + usePublishRangeSelection(rangeSelection, onRangeSelect); + setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag + + useTimelineSelectionLifecycle(timelineElements, selectedElementId, setShowPopover, () => + setRangeSelection(null), + ); + + const { major, minor, majorTickInterval } = useTimelineTicks( + displayDuration, + pps, + timeDisplayMode, + timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined, + ); + + const getPreviewElement = useCallback( + (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), + [resizingClip], + ); + + if (!timelineReady || timelineElements.length === 0) { + return ( + + ); } + return ( -
-
- - +
+
{ + lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload + recordTimelineScroll(e.currentTarget); + syncScrollViewport(e.currentTarget, true); + }} + {...timelineFocus.timelineFocusProps} + onDragOver={assetDrop.handleAssetDragOver} + onDragLeave={assetDrop.handleAssetDragLeave} + onDrop={assetDrop.handleAssetDrop} + onPointerDown={(e) => { + // Interactive controls own their clicks; scrubbing would preventDefault and eat them. + if (e.target instanceof Element && e.target.closest("button, input, select, a")) return; + if (splitAllAtPointer(e)) return; + handlePointerDown(e); + }} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerCancel} + onLostPointerCapture={handlePointerCancel} + > + { + if (draggedClip?.started || resizingClip) return; + setClipContextMenu(null); + openGapMenu({ x: e.clientX, y: e.clientY, track, time }); + }} + /> + {activeTool === "razor" && razorGuideX !== null && }
- +
); -} - -const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) { - return ( - - - - ); -}); - -export const Timeline = Object.assign(TimelineComposed, { - Provider: TimelineProvider, - Frame: TimelineFrame, - Ruler: TimelineRuler, - Lanes: TimelineLanes, - Playhead: TimelinePlayhead, - RazorGuide: TimelineRazorGuide, - ShortcutHint: TimelineShortcutHint, - EditPopover: TimelineEditPopover, - ClipMenu: TimelineClipMenu, - KeyframeMenu: TimelineKeyframeMenu, - GapMenu: TimelineGapMenu, - EmptyState: TimelineEmptyStatePart, - Overlays: TimelineOverlays, }); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index ff8f0a73f7..1ba3505084 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -122,6 +122,9 @@ export function TimelineLanes({ focusedTargetId, rowGeometry, scrollRef, + onToggleRow: (row) => { + if (row.elementId) toggleLaneOwnerExpanded(row.elementId); + }, }); return (
{ - const keys = els.map(getTimelineElementIdentity); - if (keys.length > 0) toggleRowExpandedTracked(keys); + if (keyframeClipKey) toggleLaneOwnerExpanded(keyframeClipKey); }} onToggleTrackHidden={onToggleTrackHidden} onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 3fb034567d..d91dd040cf 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -83,7 +83,7 @@ export interface BuildTimelineLogicalRowsInput { /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ expandedLaneOwnerIds?: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ - expandedLaneOwnerIds: ReadonlySet; + expandedLaneOwnerIds?: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; gsapAnimations: ReadonlyMap; @@ -249,7 +249,7 @@ export function buildTimelineLogicalRows({ selectedElementId, selectedElementIds, collapsedGroupIds, - expandedLaneOwnerIds, + expandedLaneOwnerIds = new Set(), groups, trackGroupOf, gsapAnimations, diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 2de67d4b7f..760e583c94 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -83,7 +83,7 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5); */ export const TRACKS_LEFT_PAD = 48; -interface TimelineTrackHeightClip { +export interface TimelineTrackHeightClip { clipId: string; laneCount: number; /** Audio automation lanes shown when expanded, reserved at their own height. */ From e05aea6099d8b41f2c5d64fef2f362329078381f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 13:33:04 -0400 Subject: [PATCH 14/42] test(studio): remove obsolete expanded fixture assertions --- packages/studio/src/hooks/useStudioTestHooks.test.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index 584063511b..757af43241 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -16,7 +16,7 @@ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [ "dense-short", "long-overlap", - "keyframe-heavy-expanded", + "keyframe-heavy", "composition-heavy", "remote-unsupported", ]; @@ -75,10 +75,9 @@ describe("timeline performance fixture", () => { expect(fixture.summary.elementCount).toBe(1_000); expect(fixture.summary.duration).toBeGreaterThan(0); expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000); - if (profile === "keyframe-heavy-expanded") { + if (profile === "keyframe-heavy") { expect(fixture.keyframeCache.size).toBe(1_000); expect(fixture.gsapAnimations.size).toBe(1_000); - expect(fixture.expandedClipIds.size).toBe(1_000); } }); @@ -104,7 +103,7 @@ describe("timeline performance fixture", () => { const summary = api.loadTimelinePerformanceFixture({ elementCount: 1_000, - profile: "keyframe-heavy-expanded", + profile: "keyframe-heavy", }); expect(summary.elementCount).toBe(1_000); @@ -119,7 +118,6 @@ describe("timeline performance fixture", () => { }); expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0); expect(usePlayerStore.getState().elements).toHaveLength(1_000); - expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); expect(hasTimelinePerformanceFixtureLease()).toBe(true); api.resetTimelinePerformanceFixture(); From 0757ed24d8db04e068aaf4720eaa8db5a070ed0d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 20 Sep 2026 15:53:07 -0400 Subject: [PATCH 15/42] fix(studio): resolve provider rebase without clip auto expansion --- .../studio/src/player/components/useTimelineProviderState.tsx | 2 -- packages/studio/src/player/store/timelineResetState.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 03645bcbb7..1ce28225e3 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -19,7 +19,6 @@ import { useTimelineOverlaysState } from "./useTimelineOverlaysState"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; -import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; @@ -136,7 +135,6 @@ export function useTimelineProviderState({ [duration, timelineElements], ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); - useAutoExpandKeyframedClips(gsapAnimations); const { tracks, trackStyles, diff --git a/packages/studio/src/player/store/timelineResetState.ts b/packages/studio/src/player/store/timelineResetState.ts index 8661277f00..c56e445b5d 100644 --- a/packages/studio/src/player/store/timelineResetState.ts +++ b/packages/studio/src/player/store/timelineResetState.ts @@ -25,7 +25,6 @@ export function createTimelineResetState() { // switch can match a same-keyed clip in the new project and redirect a // paste through `sel.elementKey === paste.elementKey` to a stale t0. automationSelection: null, - expandedClipIds: new Set(), // Per-composition: ids from comp A match nothing in B, silencing all of it. collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), From 21f14338d1ecda8fe44e90773bd3107b4431984c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 01:37:21 -0400 Subject: [PATCH 16/42] fix(studio): remove inline keyframe expansion rows --- .../studio/src/hooks/useStudioTestHooks.ts | 1 - .../studio/src/player/components/Timeline.tsx | 592 +----------------- .../player/components/TimelineGroupRow.tsx | 3 - .../player/components/TimelineLanes.test.tsx | 139 ---- .../src/player/components/TimelineLanes.tsx | 63 +- .../player/components/TimelineTrackHeader.tsx | 53 +- .../player/components/TimelineTrackRow.tsx | 45 -- .../timelineKeyboardNavigation.test.ts | 137 +--- .../components/timelineKeyboardNavigation.ts | 123 +--- .../player/components/timelineLayout.test.ts | 55 +- .../src/player/components/timelineLayout.ts | 33 - .../components/useTimelineLogicalFocus.ts | 2 - .../useTimelineLogicalRows.test.tsx | 2 - .../components/useTimelineLogicalRows.ts | 6 - .../components/useTimelineTrackLayout.test.ts | 113 +--- .../components/useTimelineTrackLayout.ts | 28 +- .../player/lib/timelinePerformanceFixture.ts | 12 +- .../src/player/store/playerStore.test.ts | 25 - 18 files changed, 44 insertions(+), 1388 deletions(-) diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 8e1d4489d5..8534b9de02 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -84,7 +84,6 @@ export function useStudioTestHooks({ selectedKeyframes: new Set(), keyframeCache: fixture.keyframeCache, gsapAnimations: fixture.gsapAnimations, - expandedClipIds: fixture.expandedClipIds, }); return fixture.summary; }, diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 4ed5905e08..2ff1afa843 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,49 +1,11 @@ -import { useRef, useMemo, useCallback, useState, memo } from "react"; -import { useAdjustedBeatAnalysis, useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis"; -import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { defaultTimelineTheme } from "./timelineTheme"; -import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; -import { usePublishRangeSelection } from "./usePublishRangeSelection"; -import { useTimelinePlayhead } from "./useTimelinePlayhead"; -import { useTimelineZoom } from "./useTimelineZoom"; -import { useTimelineAssetDrop } from "./timelineDragDrop"; +import { memo } from "react"; +import type { TimelineProps } from "./TimelineTypes"; import { TimelineEmptyState } from "./TimelineEmptyState"; import { TimelineCanvas } from "./TimelineCanvas"; -import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; -import { useTimelineClipDrag } from "./useTimelineClipDrag"; -import { TimelineOverlays, type ClipContextMenuState } from "./TimelineOverlays"; -import { useTimelineEditPinning } from "./useTimelineEditPinning"; -import { useTimelineStackingSync } from "./useTimelineStackingSync"; -import { useTimelineGeometry } from "./useTimelineGeometry"; -import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; -import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; -import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; -import type { TimelineProps } from "./TimelineTypes"; -import { - getTrackStyle, - useTimelineDisplayLayout, - useTimelineTrackLayout, -} from "./useTimelineTrackLayout"; -import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; -import { useTrackGapMenu } from "./useTrackGapMenu"; -import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; -import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; -import { useTimelinePerformanceTelemetry } from "./useTimelinePerformanceTelemetry"; -import { - getEffectiveTimelineDuration, - getTimelinePreviewElement, - timelineNeedsLabelColumn, -} from "./timelineViewModel"; -import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle"; -import { useTimelineShiftModifier } from "./useTimelineShiftModifier"; -import { useTimelineTicks } from "./useTimelineTicks"; -import { getTimelineElementIdentity } from "../lib/timelineElementHelpers"; -import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow"; -import { useTimelineActiveClips } from "./useTimelineActiveClips"; -import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh"; -import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus"; -import { useClipContextMenu } from "./useTimelineClipContextMenu"; +import { TimelineOverlays } from "./TimelineOverlays"; +import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; +export * from "./TimelineProvider"; export { shouldAutoScrollTimeline, getTimelineScrollLeftForZoomTransition, @@ -57,540 +19,32 @@ export { getDefaultDroppedTrack, } from "./timelineLayout"; export { formatTimelineTickLabel, generateTicks } from "./timelineRulerGeometry"; - export { getTimelineScrollTopForGeometryChange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; -export const Timeline = memo(function Timeline({ - onSeek, - onDrillDown, - renderClipContent, - renderClipOverlay, - onFileDrop, - onAssetDrop, - onBlockDrop, - onCompositionDrop, - onDeleteElement: _onDeleteElement, - onMoveElement: onMoveElementOverride, - onMoveElements: onMoveElementsOverride, - onResizeElement: onResizeElementOverride, - onResizeElements: onResizeElementsOverride, - onBlockedEditAttempt: onBlockedEditAttemptOverride, - onSplitElement: onSplitElementOverride, - onSelectElement, - onRangeSelect, - onCopyClip, - onPasteClip, - onDuplicateClip, - canPasteClip, - theme: themeOverrides, - sessionEpoch = 0, -}: TimelineProps = {}) { - const { - onMoveElement, - onMoveElements, - onResizeElement, - onResizeElements, - onBlockedEditAttempt, - onSplitElement, - onRazorSplitAll, - onDeleteKeyframe, - onDeleteAllKeyframes, - onMoveKeyframeToPlayhead, - onMoveKeyframe, - } = useResolvedTimelineEditCallbacks({ - onMoveElement: onMoveElementOverride, - onMoveElements: onMoveElementsOverride, - onResizeElement: onResizeElementOverride, - onResizeElements: onResizeElementsOverride, - onBlockedEditAttempt: onBlockedEditAttemptOverride, - onSplitElement: onSplitElementOverride, - }); - const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]); - const refreshAfterLaneMove = useTimelineLaneMoveRefresh(); - useMusicBeatAnalysis(); - const rawElements = usePlayerStore((s) => s.elements); - const timelineElements = rawElements; - const adjustedBeatAnalysis = useAdjustedBeatAnalysis(); - const duration = usePlayerStore((s) => s.duration); - const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode); - const timelineReady = usePlayerStore((s) => s.timelineReady); - const selectedElementId = usePlayerStore((s) => s.selectedElementId); - const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); - const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); - const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); - const labelMode = useMemo( - () => timelineNeedsLabelColumn(gsapAnimations, timelineElements), - [gsapAnimations, timelineElements], - ); - // The label column provides pre-t=0 space; otherwise keep TRACKS_LEFT_PAD after the gutter. - const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; - const contentGutter = labelMode ? GUTTER : 0; - const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); - const currentTime = usePlayerStore((s) => s.currentTime); - const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); - const playheadRef = useRef(null); - const containerRef = useRef(null); - const scrollRef = useRef(null); - const activeTool = usePlayerStore((s) => s.activeTool); - const [hoveredClip, setHoveredClip] = useState(null); - const isDragging = useRef(false); - const shiftHeld = useTimelineShiftModifier(); - const [showPopover, setShowPopover] = useState(false); - const [kfContextMenu, setKfContextMenu] = useState(null); - const [clipContextMenu, setClipContextMenu] = useState(null); - const setContainerRef = useCallback((el: HTMLDivElement | null) => { - containerRef.current = el; - }, []); - const lastScrollLeftRef = useRef(0); - const effectiveDuration = useMemo( - () => getEffectiveTimelineDuration(duration, rawElements), - [duration, rawElements], - ); - const keyframeCache = usePlayerStore((s) => s.keyframeCache); - const { - tracks, - trackStyles, - trackOrder, - trackOrderRef, - laneCounts, - rowGeometry, - rowGeometryRef, - groups, - trackGroupOf, - } = useTimelineTrackLayout( - timelineElements, - gsapAnimations, - selectedElementId, - selectedElementIds, - ); - const timelineElementsRef = useRef(timelineElements); - timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements - const ppsRef = useRef(100); - const durationRef = useRef(effectiveDuration); - durationRef.current = effectiveDuration; // oxlint-disable-line react/refs -- event handlers read the latest duration - const fitPpsRef = useRef(100); - const { - pinZoomBeforeEdit, - setRangeSelectionRef, - pinnedOnMoveElement, - pinnedOnMoveElements, - pinnedOnResizeElement, - pinnedOnResizeElements, - pinnedOnFileDrop, - pinnedOnAssetDrop, - pinnedOnBlockDrop, - pinnedOnCompositionDrop, - } = useTimelineEditPinning({ - ppsRef, - fitPpsRef, - onMoveElement, - onMoveElements, - onResizeElement, - onResizeElements, - onFileDrop, - onAssetDrop, - onBlockDrop, - onCompositionDrop, - }); - const { readClipZIndex, applyStackingPatches, zSyncEnabled } = useTimelineStackingSync({ - expandedElementsRef: timelineElementsRef, - }); - const { - gapMenuModel, - gapHighlight, - setHoveredGapAction, - openGapMenu, - dismissGapMenu, - closeTrackGap, - closeAllTrackGaps, - } = useTrackGapMenu({ - tracks, - expandedElementsRef: timelineElementsRef, - trackOrderRef, - onMoveElement: pinnedOnMoveElement, - onMoveElements: pinnedOnMoveElements, - }); - const onContextMenuClip = useClipContextMenu(onSelectElement, dismissGapMenu, setClipContextMenu); - const { - draggedClip, - setDraggedClip, - resizingClip, - setResizingClip, - blockedClipRef, - suppressClickRef, - } = useTimelineClipDrag({ - scrollRef, - ppsRef, - durationRef, - trackOrderRef, - rowGeometryRef, - onMoveElement: pinnedOnMoveElement, - onMoveElements: pinnedOnMoveElements, - onResizeElement: pinnedOnResizeElement, - onResizeElements: pinnedOnResizeElements, - onBlockedEditAttempt, - onSeek, - setShowPopover, - setRangeSelectionRef, - readZIndex: zSyncEnabled ? readClipZIndex : undefined, - onStackingPatches: zSyncEnabled ? applyStackingPatches : undefined, - refreshAfterLaneMove, - sessionEpoch, - }); - const assetDrop = useTimelineAssetDrop({ - scrollRef, - ppsRef, - trackOrderRef, - rowGeometryRef, - contentOrigin, - onFileDrop: pinnedOnFileDrop, - onAssetDrop: pinnedOnAssetDrop, - onBlockDrop: pinnedOnBlockDrop, - onCompositionDrop: pinnedOnCompositionDrop, - sessionEpoch, - }); - const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowGeometry); - const resizingElementIds = - resizingClip?.groupPreview?.map((change) => change.key) ?? - (resizingClip ? [getTimelineElementIdentity(resizingClip.element)] : undefined); - const { recordTimelineScroll } = useTimelinePerformanceTelemetry({ - totalClipCount: timelineElements.length, - totalRowCount: displayLayout.displayTrackOrder.length, - zoomMode, - }); - const { viewport, showShortcutHint, setScrollRef, syncScrollViewport } = - useTimelineScrollViewport(scrollRef, [ - timelineReady, - timelineElements.length, - displayLayout.totalH, - ]); - const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } = - useTimelineGeometry({ - viewportWidth: viewport.clientWidth, - effectiveDuration, - zoomMode, - manualZoomPercent, - ppsRef, - fitPpsRef, - draggedClip, - resizingClip, - expandedElements: timelineElements, - isDragging, - scrollRef, - lastScrollLeftRef, - contentOrigin, - }); - const timelineFocus = useTimelineLogicalFocus({ - scrollRef, - tracks, - layout: displayLayout, - laneCounts, - selectedElementId, - selectedElementIds, - groups, - trackGroupOf, - gsapAnimations, - elements: timelineElements, - pixelsPerSecond: pps, - contentOrigin, - allowHorizontal: zoomMode === "manual", - viewport, - sessionEpoch, - draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined, - resizingElementIds, - clipContextMenuRowKey: clipContextMenu?.element.track, - keyframeContextMenuRowKey: kfContextMenu?.element.track, - lastScrollLeftRef, - syncScrollViewport, - }); - const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); - const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); - const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = - useTimelineKeyframeHandlers({ - expandedElements: timelineElements, - keyframeCache, - onSelectElement, - onSeek, - setSelectedElementId, - setKfContextMenu, - toggleSelectedKeyframe, - }); - const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } = - useTimelineClipRenderWindow({ - tracks, - viewport, - pixelsPerSecond: pps, - contentOrigin, - duration: displayDuration, - selectedElementId: selectedElementId ?? undefined, - draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined, - resizingElementIds, - focusedElementId: timelineFocus.pinnedElementId, - focusedEaseElementId: focusedEaseSegment?.elementId, - clipContextMenuElementId: clipContextMenu - ? getTimelineElementIdentity(clipContextMenu.element) - : undefined, - keyframeContextMenuElementId: kfContextMenu - ? getTimelineElementIdentity(kfContextMenu.element) - : undefined, - }); - useTimelineActiveClips({ - scrollRef, - currentTime, - clipStateVersion: renderTimeRange, - elementStateVersion: timelineElements, - }); - const laneGapStrips = useTimelineGapHighlights({ - gapHighlight, - tracks, - selectedElementId, - selectedElementIds, - expandedElements: timelineElements, - dragActive: draggedClip?.started === true || resizingClip != null, - displayDuration, - }); - - const { seekFromX, autoScrollDuringDrag, dragScrollRaf } = useTimelinePlayhead({ - playheadRef, - scrollRef, - ppsRef, - durationRef, - isDragging, - currentTime, - zoomMode, - manualZoomPercent, - zoomModeRef, - manualZoomPercentRef, - fitPps, - fitPpsRef, - effectiveDuration, - pps, - timelineReady, - elementsLength: timelineElements.length, - setZoomMode, - setManualZoomPercent, - onSeek, - contentOrigin, - }); - const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = - useTimelineRazorInteraction({ - active: activeTool === "razor", - scrollRef, - contentOrigin, - pixelsPerSecond: pps, - onSplitAll: onRazorSplitAll, - }); - - const { - rangeSelection, - setRangeSelection, - shiftClickClipRef, - marqueeRect, - isScrubbing, - handlePointerDown, - handlePointerMove, - handlePointerUp, - handlePointerCancel, - } = useTimelineRangeSelection({ - scrollRef, - ppsRef, - effectiveDuration, - pps, - onSeek, - seekFromX, - autoScrollDuringDrag, - dragScrollRaf, - isDragging, - setShowPopover, - elementsRef: timelineElementsRef, - clipIndex, - rowGeometryRef, - onSelectElement, - contentOrigin, - sessionEpoch, - }); - usePublishRangeSelection(rangeSelection, onRangeSelect); - setRangeSelectionRef.current = setRangeSelection; // oxlint-disable-line react/refs -- stable ref consumed by useTimelineClipDrag - - useTimelineSelectionLifecycle(timelineElements, selectedElementId, setShowPopover, () => - setRangeSelection(null), - ); - - const { major, minor, majorTickInterval } = useTimelineTicks( - displayDuration, - pps, - timeDisplayMode, - timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined, - ); - - const getPreviewElement = useCallback( - (element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip), - [resizingClip], - ); - if (!timelineReady || timelineElements.length === 0) { - return ( - - ); +function TimelineView() { + const { state, meta } = useTimelineContext(); + const { timelineReady, elements } = state; + if (!timelineReady || elements.length === 0) { + return ; } - return ( -
-
{ - lastScrollLeftRef.current = e.currentTarget.scrollLeft; // restored across post-edit reload - recordTimelineScroll(e.currentTarget); - syncScrollViewport(e.currentTarget, true); - }} - {...timelineFocus.timelineFocusProps} - onDragOver={assetDrop.handleAssetDragOver} - onDragLeave={assetDrop.handleAssetDragLeave} - onDrop={assetDrop.handleAssetDrop} - onPointerDown={(e) => { - // Interactive controls own their clicks; scrubbing would preventDefault and eat them. - if (e.target instanceof Element && e.target.closest("button, input, select, a")) return; - if (splitAllAtPointer(e)) return; - handlePointerDown(e); - }} - onPointerMove={handlePointerMove} - onPointerUp={handlePointerUp} - onPointerCancel={handlePointerCancel} - onLostPointerCapture={handlePointerCancel} - > - { - if (draggedClip?.started || resizingClip) return; - setClipContextMenu(null); - openGapMenu({ x: e.clientX, y: e.clientY, track, time }); - }} - /> - {activeTool === "razor" && razorGuideX !== null && } +
+
+ + {meta.razorGuide}
- +
); +} + +export const Timeline = memo(function Timeline(props: TimelineProps = {}) { + return ( + + + + ); }); diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 0e2c5db9be..58c8c28869 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -120,9 +120,6 @@ export function TimelineGroupRow({ index={index} rowKey={rowKey} logicalRow={logicalRow} - propertyRows={[]} - lanesId="" - headerLanesId="" top={top} height={height} virtualized={virtualized} diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 56d2a4a974..b5660d3257 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -21,7 +21,6 @@ afterEach(() => { document.body.innerHTML = ""; usePlayerStore.getState().reset(); }); - /** The z-order sort keys really are fractional: a clip nudged between two lanes * lands on the midpoint. These are the values that used to reach aria-label. */ const TRACK_A = 1 / 6; @@ -64,7 +63,6 @@ function positionTween(id: string): GsapAnimation { interface RenderLanesOptions { elements?: TimelineElement[]; animations?: Map; - expandedClipIds?: string[]; selectedElementIds?: Set; multiDragPreview?: MultiDragPreviewInput | null; draggedClip?: DraggedClipState | null; @@ -100,7 +98,6 @@ function renderLanes(options: RenderLanesOptions = {}): { ); const rowHeights = displayTrackOrder.map(() => TRACK_H); act(() => { - usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) }); root.render( { }); }); -describe("TimelineLanes disclosure target", () => { - const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]); - - /** - * `aria-controls` is an ID LIST, and the caret needs one: it reveals the - * active clip's keyframe lanes AND the track's automation lanes, which cannot - * be one element — one belongs to a clip, the other to the row. - */ - function ariaControlsIds(host: HTMLElement): string[] { - const caret = host.querySelector("button[aria-controls]"); - return (caret?.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean); - } - - function ariaControlsTargets(host: HTMLElement): (HTMLElement | null)[] { - return ariaControlsIds(host).map((id) => host.querySelector(`#${id}`)); - } - - /** The first region named, which is the keyframe lanes. */ - function ariaControlsTarget(host: HTMLElement): HTMLElement | null { - return ariaControlsTargets(host)[0] ?? null; - } - - function expectNoDisclosure(view: ReturnType): void { - expect(ariaControlsTarget(view.host)).toBeNull(); - expect(ariaControlsTargets(view.host)).toEqual([]); - act(() => view.root.unmount()); - } - - // aria-controls used to name a div in the sticky label column: it computed to - // 0x0 and held no diamonds at all. - it("resolves the caret's aria-controls to an element holding the property lanes", () => { - const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); - expectNoDisclosure(view); - }); - - it("still resolves the caret's aria-controls while the layer is collapsed", () => { - const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] }); - expectNoDisclosure(view); - }); - - // Two timelines on one page (a mini-timeline in a modal beside the main one) - // both minted `timeline-lanes-track-0`, so every caret's aria-controls - // resolved to whichever instance mounted first. - it("mints lane ids that do not collide with a second TimelineLanes on the page", () => { - const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); - const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); - - const idsFor = (host: HTMLElement) => - Array.from(host.querySelectorAll("button[aria-controls]")).flatMap((caret) => - (caret.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean), - ); - const firstIds = idsFor(first.host); - const secondIds = idsFor(second.host); - const cellIdsFor = (host: HTMLElement) => - new Set( - Array.from(host.querySelectorAll("[data-property-group][id]"), (cell) => - cell.getAttribute("id"), - ).filter((id): id is string => id !== null), - ); - const ownedIdsFor = (host: HTMLElement) => - Array.from(host.querySelectorAll("[aria-owns]"), (owner) => - owner.getAttribute("aria-owns"), - ).filter((id): id is string => id !== null); - const firstCellIds = cellIdsFor(first.host); - const secondCellIds = cellIdsFor(second.host); - - for (const { host } of [first, second]) { - const treegrid = host.querySelector('[role="treegrid"]'); - expect(treegrid?.getAttribute("aria-colcount")).toBe("2"); - expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false); - expect( - [...host.querySelectorAll('[role="rowheader"]')].every( - (cell) => cell.getAttribute("aria-colindex") === "1", - ), - ).toBe(true); - expect( - [...host.querySelectorAll('[role="gridcell"]')].every( - (cell) => cell.getAttribute("aria-colindex") === "2", - ), - ).toBe(true); - } - expect(firstIds).toEqual([]); - expect(secondIds).toEqual([]); - expect(firstCellIds.size).toBe(0); - expect(secondCellIds.size).toBe(0); - expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true); - expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true); - // Still a legal CSS id selector: the aria-controls lookups above use `#id`. - for (const id of [...firstIds, ...secondIds]) { - expect(id).toMatch(/^[A-Za-z][\w-]*$/); - } - act(() => first.root.unmount()); - act(() => second.root.unmount()); - }); - - // The passenger branch wraps [clip, lanes] in a transformed div that re-renders - // on every pointer move. An unstable key there remounts the lanes and drops the - // in-flight drag. - it("does not remount the lanes while a multi-clip drag slides the formation", () => { - const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)]; - const selectedElementIds = new Set(["clip-a", "clip-b"]); - const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({ - dragStarted: true, - draggedKey: "clip-b", - draggedOriginStart: 0, - draggedPreviewStart, - selectedKeys: selectedElementIds, - }); - const view = renderLanes({ - elements, - animations: ANIMATIONS, - expandedClipIds: ["clip-a"], - selectedElementIds, - multiDragPreview: preview(0.25), - }); - - const before = ariaControlsTarget(view.host); - const beforeLane = before?.querySelector("[data-timeline-property-lane]"); - expect(before).toBeNull(); - expect(beforeLane).toBeUndefined(); - - view.rerender({ - elements, - animations: ANIMATIONS, - expandedClipIds: ["clip-a"], - selectedElementIds, - multiDragPreview: preview(0.75), - }); - - // Node identity, not just presence: a remount replaces these nodes. - expect(ariaControlsTarget(view.host)).toBeNull(); - act(() => view.root.unmount()); - }); -}); - describe("TimelineLanes selection", () => { it("keeps a selected clip selected when it is clicked again", () => { const selected = element("clip-a", TRACK_A); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 1ba3505084..7489019af7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -2,7 +2,6 @@ import { Fragment, useId, useMemo } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; -import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; import { useAutomationLanes } from "./useAutomationLanes"; import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; @@ -14,7 +13,6 @@ import { trackShowsBeatStrip, } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; -import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { CLIP_Y, TRACK_H } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; @@ -76,7 +74,6 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, - gsapAnimations, selectedKeyframes, currentTime, onSeek, @@ -89,7 +86,6 @@ export function TimelineLanes({ onContextMenuLane, beatAnalysis, onToggleTrackHidden, - onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, @@ -209,7 +205,8 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; - const rowExpanded = keyframeClipKey !== undefined && expandedLaneOwnerIds.has(keyframeClipKey); + const rowExpanded = + isAudioTrack && keyframeClipKey !== undefined && expandedLaneOwnerIds.has(keyframeClipKey); // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose @@ -254,9 +251,6 @@ export function TimelineLanes({ index={row} rowKey={rowKey} logicalRow={logicalRow} - propertyRows={trackLogicalRows.slice(1)} - lanesId={lanesId} - headerLanesId={`${lanesId} ${automationLanesId}`} top={rowGeometry.getRowTop(row)} height={rowHeight} virtualized={rowsVirtualized} @@ -275,13 +269,12 @@ export function TimelineLanes({ els[0]?.id ?? `Track${trackDisplaySuffix(displayNumber)}` } - lanesId={`${lanesId} ${automationLanesId}`} + lanesId={automationLanesId} contentOrigin={contentOrigin} keyframeClip={keyframeClip} trackElements={els} clipCount={els.length} isExpanded={rowExpanded} - animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []} currentTime={currentTime} isTrackHidden={isTrackHidden} isAudioTrack={isAudioTrack} @@ -291,7 +284,6 @@ export function TimelineLanes({ if (keyframeClipKey) toggleLaneOwnerExpanded(keyframeClipKey); }} onToggleTrackHidden={onToggleTrackHidden} - onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onRemoveAutomationLane={removeAutomationLane} onSeek={onSeek} rovingTargetId={keyboard.rovingTargetId} @@ -357,11 +349,6 @@ export function TimelineLanes({ renderElements.map((el) => { const clipStyle = getTrackStyle(el.tag); const elementKey = getTimelineElementIdentity(el); - // Only the track's active keyframe clip shows expanded lanes; - // other clips (incl. siblings on a shared track) show compact - // diamonds on their own bar instead. - const isTrackKeyframeClip = elementKey === keyframeClipKey; - const showsLanes = isTrackKeyframeClip && rowExpanded; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); @@ -452,7 +439,7 @@ export function TimelineLanes({ ); const compactKeyframes = keyframeCache?.get(elementKey); - const compactDiamonds = !showsLanes && compactKeyframes && ( + const compactDiamonds = compactKeyframes && ( ); - // Keep this shell mounted while collapsed so aria-controls stays valid - // and multi-drag cannot remount the subtree mid-gesture. - const propertyLanes = isTrackKeyframeClip && ( - 0 - ? ((currentTime - previewElement.start) / previewElement.duration) * 100 - : 0 - } - elementId={elementKey} - selectedKeyframes={selectedKeyframes} - rovingTargetId={keyboard.rovingTargetId} - onSelectSegment={(target) => onSelectSegment?.(elementKey, target)} - onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} - onShiftClickKeyframe={(target) => - onShiftClickKeyframe?.(elementKey, target) - } - onContextMenuKeyframe={(e, target) => - onContextMenuKeyframe?.(e, elementKey, target) - } - onMoveKeyframe={(target, toClipPercentage) => - onMoveKeyframe?.(elementKey, target, toClipPercentage) ?? - Promise.resolve(false) - } - suppressClickRef={suppressClickRef} - /> - ); - // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable @@ -523,7 +470,6 @@ export function TimelineLanes({ {clip} {compactDiamonds} - {propertyLanes} ); } @@ -540,7 +486,6 @@ export function TimelineLanes({ > {clip} {compactDiamonds} - {propertyLanes}
); }) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 151833990d..8697449df4 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -1,4 +1,3 @@ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { HF_AUDIO_FX_ATTR, serializeAudioFxChain, @@ -6,22 +5,20 @@ import { } from "@hyperframes/core/audio-fx"; import { classifyAudioName } from "@hyperframes/core/audio-carve"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader"; +import { PlainTrackHeader } from "./TimelineTrackPlainHeader"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { mintGroupId } from "../../components/editor/useFxCarveGrouping"; import { runtimeAudioId } from "../lib/timelineElementHelpers"; import { TimelineFxButton } from "./TimelineFxButton"; -import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; -import { clipTimingStart } from "../../hooks/gsapShared"; -import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow"; +import { LaneToggleButton } from "./LayerDisclosureRow"; import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { trackDisplaySuffix } from "./timelineTrackDisplay"; -import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows"; +import { AutomationLaneHeaderRow } from "./trackHeaderLabelRows"; import { useMemo } from "react"; /** Accent rail + inset marking a row as a group MEMBER, matching the level-2 @@ -66,7 +63,6 @@ interface TimelineTrackHeaderProps { /** Clips on this track, so the header can say how many the row holds. */ clipCount: number; isExpanded: boolean; - animations: readonly GsapAnimation[]; currentTime: number; isTrackHidden: boolean; isAudioTrack: boolean; @@ -76,7 +72,6 @@ interface TimelineTrackHeaderProps { theme: TimelineTheme; onToggleClipExpanded: () => void; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; - onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; /** Drop one envelope. Absent while the lanes are read-only, which is what * hides the control rather than offering a button that cannot act. */ onRemoveAutomationLane?: (target: string) => void; @@ -94,7 +89,6 @@ export function TimelineTrackHeader({ trackElements, clipCount, isExpanded, - animations, currentTime, isTrackHidden, isAudioTrack, @@ -102,7 +96,6 @@ export function TimelineTrackHeader({ theme, onToggleClipExpanded, onToggleTrackHidden, - onTogglePropertyGroupKeyframe, onRemoveAutomationLane, onSeek, rovingTargetId = null, @@ -110,11 +103,6 @@ export function TimelineTrackHeader({ const clipPercentage = keyframeClip ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100 : 0; - const lanes = keyframeClip - ? // clipTimingStart, not the raw start: an expanded sub-comp child's start is - // host-absolute while its tweens are local to its own file. - getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration) - : []; // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx // owns the gutter past it, so a 0% diamond isn't clipped by this panel). const showTrackLabel = contentOrigin >= LABEL_COL_W; @@ -202,7 +190,6 @@ export function TimelineTrackHeader({ // the music glyph and the group indent and gains the `∿`. Tying layout to // disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the // moment an envelope appeared. - const isKeyframeLayer = false; // What the lane disclosure calls this row. A row of several clips is named // for the TRACK, not for whichever is selected — the lanes are the track's, // shared per property, so "Narration 2 lanes" read as if they were that one @@ -278,8 +265,7 @@ export function TimelineTrackHeader({ : {}), }} > - {!isKeyframeLayer ? ( - <> + <> {/* The two lines own exactly TRACK_H, not the whole header. `justify-center` on the header itself centred them in its FULL height — which grows by AUTOMATION_LANE_H per open lane — so @@ -359,34 +345,7 @@ export function TimelineTrackHeader({ } />
- - ) : ( - <> - - {/* The eye belongs to the LAYER, so it lives on the always-mounted - layer row exactly like a plain track's. Hanging it off a lane row - (hover-gated, and only while expanded) left a keyframed track with - no way to be hidden at all by keyboard, and put the control on a - row it does not act on. */} - - - )} + {/* Below the keyframe rows and stepping by its own height, which is how TimelineAutomationLaneSlot lays the envelopes out on the canvas. The two have to agree or a name labels the wrong curve. */} @@ -405,7 +364,7 @@ export function TimelineTrackHeader({ alsoAutomatedBy={ groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined } - top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H} + top={getTimelineLaneTop(0) + index * AUTOMATION_LANE_H} isLastLane={index === automationRows.length - 1} gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)} columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin} diff --git a/packages/studio/src/player/components/TimelineTrackRow.tsx b/packages/studio/src/player/components/TimelineTrackRow.tsx index 6eb22131c3..80e33a0e18 100644 --- a/packages/studio/src/player/components/TimelineTrackRow.tsx +++ b/packages/studio/src/player/components/TimelineTrackRow.tsx @@ -1,19 +1,10 @@ import type { ReactNode } from "react"; -import { timelineLogicalRowCellId } from "./timelineNavigationIdentity"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; interface TimelineTrackRowProps { index: number; rowKey: number; logicalRow: TimelineLogicalRow; - propertyRows: readonly TimelineLogicalRow[]; - /** Names the canvas-side content cell — the active clip's own property lanes, - * minted with this single id in TimelinePropertyLanes. */ - lanesId: string; - /** Names the header cell. Space-separated because the caret it lives under - * expands two disjoint subtrees (the clip's keyframe lanes AND the track's - * automation lanes) — see TimelineTrackHeader for why they cannot share one id. */ - headerLanesId: string; top: number; height: number; virtualized: boolean; @@ -28,9 +19,6 @@ export function TimelineTrackRow({ index, rowKey, logicalRow, - propertyRows, - lanesId, - headerLanesId, top, height, virtualized, @@ -66,39 +54,6 @@ export function TimelineTrackRow({ > {children}
- {propertyRows.map((row) => { - const group = row.propertyGroup; - const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length; - const easeCount = row.items.filter((item) => item.kind === "ease").length; - return ( - // ponytail: aria-owns maps this hidden logical row onto the two visible - // property-lane cells without duplicating interactive controls. -
-
- {group} -
-
- {keyframeCount} keyframes, {easeCount} ease controls -
-
- ); - })}
); } diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts index 853bb553ba..3ac613a7c6 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -57,7 +57,6 @@ function model(overrides: Partial[0] laneCounts: new Map([["active", 2]]), selectedElementId: "active", selectedElementIds: new Set(), - expandedClipIds: new Set(["active"]), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), groups: [], @@ -73,86 +72,6 @@ function model(overrides: Partial[0] } describe("buildTimelineLogicalRows", () => { - it("projects tracks, empty tracks, and expanded property rows with continuous indices", () => { - const rows = model(); - - expect( - rows.map(({ physicalTrackKey, logicalIndex, level, parentId, expandable }) => ({ - physicalTrackKey, - logicalIndex, - level, - parentId, - expandable, - })), - ).toEqual([ - { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null, expandable: true }, - { - physicalTrackKey: 1, - logicalIndex: 1, - level: 2, - parentId: timelineTrackRowId(1), - expandable: false, - }, - { - physicalTrackKey: 1, - logicalIndex: 2, - level: 2, - parentId: timelineTrackRowId(1), - expandable: false, - }, - { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null, expandable: false }, - { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null, expandable: false }, - ]); - expect(rows[0]?.expanded).toBe(true); - expect(rows[3]?.items).toEqual([]); - expect(rows[0]?.items.map((item) => item.elementId)).toEqual(["early", "active", "late"]); - }); - - it("orders keyframes and their segment ease controls deterministically", () => { - const rows = model({ - gsapAnimations: new Map([ - [ - "active", - [ - animation("z-animation", "position", [100, 0, 50]), - animation("a-animation", "position", [50]), - ], - ], - ]), - }); - const position = rows.find((row) => row.propertyGroup === "position")!; - - expect( - position.items.map((item) => [item.kind, item.time, item.keyframeTarget?.animationId]), - ).toEqual([ - ["keyframe", 10, "z-animation"], - ["ease", 12.5, "a-animation"], - ["keyframe", 15, "a-animation"], - ["keyframe", 15, "z-animation"], - ["ease", 17.5, "z-animation"], - ["keyframe", 20, "z-animation"], - ]); - }); - - it("uses the selected keyframed clip as the sole expanded-lane owner", () => { - const other = clip("other", 1, 0, 4); - const rows = model({ - tracks: [[1, [other, clip("active", 1, 10, 10)]]], - displayTrackOrder: [1], - laneCounts: new Map([ - ["active", 1], - ["other", 1], - ]), - selectedElementId: "other", - expandedClipIds: new Set(["other", "active"]), - gsapAnimations: new Map([ - ["active", [animation("active-position", "position", [0, 100])]], - ["other", [animation("other-visual", "visual", [0, 100], 0)]], - ]), - }); - - expect(rows.map((row) => row.propertyGroup).filter(Boolean)).toEqual(["visual"]); - }); }); describe("resolveTimelineNavigationTarget", () => { @@ -184,37 +103,6 @@ describe("resolveTimelineNavigationTarget", () => { ); }); - it("navigates every logical row including properties and empty tracks", () => { - const rows = model(); - const activeId = timelineClipFocusId("active"); - const propertyTarget = resolveTimelineNavigationTarget(rows, activeId, "ArrowDown")!; - - expect(propertyTarget.kind).toBe("keyframe"); - expect(propertyTarget.time).toBe(15); - expect( - resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageDown", { pageSize: 2 })?.id, - ).toBe(timelineTrackRowId(2)); - expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(2), "ArrowDown")?.id).toBe( - timelineTrackRowId(3), - ); - expect(resolveTimelineNavigationTarget(rows, propertyTarget.id, "ArrowUp")?.id).toBe(activeId); - expect( - resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageUp", { pageSize: 2 })?.id, - ).toBe(activeId); - }); - - it("uses a caller-supplied page size and ignores invalid page commands", () => { - const rows = model(); - const current = timelineTrackRowId(1); - - expect(resolveTimelineNavigationTarget(rows, current, "PageDown")?.id).toBe(current); - expect(resolveTimelineNavigationTarget(rows, current, "PageDown", { pageSize: 3 })?.id).toBe( - timelineTrackRowId(2), - ); - expect( - resolveTimelineNavigationTarget(rows, timelineTrackRowId(3), "PageDown", { pageSize: 1 })?.id, - ).toBe(timelineTrackRowId(3)); - }); it("supports modified Home and End across the whole logical model", () => { const rows = model(); @@ -228,14 +116,6 @@ describe("resolveTimelineNavigationTarget", () => { ).toBe(timelineTrackRowId(3)); }); - it("returns from a property row to its parent with ArrowLeft", () => { - const rows = model(); - const property = rows.find((row) => row.propertyGroup === "position")!; - - expect(resolveTimelineNavigationTarget(rows, property.id, "ArrowLeft")?.id).toBe( - timelineTrackRowId(1), - ); - }); it("breaks equal-distance vertical ties by time then stable identity", () => { const rows = buildTimelineLogicalRows({ @@ -247,7 +127,6 @@ describe("resolveTimelineNavigationTarget", () => { laneCounts: new Map(), selectedElementId: null, selectedElementIds: new Set(), - expandedClipIds: new Set(), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), groups: [], @@ -263,9 +142,8 @@ describe("resolveTimelineNavigationTarget", () => { describe("resolveTimelineFocusFallback", () => { it("chooses previous, then next, then the containing row after deletion", () => { - const before = model({ expandedClipIds: new Set() }); + const before = model(); const withoutActive = model({ - expandedClipIds: new Set(), tracks: fallbackTracks([clip("early", 1, 0), clip("late", 1, 20)]), }); expect( @@ -273,7 +151,6 @@ describe("resolveTimelineFocusFallback", () => { ).toBe(timelineClipFocusId("early")); const onlyNext = model({ - expandedClipIds: new Set(), tracks: fallbackTracks([clip("late", 1, 20)]), }); expect(resolveTimelineFocusFallback(before, onlyNext, timelineClipFocusId("active"))?.id).toBe( @@ -281,12 +158,10 @@ describe("resolveTimelineFocusFallback", () => { ); const onlyActive = model({ - expandedClipIds: new Set(), tracks: [[1, [clip("active", 1, 10, 10)]]], displayTrackOrder: [1], }); const empty = model({ - expandedClipIds: new Set(), tracks: [[1, []]], displayTrackOrder: [1], }); @@ -295,16 +170,6 @@ describe("resolveTimelineFocusFallback", () => { ); }); - it("falls back from a collapsed property row to its parent track", () => { - const before = model(); - const property = before.find((row) => row.propertyGroup === "position")!; - const after = model({ expandedClipIds: new Set() }); - - expect(resolveTimelineFocusFallback(before, after, property.items[0]!.id)?.id).toBe( - timelineTrackRowId(1), - ); - }); - it("returns null for an identity absent from the previous model", () => { expect(resolveTimelineFocusFallback(model(), model(), "missing")).toBeNull(); }); diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index d91dd040cf..109669e58b 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -1,17 +1,9 @@ -import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; -import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { groupAutomationLanes } from "./automationLaneData"; -import { - timelineKeyframeSelectionKey, - type TimelineKeyframeTarget, -} from "./timelineKeyframeIdentity"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import { timelineClipFocusId, - timelineEaseFocusId, timelineGroupRowId, - timelineKeyframeFocusId, - timelinePropertyRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; @@ -64,7 +56,6 @@ export interface TimelineLogicalRow { groupId?: string; expandable: boolean; expanded: boolean; - propertyGroup?: PropertyGroupName; items: readonly TimelineLogicalItem[]; } @@ -76,17 +67,12 @@ export interface BuildTimelineLogicalRowsInput { laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: ReadonlySet; - /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ - expandedClipIds?: ReadonlySet; /** Groups the caret has COLLAPSED — absent means expanded, the default. */ collapsedGroupIds: ReadonlySet; - /** @deprecated Accepted for fixture compatibility; expansion no longer affects rows. */ - expandedLaneOwnerIds?: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ expandedLaneOwnerIds?: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; - gsapAnimations: ReadonlyMap; } export interface TimelineNavigationOptions { @@ -120,17 +106,15 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin }); } -/** A track's active clip (if any), its element id, and its automation lanes. */ +/** A track's active clip (if any) and its element id. */ function resolveActiveTrackClip( elements: readonly TimelineElement[], laneCounts: BuildTimelineLogicalRowsInput["laneCounts"], selectedElementId: string | null, selectedElementIds: ReadonlySet, - gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"], ): { activeClip: TimelineElement | null; activeId: string | null; - lanes: ReturnType; } { const activeClip = resolveTrackKeyframeClip( elements, @@ -139,106 +123,7 @@ function resolveActiveTrackClip( selectedElementIds, ); const activeId = activeClip ? elementId(activeClip) : null; - const lanes = activeClip - ? getTimelinePropertyLanes( - gsapAnimations.get(elementId(activeClip)) ?? [], - activeClip.start, - activeClip.duration, - ) - : []; - return { activeClip, activeId, lanes }; -} - -function keyframeTarget( - keyframe: ReturnType[number]["keyframes"][number], -): TimelineKeyframeTarget { - return { - percentage: keyframe.percentage, - tweenPercentage: keyframe.tweenPercentage, - propertyGroup: keyframe.propertyGroup, - animationId: keyframe.animationId, - collidingAnimationTargets: keyframe.collidingAnimationTargets, - }; -} - -function propertyItems( - rowId: string, - clip: TimelineElement, - keyframes: ReturnType[number]["keyframes"], -): TimelineLogicalItem[] { - const id = elementId(clip); - const unique = new Map(); - for (const keyframe of keyframes) { - const target = keyframeTarget(keyframe); - const key = timelineKeyframeSelectionKey(id, target); - if (!unique.has(key)) { - unique.set(key, { - target, - time: clip.start + (keyframe.percentage / 100) * clip.duration, - }); - } - } - const ordered = [...unique.entries()].sort( - ([leftKey, left], [rightKey, right]) => - left.time - right.time || leftKey.localeCompare(rightKey), - ); - const items: TimelineLogicalItem[] = []; - // ponytail: The composite property lane owns adjacency, so the incoming keyframe - // owns an ease segment even when its previous neighbor came from another animation. - for (let index = 0; index < ordered.length; index += 1) { - const [, current] = ordered[index]!; - const previous = ordered[index - 1]?.[1]; - if (previous && current.time > previous.time && current.target.animationId !== undefined) { - items.push({ - id: timelineEaseFocusId(id, current.target), - kind: "ease", - rowId, - elementId: id, - time: previous.time + (current.time - previous.time) / 2, - keyframeTarget: current.target, - }); - } - items.push({ - id: timelineKeyframeFocusId(id, current.target), - kind: "keyframe", - rowId, - elementId: id, - time: current.time, - keyframeTarget: current.target, - }); - } - return items; -} - -/** A clip's lanes are visible when either the caret or the `∿` button opened it. */ -function isRowOpen(activeId: string | null, expandedLaneOwnerIds: ReadonlySet): boolean { - return activeId !== null && expandedLaneOwnerIds.has(activeId); -} - -/** A single automation-lane row, one level deeper than the track/group row that owns it. */ -function buildLaneRow( - track: number, - logicalIndex: number, - activeId: string, - activeClip: TimelineElement, - lane: ReturnType[number], - level: 2 | 3, - parentId: string, -): TimelineLogicalRow { - const laneRowId = timelinePropertyRowId(activeId, lane.group); - return { - id: laneRowId, - kind: "row", - physicalTrackKey: track, - logicalIndex, - level, - parentId, - elementId: activeId, - expandable: false, - expanded: false, - propertyGroup: lane.group, - items: propertyItems(laneRowId, activeClip, lane.keyframes), - }; + return { activeClip, activeId }; } /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */ @@ -252,7 +137,6 @@ export function buildTimelineLogicalRows({ expandedLaneOwnerIds = new Set(), groups, trackGroupOf, - gsapAnimations, }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] { const trackMap = new Map(tracks); const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); @@ -268,7 +152,6 @@ export function buildTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, - gsapAnimations, ); const disclosable = groupAutomationLanes(elements).length > 0; const expanded = isRowOpen(activeId, expandedLaneOwnerIds) && disclosable; diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index cd0175d567..ae841e26a4 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -17,7 +17,6 @@ import { getTimelineCanvasHeight, createTimelineRowGeometry, getTimelineRowGeometry, - trackHeights, resolveTimelineAssetDrop, getTimelineBeatEntries, } from "./timelineLayout"; @@ -60,60 +59,8 @@ describe("horizontal timeline window", () => { }); }); -/** N collapsed rows, the shape every caller passes when nothing is expanded. */ -const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H); - +/** Row geometry remains immutable and reusable for each height array. */ describe("variable timeline row geometry", () => { - const tracks = [ - [{ clipId: "a", laneCount: 0 }], - [{ clipId: "b", laneCount: 2 }], - [{ clipId: "c", laneCount: 1 }], - ]; - - it("resolves every row to the base height when no clip is expanded", () => { - expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]); - expect(trackHeights([[], [], []])).toEqual([TRACK_H, TRACK_H, TRACK_H]); - }); - - it("adds one lane height per lane on an expanded clip", () => { - expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]); - }); - - it("derives row tops from cumulative offsets", () => { - const heights = trackHeights(tracks, new Set(["b"])); - expect(getTimelineRowOffsets(heights)).toEqual([ - 0, - TRACK_H, - 2 * TRACK_H + 2 * LANE_H, - 3 * TRACK_H + 2 * LANE_H, - ]); - expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H); - }); - - it("maps y inside an expanded lane region back to the expanded track", () => { - const heights = trackHeights(tracks, new Set(["b"])); - const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5; - const row = getTimelineRowFromY(yInSecondExpandedLane, heights); - expect(Math.floor(row)).toBe(1); - expect(row).toBeGreaterThan(1.5); - expect(row).toBeLessThan(2); - }); - - it("sums resolved row heights into the canvas height", () => { - const heights = trackHeights(tracks, new Set(["b"])); - expect(getTimelineCanvasHeight(heights)).toBe( - RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD, - ); - }); - - it("reuses one immutable geometry snapshot for one height array", () => { - const heights = trackHeights(tracks, new Set(["b"])); - const first = getTimelineRowGeometry(heights); - expect(getTimelineRowGeometry(heights)).toBe(first); - expect(Object.isFrozen(first)).toBe(true); - expect(Object.isFrozen(first.rowOffsets)).toBe(true); - }); - it("looks up row boundaries through the precomputed geometry", () => { const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]); expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 760e583c94..917b90a164 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,4 +1,3 @@ -import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import type { ZoomMode } from "../store/playerStore"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; @@ -83,38 +82,6 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5); */ export const TRACKS_LEFT_PAD = 48; -export interface TimelineTrackHeightClip { - clipId: string; - laneCount: number; - /** Audio automation lanes shown when expanded, reserved at their own height. */ - automationLaneCount?: number; -} - -type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[]; - -/** - * Resolve each track's full height. Without expansion state every row is the - * legacy TRACK_H; if multiple clips in one track expand, the tallest one owns - * the shared row height. - */ -export function trackHeights( - tracks: TimelineTrackHeightInput, - expandedClipIds?: ReadonlySet, -): number[] { - return tracks.map((clips) => { - let laneCount = 0; - let automationLanes = 0; - for (const clip of clips) { - if (!expandedClipIds?.has(clip.clipId)) continue; - laneCount = Math.max(laneCount, clip.laneCount); - automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0); - } - return ( - TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H - ); - }); -} - function validRowHeight(height: number | undefined): number { if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H; return height; diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts index 03c8374ed6..e157185205 100644 --- a/packages/studio/src/player/components/useTimelineLogicalFocus.ts +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -34,7 +34,6 @@ interface TimelineLogicalFocusInput { } export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { - const expandedClipIds = usePlayerStore((state) => state.expandedClipIds); const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds); const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds); const projectId = usePlayerStore((state) => state.timelineProjectId); @@ -44,7 +43,6 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { laneCounts: input.laneCounts, selectedElementId: input.selectedElementId, selectedElementIds: input.selectedElementIds, - expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups: input.groups, diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index 8e68e53a7d..cb57440ffb 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -22,7 +22,6 @@ const tracks: TrackInput = Array.from( ); const laneCounts = new Map(); const selectedElementIds = new Set(); -const expandedClipIds = new Set(); const collapsedGroupIds = new Set(); const expandedLaneOwnerIds = new Set(); const groups: never[] = []; @@ -43,7 +42,6 @@ function Harness({ laneCounts, selectedElementId: null, selectedElementIds, - expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.ts b/packages/studio/src/player/components/useTimelineLogicalRows.ts index a921936981..1465824554 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.ts +++ b/packages/studio/src/player/components/useTimelineLogicalRows.ts @@ -13,12 +13,10 @@ export function useTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, - expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, - gsapAnimations, }: TimelineLogicalRowsInput) { return useMemo( () => @@ -28,21 +26,17 @@ export function useTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, - expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, - gsapAnimations, }), [ displayTrackOrder, - expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, - gsapAnimations, laneCounts, selectedElementId, selectedElementIds, diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index e23b08e22e..7c3c37289e 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -5,9 +5,8 @@ import { createRoot } from "react-dom/client"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it } from "vitest"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { LANE_H, TRACK_H } from "./timelineLayout"; +import { TRACK_H } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; -import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineTrackLayout"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -23,7 +22,6 @@ function renderTrackLayout( layout: ReturnType; unmount: () => void; } { - usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) }); let layout: ReturnType | undefined; function Probe() { @@ -157,61 +155,6 @@ describe("collapsed audio groups", () => { }); }); -describe("useTimelineTrackLayout", () => { - it("counts a flat tween lane and reserves its expanded row height", () => { - const elements: TimelineElement[] = [ - { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, - ]; - const animations = new Map([ - [ - "clip-1", - [ - { - id: "position-tween", - targetSelector: "#clip-1", - method: "to", - position: 0, - duration: 1, - properties: { x: 420 }, - propertyGroup: "position", - }, - ], - ], - ]); - const { layout, unmount } = renderTrackLayout(elements, animations); - - expect(layout.laneCounts.get("clip-1")).toBe(1); - expect(layout.rowHeights).toEqual([TRACK_H + LANE_H]); - expect(layout.rowGeometry.rowKeys).toEqual([0]); - expect(layout.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H); - unmount(); - }); - - // The row height reserved here and the lanes actually rendered are two - // readings of the same question. They used to be two inline copies of the - // group-set rule, and a mixed-group tween made them disagree: zero reserved - // rows under two rendered lanes. - it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => { - const elements: TimelineElement[] = [ - { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, - ]; - const mixed: GsapAnimation = { - id: "entrance", - targetSelector: "#clip-1", - method: "to", - position: 0, - duration: 1, - properties: { x: 420, opacity: 1 }, - }; - const animations = new Map([["clip-1", [mixed]]]); - const { layout, unmount } = renderTrackLayout(elements, animations); - - expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2); - expect(layout.laneCounts.get("clip-1")).toBe(2); - expect(layout.rowHeights).toEqual([TRACK_H + 2 * LANE_H]); - unmount(); - }); -}); const audioClip = (id: string, over: Partial = {}): TimelineElement => ({ id, key: id, @@ -222,60 +165,6 @@ const audioClip = (id: string, over: Partial = {}): TimelineEle ...over, }); -/** - * Clips sharing a row share a lane row per property, so the height they reserve - * is the track's grouped count — and the row is open when ANY of them is - * expanded, or clicking a sibling collapsed it. - */ -describe("a track several clips share", () => { - const peaking = (gain: number) => - JSON.stringify({ - version: 1, - nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }], - }); - const lanes = (...targets: string[]) => - JSON.stringify({ - version: 1, - lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })), - }); - const narration1 = audioClip("narration-1", { - fxChain: peaking(-3), - automation: lanes("fx.n1.gain"), - }); - const narration2 = audioClip("narration-2", { - start: 10, - fxChain: peaking(-6), - automation: lanes("fx.n1.gain", "volume"), - }); - - /** Reserved height for the row, with only narration-1 ever expanded. */ - function rowHeight(selectedElementId: string | null): number { - usePlayerStore.setState({ expandedClipIds: new Set(["narration-1"]) }); - let height = 0; - function Probe() { - height = - useTimelineTrackLayout([narration1, narration2], new Map(), selectedElementId, new Set()) - .rowHeights[0] ?? 0; - return null; - } - const root = createRoot(document.createElement("div")); - act(() => root.render(React.createElement(Probe))); - act(() => root.unmount()); - return height; - } - - it("reserves one row per property, not per clip's lane", () => { - // Two properties across the two clips — a shared 1 kHz peaking gain and a - // volume envelope on one of them — so two rows, not three. - expect(rowHeight("narration-1")).toBe(TRACK_H + 2 * AUTOMATION_LANE_H); - }); - - it("stays open at the same height when the selection moves to a sibling", () => { - // Expansion is stored per clip but reads as the row's: asking only about the - // active clip collapsed the row the moment another was clicked. - expect(rowHeight("narration-2")).toBe(rowHeight("narration-1")); - }); -}); describe("resolveTrackKeyframeClip", () => { const none = new Map(); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 11e9f57607..22db732401 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -10,8 +10,6 @@ import { TRACK_H, createTimelineRowGeometry, type TimelineRowGeometry, - trackHeights, - type TimelineTrackHeightClip, } from "./timelineLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import { groupAutomationElement } from "./groupAutomationElement"; @@ -130,11 +128,7 @@ function computeLaneCounts( return laneCounts; } -/** Group anchor rows have no elements of their own (`groupTimelineTracks` - * pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever - * looks at a row's clips — always gives them TRACK_H. Override those - * specific rows post-hoc: TRACK_H while collapsed, plus the group's own - * automation rows once its `∿` is open. */ +/** Group anchor rows have no elements of their own, so size their own automation rows explicitly. */ function applyGroupStripHeights( tracks: readonly (readonly [number, readonly TimelineElement[]])[], rowHeights: number[], @@ -162,26 +156,6 @@ function useTimelineRowHeights( const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); - // Keyframe lanes follow only the active clip, so a track with several - // keyframed elements never reserves empty lanes for the ones not shown. - // Automation lanes follow the whole row: they are shared per property. - const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => { - const active = resolveTrackKeyframeClip( - elements, - laneCounts, - selectedElementId, - selectedElementIds, - ); - if (!active) return []; - const clipId = active.key ?? active.id; - return [ - { - clipId, - laneCount: 0, - automationLaneCount: trackAutomationLaneCount(elements), - }, - ]; - }); const rowHeights = applyGroupStripHeights( tracks, tracks.map(([, elements], index) => { diff --git a/packages/studio/src/player/lib/timelinePerformanceFixture.ts b/packages/studio/src/player/lib/timelinePerformanceFixture.ts index e3fe74fb3d..8f5f6f7423 100644 --- a/packages/studio/src/player/lib/timelinePerformanceFixture.ts +++ b/packages/studio/src/player/lib/timelinePerformanceFixture.ts @@ -4,7 +4,7 @@ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore"; export type TimelinePerformanceFixtureProfile = | "dense-short" | "long-overlap" - | "keyframe-heavy-expanded" + | "keyframe-heavy" | "composition-heavy" | "remote-unsupported"; @@ -25,7 +25,6 @@ export interface TimelinePerformanceFixture { elements: TimelineElement[]; keyframeCache: Map; gsapAnimations: Map; - expandedClipIds: Set; } const TRACK_COUNT = 1_000; @@ -35,7 +34,7 @@ const PROFILE_GEOMETRY: Readonly< > = Object.freeze({ "dense-short": { duration: 120, clipDuration: 1.5 }, "long-overlap": { duration: 7_200, clipDuration: 120 }, - "keyframe-heavy-expanded": { duration: 600, clipDuration: 8 }, + "keyframe-heavy": { duration: 600, clipDuration: 8 }, "composition-heavy": { duration: 900, clipDuration: 12 }, "remote-unsupported": { duration: 900, clipDuration: 12 }, }); @@ -116,7 +115,6 @@ export function createTimelinePerformanceFixture( const elements: TimelineElement[] = []; const keyframeCache = new Map(); const gsapAnimations = new Map(); - const expandedClipIds = new Set(); for (let index = 0; index < spec.elementCount; index += 1) { const id = `perf-${spec.profile}-${spec.elementCount}-${index}`; @@ -143,10 +141,9 @@ export function createTimelinePerformanceFixture( ? `https://media.invalid/perf-${index % 32}.mp4` : `assets/perf-${index % 32}.unsupported`; } - if (spec.profile === "keyframe-heavy-expanded") { + if (spec.profile === "keyframe-heavy") { keyframeCache.set(id, keyframeData()); gsapAnimations.set(id, [fixtureAnimation(id, start, geometry.clipDuration)]); - expandedClipIds.add(id); } elements.push(element); } @@ -157,11 +154,10 @@ export function createTimelinePerformanceFixture( duration: geometry.duration, trackCount: TRACK_COUNT, keyframedElementCount: keyframeCache.size, - expandedElementCount: expandedClipIds.size, + expandedElementCount: 0, }), elements, keyframeCache, gsapAnimations, - expandedClipIds, }; } diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 0b9ffff79e..1c86975965 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -27,7 +27,6 @@ describe("usePlayerStore", () => { expect(state.loopEnabled).toBe(false); expect(state.zoomMode).toBe("fit"); expect(state.manualZoomPercent).toBe(100); - expect(state.expandedClipIds).toEqual(new Set()); }); }); @@ -55,30 +54,6 @@ describe("usePlayerStore", () => { }); }); - describe("expandedClipIds", () => { - it("toggles clip membership", () => { - const store = usePlayerStore.getState(); - - store.toggleClipExpanded("clip-1"); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); - - store.toggleClipExpanded("clip-1"); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - }); - - it("sets clip membership idempotently", () => { - const store = usePlayerStore.getState(); - - store.setClipExpanded("clip-1", true); - store.setClipExpanded("clip-1", true); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); - - store.setClipExpanded("clip-1", false); - store.setClipExpanded("clip-1", false); - expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); - }); - }); - describe("focused ease requests", () => { it("stamps the current project session and only lets its nonce clear it", () => { const store = usePlayerStore.getState(); From a4d7195933003ae88565d25900c0dd3110cd62a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 01:46:47 -0400 Subject: [PATCH 17/42] fix(studio): restore automation disclosure state --- .../src/player/components/timelineKeyboardNavigation.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 109669e58b..eaf7bf1ff6 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -142,8 +142,8 @@ export function buildTimelineLogicalRows({ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); const rows: TimelineLogicalRow[] = []; - // A real track's own row (level 1 ungrouped, level 2 under a group) plus, - // when its clip's lanes are open, the lane rows one level deeper. + // A real track's own row (level 1 ungrouped, level 2 under a group) plus + // its audio automation disclosure state. function emitTrack(track: number, level: 1 | 2, parentId: string | null): void { const elements = trackMap.get(track) ?? []; const trackId = timelineTrackRowId(track); @@ -154,7 +154,8 @@ export function buildTimelineLogicalRows({ selectedElementIds, ); const disclosable = groupAutomationLanes(elements).length > 0; - const expanded = isRowOpen(activeId, expandedLaneOwnerIds) && disclosable; + const expanded = + activeId !== null && expandedLaneOwnerIds.has(activeId) && disclosable; rows.push({ id: trackId, kind: "row", From d71663ccf115ecdabbac64b0f2c71298aca3ece1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 08:29:27 -0400 Subject: [PATCH 18/42] fix(studio): remove stale timeline model inputs --- packages/studio/src/player/components/TimelineLanes.test.tsx | 1 - packages/studio/src/player/components/TimelineTrackHeader.tsx | 4 ++++ .../studio/src/player/components/useTimelineLogicalFocus.ts | 3 --- .../src/player/components/useTimelineLogicalRows.test.tsx | 2 -- .../studio/src/player/components/useTimelineProviderState.tsx | 2 -- 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index b5660d3257..10eb2d7505 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -121,7 +121,6 @@ function renderLanes(options: RenderLanesOptions = {}): { expandedLaneOwnerIds: new Set(), groups: [], trackGroupOf: new Map(), - gsapAnimations, })} clipIndex={createTimelineClipIndex(tracks)} renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 8697449df4..90ed0f105d 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -53,6 +53,8 @@ interface TimelineTrackHeaderProps { * clip and one by the row. Minted by TimelineLanes, the one place that sees * every subtree. */ lanesId: string; + /** @deprecated Keyframe property lanes were removed; retained only for stale callers during migration. */ + animations?: readonly unknown[]; contentOrigin: number; /** The track's active keyframe clip (selected, else primary) — the one whose * disclosure + property rows this header shows, whether expanded or not. */ @@ -71,6 +73,8 @@ interface TimelineTrackHeaderProps { rovingTargetId?: string | null; theme: TimelineTheme; onToggleClipExpanded: () => void; + /** @deprecated Keyframe property controls were removed. */ + onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; /** Drop one envelope. Absent while the lanes are read-only, which is what * hides the control rather than offering a button that cannot act. */ diff --git a/packages/studio/src/player/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts index e157185205..d5190e89de 100644 --- a/packages/studio/src/player/components/useTimelineLogicalFocus.ts +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -1,5 +1,4 @@ import type { RefObject } from "react"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; @@ -18,7 +17,6 @@ interface TimelineLogicalFocusInput { selectedElementIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; - gsapAnimations: ReadonlyMap; elements: readonly TimelineElement[]; pixelsPerSecond: number; contentOrigin: number; @@ -47,7 +45,6 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { expandedLaneOwnerIds, groups: input.groups, trackGroupOf: input.trackGroupOf, - gsapAnimations: input.gsapAnimations, }); const focus = useTimelineFocusCoordinator({ scrollRef: input.scrollRef, diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index cb57440ffb..86071476d5 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -26,7 +26,6 @@ const collapsedGroupIds = new Set(); const expandedLaneOwnerIds = new Set(); const groups: never[] = []; const trackGroupOf = new Map(); -const gsapAnimations = new Map(); function Harness({ snapshots, @@ -46,7 +45,6 @@ function Harness({ expandedLaneOwnerIds, groups, trackGroupOf, - gsapAnimations, }); snapshots.push(logicalRows); return null; diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 1ce28225e3..f89c61e426 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -147,7 +147,6 @@ export function useTimelineProviderState({ trackGroupOf, } = useTimelineTrackLayout( timelineElements, - gsapAnimations, selectedElementId, selectedElementIds, ); @@ -259,7 +258,6 @@ export function useTimelineProviderState({ selectedElementIds, groups, trackGroupOf, - gsapAnimations, elements: timelineElements, pixelsPerSecond: pps, contentOrigin, From 2e82cd9ce1c93d327b38bc8afe74aca3b818552a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 08:58:32 -0400 Subject: [PATCH 19/42] fix(studio): pass animation state to timeline layout --- .../studio/src/player/components/useTimelineProviderState.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index f89c61e426..264b751fa0 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -147,6 +147,7 @@ export function useTimelineProviderState({ trackGroupOf, } = useTimelineTrackLayout( timelineElements, + gsapAnimations, selectedElementId, selectedElementIds, ); From cae707fce891e410e25a47bdc07d279af854622c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:30:00 -0400 Subject: [PATCH 20/42] fix(studio): remove stale expansion tests and helpers --- .../src/player/components/Timeline.test.ts | 40 +- .../player/components/TimelineLanes.test.tsx | 19 - .../src/player/components/TimelineLanes.tsx | 13 +- .../components/TimelineTrackHeader.test.tsx | 979 ++---------------- .../player/components/TimelineTrackHeader.tsx | 149 ++- .../components/timelineKeyboardNavigation.ts | 17 +- .../player/components/timelineLayout.test.ts | 5 +- .../src/player/components/timelineLayout.ts | 5 - .../components/useTimelineTrackLayout.test.ts | 22 - 9 files changed, 157 insertions(+), 1092 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 41c8153781..5d9c06ec26 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -165,7 +165,7 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("keeps the label column stable while a nested clip stays one row", () => { + it("keeps a nested clip in one track row across the playhead", () => { usePlayerStore.setState({ duration: 20, timelineReady: true, @@ -203,7 +203,7 @@ describe("Timeline provider boundary", () => { renderTimelineGeometry("clip-1"); const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); - expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); + expect(clip.style.height).toBe(`${TRACK_H}px`); expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); @@ -535,41 +535,6 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - // The caret belongs to the row, not to whichever clip on it is selected: the - // automation lanes below it are the track's, shared per property. Toggling one - // clip left the row's state depending on the selection, and a collapse that - // only dropped the active clip left the row stuck open. - it("expands and collapses every clip on a shared track together", () => { - const host = createSizedTimelineHost(720); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - elements: [ - { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, - { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); - - expect(host.querySelector('button[aria-label$=" lanes"]')).toBeNull(); - - // Every clip bar on the row is capped to one track height. Only the clip - // owning the property lanes used to be, so its siblings stretched the whole - // expanded row and painted their waveforms over the envelopes below. - expect( - ["narration-1", "narration-2"].map( - (id) => host.querySelector(`[data-el-id="${id}"]`)?.style.height, - ), - ).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]); - - act(() => root.unmount()); - }); - // The lanes are the row's, and selecting a clip must not rebuild them. They // used to hang off the active clip's property lanes, so clicking a sibling // moved the whole subtree into a different element and remounted every lane — @@ -586,6 +551,7 @@ describe("Timeline provider boundary", () => { duration: 8, timelineReady: true, selectedElementId: "narration-2", + expandedLaneOwnerIds: new Set(["narration-2"]), elements: [ { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 10eb2d7505..f50ce87beb 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -41,25 +41,6 @@ function element(id: string, track: number): TimelineElement { return { id, label: id, tag: "div", start: 0, duration: 2, track }; } -function positionTween(id: string): GsapAnimation { - return { - id: `${id}-tween`, - targetSelector: `#${id}`, - method: "to", - position: 0, - duration: 2, - properties: {}, - propertyGroup: "position", - keyframes: { - format: "percentage", - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 100, properties: { x: 100 } }, - ], - }, - }; -} - interface RenderLanesOptions { elements?: TimelineElement[]; animations?: Map; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 7489019af7..9d6adfdb4c 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -8,10 +8,7 @@ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelecti import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; -import { - resolveTrackKeyframeClip, - trackShowsBeatStrip, -} from "./useTimelineTrackLayout"; +import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { CLIP_Y, TRACK_H } from "./timelineLayout"; @@ -76,7 +73,6 @@ export function TimelineLanes({ keyframeCache, selectedKeyframes, currentTime, - onSeek, onSelectSegment, onClickKeyframe, onShiftClickKeyframe, @@ -160,7 +156,6 @@ export function TimelineLanes({ toggleLaneOwnerExpanded={toggleLaneOwnerExpanded} lanes={automationLanes} pps={pps} - currentTime={currentTime} compositionDuration={compositionDuration} beatTimes={beatAnalysis?.beatTimes} contentGutter={contentGutter} @@ -206,7 +201,9 @@ export function TimelineLanes({ ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; const rowExpanded = - isAudioTrack && keyframeClipKey !== undefined && expandedLaneOwnerIds.has(keyframeClipKey); + isAudioTrack && + keyframeClipKey !== undefined && + expandedLaneOwnerIds.has(keyframeClipKey); // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose @@ -285,8 +282,6 @@ export function TimelineLanes({ }} onToggleTrackHidden={onToggleTrackHidden} onRemoveAutomationLane={removeAutomationLane} - onSeek={onSeek} - rovingTargetId={keyboard.rovingTargetId} />
{ - document.body.innerHTML = ""; -}); +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +afterEach(() => (document.body.innerHTML = "")); const ELEMENT: TimelineElement = { id: "clip-1", @@ -27,909 +20,95 @@ const ELEMENT: TimelineElement = { track: 0, }; -function animation( - id: string, - propertyGroup: PropertyGroupName, - keyframes: Array<{ - percentage: number; - properties: Record; - ease?: string; - }>, -): GsapAnimation { - return { - id, - targetSelector: "#clip-1", - method: "to", - position: 0, - duration: 2, - properties: {}, - propertyGroup, - keyframes: { format: "percentage", keyframes }, - }; -} - -const POSITION = animation("position-tween", "position", [ - { percentage: 0, properties: { x: 0, y: 0 } }, - { percentage: 50, properties: { x: 100, y: 50 } }, - { percentage: 100, properties: { x: 200, y: 100 } }, -]); - -const OPACITY = animation("opacity-tween", "visual", [ - { percentage: 0, properties: { opacity: 0 } }, - { percentage: 50, properties: { opacity: 0.5 } }, - { percentage: 100, properties: { opacity: 1 } }, -]); - -interface RenderHeaderOptions { - keyframeClip?: TimelineElement; - /** Every clip on the track; defaults to just the keyframe clip. */ - trackElements?: readonly TimelineElement[]; - animations?: GsapAnimation[]; - clipCount?: number; - currentTime?: number; - expanded?: boolean; - onSeek?: (time: number) => void; - onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; - onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"]; - onRemoveAutomationLane?: (target: string) => void; - isAudioTrack?: boolean; - isGroupMember?: boolean; - isTrackHidden?: boolean; -} - -function renderHeader(options: RenderHeaderOptions = {}): { - host: HTMLDivElement; - root: Root; - rerender: (next: RenderHeaderOptions) => void; -} { +function renderHeader( + options: { + clip?: TimelineElement; + elements?: readonly TimelineElement[]; + expanded?: boolean; + audio?: boolean; + hidden?: boolean; + onHidden?: (track: number, hidden: boolean, display: number) => void; + onRemove?: (target: string) => void; + } = {}, +) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const render = (raw: RenderHeaderOptions) => { - // Defaults resolved once, up front, rather than as a `??` per prop in the - // JSX — a dozen of those is a dozen branches through one arrow. - const next = { - keyframeClip: ELEMENT, - clipCount: 1, - animations: [POSITION, OPACITY], - currentTime: 0, - isAudioTrack: false, - isGroupMember: false, - isTrackHidden: false, - onToggleTrackHidden: vi.fn(), - ...raw, - }; - act(() => { - root.render( - , - ); - }); - }; - render(options); - return { host, root, rerender: render }; -} - -function click(host: HTMLElement, label: string) { - const button = host.querySelector(`button[aria-label="${label}"]`); - expect(button).not.toBeNull(); - act(() => button?.click()); + const clip = options.clip ?? ELEMENT; + act(() => + root.render( + , + ), + ); + return { host, root }; } describe("TimelineTrackHeader", () => { - // §5: gain stages multiply. A group fading to 0.42 under a clip fading to - // 0.80 plays at 0.34, and an author who drew both hears something quieter - // than either with nothing on screen to say why. Not a warning; an - // explanation. - it("says so when the clip's group is fading the same parameter", () => { - const automation = JSON.stringify({ - version: 1, - lanes: [ - { - target: "volume", - points: [ - { t: 0, v: 1 }, - { t: 2, v: 0.4 }, - ], - }, - ], - }); - const clip: TimelineElement = { - ...ELEMENT, - tag: "audio", - automation, - audioGroup: "voiceover", - audioGroupLabel: "Voiceover", - audioGroupAutomation: automation, - }; - const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] }); - expect(view.host.textContent).toContain("Voiceover is also fading this."); - act(() => view.root.unmount()); - }); - - // The same clip with an un-automated group must stay quiet — the note is - // only honest when the two curves actually multiply. - it("stays quiet when the group automates nothing", () => { - const automation = JSON.stringify({ - version: 1, - lanes: [ - { - target: "volume", - points: [ - { t: 0, v: 1 }, - { t: 2, v: 0.4 }, - ], - }, - ], - }); - const clip: TimelineElement = { - ...ELEMENT, - tag: "audio", - automation, - audioGroup: "voiceover", - audioGroupLabel: "Voiceover", - }; - const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] }); - expect(view.host.textContent).not.toContain("is also fading this"); - act(() => view.root.unmount()); + it("keeps visibility controls off audio headers", () => { + const { host, root } = renderHeader({ audio: true }); + expect(host.querySelector('button[aria-label^="Hide track"]')).toBeNull(); + act(() => root.unmount()); }); - // An expanded sub-composition child sits on the MASTER timeline at a - // host-absolute start, but its tweens are parsed from its own file and are - // local to it. Feeding the raw start straight into the clip-% math put every - // lane keyframe far outside the clip. - it("keeps an expanded sub-comp child's lane percentages inside the clip", () => { - const child: TimelineElement = { - id: "pill", - tag: "div", - start: 16.5, - duration: 2, - track: 0, - expandedParentStart: 16, - sourceFile: "scene.html", - }; - const local: GsapAnimation = { - id: "pill-tween", - targetSelector: "#pill", - method: "to", - position: 0.5, - resolvedStart: 0.5, - duration: 2, - properties: {}, - propertyGroup: "position", - keyframes: { - format: "percentage", - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 100, properties: { x: 100 } }, - ], - }, - }; - // Playhead at the clip's midpoint (master time), so the 100% keyframe is - // ahead of it. On the raw host-absolute basis every keyframe rebased to a - // large negative percentage and nothing was ever ahead of the playhead. - const view = renderHeader({ - keyframeClip: child, - animations: [local], - currentTime: 17.5, - }); - - expect( - view.host.querySelector('button[aria-label="Next Position keyframe"]') - ?.disabled, - ).toBe(false); - act(() => view.root.unmount()); - }); - - // The header shows one clip's lanes, so how many clips the track holds is - // otherwise invisible from the label column. A single-clip track stays silent. - it("shows the track's clip count only once the track holds more than one clip", () => { - const view = renderHeader({ clipCount: 1 }); - expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull(); - - view.rerender({ clipCount: 3 }); - expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3"); - act(() => view.root.unmount()); - }); - - // The visibility control is the old hide eye. On an audio track it silences - // rather than hides, and the row already says so with a speaker elsewhere — - // so the eye's slot stays empty there. A non-audio track is untouched. - it("keeps the visibility control off audio track headers", () => { - const audio: TimelineElement = { ...ELEMENT, tag: "audio" }; - const view = renderHeader({ - keyframeClip: audio, - trackElements: [audio], - isAudioTrack: true, - animations: [], - }); - const labels = Array.from(view.host.querySelectorAll("button")).map((b) => - b.getAttribute("aria-label"), - ); - expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false); - expect(labels).not.toContain("Mute"); - act(() => view.root.unmount()); - }); - - // The escape hatch. `data-hidden` on audio silences it in preview and drops it - // from the render; the panel's "Muted" is the unrelated HTML `muted` - // attribute, and nothing else writes it. Withholding the eye unconditionally - // meant a track hidden by "Hide all" (or by hand, or before that rule existed) - // was silent with no control anywhere to bring it back. - it("offers the eye on an audio track that is already hidden, so it can be restored", () => { - const audio: TimelineElement = { ...ELEMENT, tag: "audio" }; - const view = renderHeader({ - keyframeClip: audio, - trackElements: [audio], - isAudioTrack: true, - isTrackHidden: true, - animations: [], - }); - const labels = Array.from(view.host.querySelectorAll("button")).map((b) => - b.getAttribute("aria-label"), - ); - expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true); - act(() => view.root.unmount()); - }); - - it("keeps it on a non-audio track", () => { - const view = renderHeader({ isAudioTrack: false }); - const labels = Array.from(view.host.querySelectorAll("button")).map((b) => - b.getAttribute("aria-label"), - ); - expect(labels.some((l) => l && /^Hide track/.test(l))).toBe(true); - act(() => view.root.unmount()); - }); - - // The eye acts on the layer, so it has to be reachable without a pointer and - // in every disclosure state — a hover-gated eye is unusable by keyboard. - it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => { - const view = renderHeader({ expanded: true }); - expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull(); - - view.rerender({ expanded: false }); - expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull(); - act(() => view.root.unmount()); - }); - - // trackNumber is a fractional z-order sort key, so building the label from it - // made screen readers announce "Hide track 0.16666666666666666". The display - // number is label-only; the toggle still routes by the real key. - it("announces the display track number but toggles with the real fractional key", () => { - const onToggleTrackHidden = vi.fn(); - const view = renderHeader({ onToggleTrackHidden }); - const eye = view.host.querySelector('button[aria-label="Hide track 1"]'); - - expect(eye).not.toBeNull(); + it("toggles the real track key while announcing its display number", () => { + const onHidden = vi.fn(); + const { host, root } = renderHeader({ onHidden }); + const eye = host.querySelector('button[aria-label="Hide track 1"]'); expect(eye?.title).toBe("Hide track 1"); - expect(view.host.innerHTML).not.toContain("0.16666666666666666"); - act(() => eye?.click()); - // The real fractional key acts; the display row rides along for the label. - expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1); - act(() => view.root.unmount()); - }); - - it("adds and removes a keyframe on the explicitly targeted property-group tween", () => { - const onTogglePropertyGroupKeyframe = vi.fn(); - const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe }); - - click(view.host, "Add Opacity keyframe"); - expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( - ELEMENT, - expect.objectContaining({ - animationId: "opacity-tween", - propertyGroup: "visual", - tweenPercentage: 25, - properties: { opacity: 0.25 }, - remove: false, - }), - ); - - view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe }); - click(view.host, "Remove Opacity keyframe"); - expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( - ELEMENT, - expect.objectContaining({ - animationId: "opacity-tween", - propertyGroup: "visual", - tweenPercentage: 50, - properties: { opacity: 0.5 }, - remove: true, - }), - ); - expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith( - ELEMENT, - expect.objectContaining({ animationId: "position-tween" }), - ); - act(() => view.root.unmount()); - }); - - it("seeks only to the selected group's adjacent keyframes", () => { - const onSeek = vi.fn(); - const view = renderHeader({ - currentTime: 1, - animations: [ - POSITION, - animation("opacity-tween", "visual", [ - { percentage: 25, properties: { opacity: 0.25 } }, - { percentage: 50, properties: { opacity: 0.5 } }, - { percentage: 75, properties: { opacity: 0.75 } }, - ]), - ], - onSeek, - }); - - click(view.host, "Next Position keyframe"); - expect(onSeek).toHaveBeenLastCalledWith(2); - click(view.host, "Previous Position keyframe"); - expect(onSeek).toHaveBeenLastCalledWith(0); - expect(onSeek).not.toHaveBeenCalledWith(1.5); - act(() => view.root.unmount()); - }); - - // The lane header sits inside the track row, whose own click handler selects - // the track. Every control in the label column has to own its click, or - // seeking to a keyframe also reselects whatever is behind the header. - it("keeps lane-header control clicks off the ancestor track row", () => { - const onAncestorClick = vi.fn(); - const view = renderHeader({ - currentTime: 1, - onSeek: vi.fn(), - onTogglePropertyGroupKeyframe: vi.fn(), - }); - // React 18 delegates from the root container, so an ancestor of it is where - // a leaked click actually shows up. - document.body.addEventListener("click", onAncestorClick); - - // Every control in the lane's label column, found by row rather than by - // label, so a wording change to one button can't silently drop it here. - const controls = view.host.querySelectorAll( - '[data-property-group="position"] button', - ); - expect(controls.length).toBeGreaterThanOrEqual(3); - for (const button of controls) { - act(() => { - button.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - } - - document.body.removeEventListener("click", onAncestorClick); - expect(onAncestorClick).not.toHaveBeenCalled(); - act(() => view.root.unmount()); + expect(onHidden).toHaveBeenCalledWith(1 / 6, true, 1); + act(() => root.unmount()); }); - it("fills the toggle diamond exactly at that group's keyframe", () => { - const view = renderHeader({ currentTime: 0.5 }); - const positionToggle = view.host.querySelector( - 'button[aria-label="Add Position keyframe"]', - ); - expect(positionToggle?.textContent).toBe("◇"); - - view.rerender({ currentTime: 1 }); - expect( - view.host.querySelector('button[aria-label="Remove Position keyframe"]') - ?.textContent, - ).toBe("◆"); - act(() => view.root.unmount()); - }); - - it("updates formatted group values when the playhead moves", () => { - const view = renderHeader({ currentTime: 0.5 }); - expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( - "50, 25", - ); - expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%"); - - view.rerender({ currentTime: 1.5 }); - expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( - "150, 75", - ); - expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%"); - act(() => view.root.unmount()); - }); - - it("samples mid-segment values along the segment's ease, not linearly", () => { - // GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50% - // runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so - // the readout is 12.5/6.25 and NOT the linear 50/25. - const eased = animation("eased-position", "position", [ - { percentage: 0, properties: { x: 0, y: 0 } }, - { percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" }, - { percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" }, - ]); - const onTogglePropertyGroupKeyframe = vi.fn(); - const view = renderHeader({ - animations: [eased], - currentTime: 0.5, - onTogglePropertyGroupKeyframe, - }); - - expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( - "12.5, 6.25", - ); - - // The same sampled value is what an added keyframe gets stamped with, so a - // header insert lands on the existing curve instead of deforming it. - click(view.host, "Add Position keyframe"); - expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce(); - expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({ - properties: { x: 12.5, y: 6.25 }, - }); - act(() => view.root.unmount()); - }); - - it("disables the previous chevron at or before the group's first keyframe", () => { - const view = renderHeader({ currentTime: 0 }); - const prevAt0 = view.host.querySelector( - 'button[aria-label="Previous Position keyframe"]', - ); - expect(prevAt0).not.toBeNull(); - expect(prevAt0?.disabled).toBe(true); - - view.rerender({ currentTime: 1 }); - const prevAt1 = view.host.querySelector( - 'button[aria-label="Previous Position keyframe"]', - ); - expect(prevAt1?.disabled).toBe(false); - act(() => view.root.unmount()); - }); - - it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => { - const view = renderHeader({ expanded: false }); - expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0); - - const assertAligned = (animations: GsapAnimation[]) => { - view.rerender({ animations }); - const lanesHost = document.createElement("div"); - document.body.append(lanesHost); - const lanesRoot = createRoot(lanesHost); - act(() => { - lanesRoot.render( - , - ); - }); - expect( - Array.from(view.host.querySelectorAll("[data-timeline-lane-top]")).map( - (row) => row.style.top, - ), - ).toEqual( - Array.from(lanesHost.querySelectorAll("[data-timeline-lane-top]")).map( - (row) => row.style.top, - ), - ); - expect( - Array.from(lanesHost.querySelectorAll("[data-timeline-property-lane]")).map( - (row) => row.style.left, - ), - ).toEqual(animations.map(() => "120px")); - act(() => lanesRoot.unmount()); - }; - - assertAligned([POSITION]); - assertAligned([POSITION, OPACITY]); - act(() => view.root.unmount()); - }); - - /** - * Automation lanes are named in the label column, on the same tree as the - * keyframe rows — not painted inside the lane, where the name sat on top of - * the envelope it belonged to and scrolled away from its own row. - */ - describe("audio automation rows", () => { - const BED: TimelineElement = { - id: "bed", - label: "Music Bed", + it("renders and removes audio envelope rows", () => { + const onRemove = vi.fn(); + const clip: TimelineElement = { + ...ELEMENT, tag: "audio", - start: 0, - duration: 10, - track: 0, - fxChain: JSON.stringify({ - version: 1, - nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }], - }), - automation: JSON.stringify({ - version: 1, - lanes: [ - { - target: "volume", - points: [ - { t: 0, v: 1 }, - { t: 5, v: 0.4 }, - ], - }, - { - target: "fx.n1.gain", - points: [ - { t: 0, v: 0 }, - { t: 5, v: -6 }, - ], - }, - ], - }), - } as TimelineElement; - - it("names every envelope in the label column", () => { - const { host, root } = renderHeader({ keyframeClip: BED, animations: [] }); - const rows = Array.from(host.querySelectorAll("[data-automation-lane-label]")); - // The attribute is the ROW's identity, which is the label: a row can hold - // several clips' envelopes, whose lane targets differ from each other. - expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([ - "Peaking EQ 1.6 kHz · Gain", - "Volume", - ]); - // A band is named by its frequency: with several of them, "Peaking EQ" says - // nothing about which is which. Bands sit above the level lanes. - // Two lines per row: what the effect is, then which knob the envelope - // drives. One line truncated mid-word in a column this narrow. - expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([ - "Peaking EQ 1.6 kHz", - "Volume", - ]); - expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual( - [ - "Gain", - // Volume has no effect behind it, so it has no second line at all. - undefined, - ], - ); - act(() => root.unmount()); - }); - - it("hides them when the track is collapsed", () => { - const { host, root } = renderHeader({ - keyframeClip: BED, - animations: [], - expanded: false, - }); - expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); - act(() => root.unmount()); - }); - - it("removes just that envelope from the label column", () => { - // The panel's automate toggle can only reach a parameter it still shows; a - // carve's own lanes are not in it at all, so without this an envelope could - // be created and never deleted. - const onRemoveAutomationLane = vi.fn(); - const { host, root } = renderHeader({ - keyframeClip: BED, - animations: [], - onRemoveAutomationLane, - }); - const button = host.querySelector( - 'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]', - ); - expect(button).not.toBeNull(); - act(() => button?.click()); - expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain"); - act(() => root.unmount()); - }); - - it("offers no remove button when the lanes are read-only", () => { - const { host, root } = renderHeader({ keyframeClip: BED, animations: [] }); - expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0); - act(() => root.unmount()); - }); - - it("stacks each envelope's row where its lane is drawn", () => { - // Same rhythm the canvas uses: automation begins below the keyframe lanes - // and steps by its own taller row height. - const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] }); - const tops = Array.from( - host.querySelectorAll("[data-automation-lane-label]"), - ).map((r) => r.style.top); - const base = getTimelineLaneTop(1); - expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]); - act(() => root.unmount()); - }); - }); - - /** - * Several clips on one row share a lane row per property, so the label column - * has to name the row for the property and the header for the track — not for - * whichever clip happens to be selected. - */ - describe("a track several clips share", () => { - const clip = (id: string, over: Partial): TimelineElement => - ({ - id, - key: id, - label: id, - tag: "audio", - start: 0, - duration: 4, - track: 0, - ...over, - }) as TimelineElement; - const PEAKING = (gain: number) => - JSON.stringify({ - version: 1, - nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }], - }); - const NARRATION_1 = clip("narration-1", { - fxChain: PEAKING(-3), - automation: JSON.stringify({ - version: 1, - lanes: [{ target: "fx.n1.gain", points: [{ t: 0, v: -3 }] }], - }), - }); - const NARRATION_2 = clip("narration-2", { - start: 4, - fxChain: PEAKING(-6), - automation: JSON.stringify({ - version: 1, - lanes: [ - { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] }, - { target: "volume", points: [{ t: 0, v: 1 }] }, - ], - }), - }); - const ROW = { trackElements: [NARRATION_1, NARRATION_2], clipCount: 2, animations: [] }; - - it("lists every clip's envelopes, whichever clip is selected", () => { - const labels = (host: HTMLElement) => - Array.from(host.querySelectorAll("[data-automation-lane-label]")).map((r) => - r.getAttribute("data-automation-lane-label"), - ); - const first = renderHeader({ ...ROW, keyframeClip: NARRATION_1 }); - const second = renderHeader({ ...ROW, keyframeClip: NARRATION_2 }); - // narration-1 has no volume envelope, but the row is still there — it is - // the track's, and it was its sibling's before the selection moved. - expect(labels(first.host)).toEqual(["Peaking EQ 1 kHz · Gain", "Volume"]); - expect(labels(second.host)).toEqual(labels(first.host)); - act(() => first.root.unmount()); - act(() => second.root.unmount()); - }); - - it("removes only from the clip it is showing, and offers nothing where it has no lane", () => { - // A write can only reach the selected clip, so a button on a row that clip - // is absent from could only remove nothing, or somebody else's envelope. - const onRemoveAutomationLane = vi.fn(); - const { host, root } = renderHeader({ - ...ROW, - keyframeClip: NARRATION_1, - onRemoveAutomationLane, - }); - expect( - Array.from(host.querySelectorAll('button[aria-label$="automation"]')).map((b) => - b.getAttribute("aria-label"), - ), - ).toEqual(["Remove Peaking EQ 1 kHz · Gain automation"]); - act(() => host.querySelector('button[aria-label$="automation"]')?.click()); - expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain"); - act(() => root.unmount()); - }); - - it("names the header for the track, not for one of the clips on it", () => { - const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 }); - expect(view.host.textContent).not.toContain("narration-2"); - // Asserted on the rendered text, not a `title`: the name wraps now rather - // than truncating, so it no longer carries a tooltip to be found by. - expect(view.host.textContent).toContain("Track 1"); - // Alone on the track it is still named for itself. - view.rerender({ - ...ROW, - keyframeClip: NARRATION_2, - trackElements: [NARRATION_2], - clipCount: 1, - }); - expect(view.host.textContent).toContain("narration-2"); - act(() => view.root.unmount()); - }); + automation: JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }), + }; + const { host, root } = renderHeader({ clip, audio: true, hidden: true, onRemove }); + expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); + act(() => host.querySelector('button[aria-label$="automation"]')?.click()); + expect(onRemove).toHaveBeenCalledWith("volume"); + act(() => root.unmount()); }); - describe("audio ids and canary gates", () => { - const VOICE: TimelineElement = { - id: "voice-1", - key: "index.html#voice-1", - domId: "voice-1", + it("hides audio envelope rows when collapsed", () => { + const clip: TimelineElement = { + ...ELEMENT, tag: "audio", - start: 0, - duration: 5, - track: 0, - }; - const VOICE_2: TimelineElement = { - ...VOICE, - id: "voice-2", - key: "index.html#voice-2", - domId: "voice-2", + automation: JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }), }; - - // A member row is `aria-level="2"`, and without this it looked identical to - // every top-level row — the nesting existed for a screen reader and not for - // an eye. B2's design called for the accent rail; only the semantics shipped. - it("indents a group member's row and gives it the accent rail", () => { - const view = renderHeader({ - keyframeClip: VOICE, - animations: [], - expanded: false, - isAudioTrack: true, - }); - const header = () => view.host.querySelector('[role="rowheader"]'); - - expect(header()?.style.paddingLeft).toBe(""); - expect(header()?.style.borderLeft).toBe(""); - expect(header()?.style.background).not.toContain("linear-gradient"); - - view.rerender({ - keyframeClip: VOICE, - animations: [], - expanded: false, - isAudioTrack: true, - isGroupMember: true, - }); - expect(header()?.style.paddingLeft).toBe("14px"); - expect(header()?.style.borderLeft).toContain("2px"); - act(() => view.root.unmount()); - }); - - it("offers the FX button on every audio track", () => { - const view = renderHeader({ - keyframeClip: VOICE, - animations: [], - expanded: false, - isAudioTrack: true, - }); - expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); - act(() => view.root.unmount()); - }); - - // A visual track has no chain to open, so the button must not follow the - // header onto every row. - it("withholds the FX button from a non-audio track", () => { - const view = renderHeader({ - keyframeClip: ELEMENT, - animations: [], - expanded: false, - }); - expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull(); - act(() => view.root.unmount()); - }); - - // A chain belongs to ONE bus, so a track carrying several ungrouped clips - // gets the pointer instead of the FX button — group first, then mix. - it("shows the group pointer on a multi-clip ungrouped audio track", () => { - const opts = { - keyframeClip: VOICE, - trackElements: [VOICE, VOICE_2], - clipCount: 2, - animations: [], - expanded: false, - isAudioTrack: true, - }; - const pointer = (host: HTMLElement) => - host.querySelector('button[aria-label="Effects — group these clips first"]'); - const view = renderHeader(opts); - expect(pointer(view.host)).not.toBeNull(); - // One clip needs no grouping — the real FX button takes its place. - view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 }); - expect(pointer(view.host)).toBeNull(); - expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); - act(() => view.root.unmount()); - }); - - // The header is a 48px column of exactly TWO lines — what the row is, then - // what you can do to it. The group pointer used to render as a sibling of - // both, making a third: 17 + 24 + 24 + gaps in a 48px box, which - // `justify-center` then spilled evenly out of the top and bottom. The name - // rode 10px above its own row and the pointer collided with the row below. - // The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes - // are absolutely positioned from its top. `justify-center` on the header - // itself therefore centred the two lines in the FULL height, so opening a - // lane pushed the name and its controls down on top of the lane rows. - it("pins the two lines to the top TRACK_H, whatever the header grows to", () => { - const automated: TimelineElement = { - ...VOICE, - automation: JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }), - }; - const view = renderHeader({ - keyframeClip: automated, - trackElements: [automated], - clipCount: 1, - animations: [], - expanded: true, - isAudioTrack: true, - }); - const header = view.host.querySelector('[role="rowheader"]'); - const lines = header?.children[0] as HTMLElement | undefined; - expect(lines?.style.height).toBe(`${TRACK_H}px`); - // The lane row is a sibling of the wrapper, not inside it — it stacks - // BELOW the two lines rather than sharing their box. - expect((header?.children.length ?? 0) > 1).toBe(true); - act(() => view.root.unmount()); - }); - - // The header is ONE line: name, clip count, then every control anchored to - // the right edge. It was two — a name line and a control line — which is - // what let a stray third child overflow the 48px box; now there is a single - // row and the controls share one right-aligned group. - it("keeps the name and every control on one line, controls to the right", () => { - const view = renderHeader({ - keyframeClip: VOICE, - trackElements: [VOICE, VOICE_2], - clipCount: 2, - animations: [], - expanded: false, - isAudioTrack: true, - }); - const header = view.host.querySelector('[role="rowheader"]'); - // One TRACK_H-tall wrapper holding one line. - const lines = header?.children[0]; - expect(lines?.children).toHaveLength(1); - // The controls live in a right-anchored group at the end of that line, - // after the name and the clip count — `ml-auto` is what holds the edge. - const line = lines?.children[0]; - const controls = line?.lastElementChild as HTMLElement | null; - expect(controls?.className).toContain("ml-auto"); - expect( - controls?.querySelector('button[aria-label="Effects — group these clips first"]'), - ).not.toBeNull(); - // And the clip count is beside the name, not out with the controls. - expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull(); - expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull(); - act(() => view.root.unmount()); - }); - }); -}); - -// A host themes the timeline by overriding --timeline-* on theme.css, so the -// rendered gutter must carry the CSS variable, not a baked-in colour. -describe("theming", () => { - it("reads the gutter background and border from timeline theme tokens", () => { - const view = renderHeader({}); - const header = view.host.querySelector('[role="rowheader"]'); - expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground); - expect(header?.style.background).toContain("var(--timeline-gutter-bg)"); - expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder); - act(() => view.root.unmount()); - }); - - // happy-dom's `background` shorthand garbles a `var()` layer next to - // `linear-gradient(...)` (verified), so this checks the same value through - // the pure function instead of the broken CSSOM roundtrip. - it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => { - const filled = gutterFill(defaultTimelineTheme.gutterBackground, true); - expect(filled).toContain("linear-gradient"); - expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true); - expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe( - defaultTimelineTheme.gutterBackground, - ); + const { host, root } = renderHeader({ clip, audio: true, expanded: false }); + expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); + act(() => root.unmount()); + }); + + it("names a shared audio track rather than the selected clip", () => { + const first = { ...ELEMENT, id: "first", tag: "audio" }; + const second = { ...ELEMENT, id: "second", tag: "audio" }; + const { host, root } = renderHeader({ clip: second, elements: [first, second], audio: true }); + expect(host.textContent).toContain("Track 1"); + expect(host.textContent).not.toContain("second"); + act(() => root.unmount()); }); }); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 90ed0f105d..ebba265ac7 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -53,8 +53,6 @@ interface TimelineTrackHeaderProps { * clip and one by the row. Minted by TimelineLanes, the one place that sees * every subtree. */ lanesId: string; - /** @deprecated Keyframe property lanes were removed; retained only for stale callers during migration. */ - animations?: readonly unknown[]; contentOrigin: number; /** The track's active keyframe clip (selected, else primary) — the one whose * disclosure + property rows this header shows, whether expanded or not. */ @@ -65,21 +63,16 @@ interface TimelineTrackHeaderProps { /** Clips on this track, so the header can say how many the row holds. */ clipCount: number; isExpanded: boolean; - currentTime: number; isTrackHidden: boolean; isAudioTrack: boolean; /** This track is a member of an audio group — indents the row under its header. */ isGroupMember?: boolean; - rovingTargetId?: string | null; theme: TimelineTheme; onToggleClipExpanded: () => void; - /** @deprecated Keyframe property controls were removed. */ - onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; /** Drop one envelope. Absent while the lanes are read-only, which is what * hides the control rather than offering a button that cannot act. */ onRemoveAutomationLane?: (target: string) => void; - onSeek?: (time: number) => void; } // fallow-ignore-next-line complexity @@ -93,7 +86,6 @@ export function TimelineTrackHeader({ trackElements, clipCount, isExpanded, - currentTime, isTrackHidden, isAudioTrack, isGroupMember = false, @@ -101,12 +93,7 @@ export function TimelineTrackHeader({ onToggleClipExpanded, onToggleTrackHidden, onRemoveAutomationLane, - onSeek, - rovingTargetId = null, }: TimelineTrackHeaderProps) { - const clipPercentage = keyframeClip - ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100 - : 0; // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx // owns the gutter past it, so a 0% diamond isn't clipped by this panel). const showTrackLabel = contentOrigin >= LABEL_COL_W; @@ -270,85 +257,81 @@ export function TimelineTrackHeader({ }} > <> - {/* The two lines own exactly TRACK_H, not the whole header. + {/* The two lines own exactly TRACK_H, not the whole header. `justify-center` on the header itself centred them in its FULL height — which grows by AUTOMATION_LANE_H per open lane — so opening one pushed the name and its controls down THROUGH the lane rows below, which are absolutely positioned from the top. */} -
- - {singleAudioClip && ( +
+ + {singleAudioClip && ( + writeClipFxChain(singleAudioClip, next, false)} + onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} + // Muted, an audition is silent — so the hover lifts the mute on + // the running graph and puts it back on the way out, the same + // borrow-and-return it already does with the playhead. + auditionSpans={[singleAudioClip]} + isMuted={isTrackHidden} + onSetMutedLive={(muted) => + onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null) + } + onOpenRack={() => openClipFxRack(singleAudioClip)} + /> + )} + {clipCount > 1 && + !isTrackGrouped && + (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && ( writeClipFxChain(singleAudioClip, next, false)} - onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} - // Muted, an audition is silent — so the hover lifts the mute on - // the running graph and puts it back on the way out, the same - // borrow-and-return it already does with the playhead. - auditionSpans={[singleAudioClip]} - isMuted={isTrackHidden} - onSetMutedLive={(muted) => - onSetElementAttributeLive?.( - singleAudioClip, - "data-hidden", - muted ? "" : null, - ) + variant="group-pointer" + clipCount={trackElements.length} + defaultLabel={trackLabel} + // Groups are audio-only in v1 (§1.4). A video track showing no + // button at all is the silent limit §5 forbids, so it gets the + // button and a reason instead. + refusal={ + isAudioTrack + ? undefined + : "Video audio can't be grouped yet — only audio clips can join a group." } - onOpenRack={() => openClipFxRack(singleAudioClip)} + onGroupClips={groupUngroupedClips} /> )} - {clipCount > 1 && - !isTrackGrouped && - (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && ( - - )} - {/* The lane disclosure, on the row's own layout rather than by + {/* The lane disclosure, on the row's own layout rather than by swapping it for a keyframe-layer row. */} - {disclosable && ( - - )} - - } - /> -
+ {disclosable && ( + + )} + + } + /> +
{/* Below the keyframe rows and stepping by its own height, which is how TimelineAutomationLaneSlot lays the envelopes out on the canvas. The diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index eaf7bf1ff6..b116a5b06a 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -154,8 +154,7 @@ export function buildTimelineLogicalRows({ selectedElementIds, ); const disclosable = groupAutomationLanes(elements).length > 0; - const expanded = - activeId !== null && expandedLaneOwnerIds.has(activeId) && disclosable; + const expanded = activeId !== null && expandedLaneOwnerIds.has(activeId) && disclosable; rows.push({ id: trackId, kind: "row", @@ -330,17 +329,3 @@ export function resolveTimelineFocusFallback( } return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null; } - -/** - * Does a track have anything to open — the header's own `disclosable`. - * - * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and - * keyed on tweens alone here an audio track whose only disclosable content is - * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid, - * so ArrowRight could not open it. Automation rows are counted per shared - * PROPERTY across the track's clips, the way the header counts them, not per - * clip. - */ -function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean { - return laneCount > 0 || groupAutomationLanes(elements).length > 0; -} diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index ae841e26a4..f88f04c7b8 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -13,7 +13,6 @@ import { getTimelineRowTop, getTimelineScrubTime, getTimelineRowFromY, - getTimelineRowOffsets, getTimelineCanvasHeight, createTimelineRowGeometry, getTimelineRowGeometry, @@ -23,6 +22,10 @@ import { import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry"; import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; +function baseRows(count: number): number[] { + return Array.from({ length: count }, () => TRACK_H); +} + describe("horizontal timeline window", () => { it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => { expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual( diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 917b90a164..3e8c689fec 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -188,11 +188,6 @@ export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineR return geometry; } -/** Cumulative top offsets, including the final bottom boundary. */ -export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] { - return [...getTimelineRowGeometry(rowHeights).rowOffsets]; -} - export function getTimelineRowHeight( row: number, rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index 7c3c37289e..c8392e03ff 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -15,27 +15,6 @@ afterEach(() => { usePlayerStore.getState().reset(); }); -function renderTrackLayout( - elements: TimelineElement[], - animations: Map, -): { - layout: ReturnType; - unmount: () => void; -} { - - let layout: ReturnType | undefined; - function Probe() { - layout = useTimelineTrackLayout(elements, animations, null, new Set()); - return null; - } - - const root = createRoot(document.createElement("div")); - act(() => root.render(React.createElement(Probe))); - if (!layout) throw new Error("Timeline track layout did not render"); - - return { layout, unmount: () => act(() => root.unmount()) }; -} - describe("collapsed audio groups", () => { const member = (id: string, track: number): TimelineElement => ({ id, @@ -165,7 +144,6 @@ const audioClip = (id: string, over: Partial = {}): TimelineEle ...over, }); - describe("resolveTrackKeyframeClip", () => { const none = new Map(); From e711b695b8d671cf379accf7d8d49e5f9909d5f9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:34:19 -0400 Subject: [PATCH 21/42] test(studio): cover shared audio lane labels --- .../src/player/components/TimelineTrackHeader.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index d7d3cc4730..f667d74647 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -104,8 +104,9 @@ describe("TimelineTrackHeader", () => { }); it("names a shared audio track rather than the selected clip", () => { - const first = { ...ELEMENT, id: "first", tag: "audio" }; - const second = { ...ELEMENT, id: "second", tag: "audio" }; + const automation = JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }); + const first = { ...ELEMENT, id: "first", tag: "audio", automation }; + const second = { ...ELEMENT, id: "second", tag: "audio", automation }; const { host, root } = renderHeader({ clip: second, elements: [first, second], audio: true }); expect(host.textContent).toContain("Track 1"); expect(host.textContent).not.toContain("second"); From fe99d5a04d1913bd4ea6af4a3a7980ce29fb506b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:41:22 -0400 Subject: [PATCH 22/42] fix(studio): remove unused keyframe callback plumbing --- packages/studio/src/player/components/TimelineCanvas.tsx | 1 - packages/studio/src/player/components/timelineLaneProps.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index 324fd6d372..d4e2cb9d8a 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -59,7 +59,6 @@ export const TimelineCanvas = memo(function TimelineCanvas() { draggedElement={draggedElement} multiDragPreview={multiDragPreview} onToggleTrackHidden={props.onToggleTrackHidden} - onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe} onResizeElement={props.onResizeElement} onMoveElement={props.onMoveElement} onRazorSplit={props.onRazorSplit} diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index 5c997850bf..4e322cb56a 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -117,7 +117,6 @@ export interface TimelineLanesProps extends TimelineLaneBaseProps { snapGuide: TimelineSnapTarget | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; - onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; From dd1bc9629b979c835414fa9ed60bce2b45e8023f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:50:39 -0400 Subject: [PATCH 23/42] fix(studio): clear rebase lint leftovers --- .../studio/src/player/components/useTimelineTrackLayout.test.ts | 1 - packages/studio/src/player/components/useTimelineTrackLayout.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index c8392e03ff..404625446f 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -2,7 +2,6 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it } from "vitest"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { TRACK_H } from "./timelineLayout"; diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 22db732401..11bdc44a96 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -158,7 +158,7 @@ function useTimelineRowHeights( const laneCounts = computeLaneCounts(tracks, gsapAnimations); const rowHeights = applyGroupStripHeights( tracks, - tracks.map(([, elements], index) => { + tracks.map(([, elements]) => { const active = resolveTrackKeyframeClip(elements, laneCounts, selectedElementId, selectedElementIds); const activeId = active ? (active.key ?? active.id) : null; return activeId !== null && expandedLaneOwnerIds.has(activeId) From 4ab6d738791e7b759b6ada3e971a871a5ca6dd0c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:54:41 -0400 Subject: [PATCH 24/42] fix(studio): remove unused timeline header export --- packages/studio/src/player/components/TimelineTrackHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index ebba265ac7..37a0bceb68 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -31,7 +31,7 @@ const GROUP_MEMBER_INDENT = 14; const GROUP_MEMBER_TINT = "rgba(255,255,255,0.035)"; /** The gutter fill for a row, tinted when it belongs to a group. */ -export function gutterFill(base: string, isGroupMember: boolean): string { +function gutterFill(base: string, isGroupMember: boolean): string { return isGroupMember ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}` : base; From ceb5b4b7de356ad55ae04f8303488e4c2ace4cdf Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 10:57:23 -0400 Subject: [PATCH 25/42] style(studio): format timeline cleanup --- .../timelineKeyboardNavigation.test.ts | 5 +---- .../components/useTimelineTrackLayout.ts | 22 +++++++------------ .../studio/src/player/store/keyframeSlice.ts | 1 - 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts index 3ac613a7c6..0bf49a51e8 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -71,8 +71,7 @@ function model(overrides: Partial[0] }); } -describe("buildTimelineLogicalRows", () => { -}); +describe("buildTimelineLogicalRows", () => {}); describe("resolveTimelineNavigationTarget", () => { it("navigates horizontal items plus row Home and End", () => { @@ -103,7 +102,6 @@ describe("resolveTimelineNavigationTarget", () => { ); }); - it("supports modified Home and End across the whole logical model", () => { const rows = model(); const current = timelineTrackRowId(2); @@ -116,7 +114,6 @@ describe("resolveTimelineNavigationTarget", () => { ).toBe(timelineTrackRowId(3)); }); - it("breaks equal-distance vertical ties by time then stable identity", () => { const rows = buildTimelineLogicalRows({ tracks: [ diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 11bdc44a96..d5d74d08d9 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -6,11 +6,7 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; -import { - TRACK_H, - createTimelineRowGeometry, - type TimelineRowGeometry, -} from "./timelineLayout"; +import { TRACK_H, createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import { groupAutomationElement } from "./groupAutomationElement"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; @@ -159,7 +155,12 @@ function useTimelineRowHeights( const rowHeights = applyGroupStripHeights( tracks, tracks.map(([, elements]) => { - const active = resolveTrackKeyframeClip(elements, laneCounts, selectedElementId, selectedElementIds); + const active = resolveTrackKeyframeClip( + elements, + laneCounts, + selectedElementId, + selectedElementIds, + ); const activeId = active ? (active.key ?? active.id) : null; return activeId !== null && expandedLaneOwnerIds.has(activeId) ? TRACK_H + trackAutomationLaneCount(elements) * AUTOMATION_LANE_H @@ -175,14 +176,7 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [ - expandedLaneOwnerIds, - gsapAnimations, - groups, - tracks, - selectedElementId, - selectedElementIds, - ]); + }, [expandedLaneOwnerIds, gsapAnimations, groups, tracks, selectedElementId, selectedElementIds]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index da9f25030c..8ed66c9bd7 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -152,7 +152,6 @@ export function createKeyframeSlice( }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), - collapsedGroupIds: new Set(), toggleGroupExpanded: (id) => set((state) => { From d992255b24975875c827297caf7d022524a5001e Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:09:52 -0400 Subject: [PATCH 26/42] fix(studio): restore composable timeline exports --- .../studio/src/player/components/Timeline.tsx | 41 +++++++++++++++---- .../player/components/TimelineLanes.test.tsx | 4 +- .../src/player/components/TimelineLanes.tsx | 2 +- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 2ff1afa843..f182e99fc6 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,8 +1,19 @@ import { memo } from "react"; import type { TimelineProps } from "./TimelineTypes"; -import { TimelineEmptyState } from "./TimelineEmptyState"; -import { TimelineCanvas } from "./TimelineCanvas"; -import { TimelineOverlays } from "./TimelineOverlays"; +import { + TimelineEmptyStatePart, + TimelineEditPopover, + TimelineClipMenu, + TimelineFrame, + TimelineGapMenu, + TimelineKeyframeMenu, + TimelineLanes, + TimelineOverlays, + TimelinePlayhead, + TimelineRazorGuide, + TimelineRuler, + TimelineShortcutHint, +} from "./TimelineParts"; import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; export * from "./TimelineProvider"; @@ -28,23 +39,39 @@ function TimelineView() { const { state, meta } = useTimelineContext(); const { timelineReady, elements } = state; if (!timelineReady || elements.length === 0) { - return ; + return ; } return (
- - {meta.razorGuide} + +
); } -export const Timeline = memo(function Timeline(props: TimelineProps = {}) { +const TimelineComposed = memo(function TimelineComposed(props: TimelineProps = {}) { return ( ); }); + +export const Timeline = Object.assign(TimelineComposed, { + Provider: TimelineProvider, + Frame: TimelineFrame, + Ruler: TimelineRuler, + Lanes: TimelineLanes, + Playhead: TimelinePlayhead, + RazorGuide: TimelineRazorGuide, + ShortcutHint: TimelineShortcutHint, + EditPopover: TimelineEditPopover, + ClipMenu: TimelineClipMenu, + KeyframeMenu: TimelineKeyframeMenu, + GapMenu: TimelineGapMenu, + EmptyState: TimelineEmptyStatePart, + Overlays: TimelineOverlays, +}); diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index f50ce87beb..cea8697683 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -208,7 +208,9 @@ describe("TimelineLanes track numbering", () => { const onToggleTrackHidden = vi.fn(); const view = renderLanes({ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)], - onToggleTrackHidden, + onToggleTrackHidden: (track, hidden, displayNumber) => { + onToggleTrackHidden(track, hidden, displayNumber); + }, }); const second = view.host.querySelector('button[aria-label="Hide track 2"]'); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 9d6adfdb4c..c81c6bdd5f 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -156,6 +156,7 @@ export function TimelineLanes({ toggleLaneOwnerExpanded={toggleLaneOwnerExpanded} lanes={automationLanes} pps={pps} + currentTime={currentTime} compositionDuration={compositionDuration} beatTimes={beatAnalysis?.beatTimes} contentGutter={contentGutter} @@ -272,7 +273,6 @@ export function TimelineLanes({ trackElements={els} clipCount={els.length} isExpanded={rowExpanded} - currentTime={currentTime} isTrackHidden={isTrackHidden} isAudioTrack={isAudioTrack} isGroupMember={groupMemberTracks.has(trackNum)} From 10fe462e403c1dd09180717d9e311375b85de6bc Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:22:26 -0400 Subject: [PATCH 27/42] fix(studio): keep audio lanes open across selection --- .../studio/src/player/components/Timeline.test.ts | 3 ++- .../studio/src/player/components/TimelineLanes.tsx | 14 +++++++++----- .../components/timelineKeyboardNavigation.test.ts | 2 -- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 5d9c06ec26..2eb661ceb3 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -203,7 +203,8 @@ describe("Timeline provider boundary", () => { renderTimelineGeometry("clip-1"); const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); - expect(clip.style.height).toBe(`${TRACK_H}px`); + const row = clip.parentElement?.parentElement; + expect(row?.style.height).toBe(`${TRACK_H}px`); expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c81c6bdd5f..73aa1c7374 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -201,10 +201,13 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; - const rowExpanded = - isAudioTrack && - keyframeClipKey !== undefined && - expandedLaneOwnerIds.has(keyframeClipKey); + const expandedAudioOwner = els.find((element) => + expandedLaneOwnerIds.has(getTimelineElementIdentity(element)), + ); + const laneOwnerKey = expandedAudioOwner + ? getTimelineElementIdentity(expandedAudioOwner) + : keyframeClipKey; + const rowExpanded = isAudioTrack && laneOwnerKey !== undefined; // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose @@ -278,7 +281,8 @@ export function TimelineLanes({ isGroupMember={groupMemberTracks.has(trackNum)} theme={theme} onToggleClipExpanded={() => { - if (keyframeClipKey) toggleLaneOwnerExpanded(keyframeClipKey); + const owner = laneOwnerKey ?? keyframeClipKey; + if (owner) toggleLaneOwnerExpanded(owner); }} onToggleTrackHidden={onToggleTrackHidden} onRemoveAutomationLane={removeAutomationLane} diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts index 0bf49a51e8..c453bba250 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -71,8 +71,6 @@ function model(overrides: Partial[0] }); } -describe("buildTimelineLogicalRows", () => {}); - describe("resolveTimelineNavigationTarget", () => { it("navigates horizontal items plus row Home and End", () => { const rows = model(); From 9fa0d88a3103f65b92842521483efffefa83ca91 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:29:51 -0400 Subject: [PATCH 28/42] fix(studio): align timeline lane test contract --- .../studio/src/player/components/TimelineLanes.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index cea8697683..f6446da1b4 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -47,7 +47,11 @@ interface RenderLanesOptions { selectedElementIds?: Set; multiDragPreview?: MultiDragPreviewInput | null; draggedClip?: DraggedClipState | null; - onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"]; + onToggleTrackHidden?: ( + track: number, + hidden: boolean, + displayNumber?: number | null, + ) => void | Promise; onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; hoveredClip?: string | null; renderClipContent?: React.ComponentProps["renderClipContent"]; @@ -137,7 +141,6 @@ function renderLanes(options: RenderLanesOptions = {}): { currentTime={0} onContextMenuLane={next.onContextMenuLane} onToggleTrackHidden={next.onToggleTrackHidden} - onTogglePropertyGroupKeyframe={vi.fn()} onResizeElement={vi.fn()} onMoveElement={vi.fn()} onSelectElement={onSelectElement} From adf7d738144684291d57b1e43da98a498fc8ffe4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:32:48 -0400 Subject: [PATCH 29/42] fix(studio): remove stale lane test import --- packages/studio/src/player/components/TimelineLanes.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index f6446da1b4..a1792f74e3 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -12,7 +12,6 @@ import { createTimelineClipIndex } from "../lib/timelineClipIndex"; import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; -import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; From f4f9a568917336b9e9064c1f1978c72ee9254df1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:42:20 -0400 Subject: [PATCH 30/42] fix(studio): align track header test callback --- .../studio/src/player/components/TimelineTrackHeader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index f667d74647..9c350508d5 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -27,7 +27,7 @@ function renderHeader( expanded?: boolean; audio?: boolean; hidden?: boolean; - onHidden?: (track: number, hidden: boolean, display: number) => void; + onHidden?: (track: number, hidden: boolean, displayNumber?: number | null) => void; onRemove?: (target: string) => void; } = {}, ) { From efd2e2c30ffb7ae18c82f217ae21be6a3f2e4466 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 11:54:38 -0400 Subject: [PATCH 31/42] fix(studio): remove stale track header test prop --- .../studio/src/player/components/TimelineTrackHeader.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 9c350508d5..370abfb384 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -47,7 +47,6 @@ function renderHeader( trackElements={options.elements ?? [clip]} clipCount={options.elements?.length ?? 1} isExpanded={options.expanded !== false} - currentTime={0} isTrackHidden={options.hidden ?? false} isAudioTrack={options.audio ?? false} isGroupMember={false} From 5b67b0a533aed7fc8a60a84db4ca246f78434d2b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 12:06:49 -0400 Subject: [PATCH 32/42] test(studio): preserve ruler half pixel alignment --- packages/studio/src/player/components/Timeline.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 2eb661ceb3..8e14d9bb9b 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -208,7 +208,7 @@ describe("Timeline provider boundary", () => { expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); - expect(Number.parseFloat(rulerTick.style.left)).toBe(1000); + expect(Number.parseFloat(rulerTick.style.left)).toBe(999.5); expect(collapsedHeader.textContent).toContain("Outro"); expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, From 48246eb33f7f6a1d10d1ee07d20a585c3f63da17 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 13:18:04 -0400 Subject: [PATCH 33/42] test(studio): keep audio lane fixtures drawable --- .../src/player/components/TimelineTrackHeader.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 370abfb384..e4ab5678d5 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -82,7 +82,10 @@ describe("TimelineTrackHeader", () => { const clip: TimelineElement = { ...ELEMENT, tag: "audio", - automation: JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }), + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }), }; const { host, root } = renderHeader({ clip, audio: true, hidden: true, onRemove }); expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); @@ -103,7 +106,10 @@ describe("TimelineTrackHeader", () => { }); it("names a shared audio track rather than the selected clip", () => { - const automation = JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }); + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); const first = { ...ELEMENT, id: "first", tag: "audio", automation }; const second = { ...ELEMENT, id: "second", tag: "audio", automation }; const { host, root } = renderHeader({ clip: second, elements: [first, second], audio: true }); From 066bb98fb15749709d0c6bf067a2ac1dc34c8f55 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 13:31:05 -0400 Subject: [PATCH 34/42] test(studio): assert shared lane owner label --- .../studio/src/player/components/TimelineTrackHeader.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index e4ab5678d5..e602ab8812 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -113,7 +113,7 @@ describe("TimelineTrackHeader", () => { const first = { ...ELEMENT, id: "first", tag: "audio", automation }; const second = { ...ELEMENT, id: "second", tag: "audio", automation }; const { host, root } = renderHeader({ clip: second, elements: [first, second], audio: true }); - expect(host.textContent).toContain("Track 1"); + expect(host.querySelector('button[aria-label="Hide Track 1 lanes"]')).not.toBeNull(); expect(host.textContent).not.toContain("second"); act(() => root.unmount()); }); From 3d2dce246f5d12d1aa8eea3a628a11ced45ed849 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 14:13:32 -0400 Subject: [PATCH 35/42] fix(studio): align audio lane expansion with layout state --- .../player/components/TimelineLanes.test.tsx | 25 +++++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 3 ++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index a1792f74e3..b296da4b8c 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -251,6 +251,31 @@ describe("TimelineLanes track numbering", () => { }); }); +describe("TimelineLanes audio disclosure", () => { + it("keeps a keyframed audio lane collapsed until its owner is expanded", () => { + const audio = element("audio-clip", TRACK_A); + audio.tag = "audio"; + audio.automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + const view = renderLanes({ elements: [audio] }); + + expect(view.host.querySelector('button[aria-label="Show Track 1 lanes"]')).not.toBeNull(); + expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); + + act(() => { + view.host + .querySelector('button[aria-label="Show Track 1 lanes"]') + ?.click(); + }); + + expect(view.host.querySelector('button[aria-label="Hide Track 1 lanes"]')).not.toBeNull(); + expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); + act(() => view.root.unmount()); + }); +}); + describe("TimelineLanes selection", () => { it("keeps a selected clip selected when it is clicked again", () => { const selected = element("clip-a", TRACK_A); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 73aa1c7374..7dd786eb9b 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -207,7 +207,8 @@ export function TimelineLanes({ const laneOwnerKey = expandedAudioOwner ? getTimelineElementIdentity(expandedAudioOwner) : keyframeClipKey; - const rowExpanded = isAudioTrack && laneOwnerKey !== undefined; + const rowExpanded = + isAudioTrack && laneOwnerKey !== undefined && expandedLaneOwnerIds.has(laneOwnerKey); // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose From 214bced7de9ec6934475d4f334288d08df83ce59 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 14:27:50 -0400 Subject: [PATCH 36/42] test(studio): use the audio lane owner label --- .../src/player/components/TimelineLanes.test.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index b296da4b8c..7adc06834e 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -261,16 +261,20 @@ describe("TimelineLanes audio disclosure", () => { }); const view = renderLanes({ elements: [audio] }); - expect(view.host.querySelector('button[aria-label="Show Track 1 lanes"]')).not.toBeNull(); + expect( + view.host.querySelector('button[aria-label^="Show "][aria-label$=" lanes"]'), + ).not.toBeNull(); expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); act(() => { view.host - .querySelector('button[aria-label="Show Track 1 lanes"]') + .querySelector('button[aria-label^="Show "][aria-label$=" lanes"]') ?.click(); }); - expect(view.host.querySelector('button[aria-label="Hide Track 1 lanes"]')).not.toBeNull(); + expect( + view.host.querySelector('button[aria-label^="Hide "][aria-label$=" lanes"]'), + ).not.toBeNull(); expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); act(() => view.root.unmount()); }); From 738926f63e7c388df816f5b4fe5f76e0f762d5c5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 15:12:56 -0400 Subject: [PATCH 37/42] fix(studio): unify audio lane expansion state --- .../src/components/nle/TimelinePane.tsx | 6 --- .../src/player/components/Timeline.test.ts | 2 +- .../components/TimelineAutomationLaneSlot.tsx | 4 +- .../player/components/TimelineGroupRow.tsx | 5 +-- .../player/components/TimelineLanes.test.tsx | 36 ++++++++++++++++++ .../src/player/components/TimelineLanes.tsx | 28 ++++++-------- .../player/components/TimelineTrackHeader.tsx | 4 +- .../components/timelineKeyboardNavigation.ts | 8 ++-- .../components/useTimelineProviderState.tsx | 7 +--- .../components/useTimelineTrackLayout.test.ts | 35 +++++++++++++++++- .../components/useTimelineTrackLayout.ts | 37 +++++++------------ .../studio/src/player/store/keyframeSlice.ts | 17 +++++---- packages/studio/src/telemetry/events.test.ts | 6 --- packages/studio/src/telemetry/events.ts | 5 --- scripts/check-no-main-deletions.mjs | 8 ---- 15 files changed, 115 insertions(+), 93 deletions(-) diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 17e559288c..fadffad05b 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -180,10 +180,6 @@ export function TimelinePane({ operation?: TimelineMoveOperation, coalesceMs?: number, ) => { - // Match the sibling handlers: report the telemetry when the batch touches at - // least one expanded sub-comp child (the clips being rebased to local coords). - if (edits.some(({ element }) => element.expandedParentStart !== undefined)) { - } if (!onMoveElements) return; return forwardRebasedTimelineMoveElements( edits, @@ -222,8 +218,6 @@ export function TimelinePane({ options?: { coalesceKey?: string }, ) => { if (!onResizeElements) return; - if (changes.some(({ element }) => element.expandedParentStart !== undefined)) { - } return forwardRebasedTimelineResizeElements(changes, options, onResizeElements); }, [onResizeElements], diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index 8e14d9bb9b..aff5e32fb3 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -552,7 +552,7 @@ describe("Timeline provider boundary", () => { duration: 8, timelineReady: true, selectedElementId: "narration-2", - expandedLaneOwnerIds: new Set(["narration-2"]), + expandedLaneOwnerIds: new Set(["narration-1", "narration-2"]), elements: [ { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx index 9c70b6e03a..20967db6c1 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx @@ -141,7 +141,7 @@ export interface TimelineAutomationLaneSlotProps { lanes: UseAutomationLanesResult; pps: number; /** Keyframe lanes already stacked above, which automation sits under. */ - laneCount: number; + 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`. */ @@ -168,7 +168,7 @@ export function TimelineAutomationLaneSlot({ isSelected, lanes, pps, - laneCount, + laneCount = 0, topOffset, accentColor, currentTime, diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 58c8c28869..bfd0c1b71d 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -37,7 +37,7 @@ interface TimelineGroupRowProps { collapsedGroupIds: ReadonlySet; expandedLaneOwnerIds: ReadonlySet; toggleGroupExpanded: (id: string) => void; - toggleLaneOwnerExpanded: (id: string) => void; + toggleLaneOwnerExpanded: (ids: readonly string[]) => void; lanes: UseAutomationLanesResult; pps: number; currentTime: number; @@ -151,7 +151,7 @@ export function TimelineGroupRow({ // not own and cannot show. laneCount={groupAutomationLanes([groupElement]).length} isLaneOpen={isLaneOpen} - onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} + onToggleLanes={() => toggleLaneOwnerExpanded([group.id])} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} @@ -199,7 +199,6 @@ export function TimelineGroupRow({ lanes={lanes} pps={pps} // Below the strip, which sits directly under the header row. - laneCount={0} topOffset={TRACK_H} accentColor={GROUP_LANE_ACCENT} currentTime={currentTime} diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 7adc06834e..410327ef6f 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -278,6 +278,42 @@ describe("TimelineLanes audio disclosure", () => { expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); act(() => view.root.unmount()); }); + + it("renders an expanded envelope on a video track", () => { + const video = element("video-clip", TRACK_A); + video.tag = "video"; + video.automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + usePlayerStore.setState({ expandedLaneOwnerIds: new Set([video.id]) }); + const view = renderLanes({ elements: [video] }); + + expect( + view.host.querySelector('button[aria-label^="Hide "][aria-label$=" lanes"]'), + ).not.toBeNull(); + expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); + act(() => view.root.unmount()); + }); + + it("toggles every clip owner on a shared audio row", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + const first = { ...element("audio-1", TRACK_A), tag: "audio", automation }; + const second = { ...element("audio-2", TRACK_A), tag: "audio", automation }; + const view = renderLanes({ elements: [first, second] }); + + act(() => + view.host + .querySelector('button[aria-label="Show Track 1 lanes"]') + ?.click(), + ); + + expect(usePlayerStore.getState().expandedLaneOwnerIds).toEqual(new Set(["audio-1", "audio-2"])); + act(() => view.root.unmount()); + }); }); describe("TimelineLanes selection", () => { diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 7dd786eb9b..d53e085a7c 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -8,7 +8,11 @@ import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelecti import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; -import { resolveTrackKeyframeClip, trackShowsBeatStrip } from "./useTimelineTrackLayout"; +import { + isTimelineRowExpanded, + resolveTrackKeyframeClip, + trackShowsBeatStrip, +} from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { CLIP_Y, TRACK_H } from "./timelineLayout"; @@ -115,7 +119,10 @@ export function TimelineLanes({ rowGeometry, scrollRef, onToggleRow: (row) => { - if (row.elementId) toggleLaneOwnerExpanded(row.elementId); + const ownerIds = row.groupId + ? [row.groupId] + : row.items.filter((item) => item.kind === "clip").map((item) => item.elementId); + if (ownerIds.length > 0) toggleLaneOwnerExpanded(ownerIds); }, }); return ( @@ -201,14 +208,7 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; - const expandedAudioOwner = els.find((element) => - expandedLaneOwnerIds.has(getTimelineElementIdentity(element)), - ); - const laneOwnerKey = expandedAudioOwner - ? getTimelineElementIdentity(expandedAudioOwner) - : keyframeClipKey; - const rowExpanded = - isAudioTrack && laneOwnerKey !== undefined && expandedLaneOwnerIds.has(laneOwnerKey); + const rowExpanded = isTimelineRowExpanded(els, expandedLaneOwnerIds); // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose @@ -223,9 +223,7 @@ export function TimelineLanes({ // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. const lanesId = `${lanesIdPrefix}-track-${row}`; - // The caret reveals two canvas regions now: the active clip's keyframe - // lanes and the track's automation lanes. They cannot be one element — - // one belongs to a clip, the other to the row — so the caret names both. + // The caret reveals the track's automation lanes in the row-owned region. const automationLanesId = `${lanesId}-automation`; // The header's remove buttons write through the same binding the lanes // themselves edit through, so a deletion persists exactly like dragging @@ -282,8 +280,7 @@ export function TimelineLanes({ isGroupMember={groupMemberTracks.has(trackNum)} theme={theme} onToggleClipExpanded={() => { - const owner = laneOwnerKey ?? keyframeClipKey; - if (owner) toggleLaneOwnerExpanded(owner); + toggleLaneOwnerExpanded(els.map(getTimelineElementIdentity)); }} onToggleTrackHidden={onToggleTrackHidden} onRemoveAutomationLane={removeAutomationLane} @@ -513,7 +510,6 @@ export function TimelineLanes({ }} lanes={automationLanes} pps={pps} - laneCount={0} accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} currentTime={currentTime} beatTimes={beatAnalysis?.beatTimes} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 37a0bceb68..e075c6e735 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -15,7 +15,7 @@ import { TimelineFxButton } from "./TimelineFxButton"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { LaneToggleButton } from "./LayerDisclosureRow"; -import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout"; +import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { trackDisplaySuffix } from "./timelineTrackDisplay"; import { AutomationLaneHeaderRow } from "./trackHeaderLabelRows"; @@ -351,7 +351,7 @@ export function TimelineTrackHeader({ alsoAutomatedBy={ groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined } - top={getTimelineLaneTop(0) + index * AUTOMATION_LANE_H} + top={index * AUTOMATION_LANE_H} isLastLane={index === automationRows.length - 1} gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)} columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin} diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index b116a5b06a..1f75762d64 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -6,7 +6,7 @@ import { timelineGroupRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; -import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import { isTimelineRowExpanded, resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; export type TimelineNavigationKey = @@ -70,7 +70,7 @@ export interface BuildTimelineLogicalRowsInput { /** Groups the caret has COLLAPSED — absent means expanded, the default. */ collapsedGroupIds: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ - expandedLaneOwnerIds?: ReadonlySet; + expandedLaneOwnerIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; } @@ -134,7 +134,7 @@ export function buildTimelineLogicalRows({ selectedElementId, selectedElementIds, collapsedGroupIds, - expandedLaneOwnerIds = new Set(), + expandedLaneOwnerIds, groups, trackGroupOf, }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] { @@ -154,7 +154,7 @@ export function buildTimelineLogicalRows({ selectedElementIds, ); const disclosable = groupAutomationLanes(elements).length > 0; - const expanded = activeId !== null && expandedLaneOwnerIds.has(activeId) && disclosable; + const expanded = isTimelineRowExpanded(elements, expandedLaneOwnerIds); rows.push({ id: trackId, kind: "row", diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 264b751fa0..5fb8d25743 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -145,12 +145,7 @@ export function useTimelineProviderState({ rowGeometryRef, groups, trackGroupOf, - } = useTimelineTrackLayout( - timelineElements, - gsapAnimations, - selectedElementId, - selectedElementIds, - ); + } = useTimelineTrackLayout(timelineElements, gsapAnimations); const timelineElementsRef = useRef(timelineElements); timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements const ppsRef = useRef(100); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index 404625446f..2d9675ac74 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -34,7 +34,7 @@ describe("collapsed audio groups", () => { const elements = [member("voice-1", 0), member("voice-2", 1)]; let layout: ReturnType | undefined; function Probe() { - layout = useTimelineTrackLayout(elements, new Map(), null, new Set()); + layout = useTimelineTrackLayout(elements, new Map()); return null; } const root = createRoot(document.createElement("div")); @@ -66,7 +66,7 @@ describe("collapsed audio groups", () => { ]; let layout: ReturnType | undefined; function Probe() { - layout = useTimelineTrackLayout(elements, new Map(), null, new Set()); + layout = useTimelineTrackLayout(elements, new Map()); return null; } const root = createRoot(document.createElement("div")); @@ -183,3 +183,34 @@ describe("resolveTrackKeyframeClip", () => { expect(picked).toBe(b); }); }); + +describe("audio lane row height", () => { + it("stays open when selection moves to a sibling on the same track", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + const elements = [ + audioClip("narration-1", { track: 0, automation }), + audioClip("narration-2", { track: 0, automation }), + ]; + usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["narration-1", "narration-2"]) }); + let layout: ReturnType | undefined; + function Probe() { + layout = useTimelineTrackLayout(elements, new Map()); + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + const firstHeight = layout!.rowHeights[0]; + + act(() => { + usePlayerStore.setState({ selectedElementId: "narration-2" }); + root.render(React.createElement(Probe)); + }); + + expect(firstHeight).toBe(TRACK_H + AUTOMATION_LANE_H); + expect(layout!.rowHeights[0]).toBe(firstHeight); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index d5d74d08d9..6c7f2f017c 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -58,15 +58,6 @@ function trackAutomationLaneCount(elements: readonly TimelineElement[]): number return groupAutomationLanes(elements).length; } -/** - * Is this row disclosed? Expansion is stored per clip, but it reads as a property - * of the ROW: the active clip changes with the selection, so asking only about it - * collapsed the row the moment you clicked a sibling. Any expanded clip on the - * track holds the row open — and the caret expands and collapses all of them - * together (see TimelineLanes), so the two can only disagree on state predating - * this rule or written by the keyframe auto-expand. - */ - /** * The single keyframed element whose property lanes a track shows when expanded. * A track can hold several elements (same z-index is common), but keyframes are @@ -102,6 +93,17 @@ export function resolveTrackKeyframeClip( ); } +/** One row owns one disclosure state, even when several clips share its track. */ +export function isTimelineRowExpanded( + elements: readonly TimelineElement[], + expandedLaneOwnerIds: ReadonlySet, +): boolean { + return ( + groupAutomationLanes(elements).length > 0 && + elements.some((element) => expandedLaneOwnerIds.has(element.key ?? element.id)) + ); +} + /** Lanes per clip: the count of distinct property groups whose tween contributes * a lane (real keyframes or a synthesizable flat tween). */ function computeLaneCounts( @@ -145,8 +147,6 @@ function applyGroupStripHeights( function useTimelineRowHeights( tracks: [number, TimelineElement[]][], gsapAnimations: Map, - selectedElementId: string | null, - selectedElementIds: ReadonlySet, groups: readonly TimelineTrackGroupInfo[], ) { const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); @@ -155,14 +155,7 @@ function useTimelineRowHeights( const rowHeights = applyGroupStripHeights( tracks, tracks.map(([, elements]) => { - const active = resolveTrackKeyframeClip( - elements, - laneCounts, - selectedElementId, - selectedElementIds, - ); - const activeId = active ? (active.key ?? active.id) : null; - return activeId !== null && expandedLaneOwnerIds.has(activeId) + return isTimelineRowExpanded(elements, expandedLaneOwnerIds) ? TRACK_H + trackAutomationLaneCount(elements) * AUTOMATION_LANE_H : TRACK_H; }), @@ -176,7 +169,7 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [expandedLaneOwnerIds, gsapAnimations, groups, tracks, selectedElementId, selectedElementIds]); + }, [expandedLaneOwnerIds, gsapAnimations, groups, tracks]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { @@ -190,8 +183,6 @@ function useTimelineRowHeights( export function useTimelineTrackLayout( expandedElements: TimelineElement[], gsapAnimations: Map, - selectedElementId: string | null, - selectedElementIds: ReadonlySet, ) { const { tracks, trackStyles, trackOrder, groups, trackGroupOf } = useTimelineTrackDerivations(expandedElements); @@ -200,8 +191,6 @@ export function useTimelineTrackLayout( const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, - selectedElementId, - selectedElementIds, groups, ); diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index 8ed66c9bd7..f91471d10f 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -88,9 +88,6 @@ export interface KeyframeSlice { toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; - /** Clips whose keyframe property lanes are expanded in the timeline. */ - /** Union-expand clips (keyframed clips are expanded by default on load). */ - /** * Groups whose member rows the caret has HIDDEN (structural, not lanes). * @@ -103,9 +100,9 @@ export interface KeyframeSlice { collapsedGroupIds: Set; toggleGroupExpanded: (id: string) => void; - /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ + /** Rows (clip ids or group id) whose automation-lane rows the `∿` button opened. */ expandedLaneOwnerIds: Set; - toggleLaneOwnerExpanded: (id: string) => void; + toggleLaneOwnerExpanded: (ids: readonly string[]) => void; /** * Project/session/element-scoped request. Its nonce is monotonic across store @@ -162,11 +159,15 @@ export function createKeyframeSlice( }), expandedLaneOwnerIds: new Set(), - toggleLaneOwnerExpanded: (id) => + toggleLaneOwnerExpanded: (ids) => set((state) => { + if (ids.length === 0) return state; const next = new Set(state.expandedLaneOwnerIds); - if (next.has(id)) next.delete(id); - else next.add(id); + const shouldExpand = ids.some((id) => !next.has(id)); + for (const id of ids) { + if (shouldExpand) next.add(id); + else next.delete(id); + } return { expandedLaneOwnerIds: next }; }), diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index fcb428ec45..099e5bef1d 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -12,7 +12,6 @@ const { trackPreviewFirstFrame, trackStudioRenderStart, trackStudioRazorSplit, - trackStudioKeyframeLaneExpand, trackStudioSegmentEaseEdit, trackStudioFeedback, trackStudioTimelinePerformance, @@ -106,11 +105,6 @@ describe("studio telemetry events", () => { expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 }); }); - it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => { - trackStudioKeyframeLaneExpand({ expanded: true }); - expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true }); - }); - it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => { trackStudioSegmentEaseEdit({ action: "commit", ease: "power2.out" }); expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", { diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index 59994a46d8..f788a11d25 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -84,11 +84,6 @@ export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: nu }); } -// Adoption signal for the per-clip keyframe-lane caret toggle. -export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void { - trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded }); -} - // Adoption signal for opening and committing the per-segment ease editor. export function trackStudioSegmentEaseEdit(props: { action: "open" | "commit"; diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index beaa9eb9d3..ba47684e6a 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -40,10 +40,6 @@ const STORYBOARD_VIEW_REASON = "owner-directed removal of the Studio storyboard view; its only readers were deleted with it"; export const ALLOWED_DELETIONS = new Map([ - [ - "packages/studio/src/player/components/timelineKeyboardNavigation.test.ts", - "the inline expansion row navigation feature is removed, so its dedicated tests are removed", - ], [ "packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx", "the inline expansion auto-expand feature is removed, so its dedicated tests are removed", @@ -56,10 +52,6 @@ export const ALLOWED_DELETIONS = new Map([ "packages/studio/src/player/components/useTimelineClipDisclosure.ts", "the timeline no longer exposes inline clip disclosure controls", ], - [ - "packages/studio/src/player/components/useTimelineTrackLayout.test.ts", - "the inline expansion row layout feature is removed, so its dedicated tests are removed", - ], [ "packages/studio/src/player/hooks/useTimelineRowElements.ts", "D-834 removes the duplicate row-source hook; manifest elements are now the single timeline row owner", From 47f9904957fc3464f4ea6605a40835cc135ca6a5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:00:48 -0400 Subject: [PATCH 38/42] fix(studio): align automation lane labels --- .../studio/src/player/components/TimelineTrackHeader.test.tsx | 4 +++- packages/studio/src/player/components/TimelineTrackHeader.tsx | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index e602ab8812..992e034e42 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -88,7 +88,9 @@ describe("TimelineTrackHeader", () => { }), }; const { host, root } = renderHeader({ clip, audio: true, hidden: true, onRemove }); - expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); + const lane = host.querySelector("[data-automation-lane-label]"); + expect(lane).not.toBeNull(); + expect(lane?.style.top).toBe("48px"); act(() => host.querySelector('button[aria-label$="automation"]')?.click()); expect(onRemove).toHaveBeenCalledWith("volume"); act(() => root.unmount()); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index e075c6e735..6cf204fc03 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -15,7 +15,7 @@ import { TimelineFxButton } from "./TimelineFxButton"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { LaneToggleButton } from "./LayerDisclosureRow"; -import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; +import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { trackDisplaySuffix } from "./timelineTrackDisplay"; import { AutomationLaneHeaderRow } from "./trackHeaderLabelRows"; @@ -351,7 +351,7 @@ export function TimelineTrackHeader({ alsoAutomatedBy={ groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined } - top={index * AUTOMATION_LANE_H} + top={getTimelineLaneTop(index)} isLastLane={index === automationRows.length - 1} gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)} columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin} From af9b917950978b6551f3fee72623310569efc3d0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 16:29:54 -0400 Subject: [PATCH 39/42] fix(studio): remove stale lane height import --- packages/studio/src/player/components/TimelineTrackHeader.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 6cf204fc03..2f8045915b 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -13,7 +13,6 @@ import { mintGroupId } from "../../components/editor/useFxCarveGrouping"; import { runtimeAudioId } from "../lib/timelineElementHelpers"; import { TimelineFxButton } from "./TimelineFxButton"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; -import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { LaneToggleButton } from "./LayerDisclosureRow"; import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; From 8c3da131b1feb2556d883a28d50c51afd24b9900 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 16:37:28 -0400 Subject: [PATCH 40/42] test(studio): assert automation labels against the real curve, not a formula The previous test recomputed the expected top with the same formula the fix uses, so a regression in TimelineAutomationLaneSlot's own offset would still read green. Moved it to TimelineLanes.test.tsx, which mounts both the header and the canvas, and compares their rendered [data-automation-lane-label]/[data-automation-lane] tops directly. --- .../player/components/TimelineLanes.test.tsx | 21 +++++++++++++++++ .../components/TimelineTrackHeader.test.tsx | 23 +------------------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index 8b77b8da5c..add507696b 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -314,6 +314,27 @@ describe("TimelineLanes audio disclosure", () => { expect(usePlayerStore.getState().expandedLaneOwnerIds).toEqual(new Set(["audio-1", "audio-2"])); act(() => view.root.unmount()); }); + + it("keeps a second automation label aligned with its own curve", () => { + const audio = element("audio-clip", TRACK_A); + audio.tag = "audio"; + audio.automation = JSON.stringify({ + version: 1, + lanes: [ + { target: "volume", points: [{ t: 0, v: 1 }] }, + { target: "rate", points: [{ t: 0, v: 1 }] }, + ], + }); + usePlayerStore.setState({ expandedLaneOwnerIds: new Set([audio.id]) }); + const view = renderLanes({ elements: [audio] }); + + const labels = [...view.host.querySelectorAll("[data-automation-lane-label]")]; + const curves = [...view.host.querySelectorAll("[data-automation-lane]")]; + expect(labels).toHaveLength(2); + expect(curves).toHaveLength(2); + expect(labels.map((el) => el.style.top)).toEqual(curves.map((el) => el.style.top)); + act(() => view.root.unmount()); + }); }); describe("TimelineLanes selection", () => { diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index b1211b6257..992e034e42 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -6,8 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { defaultTimelineTheme } from "./timelineTheme"; import type { TimelineElement } from "../store/playerStore"; -import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; -import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { LABEL_COL_W } from "./timelineLayout"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); afterEach(() => (document.body.innerHTML = "")); @@ -108,26 +107,6 @@ describe("TimelineTrackHeader", () => { act(() => root.unmount()); }); - it("stacks a second automation label at the curve's own stride, not the keyframe stride", () => { - const clip: TimelineElement = { - ...ELEMENT, - tag: "audio", - automation: JSON.stringify({ - version: 1, - lanes: [ - { target: "volume", points: [{ t: 0, v: 1 }] }, - { target: "rate", points: [{ t: 0, v: 1 }] }, - ], - }), - }; - const { host, root } = renderHeader({ clip, audio: true }); - const labels = [...host.querySelectorAll("[data-automation-lane-label]")]; - expect(labels).toHaveLength(2); - const curveTops = labels.map((_, index) => TRACK_H + index * AUTOMATION_LANE_H); - expect(labels.map((el) => Number(el.dataset.timelineLaneTop))).toEqual(curveTops); - act(() => root.unmount()); - }); - it("names a shared audio track rather than the selected clip", () => { const automation = JSON.stringify({ version: 1, From 0ea52b6f571881f5d96486ca11e36f1816b1f5f7 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 23:02:31 -0400 Subject: [PATCH 41/42] revert(studio): restore the keyframe-row feature from main Miguel: keep the per-property keyframe rows (diamonds, prev/next arrows, disclosure, auto-open) exactly as main. This PR's real scope is removing dead expandedParentStart consumers, not the keyframe-row UX. --- .../src/hooks/useStudioTestHooks.test.tsx | 8 +- .../studio/src/hooks/useStudioTestHooks.ts | 1 + .../src/player/components/Timeline.test.ts | 164 ++- .../studio/src/player/components/Timeline.tsx | 2 +- .../components/TimelineAutomationLaneSlot.tsx | 4 +- .../src/player/components/TimelineCanvas.tsx | 1 + .../player/components/TimelineGroupRow.tsx | 8 +- .../player/components/TimelineLanes.test.tsx | 236 +++-- .../src/player/components/TimelineLanes.tsx | 87 +- .../components/TimelineTrackHeader.test.tsx | 977 ++++++++++++++++-- .../player/components/TimelineTrackHeader.tsx | 229 ++-- .../player/components/TimelineTrackRow.tsx | 45 + .../timelineKeyboardNavigation.test.ts | 142 ++- .../components/timelineKeyboardNavigation.ts | 158 ++- .../player/components/timelineLaneProps.ts | 1 + .../player/components/timelineLayout.test.ts | 60 +- .../src/player/components/timelineLayout.ts | 38 + .../useAutoExpandKeyframedClips.test.tsx | 75 ++ .../components/useAutoExpandKeyframedClips.ts | 48 + .../components/useTimelineClipDisclosure.ts | 41 + .../components/useTimelineLogicalFocus.ts | 5 + .../useTimelineLogicalRows.test.tsx | 55 +- .../components/useTimelineLogicalRows.ts | 6 + .../components/useTimelineProviderState.tsx | 10 +- .../components/useTimelineTrackLayout.test.ts | 171 ++- .../components/useTimelineTrackLayout.ts | 90 +- .../player/lib/timelinePerformanceFixture.ts | 12 +- .../studio/src/player/store/keyframeSlice.ts | 45 +- .../src/player/store/playerStore.test.ts | 25 + .../src/player/store/timelineResetState.ts | 1 + 30 files changed, 2341 insertions(+), 404 deletions(-) create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx create mode 100644 packages/studio/src/player/components/useAutoExpandKeyframedClips.ts create mode 100644 packages/studio/src/player/components/useTimelineClipDisclosure.ts diff --git a/packages/studio/src/hooks/useStudioTestHooks.test.tsx b/packages/studio/src/hooks/useStudioTestHooks.test.tsx index 757af43241..584063511b 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.test.tsx +++ b/packages/studio/src/hooks/useStudioTestHooks.test.tsx @@ -16,7 +16,7 @@ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); const PROFILES: readonly TimelinePerformanceFixtureProfile[] = [ "dense-short", "long-overlap", - "keyframe-heavy", + "keyframe-heavy-expanded", "composition-heavy", "remote-unsupported", ]; @@ -75,9 +75,10 @@ describe("timeline performance fixture", () => { expect(fixture.summary.elementCount).toBe(1_000); expect(fixture.summary.duration).toBeGreaterThan(0); expect(new Set(fixture.elements.map((element) => element.key)).size).toBe(1_000); - if (profile === "keyframe-heavy") { + if (profile === "keyframe-heavy-expanded") { expect(fixture.keyframeCache.size).toBe(1_000); expect(fixture.gsapAnimations.size).toBe(1_000); + expect(fixture.expandedClipIds.size).toBe(1_000); } }); @@ -103,7 +104,7 @@ describe("timeline performance fixture", () => { const summary = api.loadTimelinePerformanceFixture({ elementCount: 1_000, - profile: "keyframe-heavy", + profile: "keyframe-heavy-expanded", }); expect(summary.elementCount).toBe(1_000); @@ -118,6 +119,7 @@ describe("timeline performance fixture", () => { }); expect(usePlayerStore.getState().lintFindingsByElement.size).toBe(0); expect(usePlayerStore.getState().elements).toHaveLength(1_000); + expect(usePlayerStore.getState().expandedClipIds.size).toBe(1_000); expect(hasTimelinePerformanceFixtureLease()).toBe(true); api.resetTimelinePerformanceFixture(); diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts index 8534b9de02..8e1d4489d5 100644 --- a/packages/studio/src/hooks/useStudioTestHooks.ts +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -84,6 +84,7 @@ export function useStudioTestHooks({ selectedKeyframes: new Set(), keyframeCache: fixture.keyframeCache, gsapAnimations: fixture.gsapAnimations, + expandedClipIds: fixture.expandedClipIds, }); return fixture.summary; }, diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index aff5e32fb3..e465dab913 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -26,6 +26,7 @@ import { FIT_ZOOM_HEADROOM, GUTTER, LABEL_COL_W, + LANE_H, MIN_TIMELINE_EXTENT_S, PLAYHEAD_HEAD_W, RULER_H, @@ -33,8 +34,10 @@ import { TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, + getTimelineLaneTop, createTimelineRowGeometry, } from "./timelineLayout"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; import { TimelineEditProvider } from "../../contexts/TimelineEditContext"; @@ -109,6 +112,15 @@ function createSizedTimelineHost(width: number): HTMLDivElement { return host; } +function expectTrackExpansion( + row: HTMLElement | null | undefined, + expandedClipIds: string[], + height: number, +) { + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds)); + expect(row?.style.height).toBe(`${height}px`); +} + function renderBasicTimeline() { const host = createSizedTimelineHost(640); usePlayerStore.setState({ @@ -123,6 +135,26 @@ function renderBasicTimeline() { return { host, root }; } +function renderSharedAutomationTimeline(selectedElementId?: string) { + const host = createSizedTimelineHost(720); + const automation = JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }); + usePlayerStore.setState({ + duration: 8, + timelineReady: true, + ...(selectedElementId ? { selectedElementId } : {}), + elements: [ + { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, + { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, + ], + }); + const root = createRoot(host); + act(() => root.render(React.createElement(Timeline))); + return { host, root }; +} + describe("Timeline provider boundary", () => { it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => { usePlayerStore.setState({ @@ -165,7 +197,7 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("keeps a nested clip in one track row across the playhead", () => { + it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => { usePlayerStore.setState({ duration: 20, timelineReady: true, @@ -173,6 +205,7 @@ describe("Timeline provider boundary", () => { zoomMode: "manual", manualZoomPercent: 100, selectedElementId: "clip-1", + expandedClipIds: new Set(["clip-1"]), elements: [ { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 }, { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 }, @@ -202,13 +235,59 @@ describe("Timeline provider boundary", () => { const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = renderTimelineGeometry("clip-1"); const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); - expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); - const row = clip.parentElement?.parentElement; - expect(row?.style.height).toBe(`${TRACK_H}px`); + const diamond = host.querySelector( + '[data-keyframe-group="position"][data-keyframe-percentage="50"]', + ); + if (!diamond) throw new Error("Missing expanded position keyframe"); + const propertyLane = diamond.closest("[data-timeline-property-lane]"); + if (!propertyLane) throw new Error("Missing flat position property lane"); + const headerLane = trackHeader.querySelector('[data-property-group="position"]'); + if (!headerLane) throw new Error("Missing position property header"); + // Absolute x rebuilds from the content origin (the ruler-origin spacer), + // which now insets a GUTTER past the LABEL_COL_W label column so a 0% + // diamond has room to its left. The content row reaches that same origin via + // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still + // coincide on the shared time x. + const contentOrigin = Number.parseFloat(rulerOrigin.style.width); + const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5; + const diamondX = + contentOrigin + + Number.parseFloat(propertyLane.style.left) + + Number.parseFloat(diamond.style.left) + + Number.parseFloat(diamond.style.width) / 2; + + expect(clip.contains(propertyLane)).toBe(false); + expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); + expect(clip.style.bottom).toBe(""); + expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`); + expect(propertyLane.style.top).toBe(headerLane.style.top); + expect(propertyLane.style.background).toBe(""); + expect(propertyLane.style.border).toBe(""); + expect(propertyLane.style.borderRadius).toBe(""); + const treegrid = host.querySelector('[role="treegrid"]'); + const semanticRows = treegrid?.querySelectorAll('[role="row"]') ?? []; + expect(treegrid?.getAttribute("aria-rowcount")).toBe("3"); + expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([ + "1", + "2", + "3", + ]); + expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1"); + expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true"); + expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2"); + expect(semanticRows[1]?.textContent).toContain("position"); + expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe( + headerLane.id, + ); + expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe( + propertyLane.id, + ); + expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false); expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); - expect(Number.parseFloat(rulerTick.style.left)).toBe(999.5); + expect(diamondX).toBe(rulerX); + expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000); expect(collapsedHeader.textContent).toContain("Outro"); expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, @@ -531,8 +610,60 @@ describe("Timeline provider boundary", () => { root.render(React.createElement(Timeline)); }); - expect(host.querySelectorAll('button[aria-label$=" lanes"]')).toHaveLength(0); - expect(host.querySelectorAll('[role="row"]')).toHaveLength(2); + // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure + // lives in the left column. clip-2 has no keyframes so it never shows one. + const collapseButton = host.querySelector( + 'button[aria-label="Hide clip-1 lanes"]', + ); + expect(collapseButton).not.toBeNull(); + expect(host.querySelector('button[aria-label="Show clip-2 lanes"]')).toBeNull(); + expect(host.querySelector('button[aria-label="Hide clip-2 lanes"]')).toBeNull(); + + const clip = host.querySelector('[data-el-id="clip-1"]'); + const row = clip?.parentElement?.parentElement; + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + + // Collapsing sticks (does not bounce back open via auto-expand). + act(() => collapseButton?.click()); + expectTrackExpansion(row, [], TRACK_H); + + const expandButton = host.querySelector( + 'button[aria-label="Show clip-1 lanes"]', + ); + expect(expandButton).not.toBeNull(); + act(() => expandButton?.click()); + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + act(() => root.unmount()); + }); + + // The caret belongs to the row, not to whichever clip on it is selected: the + // automation lanes below it are the track's, shared per property. Toggling one + // clip left the row's state depending on the selection, and a collapse that + // only dropped the active clip left the row stuck open. + it("expands and collapses every clip on a shared track together", () => { + const { host, root } = renderSharedAutomationTimeline(); + + const row = host.querySelector('[data-el-id="narration-1"]')?.parentElement + ?.parentElement; + // A row of several clips is named for the track, so the caret is too. + const caret = () => host.querySelector('button[aria-label$=" lanes"]'); + expect(caret()?.getAttribute("aria-label")).toBe("Show Track 1 lanes"); + + act(() => caret()?.click()); + // One shared volume row, and BOTH clips hold it open. + expectTrackExpansion(row, ["narration-1", "narration-2"], TRACK_H + AUTOMATION_LANE_H); + + // Every clip bar on the row is capped to one track height. Only the clip + // owning the property lanes used to be, so its siblings stretched the whole + // expanded row and painted their waveforms over the envelopes below. + expect( + ["narration-1", "narration-2"].map( + (id) => host.querySelector(`[data-el-id="${id}"]`)?.style.height, + ), + ).toEqual([`${TRACK_H - 2 * CLIP_Y}px`, `${TRACK_H - 2 * CLIP_Y}px`]); + + act(() => caret()?.click()); + expectTrackExpansion(row, [], TRACK_H); act(() => root.unmount()); }); @@ -543,23 +674,8 @@ describe("Timeline provider boundary", () => { // a lane to select its clip therefore made the handles vanish under the // pointer, which is the one gesture the read-only lane exists to support. it("keeps the automation lanes mounted when the selection moves along the row", () => { - const host = createSizedTimelineHost(720); - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ - duration: 8, - timelineReady: true, - selectedElementId: "narration-2", - expandedLaneOwnerIds: new Set(["narration-1", "narration-2"]), - elements: [ - { id: "narration-1", tag: "audio", start: 0, duration: 4, track: 0, automation }, - { id: "narration-2", tag: "audio", start: 4, duration: 4, track: 0, automation }, - ], - }); - const root = createRoot(host); - act(() => root.render(React.createElement(Timeline))); + const { host, root } = renderSharedAutomationTimeline("narration-2"); + act(() => host.querySelector('button[aria-label$=" lanes"]')?.click()); const before = [...host.querySelectorAll(".hf-automation-lane")]; expect(before).toHaveLength(2); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index f182e99fc6..28a3b3ecee 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -1,5 +1,6 @@ import { memo } from "react"; import type { TimelineProps } from "./TimelineTypes"; +import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; import { TimelineEmptyStatePart, TimelineEditPopover, @@ -14,7 +15,6 @@ import { TimelineRuler, TimelineShortcutHint, } from "./TimelineParts"; -import { TimelineProvider, useTimelineContext } from "./TimelineProvider"; export * from "./TimelineProvider"; export { diff --git a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx index 20967db6c1..9c70b6e03a 100644 --- a/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx +++ b/packages/studio/src/player/components/TimelineAutomationLaneSlot.tsx @@ -141,7 +141,7 @@ export interface TimelineAutomationLaneSlotProps { lanes: UseAutomationLanesResult; pps: number; /** Keyframe lanes already stacked above, which automation sits under. */ - laneCount?: number; + 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`. */ @@ -168,7 +168,7 @@ export function TimelineAutomationLaneSlot({ isSelected, lanes, pps, - laneCount = 0, + laneCount, topOffset, accentColor, currentTime, diff --git a/packages/studio/src/player/components/TimelineCanvas.tsx b/packages/studio/src/player/components/TimelineCanvas.tsx index ee1080c1c0..74f11dd062 100644 --- a/packages/studio/src/player/components/TimelineCanvas.tsx +++ b/packages/studio/src/player/components/TimelineCanvas.tsx @@ -59,6 +59,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() { draggedElement={draggedElement} multiDragPreview={multiDragPreview} onToggleTrackHidden={props.onToggleTrackHidden} + onTogglePropertyGroupKeyframe={props.onTogglePropertyGroupKeyframe} onResizeElement={props.onResizeElement} onMoveElement={props.onMoveElement} onRazorSplit={props.onRazorSplit} diff --git a/packages/studio/src/player/components/TimelineGroupRow.tsx b/packages/studio/src/player/components/TimelineGroupRow.tsx index 3b67df2cdd..615faf5011 100644 --- a/packages/studio/src/player/components/TimelineGroupRow.tsx +++ b/packages/studio/src/player/components/TimelineGroupRow.tsx @@ -37,7 +37,7 @@ interface TimelineGroupRowProps { collapsedGroupIds: ReadonlySet; expandedLaneOwnerIds: ReadonlySet; toggleGroupExpanded: (id: string) => void; - toggleLaneOwnerExpanded: (ids: readonly string[]) => void; + toggleLaneOwnerExpanded: (id: string) => void; lanes: UseAutomationLanesResult; pps: number; currentTime: number; @@ -120,6 +120,9 @@ export function TimelineGroupRow({ index={index} rowKey={rowKey} logicalRow={logicalRow} + propertyRows={[]} + lanesId="" + headerLanesId="" top={top} height={height} virtualized={virtualized} @@ -151,7 +154,7 @@ export function TimelineGroupRow({ // not own and cannot show. laneCount={groupAutomationLanes([groupElement]).length} isLaneOpen={isLaneOpen} - onToggleLanes={() => toggleLaneOwnerExpanded([group.id])} + onToggleLanes={() => toggleLaneOwnerExpanded(group.id)} fxChain={group.fxChain} onFxChainChange={(next) => writeGroupFxChain(next, false)} onFxChainPreview={(next) => writeGroupFxChain(next, true)} @@ -199,6 +202,7 @@ export function TimelineGroupRow({ lanes={lanes} pps={pps} // Below the strip, which sits directly under the header row. + laneCount={0} topOffset={TRACK_H} accentColor={GROUP_LANE_ACCENT} currentTime={currentTime} diff --git a/packages/studio/src/player/components/TimelineLanes.test.tsx b/packages/studio/src/player/components/TimelineLanes.test.tsx index add507696b..f5fcbd4940 100644 --- a/packages/studio/src/player/components/TimelineLanes.test.tsx +++ b/packages/studio/src/player/components/TimelineLanes.test.tsx @@ -12,6 +12,7 @@ import { createTimelineClipIndex } from "../lib/timelineClipIndex"; import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { MultiDragPreviewInput } from "./timelineMultiDragPreview"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -20,6 +21,7 @@ afterEach(() => { document.body.innerHTML = ""; usePlayerStore.getState().reset(); }); + /** The z-order sort keys really are fractional: a clip nudged between two lanes * lands on the midpoint. These are the values that used to reach aria-label. */ const TRACK_A = 1 / 6; @@ -40,17 +42,33 @@ function element(id: string, track: number): TimelineElement { return { id, label: id, tag: "div", start: 0, duration: 2, track }; } +function positionTween(id: string): GsapAnimation { + return { + id: `${id}-tween`, + targetSelector: `#${id}`, + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 100 } }, + ], + }, + }; +} + interface RenderLanesOptions { elements?: TimelineElement[]; animations?: Map; + expandedClipIds?: string[]; selectedElementIds?: Set; multiDragPreview?: MultiDragPreviewInput | null; draggedClip?: DraggedClipState | null; - onToggleTrackHidden?: ( - track: number, - hidden: boolean, - displayNumber?: number | null, - ) => void | Promise; + onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"]; onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void; hoveredClip?: string | null; renderClipContent?: React.ComponentProps["renderClipContent"]; @@ -82,6 +100,7 @@ function renderLanes(options: RenderLanesOptions = {}): { ); const rowHeights = displayTrackOrder.map(() => TRACK_H); act(() => { + usePlayerStore.setState({ expandedClipIds: new Set(next.expandedClipIds ?? []) }); root.render( { const onToggleTrackHidden = vi.fn(); const view = renderLanes({ elements: [element("clip-a", TRACK_A), element("clip-b", TRACK_B)], - onToggleTrackHidden: (track, hidden, displayNumber) => { - onToggleTrackHidden(track, hidden, displayNumber); - }, + onToggleTrackHidden, }); const second = view.host.querySelector('button[aria-label="Hide track 2"]'); @@ -251,88 +271,146 @@ describe("TimelineLanes track numbering", () => { }); }); -describe("TimelineLanes audio disclosure", () => { - it("keeps a keyframed audio lane collapsed until its owner is expanded", () => { - const audio = element("audio-clip", TRACK_A); - audio.tag = "audio"; - audio.automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - const view = renderLanes({ elements: [audio] }); - - expect( - view.host.querySelector('button[aria-label^="Show "][aria-label$=" lanes"]'), - ).not.toBeNull(); - expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); - - act(() => { - view.host - .querySelector('button[aria-label^="Show "][aria-label$=" lanes"]') - ?.click(); - }); - - expect( - view.host.querySelector('button[aria-label^="Hide "][aria-label$=" lanes"]'), - ).not.toBeNull(); - expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); +describe("TimelineLanes disclosure target", () => { + const ANIMATIONS = new Map([["clip-a", [positionTween("clip-a")]]]); + + /** + * `aria-controls` is an ID LIST, and the caret needs one: it reveals the + * active clip's keyframe lanes AND the track's automation lanes, which cannot + * be one element — one belongs to a clip, the other to the row. + */ + function ariaControlsIds(host: HTMLElement): string[] { + const caret = host.querySelector("button[aria-controls]"); + return (caret?.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean); + } + + function ariaControlsTargets(host: HTMLElement): (HTMLElement | null)[] { + return ariaControlsIds(host).map((id) => host.querySelector(`#${id}`)); + } + + /** The first region named, which is the keyframe lanes. */ + function ariaControlsTarget(host: HTMLElement): HTMLElement | null { + return ariaControlsTargets(host)[0] ?? null; + } + + // aria-controls used to name a div in the sticky label column: it computed to + // 0x0 and held no diamonds at all. + it("resolves the caret's aria-controls to an element holding the property lanes", () => { + const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + const target = ariaControlsTarget(view.host); + + expect(target).not.toBeNull(); + expect(target?.querySelectorAll("[data-timeline-property-lane]").length).toBeGreaterThan(0); + // Every region it names has to exist, or the caret points at nothing. + expect(ariaControlsTargets(view.host).length).toBeGreaterThan(1); + expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true); act(() => view.root.unmount()); }); - it("renders an expanded envelope on a video track", () => { - const video = element("video-clip", TRACK_A); - video.tag = "video"; - video.automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - usePlayerStore.setState({ expandedLaneOwnerIds: new Set([video.id]) }); - const view = renderLanes({ elements: [video] }); + it("still resolves the caret's aria-controls while the layer is collapsed", () => { + const view = renderLanes({ animations: ANIMATIONS, expandedClipIds: [] }); + const target = ariaControlsTarget(view.host); - expect( - view.host.querySelector('button[aria-label^="Hide "][aria-label$=" lanes"]'), - ).not.toBeNull(); - expect(view.host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(1); + expect(target).not.toBeNull(); + expect(target?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0); + // Including the automation region, which is mounted empty while collapsed + // for exactly this reason. + expect(ariaControlsTargets(view.host).every(Boolean)).toBe(true); act(() => view.root.unmount()); }); - it("toggles every clip owner on a shared audio row", () => { - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - const first = { ...element("audio-1", TRACK_A), tag: "audio", automation }; - const second = { ...element("audio-2", TRACK_A), tag: "audio", automation }; - const view = renderLanes({ elements: [first, second] }); - - act(() => - view.host - .querySelector('button[aria-label="Show Track 1 lanes"]') - ?.click(), - ); + // Two timelines on one page (a mini-timeline in a modal beside the main one) + // both minted `timeline-lanes-track-0`, so every caret's aria-controls + // resolved to whichever instance mounted first. + it("mints lane ids that do not collide with a second TimelineLanes on the page", () => { + const first = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); + const second = renderLanes({ animations: ANIMATIONS, expandedClipIds: ["clip-a"] }); - expect(usePlayerStore.getState().expandedLaneOwnerIds).toEqual(new Set(["audio-1", "audio-2"])); - act(() => view.root.unmount()); + const idsFor = (host: HTMLElement) => + Array.from(host.querySelectorAll("button[aria-controls]")).flatMap((caret) => + (caret.getAttribute("aria-controls") ?? "").split(/\s+/).filter(Boolean), + ); + const firstIds = idsFor(first.host); + const secondIds = idsFor(second.host); + const cellIdsFor = (host: HTMLElement) => + new Set( + Array.from(host.querySelectorAll("[data-property-group][id]"), (cell) => + cell.getAttribute("id"), + ).filter((id): id is string => id !== null), + ); + const ownedIdsFor = (host: HTMLElement) => + Array.from(host.querySelectorAll("[aria-owns]"), (owner) => + owner.getAttribute("aria-owns"), + ).filter((id): id is string => id !== null); + const firstCellIds = cellIdsFor(first.host); + const secondCellIds = cellIdsFor(second.host); + + for (const { host } of [first, second]) { + const treegrid = host.querySelector('[role="treegrid"]'); + expect(treegrid?.getAttribute("aria-colcount")).toBe("2"); + expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false); + expect( + [...host.querySelectorAll('[role="rowheader"]')].every( + (cell) => cell.getAttribute("aria-colindex") === "1", + ), + ).toBe(true); + expect( + [...host.querySelectorAll('[role="gridcell"]')].every( + (cell) => cell.getAttribute("aria-colindex") === "2", + ), + ).toBe(true); + } + expect(firstIds.length).toBeGreaterThan(0); + expect(firstIds.some((id) => secondIds.includes(id))).toBe(false); + expect(firstCellIds.size).toBeGreaterThan(0); + expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false); + expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true); + expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true); + // Still a legal CSS id selector: the aria-controls lookups above use `#id`. + for (const id of [...firstIds, ...secondIds]) { + expect(id).toMatch(/^[A-Za-z][\w-]*$/); + } + act(() => first.root.unmount()); + act(() => second.root.unmount()); }); - it("keeps a second automation label aligned with its own curve", () => { - const audio = element("audio-clip", TRACK_A); - audio.tag = "audio"; - audio.automation = JSON.stringify({ - version: 1, - lanes: [ - { target: "volume", points: [{ t: 0, v: 1 }] }, - { target: "rate", points: [{ t: 0, v: 1 }] }, - ], + // The passenger branch wraps [clip, lanes] in a transformed div that re-renders + // on every pointer move. An unstable key there remounts the lanes and drops the + // in-flight drag. + it("does not remount the lanes while a multi-clip drag slides the formation", () => { + const elements = [element("clip-a", TRACK_A), element("clip-b", TRACK_A)]; + const selectedElementIds = new Set(["clip-a", "clip-b"]); + const preview = (draggedPreviewStart: number): MultiDragPreviewInput => ({ + dragStarted: true, + draggedKey: "clip-b", + draggedOriginStart: 0, + draggedPreviewStart, + selectedKeys: selectedElementIds, + }); + const view = renderLanes({ + elements, + animations: ANIMATIONS, + expandedClipIds: ["clip-a"], + selectedElementIds, + multiDragPreview: preview(0.25), }); - usePlayerStore.setState({ expandedLaneOwnerIds: new Set([audio.id]) }); - const view = renderLanes({ elements: [audio] }); - - const labels = [...view.host.querySelectorAll("[data-automation-lane-label]")]; - const curves = [...view.host.querySelectorAll("[data-automation-lane]")]; - expect(labels).toHaveLength(2); - expect(curves).toHaveLength(2); - expect(labels.map((el) => el.style.top)).toEqual(curves.map((el) => el.style.top)); + + const before = ariaControlsTarget(view.host); + const beforeLane = before?.querySelector("[data-timeline-property-lane]"); + expect(before).not.toBeNull(); + expect(beforeLane).not.toBeNull(); + + view.rerender({ + elements, + animations: ANIMATIONS, + expandedClipIds: ["clip-a"], + selectedElementIds, + multiDragPreview: preview(0.75), + }); + + // Node identity, not just presence: a remount replaces these nodes. + expect(ariaControlsTarget(view.host)).toBe(before); + expect(before?.querySelector("[data-timeline-property-lane]")).toBe(beforeLane); act(() => view.root.unmount()); }); }); diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index c2e8bfdeff..a40978b20d 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -2,18 +2,21 @@ import { Fragment, useId, useMemo } from "react"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot"; import { useAutomationLanes } from "./useAutomationLanes"; import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard"; import { TimelineTrackHeader } from "./TimelineTrackHeader"; import { TimelineGroupRow } from "./TimelineGroupRow"; import { useTimelineLaneRowIndexes, useTimelineGroupDisclosure } from "./useTimelineLaneRowIndexes"; +import { useTimelineClipDisclosure } from "./useTimelineClipDisclosure"; import { - isTimelineRowExpanded, + isTrackRowExpanded, resolveTrackKeyframeClip, trackShowsBeatStrip, } from "./useTimelineTrackLayout"; import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay"; +import { clipTimingStart } from "../../hooks/gsapShared"; import { getTimelineEditCapabilities } from "./timelineEditing"; import { CLIP_Y, TRACK_H } from "./timelineLayout"; import { usePlayerStore } from "../store/playerStore"; @@ -77,8 +80,10 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, + gsapAnimations, selectedKeyframes, currentTime, + onSeek, onSelectSegment, onClickKeyframe, onShiftClickKeyframe, @@ -88,6 +93,7 @@ export function TimelineLanes({ onContextMenuLane, beatAnalysis, onToggleTrackHidden, + onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, @@ -96,6 +102,7 @@ export function TimelineLanes({ // ponytail: One per-instance namespace prevents aria-controls and aria-owns // from resolving into a second timeline that renders the same logical rows. const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`; + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } = useTimelineGroupDisclosure(); const automationLanes = useAutomationLanes(); @@ -112,6 +119,10 @@ export function TimelineLanes({ () => new Set(groups.flatMap((group) => group.memberTracks)), [groups], ); + const { + toggleRowExpanded: toggleRowExpandedTracked, + toggleClipExpanded: toggleClipExpandedTracked, + } = useTimelineClipDisclosure(); const actorWindows = useTimelineMultiDragActorWindows( multiDragPreview, rowsVirtualized, @@ -123,10 +134,7 @@ export function TimelineLanes({ rowGeometry, scrollRef, onToggleRow: (row) => { - const ownerIds = row.groupId - ? [row.groupId] - : row.items.filter((item) => item.kind === "clip").map((item) => item.elementId); - if (ownerIds.length > 0) toggleLaneOwnerExpanded(ownerIds); + if (row.elementId) toggleClipExpandedTracked(row.elementId); }, }); return ( @@ -208,7 +216,7 @@ export function TimelineLanes({ selectedElementIds, ); const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; - const rowExpanded = isTimelineRowExpanded(els, expandedLaneOwnerIds); + const rowExpanded = isTrackRowExpanded(els, expandedClipIds); // How tall a clip BAR is drawn. An expanded row is mostly lanes, and a // clip left to fill it painted its waveform straight over them — so the // bar is capped for every clip on the row, not just the one whose @@ -224,7 +232,9 @@ export function TimelineLanes({ // on the canvas. Keyed by display row, not by `trackNum`, which is a // fractional sort key and would mint ids like `...-0.16666666666666666`. const lanesId = `${lanesIdPrefix}-track-${row}`; - // The caret reveals the track's automation lanes in the row-owned region. + // The caret reveals two canvas regions now: the active clip's keyframe + // lanes and the track's automation lanes. They cannot be one element — + // one belongs to a clip, the other to the row — so the caret names both. const automationLanesId = `${lanesId}-automation`; // The header's remove buttons write through the same binding the lanes // themselves edit through, so a deletion persists exactly like dragging @@ -252,6 +262,9 @@ export function TimelineLanes({ index={row} rowKey={rowKey} logicalRow={logicalRow} + propertyRows={trackLogicalRows.slice(1)} + lanesId={lanesId} + headerLanesId={`${lanesId} ${automationLanesId}`} top={rowGeometry.getRowTop(row)} height={rowHeight} virtualized={rowsVirtualized} @@ -270,21 +283,27 @@ export function TimelineLanes({ els[0]?.id ?? `Track${trackDisplaySuffix(displayNumber)}` } - lanesId={automationLanesId} + lanesId={`${lanesId} ${automationLanesId}`} contentOrigin={contentOrigin} keyframeClip={keyframeClip} trackElements={els} clipCount={els.length} isExpanded={rowExpanded} + animations={keyframeClipKey ? (gsapAnimations.get(keyframeClipKey) ?? []) : []} + currentTime={currentTime} isTrackHidden={isTrackHidden} isAudioTrack={isAudioTrack} isGroupMember={groupMemberTracks.has(trackNum)} theme={theme} onToggleClipExpanded={() => { - toggleLaneOwnerExpanded(els.map(getTimelineElementIdentity)); + const keys = els.map(getTimelineElementIdentity); + if (keys.length > 0) toggleRowExpandedTracked(keys); }} onToggleTrackHidden={onToggleTrackHidden} + onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onRemoveAutomationLane={removeAutomationLane} + onSeek={onSeek} + rovingTargetId={keyboard.rovingTargetId} />
{ const clipStyle = getTrackStyle(el.tag); const elementKey = getTimelineElementIdentity(el); + // Only the track's active keyframe clip shows expanded lanes; + // other clips (incl. siblings on a shared track) show compact + // diamonds on their own bar instead. + const isTrackKeyframeClip = elementKey === keyframeClipKey; + const showsLanes = isTrackKeyframeClip && rowExpanded; const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey || selectedElementIds.has(elementKey); @@ -437,7 +461,7 @@ export function TimelineLanes({ ); const compactKeyframes = keyframeCache?.get(elementKey); - const compactDiamonds = compactKeyframes && ( + const compactDiamonds = !showsLanes && compactKeyframes && ( ); + // Keep this shell mounted while collapsed so aria-controls stays valid + // and multi-drag cannot remount the subtree mid-gesture. + const propertyLanes = isTrackKeyframeClip && ( + 0 + ? ((currentTime - previewElement.start) / previewElement.duration) * 100 + : 0 + } + elementId={elementKey} + selectedKeyframes={selectedKeyframes} + rovingTargetId={keyboard.rovingTargetId} + onSelectSegment={(target) => onSelectSegment?.(elementKey, target)} + onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)} + onShiftClickKeyframe={(target) => + onShiftClickKeyframe?.(elementKey, target) + } + onContextMenuKeyframe={(e, target) => + onContextMenuKeyframe?.(e, elementKey, target) + } + onMoveKeyframe={(target, toClipPercentage) => + onMoveKeyframe?.(elementKey, target, toClipPercentage) ?? + Promise.resolve(false) + } + suppressClickRef={suppressClickRef} + /> + ); + // Keep one keyed top-level child per element. Returning an // array here makes React reconcile the outer array by // position, so a window shift remounts otherwise stable @@ -468,6 +532,7 @@ export function TimelineLanes({ {clip} {compactDiamonds} + {propertyLanes} ); } @@ -484,6 +549,7 @@ export function TimelineLanes({ > {clip} {compactDiamonds} + {propertyLanes}
); }) @@ -517,6 +583,7 @@ export function TimelineLanes({ }} lanes={automationLanes} pps={pps} + laneCount={keyframeClipKey ? (laneCounts.get(keyframeClipKey) ?? 0) : 0} accentColor={getTrackStyle(keyframeClip?.tag ?? "").accent} currentTime={currentTime} beatTimes={beatAnalysis?.beatTimes} diff --git a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx index 992e034e42..ba40ffcb40 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.test.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.test.tsx @@ -1,15 +1,22 @@ // @vitest-environment happy-dom import React, { act } from "react"; -import { createRoot } from "react-dom/client"; +import { createRoot, type Root } from "react-dom/client"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { TimelineTrackHeader } from "./TimelineTrackHeader"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineTrackHeader, gutterFill } from "./TimelineTrackHeader"; import { defaultTimelineTheme } from "./timelineTheme"; -import type { TimelineElement } from "../store/playerStore"; -import { LABEL_COL_W } from "./timelineLayout"; +import { type TimelineElement } from "../store/playerStore"; +import type { TimelineEditCallbacks } from "./timelineCallbacks"; +import { getTimelineLaneTop, LABEL_COL_W, TRACK_H } from "./timelineLayout"; +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; -Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -afterEach(() => (document.body.innerHTML = "")); +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; +}); const ELEMENT: TimelineElement = { id: "clip-1", @@ -20,103 +27,909 @@ const ELEMENT: TimelineElement = { track: 0, }; -function renderHeader( - options: { - clip?: TimelineElement; - elements?: readonly TimelineElement[]; - expanded?: boolean; - audio?: boolean; - hidden?: boolean; - onHidden?: (track: number, hidden: boolean, displayNumber?: number | null) => void; - onRemove?: (target: string) => void; - } = {}, -) { +function animation( + id: string, + propertyGroup: PropertyGroupName, + keyframes: Array<{ + percentage: number; + properties: Record; + ease?: string; + }>, +): GsapAnimation { + return { + id, + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup, + keyframes: { format: "percentage", keyframes }, + }; +} + +const POSITION = animation("position-tween", "position", [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 50, properties: { x: 100, y: 50 } }, + { percentage: 100, properties: { x: 200, y: 100 } }, +]); + +const OPACITY = animation("opacity-tween", "visual", [ + { percentage: 0, properties: { opacity: 0 } }, + { percentage: 50, properties: { opacity: 0.5 } }, + { percentage: 100, properties: { opacity: 1 } }, +]); + +interface RenderHeaderOptions { + keyframeClip?: TimelineElement; + /** Every clip on the track; defaults to just the keyframe clip. */ + trackElements?: readonly TimelineElement[]; + animations?: GsapAnimation[]; + clipCount?: number; + currentTime?: number; + expanded?: boolean; + onSeek?: (time: number) => void; + onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; + onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"]; + onRemoveAutomationLane?: (target: string) => void; + isAudioTrack?: boolean; + isGroupMember?: boolean; + isTrackHidden?: boolean; +} + +function renderHeader(options: RenderHeaderOptions = {}): { + host: HTMLDivElement; + root: Root; + rerender: (next: RenderHeaderOptions) => void; +} { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); - const clip = options.clip ?? ELEMENT; - act(() => - root.render( - , - ), - ); - return { host, root }; + const render = (raw: RenderHeaderOptions) => { + // Defaults resolved once, up front, rather than as a `??` per prop in the + // JSX — a dozen of those is a dozen branches through one arrow. + const next = { + keyframeClip: ELEMENT, + clipCount: 1, + animations: [POSITION, OPACITY], + currentTime: 0, + isAudioTrack: false, + isGroupMember: false, + isTrackHidden: false, + onToggleTrackHidden: vi.fn(), + ...raw, + }; + act(() => { + root.render( + , + ); + }); + }; + render(options); + return { host, root, rerender: render }; +} + +function click(host: HTMLElement, label: string) { + const button = host.querySelector(`button[aria-label="${label}"]`); + expect(button).not.toBeNull(); + act(() => button?.click()); } describe("TimelineTrackHeader", () => { - it("keeps visibility controls off audio headers", () => { - const { host, root } = renderHeader({ audio: true }); - expect(host.querySelector('button[aria-label^="Hide track"]')).toBeNull(); - act(() => root.unmount()); + // §5: gain stages multiply. A group fading to 0.42 under a clip fading to + // 0.80 plays at 0.34, and an author who drew both hears something quieter + // than either with nothing on screen to say why. Not a warning; an + // explanation. + it("says so when the clip's group is fading the same parameter", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.4 }, + ], + }, + ], + }); + const clip: TimelineElement = { + ...ELEMENT, + tag: "audio", + automation, + audioGroup: "voiceover", + audioGroupLabel: "Voiceover", + audioGroupAutomation: automation, + }; + const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] }); + expect(view.host.textContent).toContain("Voiceover is also fading this."); + act(() => view.root.unmount()); + }); + + // The same clip with an un-automated group must stay quiet — the note is + // only honest when the two curves actually multiply. + it("stays quiet when the group automates nothing", () => { + const automation = JSON.stringify({ + version: 1, + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 0.4 }, + ], + }, + ], + }); + const clip: TimelineElement = { + ...ELEMENT, + tag: "audio", + automation, + audioGroup: "voiceover", + audioGroupLabel: "Voiceover", + }; + const view = renderHeader({ keyframeClip: clip, trackElements: [clip], animations: [] }); + expect(view.host.textContent).not.toContain("is also fading this"); + act(() => view.root.unmount()); + }); + + // An expanded sub-composition child sits on the MASTER timeline at a + // host-absolute start, but its tweens are parsed from its own file and are + // local to it. Feeding the raw start straight into the clip-% math put every + // lane keyframe far outside the clip. + it("keeps an expanded sub-comp child's lane percentages inside the clip", () => { + const child: TimelineElement = { + id: "pill", + tag: "div", + start: 16.5, + duration: 2, + track: 0, + expandedParentStart: 16, + sourceFile: "scene.html", + }; + const local: GsapAnimation = { + id: "pill-tween", + targetSelector: "#pill", + method: "to", + position: 0.5, + resolvedStart: 0.5, + duration: 2, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 100 } }, + ], + }, + }; + // Playhead at the clip's midpoint (master time), so the 100% keyframe is + // ahead of it. On the raw host-absolute basis every keyframe rebased to a + // large negative percentage and nothing was ever ahead of the playhead. + const view = renderHeader({ + keyframeClip: child, + animations: [local], + currentTime: 17.5, + }); + + expect( + view.host.querySelector('button[aria-label="Next Position keyframe"]') + ?.disabled, + ).toBe(false); + act(() => view.root.unmount()); + }); + + // The header shows one clip's lanes, so how many clips the track holds is + // otherwise invisible from the label column. A single-clip track stays silent. + it("shows the track's clip count only once the track holds more than one clip", () => { + const view = renderHeader({ clipCount: 1 }); + expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull(); + + view.rerender({ clipCount: 3 }); + expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3"); + act(() => view.root.unmount()); + }); + + // The visibility control is the old hide eye. On an audio track it silences + // rather than hides, and the row already says so with a speaker elsewhere — + // so the eye's slot stays empty there. A non-audio track is untouched. + it("keeps the visibility control off audio track headers", () => { + const audio: TimelineElement = { ...ELEMENT, tag: "audio" }; + const view = renderHeader({ + keyframeClip: audio, + trackElements: [audio], + isAudioTrack: true, + animations: [], + }); + const labels = Array.from(view.host.querySelectorAll("button")).map((b) => + b.getAttribute("aria-label"), + ); + expect(labels.some((l) => l && /^(Hide|Show) track/.test(l))).toBe(false); + expect(labels).not.toContain("Mute"); + act(() => view.root.unmount()); + }); + + // The escape hatch. `data-hidden` on audio silences it in preview and drops it + // from the render; the panel's "Muted" is the unrelated HTML `muted` + // attribute, and nothing else writes it. Withholding the eye unconditionally + // meant a track hidden by "Hide all" (or by hand, or before that rule existed) + // was silent with no control anywhere to bring it back. + it("offers the eye on an audio track that is already hidden, so it can be restored", () => { + const audio: TimelineElement = { ...ELEMENT, tag: "audio" }; + const view = renderHeader({ + keyframeClip: audio, + trackElements: [audio], + isAudioTrack: true, + isTrackHidden: true, + animations: [], + }); + const labels = Array.from(view.host.querySelectorAll("button")).map((b) => + b.getAttribute("aria-label"), + ); + expect(labels.some((l) => l && /^Show track/.test(l))).toBe(true); + act(() => view.root.unmount()); + }); + + it("keeps it on a non-audio track", () => { + const view = renderHeader({ isAudioTrack: false }); + const labels = Array.from(view.host.querySelectorAll("button")).map((b) => + b.getAttribute("aria-label"), + ); + expect(labels.some((l) => l && /^Hide track/.test(l))).toBe(true); + act(() => view.root.unmount()); }); - it("toggles the real track key while announcing its display number", () => { - const onHidden = vi.fn(); - const { host, root } = renderHeader({ onHidden }); - const eye = host.querySelector('button[aria-label="Hide track 1"]'); + // The eye acts on the layer, so it has to be reachable without a pointer and + // in every disclosure state — a hover-gated eye is unusable by keyboard. + it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => { + const view = renderHeader({ expanded: true }); + expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull(); + + view.rerender({ expanded: false }); + expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull(); + act(() => view.root.unmount()); + }); + + // trackNumber is a fractional z-order sort key, so building the label from it + // made screen readers announce "Hide track 0.16666666666666666". The display + // number is label-only; the toggle still routes by the real key. + it("announces the display track number but toggles with the real fractional key", () => { + const onToggleTrackHidden = vi.fn(); + const view = renderHeader({ onToggleTrackHidden }); + const eye = view.host.querySelector('button[aria-label="Hide track 1"]'); + + expect(eye).not.toBeNull(); expect(eye?.title).toBe("Hide track 1"); + expect(view.host.innerHTML).not.toContain("0.16666666666666666"); + act(() => eye?.click()); - expect(onHidden).toHaveBeenCalledWith(1 / 6, true, 1); - act(() => root.unmount()); + // The real fractional key acts; the display row rides along for the label. + expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true, 1); + act(() => view.root.unmount()); }); - it("renders and removes audio envelope rows", () => { - const onRemove = vi.fn(); - const clip: TimelineElement = { - ...ELEMENT, + it("adds and removes a keyframe on the explicitly targeted property-group tween", () => { + const onTogglePropertyGroupKeyframe = vi.fn(); + const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe }); + + click(view.host, "Add Opacity keyframe"); + expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( + ELEMENT, + expect.objectContaining({ + animationId: "opacity-tween", + propertyGroup: "visual", + tweenPercentage: 25, + properties: { opacity: 0.25 }, + remove: false, + }), + ); + + view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe }); + click(view.host, "Remove Opacity keyframe"); + expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith( + ELEMENT, + expect.objectContaining({ + animationId: "opacity-tween", + propertyGroup: "visual", + tweenPercentage: 50, + properties: { opacity: 0.5 }, + remove: true, + }), + ); + expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith( + ELEMENT, + expect.objectContaining({ animationId: "position-tween" }), + ); + act(() => view.root.unmount()); + }); + + it("seeks only to the selected group's adjacent keyframes", () => { + const onSeek = vi.fn(); + const view = renderHeader({ + currentTime: 1, + animations: [ + POSITION, + animation("opacity-tween", "visual", [ + { percentage: 25, properties: { opacity: 0.25 } }, + { percentage: 50, properties: { opacity: 0.5 } }, + { percentage: 75, properties: { opacity: 0.75 } }, + ]), + ], + onSeek, + }); + + click(view.host, "Next Position keyframe"); + expect(onSeek).toHaveBeenLastCalledWith(2); + click(view.host, "Previous Position keyframe"); + expect(onSeek).toHaveBeenLastCalledWith(0); + expect(onSeek).not.toHaveBeenCalledWith(1.5); + act(() => view.root.unmount()); + }); + + // The lane header sits inside the track row, whose own click handler selects + // the track. Every control in the label column has to own its click, or + // seeking to a keyframe also reselects whatever is behind the header. + it("keeps lane-header control clicks off the ancestor track row", () => { + const onAncestorClick = vi.fn(); + const view = renderHeader({ + currentTime: 1, + onSeek: vi.fn(), + onTogglePropertyGroupKeyframe: vi.fn(), + }); + // React 18 delegates from the root container, so an ancestor of it is where + // a leaked click actually shows up. + document.body.addEventListener("click", onAncestorClick); + + // Every control in the lane's label column, found by row rather than by + // label, so a wording change to one button can't silently drop it here. + const controls = view.host.querySelectorAll( + '[data-property-group="position"] button', + ); + expect(controls.length).toBeGreaterThanOrEqual(3); + for (const button of controls) { + act(() => { + button.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + } + + document.body.removeEventListener("click", onAncestorClick); + expect(onAncestorClick).not.toHaveBeenCalled(); + act(() => view.root.unmount()); + }); + + it("fills the toggle diamond exactly at that group's keyframe", () => { + const view = renderHeader({ currentTime: 0.5 }); + const positionToggle = view.host.querySelector( + 'button[aria-label="Add Position keyframe"]', + ); + expect(positionToggle?.textContent).toBe("◇"); + + view.rerender({ currentTime: 1 }); + expect( + view.host.querySelector('button[aria-label="Remove Position keyframe"]') + ?.textContent, + ).toBe("◆"); + act(() => view.root.unmount()); + }); + + it("updates formatted group values when the playhead moves", () => { + const view = renderHeader({ currentTime: 0.5 }); + expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( + "50, 25", + ); + expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%"); + + view.rerender({ currentTime: 1.5 }); + expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( + "150, 75", + ); + expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%"); + act(() => view.root.unmount()); + }); + + it("samples mid-segment values along the segment's ease, not linearly", () => { + // GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50% + // runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so + // the readout is 12.5/6.25 and NOT the linear 50/25. + const eased = animation("eased-position", "position", [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" }, + { percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" }, + ]); + const onTogglePropertyGroupKeyframe = vi.fn(); + const view = renderHeader({ + animations: [eased], + currentTime: 0.5, + onTogglePropertyGroupKeyframe, + }); + + expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain( + "12.5, 6.25", + ); + + // The same sampled value is what an added keyframe gets stamped with, so a + // header insert lands on the existing curve instead of deforming it. + click(view.host, "Add Position keyframe"); + expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce(); + expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({ + properties: { x: 12.5, y: 6.25 }, + }); + act(() => view.root.unmount()); + }); + + it("disables the previous chevron at or before the group's first keyframe", () => { + const view = renderHeader({ currentTime: 0 }); + const prevAt0 = view.host.querySelector( + 'button[aria-label="Previous Position keyframe"]', + ); + expect(prevAt0).not.toBeNull(); + expect(prevAt0?.disabled).toBe(true); + + view.rerender({ currentTime: 1 }); + const prevAt1 = view.host.querySelector( + 'button[aria-label="Previous Position keyframe"]', + ); + expect(prevAt1?.disabled).toBe(false); + act(() => view.root.unmount()); + }); + + it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => { + const view = renderHeader({ expanded: false }); + expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0); + + const assertAligned = (animations: GsapAnimation[]) => { + view.rerender({ animations }); + const lanesHost = document.createElement("div"); + document.body.append(lanesHost); + const lanesRoot = createRoot(lanesHost); + act(() => { + lanesRoot.render( + , + ); + }); + expect( + Array.from(view.host.querySelectorAll("[data-timeline-lane-top]")).map( + (row) => row.style.top, + ), + ).toEqual( + Array.from(lanesHost.querySelectorAll("[data-timeline-lane-top]")).map( + (row) => row.style.top, + ), + ); + expect( + Array.from(lanesHost.querySelectorAll("[data-timeline-property-lane]")).map( + (row) => row.style.left, + ), + ).toEqual(animations.map(() => "120px")); + act(() => lanesRoot.unmount()); + }; + + assertAligned([POSITION]); + assertAligned([POSITION, OPACITY]); + act(() => view.root.unmount()); + }); + + /** + * Automation lanes are named in the label column, on the same tree as the + * keyframe rows — not painted inside the lane, where the name sat on top of + * the envelope it belonged to and scrolled away from its own row. + */ + describe("audio automation rows", () => { + const BED: TimelineElement = { + id: "bed", + label: "Music Bed", tag: "audio", + start: 0, + duration: 10, + track: 0, + fxChain: JSON.stringify({ + version: 1, + nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }], + }), automation: JSON.stringify({ version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + lanes: [ + { + target: "volume", + points: [ + { t: 0, v: 1 }, + { t: 5, v: 0.4 }, + ], + }, + { + target: "fx.n1.gain", + points: [ + { t: 0, v: 0 }, + { t: 5, v: -6 }, + ], + }, + ], }), - }; - const { host, root } = renderHeader({ clip, audio: true, hidden: true, onRemove }); - const lane = host.querySelector("[data-automation-lane-label]"); - expect(lane).not.toBeNull(); - expect(lane?.style.top).toBe("48px"); - act(() => host.querySelector('button[aria-label$="automation"]')?.click()); - expect(onRemove).toHaveBeenCalledWith("volume"); - act(() => root.unmount()); + } as TimelineElement; + + it("names every envelope in the label column", () => { + const { host, root } = renderHeader({ keyframeClip: BED, animations: [] }); + const rows = Array.from(host.querySelectorAll("[data-automation-lane-label]")); + // The attribute is the ROW's identity, which is the label: a row can hold + // several clips' envelopes, whose lane targets differ from each other. + expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([ + "Peaking EQ 1.6 kHz · Gain", + "Volume", + ]); + // A band is named by its frequency: with several of them, "Peaking EQ" says + // nothing about which is which. Bands sit above the level lanes. + // Two lines per row: what the effect is, then which knob the envelope + // drives. One line truncated mid-word in a column this narrow. + expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([ + "Peaking EQ 1.6 kHz", + "Volume", + ]); + expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual( + [ + "Gain", + // Volume has no effect behind it, so it has no second line at all. + undefined, + ], + ); + act(() => root.unmount()); + }); + + it("hides them when the track is collapsed", () => { + const { host, root } = renderHeader({ + keyframeClip: BED, + animations: [], + expanded: false, + }); + expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); + act(() => root.unmount()); + }); + + it("removes just that envelope from the label column", () => { + // The panel's automate toggle can only reach a parameter it still shows; a + // carve's own lanes are not in it at all, so without this an envelope could + // be created and never deleted. + const onRemoveAutomationLane = vi.fn(); + const { host, root } = renderHeader({ + keyframeClip: BED, + animations: [], + onRemoveAutomationLane, + }); + const button = host.querySelector( + 'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]', + ); + expect(button).not.toBeNull(); + act(() => button?.click()); + expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain"); + act(() => root.unmount()); + }); + + it("offers no remove button when the lanes are read-only", () => { + const { host, root } = renderHeader({ keyframeClip: BED, animations: [] }); + expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0); + act(() => root.unmount()); + }); + + it("stacks each envelope's row where its lane is drawn", () => { + // Same rhythm the canvas uses: automation begins below the keyframe lanes + // and steps by its own taller row height. + const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] }); + const tops = Array.from( + host.querySelectorAll("[data-automation-lane-label]"), + ).map((r) => r.style.top); + const base = getTimelineLaneTop(1); + expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]); + act(() => root.unmount()); + }); }); - it("hides audio envelope rows when collapsed", () => { - const clip: TimelineElement = { - ...ELEMENT, + /** + * Several clips on one row share a lane row per property, so the label column + * has to name the row for the property and the header for the track — not for + * whichever clip happens to be selected. + */ + describe("a track several clips share", () => { + const clip = (id: string, over: Partial): TimelineElement => + ({ + id, + key: id, + label: id, + tag: "audio", + start: 0, + duration: 4, + track: 0, + ...over, + }) as TimelineElement; + const PEAKING = (gain: number) => + JSON.stringify({ + version: 1, + nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }], + }); + const NARRATION_1 = clip("narration-1", { + fxChain: PEAKING(-3), + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "fx.n1.gain", points: [{ t: 0, v: -3 }] }], + }), + }); + const NARRATION_2 = clip("narration-2", { + start: 4, + fxChain: PEAKING(-6), + automation: JSON.stringify({ + version: 1, + lanes: [ + { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] }, + { target: "volume", points: [{ t: 0, v: 1 }] }, + ], + }), + }); + const ROW = { trackElements: [NARRATION_1, NARRATION_2], clipCount: 2, animations: [] }; + + it("lists every clip's envelopes, whichever clip is selected", () => { + const labels = (host: HTMLElement) => + Array.from(host.querySelectorAll("[data-automation-lane-label]")).map((r) => + r.getAttribute("data-automation-lane-label"), + ); + const first = renderHeader({ ...ROW, keyframeClip: NARRATION_1 }); + const second = renderHeader({ ...ROW, keyframeClip: NARRATION_2 }); + // narration-1 has no volume envelope, but the row is still there — it is + // the track's, and it was its sibling's before the selection moved. + expect(labels(first.host)).toEqual(["Peaking EQ 1 kHz · Gain", "Volume"]); + expect(labels(second.host)).toEqual(labels(first.host)); + act(() => first.root.unmount()); + act(() => second.root.unmount()); + }); + + it("removes only from the clip it is showing, and offers nothing where it has no lane", () => { + // A write can only reach the selected clip, so a button on a row that clip + // is absent from could only remove nothing, or somebody else's envelope. + const onRemoveAutomationLane = vi.fn(); + const { host, root } = renderHeader({ + ...ROW, + keyframeClip: NARRATION_1, + onRemoveAutomationLane, + }); + expect( + Array.from(host.querySelectorAll('button[aria-label$="automation"]')).map((b) => + b.getAttribute("aria-label"), + ), + ).toEqual(["Remove Peaking EQ 1 kHz · Gain automation"]); + act(() => host.querySelector('button[aria-label$="automation"]')?.click()); + expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain"); + act(() => root.unmount()); + }); + + it("names the header for the track, not for one of the clips on it", () => { + const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 }); + expect(view.host.textContent).not.toContain("narration-2"); + // Asserted on the rendered text, not a `title`: the name wraps now rather + // than truncating, so it no longer carries a tooltip to be found by. + expect(view.host.textContent).toContain("Track 1"); + // Alone on the track it is still named for itself. + view.rerender({ + ...ROW, + keyframeClip: NARRATION_2, + trackElements: [NARRATION_2], + clipCount: 1, + }); + expect(view.host.textContent).toContain("narration-2"); + act(() => view.root.unmount()); + }); + }); + + describe("audio ids and canary gates", () => { + const VOICE: TimelineElement = { + id: "voice-1", + key: "index.html#voice-1", + domId: "voice-1", tag: "audio", - automation: JSON.stringify({ version: 1, lanes: [{ target: "volume", points: [] }] }), + start: 0, + duration: 5, + track: 0, + }; + const VOICE_2: TimelineElement = { + ...VOICE, + id: "voice-2", + key: "index.html#voice-2", + domId: "voice-2", }; - const { host, root } = renderHeader({ clip, audio: true, expanded: false }); - expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0); - act(() => root.unmount()); + + // A member row is `aria-level="2"`, and without this it looked identical to + // every top-level row — the nesting existed for a screen reader and not for + // an eye. B2's design called for the accent rail; only the semantics shipped. + it("indents a group member's row and gives it the accent rail", () => { + const view = renderHeader({ + keyframeClip: VOICE, + animations: [], + expanded: false, + isAudioTrack: true, + }); + const header = () => view.host.querySelector('[role="rowheader"]'); + + expect(header()?.style.paddingLeft).toBe(""); + expect(header()?.style.borderLeft).toBe(""); + expect(header()?.style.background).not.toContain("linear-gradient"); + + view.rerender({ + keyframeClip: VOICE, + animations: [], + expanded: false, + isAudioTrack: true, + isGroupMember: true, + }); + expect(header()?.style.paddingLeft).toBe("14px"); + expect(header()?.style.borderLeft).toContain("var(--timeline-accent-rail)"); + act(() => view.root.unmount()); + }); + + it("offers the FX button on every audio track", () => { + const view = renderHeader({ + keyframeClip: VOICE, + animations: [], + expanded: false, + isAudioTrack: true, + }); + expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); + act(() => view.root.unmount()); + }); + + // A visual track has no chain to open, so the button must not follow the + // header onto every row. + it("withholds the FX button from a non-audio track", () => { + const view = renderHeader({ + keyframeClip: ELEMENT, + animations: [], + expanded: false, + }); + expect(view.host.querySelector('button[aria-label="Effects"]')).toBeNull(); + act(() => view.root.unmount()); + }); + + // A chain belongs to ONE bus, so a track carrying several ungrouped clips + // gets the pointer instead of the FX button — group first, then mix. + it("shows the group pointer on a multi-clip ungrouped audio track", () => { + const opts = { + keyframeClip: VOICE, + trackElements: [VOICE, VOICE_2], + clipCount: 2, + animations: [], + expanded: false, + isAudioTrack: true, + }; + const pointer = (host: HTMLElement) => + host.querySelector('button[aria-label="Effects — group these clips first"]'); + const view = renderHeader(opts); + expect(pointer(view.host)).not.toBeNull(); + // One clip needs no grouping — the real FX button takes its place. + view.rerender({ ...opts, trackElements: [VOICE], clipCount: 1 }); + expect(pointer(view.host)).toBeNull(); + expect(view.host.querySelector('button[aria-label="Effects"]')).not.toBeNull(); + act(() => view.root.unmount()); + }); + + // The header is a 48px column of exactly TWO lines — what the row is, then + // what you can do to it. The group pointer used to render as a sibling of + // both, making a third: 17 + 24 + 24 + gaps in a 48px box, which + // `justify-center` then spilled evenly out of the top and bottom. The name + // rode 10px above its own row and the pointer collided with the row below. + // The header GROWS by AUTOMATION_LANE_H for every open lane, and the lanes + // are absolutely positioned from its top. `justify-center` on the header + // itself therefore centred the two lines in the FULL height, so opening a + // lane pushed the name and its controls down on top of the lane rows. + it("pins the two lines to the top TRACK_H, whatever the header grows to", () => { + const automated: TimelineElement = { + ...VOICE, + automation: JSON.stringify({ + version: 1, + lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], + }), + }; + const view = renderHeader({ + keyframeClip: automated, + trackElements: [automated], + clipCount: 1, + animations: [], + expanded: true, + isAudioTrack: true, + }); + const header = view.host.querySelector('[role="rowheader"]'); + const lines = header?.children[0] as HTMLElement | undefined; + expect(lines?.style.height).toBe(`${TRACK_H}px`); + // The lane row is a sibling of the wrapper, not inside it — it stacks + // BELOW the two lines rather than sharing their box. + expect((header?.children.length ?? 0) > 1).toBe(true); + act(() => view.root.unmount()); + }); + + // The header is ONE line: name, clip count, then every control anchored to + // the right edge. It was two — a name line and a control line — which is + // what let a stray third child overflow the 48px box; now there is a single + // row and the controls share one right-aligned group. + it("keeps the name and every control on one line, controls to the right", () => { + const view = renderHeader({ + keyframeClip: VOICE, + trackElements: [VOICE, VOICE_2], + clipCount: 2, + animations: [], + expanded: false, + isAudioTrack: true, + }); + const header = view.host.querySelector('[role="rowheader"]'); + // One TRACK_H-tall wrapper holding one line. + const lines = header?.children[0]; + expect(lines?.children).toHaveLength(1); + // The controls live in a right-anchored group at the end of that line, + // after the name and the clip count — `ml-auto` is what holds the edge. + const line = lines?.children[0]; + const controls = line?.lastElementChild as HTMLElement | null; + expect(controls?.className).toContain("ml-auto"); + expect( + controls?.querySelector('button[aria-label="Effects — group these clips first"]'), + ).not.toBeNull(); + // And the clip count is beside the name, not out with the controls. + expect(controls?.querySelector('[aria-label="2 clips"]')).toBeNull(); + expect(line?.querySelector('[aria-label="2 clips"]')).not.toBeNull(); + act(() => view.root.unmount()); + }); + }); +}); + +// A host themes the timeline by overriding --timeline-* on theme.css, so the +// rendered gutter must carry the CSS variable, not a baked-in colour. +describe("theming", () => { + it("reads the gutter background and border from timeline theme tokens", () => { + const view = renderHeader({}); + const header = view.host.querySelector('[role="rowheader"]'); + expect(header?.style.background).toBe(defaultTimelineTheme.gutterBackground); + expect(header?.style.background).toContain("var(--timeline-gutter-bg)"); + expect(header?.style.borderRight).toContain(defaultTimelineTheme.gutterBorder); + act(() => view.root.unmount()); }); - it("names a shared audio track rather than the selected clip", () => { - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - const first = { ...ELEMENT, id: "first", tag: "audio", automation }; - const second = { ...ELEMENT, id: "second", tag: "audio", automation }; - const { host, root } = renderHeader({ clip: second, elements: [first, second], audio: true }); - expect(host.querySelector('button[aria-label="Hide Track 1 lanes"]')).not.toBeNull(); - expect(host.textContent).not.toContain("second"); - act(() => root.unmount()); + // happy-dom's `background` shorthand garbles a `var()` layer next to + // `linear-gradient(...)` (verified), so this checks the same value through + // the pure function instead of the broken CSSOM roundtrip. + it("overlays the group-member tint on the themed gutter, not a hard-coded colour", () => { + const filled = gutterFill(defaultTimelineTheme.gutterBackground, true); + expect(filled).toContain("linear-gradient"); + expect(filled.endsWith(defaultTimelineTheme.gutterBackground)).toBe(true); + expect(gutterFill(defaultTimelineTheme.gutterBackground, false)).toBe( + defaultTimelineTheme.gutterBackground, + ); }); }); diff --git a/packages/studio/src/player/components/TimelineTrackHeader.tsx b/packages/studio/src/player/components/TimelineTrackHeader.tsx index 6009a12efe..8ff70cc4c7 100644 --- a/packages/studio/src/player/components/TimelineTrackHeader.tsx +++ b/packages/studio/src/player/components/TimelineTrackHeader.tsx @@ -1,3 +1,4 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { HF_AUDIO_FX_ATTR, serializeAudioFxChain, @@ -5,20 +6,22 @@ import { } from "@hyperframes/core/audio-fx"; import { classifyAudioName } from "@hyperframes/core/audio-carve"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { PlainTrackHeader } from "./TimelineTrackPlainHeader"; +import { VisibilityButton, PlainTrackHeader } from "./TimelineTrackPlainHeader"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { useTimelineEditContextOptional } from "../../contexts/TimelineEditContext"; import { useDomEditActionsContextOptional } from "../../contexts/DomEditContext"; import { mintGroupId } from "../../components/editor/useFxCarveGrouping"; import { runtimeAudioId } from "../lib/timelineElementHelpers"; import { TimelineFxButton } from "./TimelineFxButton"; +import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { elementFxChain, groupAutomationLanes, isCarveLane } from "./automationLaneData"; -import { LaneToggleButton } from "./LayerDisclosureRow"; -import { LABEL_COL_W, TRACK_H } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { clipTimingStart } from "../../hooks/gsapShared"; +import { LaneToggleButton, LayerDisclosureRow } from "./LayerDisclosureRow"; +import { LABEL_COL_W, TRACK_H, getTimelineLaneTop } from "./timelineLayout"; import type { TimelineTheme } from "./timelineTheme"; import { trackDisplaySuffix } from "./timelineTrackDisplay"; -import { AutomationLaneHeaderRow } from "./trackHeaderLabelRows"; +import { AutomationLaneHeaderRow, PropertyGroupHeaderRow } from "./trackHeaderLabelRows"; import { useMemo } from "react"; /** Accent rail + inset marking a row as a group MEMBER, matching the level-2 @@ -29,7 +32,7 @@ const GROUP_MEMBER_INDENT = 14; const GROUP_MEMBER_TINT = "var(--timeline-group-member-tint)"; /** The gutter fill for a row, tinted when it belongs to a group. */ -function gutterFill(base: string, isGroupMember: boolean): string { +export function gutterFill(base: string, isGroupMember: boolean): string { return isGroupMember ? `linear-gradient(${GROUP_MEMBER_TINT}, ${GROUP_MEMBER_TINT}), ${base}` : base; @@ -61,16 +64,21 @@ interface TimelineTrackHeaderProps { /** Clips on this track, so the header can say how many the row holds. */ clipCount: number; isExpanded: boolean; + animations: readonly GsapAnimation[]; + currentTime: number; isTrackHidden: boolean; isAudioTrack: boolean; /** This track is a member of an audio group — indents the row under its header. */ isGroupMember?: boolean; + rovingTargetId?: string | null; theme: TimelineTheme; onToggleClipExpanded: () => void; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; /** Drop one envelope. Absent while the lanes are read-only, which is what * hides the control rather than offering a button that cannot act. */ onRemoveAutomationLane?: (target: string) => void; + onSeek?: (time: number) => void; } // fallow-ignore-next-line complexity @@ -84,14 +92,27 @@ export function TimelineTrackHeader({ trackElements, clipCount, isExpanded, + animations, + currentTime, isTrackHidden, isAudioTrack, isGroupMember = false, theme, onToggleClipExpanded, onToggleTrackHidden, + onTogglePropertyGroupKeyframe, onRemoveAutomationLane, + onSeek, + rovingTargetId = null, }: TimelineTrackHeaderProps) { + const clipPercentage = keyframeClip + ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100 + : 0; + const lanes = keyframeClip + ? // clipTimingStart, not the raw start: an expanded sub-comp child's start is + // host-absolute while its tweens are local to its own file. + getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration) + : []; // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx // owns the gutter past it, so a 0% diamond isn't clipped by this panel). const showTrackLabel = contentOrigin >= LABEL_COL_W; @@ -173,12 +194,13 @@ export function TimelineTrackHeader({ ); // Automation counts as something to disclose: gating the caret on tweens alone // left an audio clip's envelopes unreachable, since the track could not expand. - const disclosable = automationRows.length > 0; + const disclosable = lanes.length > 0 || automationRows.length > 0; // Which HEADER LAYOUT the row wears — not the same question as `disclosable`. // An audio track that automates something is still an audio track: it keeps // the music glyph and the group indent and gains the `∿`. Tying layout to // disclosability swapped it for the keyframe-layer row (a `◇`, no indent) the // moment an envelope appeared. + const isKeyframeLayer = !!keyframeClip && disclosable && !isAudioTrack; // What the lane disclosure calls this row. A row of several clips is named // for the TRACK, not for whichever is selected — the lanes are the track's, // shared per property, so "Narration 2 lanes" read as if they were that one @@ -254,83 +276,142 @@ export function TimelineTrackHeader({ : {}), }} > - <> - {/* The two lines own exactly TRACK_H, not the whole header. + {!isKeyframeLayer ? ( + <> + {/* The two lines own exactly TRACK_H, not the whole header. `justify-center` on the header itself centred them in its FULL height — which grows by AUTOMATION_LANE_H per open lane — so opening one pushed the name and its controls down THROUGH the lane rows below, which are absolutely positioned from the top. */} -
- - {singleAudioClip && ( - writeClipFxChain(singleAudioClip, next, false)} - onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} - // Muted, an audition is silent — so the hover lifts the mute on - // the running graph and puts it back on the way out, the same - // borrow-and-return it already does with the playhead. - auditionSpans={[singleAudioClip]} - isMuted={isTrackHidden} - onSetMutedLive={(muted) => - onSetElementAttributeLive?.(singleAudioClip, "data-hidden", muted ? "" : null) - } - onOpenRack={() => openClipFxRack(singleAudioClip)} - /> - )} - {clipCount > 1 && - !isTrackGrouped && - (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && ( +
+ + {singleAudioClip && ( writeClipFxChain(singleAudioClip, next, false)} + onChainPreview={(next) => writeClipFxChain(singleAudioClip, next, true)} + // Muted, an audition is silent — so the hover lifts the mute on + // the running graph and puts it back on the way out, the same + // borrow-and-return it already does with the playhead. + auditionSpans={[singleAudioClip]} + isMuted={isTrackHidden} + onSetMutedLive={(muted) => + onSetElementAttributeLive?.( + singleAudioClip, + "data-hidden", + muted ? "" : null, + ) } - onGroupClips={groupUngroupedClips} + onOpenRack={() => openClipFxRack(singleAudioClip)} /> )} - {/* The lane disclosure, on the row's own layout rather than by + {clipCount > 1 && + !isTrackGrouped && + (isAudioTrack ? canGroupWholeTrack : isVideoWithAudioTrack) && ( + + )} + {/* The lane disclosure, on the row's own layout rather than by swapping it for a keyframe-layer row. */} - {disclosable && ( - - )} - - } + {disclosable && ( + + )} + + } + /> +
+ + ) : ( + <> + + {/* The eye belongs to the LAYER, so it lives on the always-mounted + layer row exactly like a plain track's. Hanging it off a lane row + (hover-gated, and only while expanded) left a keyframed track with + no way to be hidden at all by keyboard, and put the control on a + row it does not act on. */} + + + )} + {/* The caret expands TWO disjoint subtrees: these label-column rows, + which carry the per-lane keyframe controls, and the diamond lanes + on the canvas. `lanesId` names the canvas lanes (rendered by + TimelineLanes), because that is what a sighted user watches appear + and what following the reference has to land on. These rows are not + empty and are not the target; they are absolutely positioned inside + the sticky column, which is what made a wrapper HERE compute to + 0x0 and hold no diamonds. */} + {isExpanded && + keyframeClip && + lanes.map((lane, laneIndex) => ( + -
- + ))} {/* Below the keyframe rows and stepping by its own height, which is how TimelineAutomationLaneSlot lays the envelopes out on the canvas. The two have to agree or a name labels the wrong curve. */} @@ -349,7 +430,7 @@ export function TimelineTrackHeader({ alsoAutomatedBy={ groupAutomatedTargets.has(row.key) ? (groupLabelForNote ?? groupOwner) : undefined } - top={TRACK_H + index * AUTOMATION_LANE_H} + top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H} isLastLane={index === automationRows.length - 1} gutterBackground={gutterFill(theme.gutterBackground, isGroupMember)} columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin} diff --git a/packages/studio/src/player/components/TimelineTrackRow.tsx b/packages/studio/src/player/components/TimelineTrackRow.tsx index 80e33a0e18..6eb22131c3 100644 --- a/packages/studio/src/player/components/TimelineTrackRow.tsx +++ b/packages/studio/src/player/components/TimelineTrackRow.tsx @@ -1,10 +1,19 @@ import type { ReactNode } from "react"; +import { timelineLogicalRowCellId } from "./timelineNavigationIdentity"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; interface TimelineTrackRowProps { index: number; rowKey: number; logicalRow: TimelineLogicalRow; + propertyRows: readonly TimelineLogicalRow[]; + /** Names the canvas-side content cell — the active clip's own property lanes, + * minted with this single id in TimelinePropertyLanes. */ + lanesId: string; + /** Names the header cell. Space-separated because the caret it lives under + * expands two disjoint subtrees (the clip's keyframe lanes AND the track's + * automation lanes) — see TimelineTrackHeader for why they cannot share one id. */ + headerLanesId: string; top: number; height: number; virtualized: boolean; @@ -19,6 +28,9 @@ export function TimelineTrackRow({ index, rowKey, logicalRow, + propertyRows, + lanesId, + headerLanesId, top, height, virtualized, @@ -54,6 +66,39 @@ export function TimelineTrackRow({ > {children}
+ {propertyRows.map((row) => { + const group = row.propertyGroup; + const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length; + const easeCount = row.items.filter((item) => item.kind === "ease").length; + return ( + // ponytail: aria-owns maps this hidden logical row onto the two visible + // property-lane cells without duplicating interactive controls. +
+
+ {group} +
+
+ {keyframeCount} keyframes, {easeCount} ease controls +
+
+ ); + })}
); } diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts index c453bba250..853bb553ba 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.test.ts @@ -57,6 +57,7 @@ function model(overrides: Partial[0] laneCounts: new Map([["active", 2]]), selectedElementId: "active", selectedElementIds: new Set(), + expandedClipIds: new Set(["active"]), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), groups: [], @@ -71,6 +72,89 @@ function model(overrides: Partial[0] }); } +describe("buildTimelineLogicalRows", () => { + it("projects tracks, empty tracks, and expanded property rows with continuous indices", () => { + const rows = model(); + + expect( + rows.map(({ physicalTrackKey, logicalIndex, level, parentId, expandable }) => ({ + physicalTrackKey, + logicalIndex, + level, + parentId, + expandable, + })), + ).toEqual([ + { physicalTrackKey: 1, logicalIndex: 0, level: 1, parentId: null, expandable: true }, + { + physicalTrackKey: 1, + logicalIndex: 1, + level: 2, + parentId: timelineTrackRowId(1), + expandable: false, + }, + { + physicalTrackKey: 1, + logicalIndex: 2, + level: 2, + parentId: timelineTrackRowId(1), + expandable: false, + }, + { physicalTrackKey: 2, logicalIndex: 3, level: 1, parentId: null, expandable: false }, + { physicalTrackKey: 3, logicalIndex: 4, level: 1, parentId: null, expandable: false }, + ]); + expect(rows[0]?.expanded).toBe(true); + expect(rows[3]?.items).toEqual([]); + expect(rows[0]?.items.map((item) => item.elementId)).toEqual(["early", "active", "late"]); + }); + + it("orders keyframes and their segment ease controls deterministically", () => { + const rows = model({ + gsapAnimations: new Map([ + [ + "active", + [ + animation("z-animation", "position", [100, 0, 50]), + animation("a-animation", "position", [50]), + ], + ], + ]), + }); + const position = rows.find((row) => row.propertyGroup === "position")!; + + expect( + position.items.map((item) => [item.kind, item.time, item.keyframeTarget?.animationId]), + ).toEqual([ + ["keyframe", 10, "z-animation"], + ["ease", 12.5, "a-animation"], + ["keyframe", 15, "a-animation"], + ["keyframe", 15, "z-animation"], + ["ease", 17.5, "z-animation"], + ["keyframe", 20, "z-animation"], + ]); + }); + + it("uses the selected keyframed clip as the sole expanded-lane owner", () => { + const other = clip("other", 1, 0, 4); + const rows = model({ + tracks: [[1, [other, clip("active", 1, 10, 10)]]], + displayTrackOrder: [1], + laneCounts: new Map([ + ["active", 1], + ["other", 1], + ]), + selectedElementId: "other", + expandedClipIds: new Set(["other", "active"]), + gsapAnimations: new Map([ + ["active", [animation("active-position", "position", [0, 100])]], + ["other", [animation("other-visual", "visual", [0, 100], 0)]], + ]), + }); + + expect(rows.map((row) => row.propertyGroup).filter(Boolean)).toEqual(["visual"]); + }); +}); + describe("resolveTimelineNavigationTarget", () => { it("navigates horizontal items plus row Home and End", () => { const rows = model(); @@ -100,6 +184,38 @@ describe("resolveTimelineNavigationTarget", () => { ); }); + it("navigates every logical row including properties and empty tracks", () => { + const rows = model(); + const activeId = timelineClipFocusId("active"); + const propertyTarget = resolveTimelineNavigationTarget(rows, activeId, "ArrowDown")!; + + expect(propertyTarget.kind).toBe("keyframe"); + expect(propertyTarget.time).toBe(15); + expect( + resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageDown", { pageSize: 2 })?.id, + ).toBe(timelineTrackRowId(2)); + expect(resolveTimelineNavigationTarget(rows, timelineTrackRowId(2), "ArrowDown")?.id).toBe( + timelineTrackRowId(3), + ); + expect(resolveTimelineNavigationTarget(rows, propertyTarget.id, "ArrowUp")?.id).toBe(activeId); + expect( + resolveTimelineNavigationTarget(rows, propertyTarget.id, "PageUp", { pageSize: 2 })?.id, + ).toBe(activeId); + }); + + it("uses a caller-supplied page size and ignores invalid page commands", () => { + const rows = model(); + const current = timelineTrackRowId(1); + + expect(resolveTimelineNavigationTarget(rows, current, "PageDown")?.id).toBe(current); + expect(resolveTimelineNavigationTarget(rows, current, "PageDown", { pageSize: 3 })?.id).toBe( + timelineTrackRowId(2), + ); + expect( + resolveTimelineNavigationTarget(rows, timelineTrackRowId(3), "PageDown", { pageSize: 1 })?.id, + ).toBe(timelineTrackRowId(3)); + }); + it("supports modified Home and End across the whole logical model", () => { const rows = model(); const current = timelineTrackRowId(2); @@ -112,6 +228,15 @@ describe("resolveTimelineNavigationTarget", () => { ).toBe(timelineTrackRowId(3)); }); + it("returns from a property row to its parent with ArrowLeft", () => { + const rows = model(); + const property = rows.find((row) => row.propertyGroup === "position")!; + + expect(resolveTimelineNavigationTarget(rows, property.id, "ArrowLeft")?.id).toBe( + timelineTrackRowId(1), + ); + }); + it("breaks equal-distance vertical ties by time then stable identity", () => { const rows = buildTimelineLogicalRows({ tracks: [ @@ -122,6 +247,7 @@ describe("resolveTimelineNavigationTarget", () => { laneCounts: new Map(), selectedElementId: null, selectedElementIds: new Set(), + expandedClipIds: new Set(), collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), groups: [], @@ -137,8 +263,9 @@ describe("resolveTimelineNavigationTarget", () => { describe("resolveTimelineFocusFallback", () => { it("chooses previous, then next, then the containing row after deletion", () => { - const before = model(); + const before = model({ expandedClipIds: new Set() }); const withoutActive = model({ + expandedClipIds: new Set(), tracks: fallbackTracks([clip("early", 1, 0), clip("late", 1, 20)]), }); expect( @@ -146,6 +273,7 @@ describe("resolveTimelineFocusFallback", () => { ).toBe(timelineClipFocusId("early")); const onlyNext = model({ + expandedClipIds: new Set(), tracks: fallbackTracks([clip("late", 1, 20)]), }); expect(resolveTimelineFocusFallback(before, onlyNext, timelineClipFocusId("active"))?.id).toBe( @@ -153,10 +281,12 @@ describe("resolveTimelineFocusFallback", () => { ); const onlyActive = model({ + expandedClipIds: new Set(), tracks: [[1, [clip("active", 1, 10, 10)]]], displayTrackOrder: [1], }); const empty = model({ + expandedClipIds: new Set(), tracks: [[1, []]], displayTrackOrder: [1], }); @@ -165,6 +295,16 @@ describe("resolveTimelineFocusFallback", () => { ); }); + it("falls back from a collapsed property row to its parent track", () => { + const before = model(); + const property = before.find((row) => row.propertyGroup === "position")!; + const after = model({ expandedClipIds: new Set() }); + + expect(resolveTimelineFocusFallback(before, after, property.items[0]!.id)?.id).toBe( + timelineTrackRowId(1), + ); + }); + it("returns null for an identity absent from the previous model", () => { expect(resolveTimelineFocusFallback(model(), model(), "missing")).toBeNull(); }); diff --git a/packages/studio/src/player/components/timelineKeyboardNavigation.ts b/packages/studio/src/player/components/timelineKeyboardNavigation.ts index 1f75762d64..00c321d897 100644 --- a/packages/studio/src/player/components/timelineKeyboardNavigation.ts +++ b/packages/studio/src/player/components/timelineKeyboardNavigation.ts @@ -1,12 +1,20 @@ +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; +import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { groupAutomationLanes } from "./automationLaneData"; -import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; +import { + timelineKeyframeSelectionKey, + type TimelineKeyframeTarget, +} from "./timelineKeyframeIdentity"; import { timelineClipFocusId, + timelineEaseFocusId, timelineGroupRowId, + timelineKeyframeFocusId, + timelinePropertyRowId, timelineTrackRowId, } from "./timelineNavigationIdentity"; -import { isTimelineRowExpanded, resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; export type TimelineNavigationKey = @@ -56,6 +64,7 @@ export interface TimelineLogicalRow { groupId?: string; expandable: boolean; expanded: boolean; + propertyGroup?: PropertyGroupName; items: readonly TimelineLogicalItem[]; } @@ -67,12 +76,14 @@ export interface BuildTimelineLogicalRowsInput { laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: ReadonlySet; + expandedClipIds: ReadonlySet; /** Groups the caret has COLLAPSED — absent means expanded, the default. */ collapsedGroupIds: ReadonlySet; /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ expandedLaneOwnerIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; + gsapAnimations: ReadonlyMap; } export interface TimelineNavigationOptions { @@ -106,15 +117,17 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin }); } -/** A track's active clip (if any) and its element id. */ +/** A track's active clip (if any), its element id, and its automation lanes. */ function resolveActiveTrackClip( elements: readonly TimelineElement[], laneCounts: BuildTimelineLogicalRowsInput["laneCounts"], selectedElementId: string | null, selectedElementIds: ReadonlySet, + gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"], ): { activeClip: TimelineElement | null; activeId: string | null; + lanes: ReturnType; } { const activeClip = resolveTrackKeyframeClip( elements, @@ -123,7 +136,111 @@ function resolveActiveTrackClip( selectedElementIds, ); const activeId = activeClip ? elementId(activeClip) : null; - return { activeClip, activeId }; + const lanes = activeClip + ? getTimelinePropertyLanes( + gsapAnimations.get(elementId(activeClip)) ?? [], + activeClip.start, + activeClip.duration, + ) + : []; + return { activeClip, activeId, lanes }; +} + +function keyframeTarget( + keyframe: ReturnType[number]["keyframes"][number], +): TimelineKeyframeTarget { + return { + percentage: keyframe.percentage, + tweenPercentage: keyframe.tweenPercentage, + propertyGroup: keyframe.propertyGroup, + animationId: keyframe.animationId, + collidingAnimationTargets: keyframe.collidingAnimationTargets, + }; +} + +function propertyItems( + rowId: string, + clip: TimelineElement, + keyframes: ReturnType[number]["keyframes"], +): TimelineLogicalItem[] { + const id = elementId(clip); + const unique = new Map(); + for (const keyframe of keyframes) { + const target = keyframeTarget(keyframe); + const key = timelineKeyframeSelectionKey(id, target); + if (!unique.has(key)) { + unique.set(key, { + target, + time: clip.start + (keyframe.percentage / 100) * clip.duration, + }); + } + } + const ordered = [...unique.entries()].sort( + ([leftKey, left], [rightKey, right]) => + left.time - right.time || leftKey.localeCompare(rightKey), + ); + const items: TimelineLogicalItem[] = []; + // ponytail: The composite property lane owns adjacency, so the incoming keyframe + // owns an ease segment even when its previous neighbor came from another animation. + for (let index = 0; index < ordered.length; index += 1) { + const [, current] = ordered[index]!; + const previous = ordered[index - 1]?.[1]; + if (previous && current.time > previous.time && current.target.animationId !== undefined) { + items.push({ + id: timelineEaseFocusId(id, current.target), + kind: "ease", + rowId, + elementId: id, + time: previous.time + (current.time - previous.time) / 2, + keyframeTarget: current.target, + }); + } + items.push({ + id: timelineKeyframeFocusId(id, current.target), + kind: "keyframe", + rowId, + elementId: id, + time: current.time, + keyframeTarget: current.target, + }); + } + return items; +} + +/** A clip's lanes are visible when either the caret or the `∿` button opened it. */ +function isRowOpen( + activeId: string | null, + expandedClipIds: ReadonlySet, + expandedLaneOwnerIds: ReadonlySet, +): boolean { + if (activeId === null) return false; + return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId); +} + +/** A single automation-lane row, one level deeper than the track/group row that owns it. */ +function buildLaneRow( + track: number, + logicalIndex: number, + activeId: string, + activeClip: TimelineElement, + lane: ReturnType[number], + level: 2 | 3, + parentId: string, +): TimelineLogicalRow { + const laneRowId = timelinePropertyRowId(activeId, lane.group); + return { + id: laneRowId, + kind: "row", + physicalTrackKey: track, + logicalIndex, + level, + parentId, + elementId: activeId, + expandable: false, + expanded: false, + propertyGroup: lane.group, + items: propertyItems(laneRowId, activeClip, lane.keyframes), + }; } /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */ @@ -133,28 +250,31 @@ export function buildTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] { const trackMap = new Map(tracks); const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group])); const rows: TimelineLogicalRow[] = []; - // A real track's own row (level 1 ungrouped, level 2 under a group) plus - // its audio automation disclosure state. + // A real track's own row (level 1 ungrouped, level 2 under a group) plus, + // when its clip's lanes are open, the lane rows one level deeper. function emitTrack(track: number, level: 1 | 2, parentId: string | null): void { const elements = trackMap.get(track) ?? []; const trackId = timelineTrackRowId(track); - const { activeId } = resolveActiveTrackClip( + const { activeClip, activeId, lanes } = resolveActiveTrackClip( elements, laneCounts, selectedElementId, selectedElementIds, + gsapAnimations, ); - const disclosable = groupAutomationLanes(elements).length > 0; - const expanded = isTimelineRowExpanded(elements, expandedLaneOwnerIds); + const disclosable = isTrackDisclosable(elements, lanes.length); + const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && disclosable; rows.push({ id: trackId, kind: "row", @@ -167,6 +287,12 @@ export function buildTimelineLogicalRows({ expanded, items: clipItems(trackId, elements), }); + if (!expanded || !activeClip || !activeId) return; + for (const lane of lanes) { + rows.push( + buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId), + ); + } } // A group's own row (level 1) plus, when its `∿` is open, its own @@ -329,3 +455,17 @@ export function resolveTimelineFocusFallback( } return nextRows[previous.rowIndex] ?? nextRows[previous.rowIndex - 1] ?? null; } + +/** + * Does a track have anything to open — the header's own `disclosable`. + * + * `TimelineTrackHeader` is `lanes.length > 0 || automationRows.length > 0`, and + * keyed on tweens alone here an audio track whose only disclosable content is + * AUTOMATION drew the `∿` while reporting itself unexpandable to the treegrid, + * so ArrowRight could not open it. Automation rows are counted per shared + * PROPERTY across the track's clips, the way the header counts them, not per + * clip. + */ +function isTrackDisclosable(elements: readonly TimelineElement[], laneCount: number): boolean { + return laneCount > 0 || groupAutomationLanes(elements).length > 0; +} diff --git a/packages/studio/src/player/components/timelineLaneProps.ts b/packages/studio/src/player/components/timelineLaneProps.ts index 4e322cb56a..5c997850bf 100644 --- a/packages/studio/src/player/components/timelineLaneProps.ts +++ b/packages/studio/src/player/components/timelineLaneProps.ts @@ -117,6 +117,7 @@ export interface TimelineLanesProps extends TimelineLaneBaseProps { snapGuide: TimelineSnapTarget | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; diff --git a/packages/studio/src/player/components/timelineLayout.test.ts b/packages/studio/src/player/components/timelineLayout.test.ts index f88f04c7b8..cd0175d567 100644 --- a/packages/studio/src/player/components/timelineLayout.test.ts +++ b/packages/studio/src/player/components/timelineLayout.test.ts @@ -13,19 +13,17 @@ import { getTimelineRowTop, getTimelineScrubTime, getTimelineRowFromY, + getTimelineRowOffsets, getTimelineCanvasHeight, createTimelineRowGeometry, getTimelineRowGeometry, + trackHeights, resolveTimelineAssetDrop, getTimelineBeatEntries, } from "./timelineLayout"; import { generateTicks, getTimelineMajorTickInterval } from "./timelineRulerGeometry"; import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; -function baseRows(count: number): number[] { - return Array.from({ length: count }, () => TRACK_H); -} - describe("horizontal timeline window", () => { it("adds the shared quarter-viewport overscan on each side and clamps to duration", () => { expect(getTimelineRenderTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20)).toEqual( @@ -62,8 +60,60 @@ describe("horizontal timeline window", () => { }); }); -/** Row geometry remains immutable and reusable for each height array. */ +/** N collapsed rows, the shape every caller passes when nothing is expanded. */ +const baseRows = (count: number) => Array.from({ length: count }, () => TRACK_H); + describe("variable timeline row geometry", () => { + const tracks = [ + [{ clipId: "a", laneCount: 0 }], + [{ clipId: "b", laneCount: 2 }], + [{ clipId: "c", laneCount: 1 }], + ]; + + it("resolves every row to the base height when no clip is expanded", () => { + expect(trackHeights(tracks)).toEqual([TRACK_H, TRACK_H, TRACK_H]); + expect(trackHeights([[], [], []])).toEqual([TRACK_H, TRACK_H, TRACK_H]); + }); + + it("adds one lane height per lane on an expanded clip", () => { + expect(trackHeights(tracks, new Set(["b"]))).toEqual([TRACK_H, TRACK_H + 2 * LANE_H, TRACK_H]); + }); + + it("derives row tops from cumulative offsets", () => { + const heights = trackHeights(tracks, new Set(["b"])); + expect(getTimelineRowOffsets(heights)).toEqual([ + 0, + TRACK_H, + 2 * TRACK_H + 2 * LANE_H, + 3 * TRACK_H + 2 * LANE_H, + ]); + expect(getTimelineRowTop(2, heights)).toBe(RULER_H + TRACKS_TOP_PAD + 2 * TRACK_H + 2 * LANE_H); + }); + + it("maps y inside an expanded lane region back to the expanded track", () => { + const heights = trackHeights(tracks, new Set(["b"])); + const yInSecondExpandedLane = getTimelineRowTop(1, heights) + TRACK_H + LANE_H * 1.5; + const row = getTimelineRowFromY(yInSecondExpandedLane, heights); + expect(Math.floor(row)).toBe(1); + expect(row).toBeGreaterThan(1.5); + expect(row).toBeLessThan(2); + }); + + it("sums resolved row heights into the canvas height", () => { + const heights = trackHeights(tracks, new Set(["b"])); + expect(getTimelineCanvasHeight(heights)).toBe( + RULER_H + TRACKS_TOP_PAD + 3 * TRACK_H + 2 * LANE_H + TRACKS_BOTTOM_PAD, + ); + }); + + it("reuses one immutable geometry snapshot for one height array", () => { + const heights = trackHeights(tracks, new Set(["b"])); + const first = getTimelineRowGeometry(heights); + expect(getTimelineRowGeometry(heights)).toBe(first); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.rowOffsets)).toBe(true); + }); + it("looks up row boundaries through the precomputed geometry", () => { const geometry = createTimelineRowGeometry([4, 8, 12], [48, 104, 76]); expect(getTimelineRowGeometry(geometry.rowHeights)).toBe(geometry); diff --git a/packages/studio/src/player/components/timelineLayout.ts b/packages/studio/src/player/components/timelineLayout.ts index 3e8c689fec..760e583c94 100644 --- a/packages/studio/src/player/components/timelineLayout.ts +++ b/packages/studio/src/player/components/timelineLayout.ts @@ -1,3 +1,4 @@ +import { AUTOMATION_LANE_H } from "./automationLaneHeight"; import type { ZoomMode } from "../store/playerStore"; import type { TimelineTimeRange } from "../lib/timelineClipIndex"; @@ -82,6 +83,38 @@ export const TRACKS_BOTTOM_PAD = Math.round(TRACK_H * 1.5); */ export const TRACKS_LEFT_PAD = 48; +export interface TimelineTrackHeightClip { + clipId: string; + laneCount: number; + /** Audio automation lanes shown when expanded, reserved at their own height. */ + automationLaneCount?: number; +} + +type TimelineTrackHeightInput = readonly (readonly TimelineTrackHeightClip[])[]; + +/** + * Resolve each track's full height. Without expansion state every row is the + * legacy TRACK_H; if multiple clips in one track expand, the tallest one owns + * the shared row height. + */ +export function trackHeights( + tracks: TimelineTrackHeightInput, + expandedClipIds?: ReadonlySet, +): number[] { + return tracks.map((clips) => { + let laneCount = 0; + let automationLanes = 0; + for (const clip of clips) { + if (!expandedClipIds?.has(clip.clipId)) continue; + laneCount = Math.max(laneCount, clip.laneCount); + automationLanes = Math.max(automationLanes, clip.automationLaneCount ?? 0); + } + return ( + TRACK_H + Math.max(0, Math.trunc(laneCount)) * LANE_H + automationLanes * AUTOMATION_LANE_H + ); + }); +} + function validRowHeight(height: number | undefined): number { if (height === undefined || !Number.isFinite(height) || height <= 0) return TRACK_H; return height; @@ -188,6 +221,11 @@ export function getTimelineRowGeometry(rowHeights: readonly number[]): TimelineR return geometry; } +/** Cumulative top offsets, including the final bottom boundary. */ +export function getTimelineRowOffsets(rowHeights: readonly number[]): number[] { + return [...getTimelineRowGeometry(rowHeights).rowOffsets]; +} + export function getTimelineRowHeight( row: number, rowHeights: readonly number[] = EMPTY_ROW_HEIGHTS, diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx b/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx new file mode 100644 index 0000000000..fdf7ff5f08 --- /dev/null +++ b/packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePlayerStore } from "../store/playerStore"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; + +const studioShell = vi.hoisted(() => ({ projectId: "project-a" })); +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContextOptional: () => studioShell, +})); + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + document.body.innerHTML = ""; + studioShell.projectId = "project-a"; + usePlayerStore.getState().reset(); +}); + +const animations = new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties: { x: 100 }, + propertyGroup: "position", + }, + ], + ], +]); + +function AutoExpandHarness({ value }: { value: Map }) { + useAutoExpandKeyframedClips(value); + return null; +} + +describe("useAutoExpandKeyframedClips", () => { + it("preserves manual collapse within a project and expands again in a different project", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const render = (projectId: string, value = new Map(animations)) => { + studioShell.projectId = projectId; + act(() => root.render()); + }; + + const projectAAnimations = new Map(animations); + render("project-a", projectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + act(() => usePlayerStore.getState().toggleClipExpanded("clip-1")); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + const refreshedProjectAAnimations = new Map(animations); + render("project-a", refreshedProjectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + render("project-b", refreshedProjectAAnimations); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + + render("project-b", new Map()); + render("project-b"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts b/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts new file mode 100644 index 0000000000..d53e0a9a11 --- /dev/null +++ b/packages/studio/src/player/components/useAutoExpandKeyframedClips.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../store/playerStore"; +import { useStudioShellContextOptional } from "../../contexts/StudioContext"; +import { animationContributesLane } from "./TimelinePropertyLanes"; + +/** + * Keyframed clips start expanded (AE/Figma default). Auto-expands each clip the + * first time it contributes a lane — real keyframes OR a synthesizable flat tween + * — tracked per-clip so a later user collapse sticks and never bounces back open + * (and clips added later still auto-expand). + */ +/** + * Prunes clips that left the source, then returns the ones that newly contribute + * a lane. The prune matters because the set is otherwise append-only: a clip + * deleted and reinserted under the same id (undo, paste) would be remembered as + * already-expanded and never auto-expand again. + */ +function freshLaneClips(gsapAnimations: Map, clips: Set) { + for (const key of clips) { + if (!gsapAnimations.has(key)) clips.delete(key); + } + const fresh: string[] = []; + for (const [key, animations] of gsapAnimations) { + if (clips.has(key)) continue; + if (animations.some(animationContributesLane)) fresh.push(key); + } + return fresh; +} + +export function useAutoExpandKeyframedClips(gsapAnimations: Map): void { + const expandClips = usePlayerStore((s) => s.expandClips); + const projectId = useStudioShellContextOptional()?.projectId ?? null; + const seen = useRef({ projectId, source: gsapAnimations, clips: new Set() }); + useEffect(() => { + if (seen.current.projectId !== projectId) { + const sourceChanged = seen.current.source !== gsapAnimations; + seen.current = { projectId, source: gsapAnimations, clips: new Set() }; + if (!sourceChanged) return; + } else { + seen.current.source = gsapAnimations; + } + const fresh = freshLaneClips(gsapAnimations, seen.current.clips); + if (fresh.length === 0) return; + for (const key of fresh) seen.current.clips.add(key); + expandClips(fresh); + }, [gsapAnimations, expandClips, projectId]); +} diff --git a/packages/studio/src/player/components/useTimelineClipDisclosure.ts b/packages/studio/src/player/components/useTimelineClipDisclosure.ts new file mode 100644 index 0000000000..fe856be17e --- /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/components/useTimelineLogicalFocus.ts b/packages/studio/src/player/components/useTimelineLogicalFocus.ts index d5190e89de..03c8374ed6 100644 --- a/packages/studio/src/player/components/useTimelineLogicalFocus.ts +++ b/packages/studio/src/player/components/useTimelineLogicalFocus.ts @@ -1,4 +1,5 @@ import type { RefObject } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../store/playerStore"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; @@ -17,6 +18,7 @@ interface TimelineLogicalFocusInput { selectedElementIds: ReadonlySet; groups: readonly TimelineTrackGroupInfo[]; trackGroupOf: ReadonlyMap; + gsapAnimations: ReadonlyMap; elements: readonly TimelineElement[]; pixelsPerSecond: number; contentOrigin: number; @@ -32,6 +34,7 @@ interface TimelineLogicalFocusInput { } export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { + const expandedClipIds = usePlayerStore((state) => state.expandedClipIds); const collapsedGroupIds = usePlayerStore((state) => state.collapsedGroupIds); const expandedLaneOwnerIds = usePlayerStore((state) => state.expandedLaneOwnerIds); const projectId = usePlayerStore((state) => state.timelineProjectId); @@ -41,10 +44,12 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) { laneCounts: input.laneCounts, selectedElementId: input.selectedElementId, selectedElementIds: input.selectedElementIds, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups: input.groups, trackGroupOf: input.trackGroupOf, + gsapAnimations: input.gsapAnimations, }); const focus = useTimelineFocusCoordinator({ scrollRef: input.scrollRef, diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx index 86071476d5..1ba44d32e5 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx +++ b/packages/studio/src/player/components/useTimelineLogicalRows.test.tsx @@ -10,9 +10,7 @@ import { useTimelineLogicalRows } from "./useTimelineLogicalRows"; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); -type TrackInput = readonly (readonly [number, readonly TimelineElement[]])[]; - -const tracks: TrackInput = Array.from( +const tracks = Array.from( { length: 1_000 }, (_, track) => [ @@ -20,31 +18,30 @@ const tracks: TrackInput = Array.from( [{ id: `clip-${track}`, tag: "div", track, start: track, duration: 1 }], ] as const satisfies readonly [number, readonly TimelineElement[]], ); +const displayTrackOrder = tracks.map(([track]) => track); const laneCounts = new Map(); const selectedElementIds = new Set(); +const expandedClipIds = new Set(); const collapsedGroupIds = new Set(); const expandedLaneOwnerIds = new Set(); const groups: never[] = []; const trackGroupOf = new Map(); +const gsapAnimations = new Map(); -function Harness({ - snapshots, - inputTracks = tracks, -}: { - snapshots: Array; - inputTracks?: TrackInput; -}) { - usePlayerStore((state) => state.currentTime); +function Harness({ snapshots }: { snapshots: Array }) { + usePlayerStore((state) => state.requestedSeekTime); const logicalRows = useTimelineLogicalRows({ - tracks: inputTracks, - displayTrackOrder: inputTracks.map(([track]) => track), + tracks, + displayTrackOrder, laneCounts, selectedElementId: null, selectedElementIds, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, }); snapshots.push(logicalRows); return null; @@ -65,36 +62,4 @@ describe("useTimelineLogicalRows", () => { expect(snapshots.at(-1)).toBe(first); act(() => root.unmount()); }); - - it("keeps a nested element in one row as the playhead crosses it", () => { - const nestedTracks = [ - [ - 0, - [ - { - id: "nested-div", - tag: "div", - track: 0, - start: 1, - duration: 2, - parentCompositionId: "scene", - }, - ], - ], - ] as const satisfies TrackInput; - const host = document.createElement("div"); - const root = createRoot(host); - const snapshots: Array = []; - act(() => root.render()); - const before = snapshots.at(-1); - - act(() => usePlayerStore.setState({ currentTime: 2.5 })); - - const after = snapshots.at(-1); - expect(before).toHaveLength(1); - expect(after).toHaveLength(1); - expect(after?.[0]?.items).toHaveLength(1); - expect(after?.map((row) => row.id)).toEqual(before?.map((row) => row.id)); - act(() => root.unmount()); - }); }); diff --git a/packages/studio/src/player/components/useTimelineLogicalRows.ts b/packages/studio/src/player/components/useTimelineLogicalRows.ts index 1465824554..a921936981 100644 --- a/packages/studio/src/player/components/useTimelineLogicalRows.ts +++ b/packages/studio/src/player/components/useTimelineLogicalRows.ts @@ -13,10 +13,12 @@ export function useTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, }: TimelineLogicalRowsInput) { return useMemo( () => @@ -26,17 +28,21 @@ export function useTimelineLogicalRows({ laneCounts, selectedElementId, selectedElementIds, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, }), [ displayTrackOrder, + expandedClipIds, collapsedGroupIds, expandedLaneOwnerIds, groups, trackGroupOf, + gsapAnimations, laneCounts, selectedElementId, selectedElementIds, diff --git a/packages/studio/src/player/components/useTimelineProviderState.tsx b/packages/studio/src/player/components/useTimelineProviderState.tsx index 5fb8d25743..03645bcbb7 100644 --- a/packages/studio/src/player/components/useTimelineProviderState.tsx +++ b/packages/studio/src/player/components/useTimelineProviderState.tsx @@ -19,6 +19,7 @@ import { useTimelineOverlaysState } from "./useTimelineOverlaysState"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; @@ -135,6 +136,7 @@ export function useTimelineProviderState({ [duration, timelineElements], ); const keyframeCache = usePlayerStore((s) => s.keyframeCache); + useAutoExpandKeyframedClips(gsapAnimations); const { tracks, trackStyles, @@ -145,7 +147,12 @@ export function useTimelineProviderState({ rowGeometryRef, groups, trackGroupOf, - } = useTimelineTrackLayout(timelineElements, gsapAnimations); + } = useTimelineTrackLayout( + timelineElements, + gsapAnimations, + selectedElementId, + selectedElementIds, + ); const timelineElementsRef = useRef(timelineElements); timelineElementsRef.current = timelineElements; // oxlint-disable-line react/refs -- event handlers read the latest elements const ppsRef = useRef(100); @@ -254,6 +261,7 @@ export function useTimelineProviderState({ selectedElementIds, groups, trackGroupOf, + gsapAnimations, elements: timelineElements, pixelsPerSecond: pps, contentOrigin, diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts index 2d9675ac74..e23b08e22e 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.test.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.test.ts @@ -2,10 +2,12 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { afterEach, describe, expect, it } from "vitest"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; -import { TRACK_H } from "./timelineLayout"; +import { LANE_H, TRACK_H } from "./timelineLayout"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; +import { getTimelinePropertyLanes } from "./TimelinePropertyLanes"; import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineTrackLayout"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -14,6 +16,28 @@ afterEach(() => { usePlayerStore.getState().reset(); }); +function renderTrackLayout( + elements: TimelineElement[], + animations: Map, +): { + layout: ReturnType; + unmount: () => void; +} { + usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) }); + + let layout: ReturnType | undefined; + function Probe() { + layout = useTimelineTrackLayout(elements, animations, null, new Set()); + return null; + } + + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + if (!layout) throw new Error("Timeline track layout did not render"); + + return { layout, unmount: () => act(() => root.unmount()) }; +} + describe("collapsed audio groups", () => { const member = (id: string, track: number): TimelineElement => ({ id, @@ -34,7 +58,7 @@ describe("collapsed audio groups", () => { const elements = [member("voice-1", 0), member("voice-2", 1)]; let layout: ReturnType | undefined; function Probe() { - layout = useTimelineTrackLayout(elements, new Map()); + layout = useTimelineTrackLayout(elements, new Map(), null, new Set()); return null; } const root = createRoot(document.createElement("div")); @@ -66,7 +90,7 @@ describe("collapsed audio groups", () => { ]; let layout: ReturnType | undefined; function Probe() { - layout = useTimelineTrackLayout(elements, new Map()); + layout = useTimelineTrackLayout(elements, new Map(), null, new Set()); return null; } const root = createRoot(document.createElement("div")); @@ -133,6 +157,61 @@ describe("collapsed audio groups", () => { }); }); +describe("useTimelineTrackLayout", () => { + it("counts a flat tween lane and reserves its expanded row height", () => { + const elements: TimelineElement[] = [ + { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, + ]; + const animations = new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties: { x: 420 }, + propertyGroup: "position", + }, + ], + ], + ]); + const { layout, unmount } = renderTrackLayout(elements, animations); + + expect(layout.laneCounts.get("clip-1")).toBe(1); + expect(layout.rowHeights).toEqual([TRACK_H + LANE_H]); + expect(layout.rowGeometry.rowKeys).toEqual([0]); + expect(layout.rowGeometry.canvasHeight).toBeGreaterThan(TRACK_H + LANE_H); + unmount(); + }); + + // The row height reserved here and the lanes actually rendered are two + // readings of the same question. They used to be two inline copies of the + // group-set rule, and a mixed-group tween made them disagree: zero reserved + // rows under two rendered lanes. + it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => { + const elements: TimelineElement[] = [ + { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, + ]; + const mixed: GsapAnimation = { + id: "entrance", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 1, + properties: { x: 420, opacity: 1 }, + }; + const animations = new Map([["clip-1", [mixed]]]); + const { layout, unmount } = renderTrackLayout(elements, animations); + + expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2); + expect(layout.laneCounts.get("clip-1")).toBe(2); + expect(layout.rowHeights).toEqual([TRACK_H + 2 * LANE_H]); + unmount(); + }); +}); const audioClip = (id: string, over: Partial = {}): TimelineElement => ({ id, key: id, @@ -143,6 +222,61 @@ const audioClip = (id: string, over: Partial = {}): TimelineEle ...over, }); +/** + * Clips sharing a row share a lane row per property, so the height they reserve + * is the track's grouped count — and the row is open when ANY of them is + * expanded, or clicking a sibling collapsed it. + */ +describe("a track several clips share", () => { + const peaking = (gain: number) => + JSON.stringify({ + version: 1, + nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }], + }); + const lanes = (...targets: string[]) => + JSON.stringify({ + version: 1, + lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })), + }); + const narration1 = audioClip("narration-1", { + fxChain: peaking(-3), + automation: lanes("fx.n1.gain"), + }); + const narration2 = audioClip("narration-2", { + start: 10, + fxChain: peaking(-6), + automation: lanes("fx.n1.gain", "volume"), + }); + + /** Reserved height for the row, with only narration-1 ever expanded. */ + function rowHeight(selectedElementId: string | null): number { + usePlayerStore.setState({ expandedClipIds: new Set(["narration-1"]) }); + let height = 0; + function Probe() { + height = + useTimelineTrackLayout([narration1, narration2], new Map(), selectedElementId, new Set()) + .rowHeights[0] ?? 0; + return null; + } + const root = createRoot(document.createElement("div")); + act(() => root.render(React.createElement(Probe))); + act(() => root.unmount()); + return height; + } + + it("reserves one row per property, not per clip's lane", () => { + // Two properties across the two clips — a shared 1 kHz peaking gain and a + // volume envelope on one of them — so two rows, not three. + expect(rowHeight("narration-1")).toBe(TRACK_H + 2 * AUTOMATION_LANE_H); + }); + + it("stays open at the same height when the selection moves to a sibling", () => { + // Expansion is stored per clip but reads as the row's: asking only about the + // active clip collapsed the row the moment another was clicked. + expect(rowHeight("narration-2")).toBe(rowHeight("narration-1")); + }); +}); + describe("resolveTrackKeyframeClip", () => { const none = new Map(); @@ -183,34 +317,3 @@ describe("resolveTrackKeyframeClip", () => { expect(picked).toBe(b); }); }); - -describe("audio lane row height", () => { - it("stays open when selection moves to a sibling on the same track", () => { - const automation = JSON.stringify({ - version: 1, - lanes: [{ target: "volume", points: [{ t: 0, v: 1 }] }], - }); - const elements = [ - audioClip("narration-1", { track: 0, automation }), - audioClip("narration-2", { track: 0, automation }), - ]; - usePlayerStore.setState({ expandedLaneOwnerIds: new Set(["narration-1", "narration-2"]) }); - let layout: ReturnType | undefined; - function Probe() { - layout = useTimelineTrackLayout(elements, new Map()); - return null; - } - const root = createRoot(document.createElement("div")); - act(() => root.render(React.createElement(Probe))); - const firstHeight = layout!.rowHeights[0]; - - act(() => { - usePlayerStore.setState({ selectedElementId: "narration-2" }); - root.render(React.createElement(Probe)); - }); - - expect(firstHeight).toBe(TRACK_H + AUTOMATION_LANE_H); - expect(layout!.rowHeights[0]).toBe(firstHeight); - act(() => root.unmount()); - }); -}); diff --git a/packages/studio/src/player/components/useTimelineTrackLayout.ts b/packages/studio/src/player/components/useTimelineTrackLayout.ts index 6c7f2f017c..0f7b5c6585 100644 --- a/packages/studio/src/player/components/useTimelineTrackLayout.ts +++ b/packages/studio/src/player/components/useTimelineTrackLayout.ts @@ -6,7 +6,13 @@ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneDa import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { DraggedClipState } from "./timelineClipDragTypes"; import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; -import { TRACK_H, createTimelineRowGeometry, type TimelineRowGeometry } from "./timelineLayout"; +import { + TRACK_H, + createTimelineRowGeometry, + type TimelineRowGeometry, + trackHeights, + type TimelineTrackHeightClip, +} from "./timelineLayout"; import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations"; import { groupAutomationElement } from "./groupAutomationElement"; import { AUTOMATION_LANE_H } from "./automationLaneHeight"; @@ -58,6 +64,21 @@ function trackAutomationLaneCount(elements: readonly TimelineElement[]): number return groupAutomationLanes(elements).length; } +/** + * Is this row disclosed? Expansion is stored per clip, but it reads as a property + * of the ROW: the active clip changes with the selection, so asking only about it + * collapsed the row the moment you clicked a sibling. Any expanded clip on the + * track holds the row open — and the caret expands and collapses all of them + * together (see TimelineLanes), so the two can only disagree on state predating + * this rule or written by the keyframe auto-expand. + */ +export function isTrackRowExpanded( + elements: readonly TimelineElement[], + expandedClipIds: ReadonlySet, +): boolean { + return elements.some((element) => expandedClipIds.has(element.key ?? element.id)); +} + /** * The single keyframed element whose property lanes a track shows when expanded. * A track can hold several elements (same z-index is common), but keyframes are @@ -93,17 +114,6 @@ export function resolveTrackKeyframeClip( ); } -/** One row owns one disclosure state, even when several clips share its track. */ -export function isTimelineRowExpanded( - elements: readonly TimelineElement[], - expandedLaneOwnerIds: ReadonlySet, -): boolean { - return ( - groupAutomationLanes(elements).length > 0 && - elements.some((element) => expandedLaneOwnerIds.has(element.key ?? element.id)) - ); -} - /** Lanes per clip: the count of distinct property groups whose tween contributes * a lane (real keyframes or a synthesizable flat tween). */ function computeLaneCounts( @@ -126,7 +136,11 @@ function computeLaneCounts( return laneCounts; } -/** Group anchor rows have no elements of their own, so size their own automation rows explicitly. */ +/** Group anchor rows have no elements of their own (`groupTimelineTracks` + * pushes them as `[anchorKey, []]`), so `trackHeights` — which only ever + * looks at a row's clips — always gives them TRACK_H. Override those + * specific rows post-hoc: TRACK_H while collapsed, plus the group's own + * automation rows once its `∿` is open. */ function applyGroupStripHeights( tracks: readonly (readonly [number, readonly TimelineElement[]])[], rowHeights: number[], @@ -147,18 +161,44 @@ function applyGroupStripHeights( function useTimelineRowHeights( tracks: [number, TimelineElement[]][], gsapAnimations: Map, + selectedElementId: string | null, + selectedElementIds: ReadonlySet, groups: readonly TimelineTrackGroupInfo[], ) { + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); const expandedLaneOwnerIds = usePlayerStore((s) => s.expandedLaneOwnerIds); const { laneCounts, rowGeometry } = useMemo(() => { const laneCounts = computeLaneCounts(tracks, gsapAnimations); + // Keyframe lanes follow only the active clip, so a track with several + // keyframed elements never reserves empty lanes for the ones not shown. + // Automation lanes follow the whole row: they are shared per property. + const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => { + const active = resolveTrackKeyframeClip( + elements, + laneCounts, + selectedElementId, + selectedElementIds, + ); + if (!active) return []; + const clipId = active.key ?? active.id; + // `trackHeights` gates the reserved lanes on this id being expanded, and the + // row is expanded when ANY of its clips is — so hand it whichever clip holds + // the row open, while the lane counts stay the active clip's (keyframes) and + // the track's (automation, shared across the row). + const holdingOpen = elements.find((element) => + expandedClipIds.has(element.key ?? element.id), + ); + return [ + { + clipId: holdingOpen ? (holdingOpen.key ?? holdingOpen.id) : clipId, + laneCount: laneCounts.get(clipId) ?? 0, + automationLaneCount: trackAutomationLaneCount(elements), + }, + ]; + }); const rowHeights = applyGroupStripHeights( tracks, - tracks.map(([, elements]) => { - return isTimelineRowExpanded(elements, expandedLaneOwnerIds) - ? TRACK_H + trackAutomationLaneCount(elements) * AUTOMATION_LANE_H - : TRACK_H; - }), + trackHeights(heightTracks, expandedClipIds), groups, expandedLaneOwnerIds, ); @@ -169,7 +209,15 @@ function useTimelineRowHeights( rowHeights, ), }; - }, [expandedLaneOwnerIds, gsapAnimations, groups, tracks]); + }, [ + expandedClipIds, + expandedLaneOwnerIds, + gsapAnimations, + groups, + tracks, + selectedElementId, + selectedElementIds, + ]); const rowGeometryRef = useRef(rowGeometry); rowGeometryRef.current = rowGeometry; return { @@ -183,6 +231,8 @@ function useTimelineRowHeights( export function useTimelineTrackLayout( expandedElements: TimelineElement[], gsapAnimations: Map, + selectedElementId: string | null, + selectedElementIds: ReadonlySet, ) { const { tracks, trackStyles, trackOrder, groups, trackGroupOf } = useTimelineTrackDerivations(expandedElements); @@ -191,6 +241,8 @@ export function useTimelineTrackLayout( const { laneCounts, rowGeometry, rowGeometryRef, rowHeights } = useTimelineRowHeights( tracks, gsapAnimations, + selectedElementId, + selectedElementIds, groups, ); diff --git a/packages/studio/src/player/lib/timelinePerformanceFixture.ts b/packages/studio/src/player/lib/timelinePerformanceFixture.ts index 8f5f6f7423..e3fe74fb3d 100644 --- a/packages/studio/src/player/lib/timelinePerformanceFixture.ts +++ b/packages/studio/src/player/lib/timelinePerformanceFixture.ts @@ -4,7 +4,7 @@ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore"; export type TimelinePerformanceFixtureProfile = | "dense-short" | "long-overlap" - | "keyframe-heavy" + | "keyframe-heavy-expanded" | "composition-heavy" | "remote-unsupported"; @@ -25,6 +25,7 @@ export interface TimelinePerformanceFixture { elements: TimelineElement[]; keyframeCache: Map; gsapAnimations: Map; + expandedClipIds: Set; } const TRACK_COUNT = 1_000; @@ -34,7 +35,7 @@ const PROFILE_GEOMETRY: Readonly< > = Object.freeze({ "dense-short": { duration: 120, clipDuration: 1.5 }, "long-overlap": { duration: 7_200, clipDuration: 120 }, - "keyframe-heavy": { duration: 600, clipDuration: 8 }, + "keyframe-heavy-expanded": { duration: 600, clipDuration: 8 }, "composition-heavy": { duration: 900, clipDuration: 12 }, "remote-unsupported": { duration: 900, clipDuration: 12 }, }); @@ -115,6 +116,7 @@ export function createTimelinePerformanceFixture( const elements: TimelineElement[] = []; const keyframeCache = new Map(); const gsapAnimations = new Map(); + const expandedClipIds = new Set(); for (let index = 0; index < spec.elementCount; index += 1) { const id = `perf-${spec.profile}-${spec.elementCount}-${index}`; @@ -141,9 +143,10 @@ export function createTimelinePerformanceFixture( ? `https://media.invalid/perf-${index % 32}.mp4` : `assets/perf-${index % 32}.unsupported`; } - if (spec.profile === "keyframe-heavy") { + if (spec.profile === "keyframe-heavy-expanded") { keyframeCache.set(id, keyframeData()); gsapAnimations.set(id, [fixtureAnimation(id, start, geometry.clipDuration)]); + expandedClipIds.add(id); } elements.push(element); } @@ -154,10 +157,11 @@ export function createTimelinePerformanceFixture( duration: geometry.duration, trackCount: TRACK_COUNT, keyframedElementCount: keyframeCache.size, - expandedElementCount: 0, + expandedElementCount: expandedClipIds.size, }), elements, keyframeCache, gsapAnimations, + expandedClipIds, }; } diff --git a/packages/studio/src/player/store/keyframeSlice.ts b/packages/studio/src/player/store/keyframeSlice.ts index f91471d10f..049f415615 100644 --- a/packages/studio/src/player/store/keyframeSlice.ts +++ b/packages/studio/src/player/store/keyframeSlice.ts @@ -88,6 +88,13 @@ export interface KeyframeSlice { toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; + /** Clips whose keyframe property lanes are expanded in the timeline. */ + expandedClipIds: Set; + toggleClipExpanded: (id: string) => void; + setClipExpanded: (id: string, expanded: boolean) => void; + /** Union-expand clips (keyframed clips are expanded by default on load). */ + expandClips: (ids: readonly string[]) => void; + /** * Groups whose member rows the caret has HIDDEN (structural, not lanes). * @@ -100,9 +107,9 @@ export interface KeyframeSlice { collapsedGroupIds: Set; toggleGroupExpanded: (id: string) => void; - /** Rows (clip ids or group id) whose automation-lane rows the `∿` button opened. */ + /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */ expandedLaneOwnerIds: Set; - toggleLaneOwnerExpanded: (ids: readonly string[]) => void; + toggleLaneOwnerExpanded: (id: string) => void; /** * Project/session/element-scoped request. Its nonce is monotonic across store @@ -149,6 +156,30 @@ export function createKeyframeSlice( }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + expandedClipIds: new Set(), + toggleClipExpanded: (id) => + set((state) => { + const next = new Set(state.expandedClipIds); + if (next.has(id)) next.delete(id); + else next.add(id); + return { expandedClipIds: next }; + }), + setClipExpanded: (id, expanded) => + set((state) => { + if (state.expandedClipIds.has(id) === expanded) return state; + const next = new Set(state.expandedClipIds); + if (expanded) next.add(id); + else next.delete(id); + return { expandedClipIds: next }; + }), + expandClips: (ids) => + set((state) => { + if (ids.every((id) => state.expandedClipIds.has(id))) return state; + const next = new Set(state.expandedClipIds); + for (const id of ids) next.add(id); + return { expandedClipIds: next }; + }), + collapsedGroupIds: new Set(), toggleGroupExpanded: (id) => set((state) => { @@ -159,15 +190,11 @@ export function createKeyframeSlice( }), expandedLaneOwnerIds: new Set(), - toggleLaneOwnerExpanded: (ids) => + toggleLaneOwnerExpanded: (id) => set((state) => { - if (ids.length === 0) return state; const next = new Set(state.expandedLaneOwnerIds); - const shouldExpand = ids.some((id) => !next.has(id)); - for (const id of ids) { - if (shouldExpand) next.add(id); - else next.delete(id); - } + if (next.has(id)) next.delete(id); + else next.add(id); return { expandedLaneOwnerIds: next }; }), diff --git a/packages/studio/src/player/store/playerStore.test.ts b/packages/studio/src/player/store/playerStore.test.ts index 1c86975965..0b9ffff79e 100644 --- a/packages/studio/src/player/store/playerStore.test.ts +++ b/packages/studio/src/player/store/playerStore.test.ts @@ -27,6 +27,7 @@ describe("usePlayerStore", () => { expect(state.loopEnabled).toBe(false); expect(state.zoomMode).toBe("fit"); expect(state.manualZoomPercent).toBe(100); + expect(state.expandedClipIds).toEqual(new Set()); }); }); @@ -54,6 +55,30 @@ describe("usePlayerStore", () => { }); }); + describe("expandedClipIds", () => { + it("toggles clip membership", () => { + const store = usePlayerStore.getState(); + + store.toggleClipExpanded("clip-1"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + store.toggleClipExpanded("clip-1"); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + }); + + it("sets clip membership idempotently", () => { + const store = usePlayerStore.getState(); + + store.setClipExpanded("clip-1", true); + store.setClipExpanded("clip-1", true); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"])); + + store.setClipExpanded("clip-1", false); + store.setClipExpanded("clip-1", false); + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set()); + }); + }); + describe("focused ease requests", () => { it("stamps the current project session and only lets its nonce clear it", () => { const store = usePlayerStore.getState(); diff --git a/packages/studio/src/player/store/timelineResetState.ts b/packages/studio/src/player/store/timelineResetState.ts index c56e445b5d..8661277f00 100644 --- a/packages/studio/src/player/store/timelineResetState.ts +++ b/packages/studio/src/player/store/timelineResetState.ts @@ -25,6 +25,7 @@ export function createTimelineResetState() { // switch can match a same-keyed clip in the new project and redirect a // paste through `sel.elementKey === paste.elementKey` to a stale t0. automationSelection: null, + expandedClipIds: new Set(), // Per-composition: ids from comp A match nothing in B, silencing all of it. collapsedGroupIds: new Set(), expandedLaneOwnerIds: new Set(), From a977479a42eda6dead41125f387efc11cb63341d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 23:39:22 -0400 Subject: [PATCH 42/42] refactor(studio): remove dead expanded-child timeline code expandedParentStart is never set anywhere on main (the inline sub-composition child-row expansion that produced it was already removed in #4132/7797c5ead). Delete its now-dead consumers: the TimelinePane move/resize/delete/split rebase wrappers, the ZMirror call site that routed through them, the timelineDragLanding topology guards, isMainTrackElement's expanded-child exclusion, laneGapFloor's host-window-start math, and the paired expanded-clip-edit telemetry. Keeps every keyframe-row feature (diamonds, prev/next arrows, disclosure, auto-open, telemetry) untouched. --- .../src/components/nle/TimelinePane.test.ts | 63 ------ .../src/components/nle/TimelinePane.tsx | 180 +----------------- .../useCanvasZOrderTimelineMirror.test.tsx | 49 ----- .../nle/useCanvasZOrderTimelineMirror.ts | 19 +- .../player/components/timelineDragLanding.ts | 6 +- .../player/components/timelineGaps.test.ts | 36 +--- .../src/player/components/timelineGaps.ts | 13 +- .../player/components/timelineZones.test.ts | 5 - .../src/player/components/timelineZones.ts | 5 +- packages/studio/src/telemetry/events.test.ts | 6 + packages/studio/src/telemetry/events.ts | 5 + scripts/check-no-main-deletions.mjs | 12 +- 12 files changed, 34 insertions(+), 365 deletions(-) delete mode 100644 packages/studio/src/components/nle/TimelinePane.test.ts diff --git a/packages/studio/src/components/nle/TimelinePane.test.ts b/packages/studio/src/components/nle/TimelinePane.test.ts deleted file mode 100644 index 1e4c0c082f..0000000000 --- a/packages/studio/src/components/nle/TimelinePane.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { TimelineElement } from "../../player"; -import { - forwardRebasedTimelineMoveElements, - forwardRebasedTimelineResizeElements, -} from "./TimelinePane"; - -describe("TimelinePane move wrapper", () => { - it("rebases expanded edits and forwards track-insert as the third argument", async () => { - const onMoveElements = vi.fn().mockResolvedValue(undefined); - const element: TimelineElement = { - id: "expanded-a", - domId: "a", - tag: "div", - start: 12, - duration: 2, - track: 0, - expandedParentStart: 10, - }; - await forwardRebasedTimelineMoveElements( - [{ element, updates: { start: 14, track: 2 } }], - "clip-lane-move:7", - "track-insert", - onMoveElements, - Number.POSITIVE_INFINITY, - ); - expect(onMoveElements).toHaveBeenCalledWith( - [ - { - element: expect.objectContaining({ id: "a", start: 2 }), - updates: { start: 4, track: 2 }, - }, - ], - "clip-lane-move:7", - "track-insert", - // The per-gesture coalesce window rides along with the shared key. - Number.POSITIVE_INFINITY, - ); - }); - - it("forwards one rebased resize batch with the shared gesture key", async () => { - const onResizeElements = vi.fn().mockResolvedValue(undefined); - const element: TimelineElement = { - id: "expanded-a", - domId: "a", - tag: "div", - start: 12, - duration: 2, - track: 0, - expandedParentStart: 10, - }; - await forwardRebasedTimelineResizeElements( - [{ element, start: 13, duration: 3 }], - { coalesceKey: "clip-group-resize:a:b" }, - onResizeElements, - ); - expect(onResizeElements).toHaveBeenCalledTimes(1); - expect(onResizeElements).toHaveBeenCalledWith( - [{ element: expect.objectContaining({ id: "a", start: 2 }), start: 3, duration: 3 }], - { coalesceKey: "clip-group-resize:a:b" }, - ); - }); -}); diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 0008cbe4aa..f08c29615f 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -1,76 +1,10 @@ -import { useCallback, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Timeline } from "../../player"; import type { TimelineElement, TimelineTimeRange } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; import { AudioMeterStrip } from "./AudioMeterStrip"; import { useTimelineEditContext } from "../../contexts/TimelineEditContext"; import { useNLEContext } from "./NLEContext"; -import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; - -type TimelineMoveEdit = { - element: TimelineElement; - updates: Pick; -}; - -export function forwardRebasedTimelineMoveElements( - edits: TimelineMoveEdit[], - coalesceKey: string | undefined, - operation: TimelineMoveOperation | undefined, - onMoveElements: ( - edits: TimelineMoveEdit[], - coalesceKey?: string, - operation?: TimelineMoveOperation, - coalesceMs?: number, - ) => Promise | void, - coalesceMs?: number, -) { - return onMoveElements( - edits.map(({ element, updates }) => { - const basis = element.expandedParentStart; - if (basis === undefined) return { element, updates }; - return { - element: { ...element, id: element.domId ?? element.id, start: element.start - basis }, - updates: { ...updates, start: Math.max(0, updates.start - basis) }, - }; - }), - coalesceKey, - operation, - coalesceMs, - ); -} - -type TimelineResizeChange = { - element: TimelineElement; - start: number; - duration: number; - playbackStart?: number; -}; - -export function forwardRebasedTimelineResizeElements( - changes: TimelineResizeChange[], - options: { coalesceKey?: string } | undefined, - onResizeElements: ( - changes: TimelineResizeChange[], - options?: { coalesceKey?: string }, - ) => Promise | void, -) { - return onResizeElements( - changes.map((change) => { - const basis = change.element.expandedParentStart; - if (basis === undefined) return change; - return { - ...change, - element: { - ...change.element, - id: change.element.domId ?? change.element.id, - start: change.element.start - basis, - }, - start: Math.max(0, change.start - basis), - }; - }), - options, - ); -} export interface TimelinePaneProps { /** Slot rendered above the timeline tracks (toolbar with split, delete, zoom) */ @@ -113,7 +47,6 @@ export interface TimelinePaneProps { canPasteClip?: () => boolean; } -// fallow-ignore-next-line complexity export function TimelinePane({ timelineToolbar, timelineFooter, @@ -141,107 +74,10 @@ export function TimelinePane({ timelineSessionEpoch, } = useNLEContext(); - // Move/resize/split come from the timeline edit context, not props — the - // wrappers below intercept expanded clips and must call the *real* handlers. - // (Delete is a direct prop; it stays that way.) + // Move/resize/split come from the timeline edit context, not props. const { onMoveElement, onMoveElements, onResizeElement, onResizeElements, onSplitElement } = useTimelineEditContext(); - // An expanded sub-comp child reaches the normal edit handlers in its own - // local coordinates: addressed by its real DOM id, with timeline time rebased - // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly - // as they do for top-level clips — no separate live-DOM path. - const toLocalElement = useCallback( - (element: TimelineElement, basis: number): TimelineElement => ({ - ...element, - id: element.domId ?? element.id, - start: element.start - basis, - }), - [], - ); - - const handleMoveElement = useCallback( - (element: TimelineElement, updates: Pick) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onMoveElement?.(element, updates); - onMoveElement?.(toLocalElement(element, basis), { - ...updates, - start: Math.max(0, updates.start - basis), - }); - }, - [onMoveElement, toLocalElement], - ); - - // Batched move (ripple / insert): rebase each expanded sub-comp child to its - // local coords, exactly as handleMoveElement does for a single clip. - const handleMoveElements = useCallback( - ( - edits: Array<{ element: TimelineElement; updates: Pick }>, - coalesceKey?: string, - operation?: TimelineMoveOperation, - coalesceMs?: number, - ) => { - if (!onMoveElements) return; - return forwardRebasedTimelineMoveElements( - edits, - coalesceKey, - operation, - onMoveElements, - coalesceMs, - ); - }, - [onMoveElements], - ); - - const handleResizeElement = useCallback( - ( - element: TimelineElement, - updates: Pick, - ) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onResizeElement?.(element, updates); - onResizeElement?.(toLocalElement(element, basis), { - ...updates, - start: Math.max(0, updates.start - basis), - }); - }, - [onResizeElement, toLocalElement], - ); - - const handleResizeElements = useCallback( - ( - changes: Array<{ - element: TimelineElement; - start: number; - duration: number; - playbackStart?: number; - }>, - options?: { coalesceKey?: string }, - ) => { - if (!onResizeElements) return; - return forwardRebasedTimelineResizeElements(changes, options, onResizeElements); - }, - [onResizeElements], - ); - - const handleDeleteElement = useCallback( - (element: TimelineElement) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onDeleteElement?.(element); - return onDeleteElement?.(toLocalElement(element, basis)); - }, - [onDeleteElement, toLocalElement], - ); - - const handleSplitElement = useCallback( - (element: TimelineElement, splitTime: number) => { - const basis = element.expandedParentStart; - if (basis === undefined) return onSplitElement?.(element, splitTime); - return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis)); - }, - [onSplitElement, toLocalElement], - ); - return (
{ expect(history.moveCoalesceKeys).toEqual([]); }); - it("maps the crossed neighbor to its timeline key and rebases expanded sub-comp children", async () => { - // t is an expanded sub-comp child (expandedParentStart 5, absolute start 5): - // the mirror must forward its persist in LOCAL time (start 0), the same - // rebase a timeline lane drag applies (forwardRebasedTimelineMoveElements). - setStoreElements([ - { - ...storeEl("a", 0, 25, 5), - sourceFile: "sub.html", - key: "sub.html#a", - expandedParentStart: 5, - }, - { - ...storeEl("b", 1, 5, 10), - sourceFile: "sub.html", - key: "sub.html#b", - expandedParentStart: 5, - }, - { - ...storeEl("t", 2, 5, 10), - sourceFile: "sub.html", - key: "sub.html#t", - expandedParentStart: 5, - }, - ]); - const edits: Array<{ element: TimelineElement; updates: { start: number; track: number } }> = - []; - const onMoveElements: TimelineEditCallbacks["onMoveElements"] = (batch) => { - edits.push(...batch); - }; - const { mirror } = mountMirrorOnlyHarness(onMoveElements); - - const mirrored = await act(async () => - mirror({ - selectionKey: "sub.html#t", - action: "bring-forward", - // The crossed sibling maps to sub.html#b via its DOM id + sourceFile — - // the same derivation reorder entries use (deriveTimelineStoreKey). - crossed: domTarget("b"), - sourceFile: "sub.html", - coalesceKey: "z-reorder:bring-forward:t", - }), - ); - expect(mirrored).toBe(true); - expect(edits).toHaveLength(1); - // Rebased to sub-comp local coords: absolute 5 − parent start 5 = 0. - expect(edits[0].element.start).toBe(0); - expect(edits[0].updates).toMatchObject({ start: 0, track: 0 }); - }); - it("maps a cross-file duplicate selector to the source-scoped crossed occurrence", async () => { setStoreElements([ { diff --git a/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts index d4edd34559..c066293226 100644 --- a/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts +++ b/packages/studio/src/components/nle/useCanvasZOrderTimelineMirror.ts @@ -13,7 +13,6 @@ import { commitZMirrorLaneMove } from "../../player/components/timelineClipDragC import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers"; import { buildStableSelector, getSelectorIndex } from "../editor/domEditingDom"; import { useStudioShellContextOptional } from "../../contexts/StudioContext"; -import { forwardRebasedTimelineMoveElements } from "./TimelinePane"; export interface MirrorZOrderInput { /** Timeline store key of the element the menu acted on (entry.key), if any. */ @@ -44,10 +43,9 @@ export interface MirrorZOrderInput { * renders and the resolver expects. No alternate row expansion is built here. * * The mirror persists through the SAME machinery as a timeline lane drag - * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements, with expanded - * children rebased to local coords via forwardRebasedTimelineMoveElements) — - * optimistic store update + rollback included, so the timeline reflects the - * lane change without a reload. The deps below deliberately OMIT + * (commitZMirrorLaneMove → persistMoveEdits → onMoveElements) — optimistic + * store update + rollback included, so the timeline reflects the lane change + * without a reload. The deps below deliberately OMIT * `readZIndex`/`onStackingPatches`: the lane→z stacking sync * (syncStackingForEdit) must not fire and recompute the z values the user just * set — commitZMirrorLaneMove never calls it, and without these deps it would @@ -160,16 +158,7 @@ function useMirrorLaneMoveCommit(): ( elements: els, trackOrder: displayTrackOrder(els), updateElement: (key, updates) => usePlayerStore.getState().updateElement(key, updates), - onMoveElements: onMoveElements - ? (edits, coalesceKey2, operation, coalesceMs) => - forwardRebasedTimelineMoveElements( - edits, - coalesceKey2, - operation, - onMoveElements, - coalesceMs, - ) - : undefined, + onMoveElements, // NO readZIndex / onStackingPatches: see the hook doc — the lane→z // stacking sync must not re-trigger and fight the just-set z values. }, diff --git a/packages/studio/src/player/components/timelineDragLanding.ts b/packages/studio/src/player/components/timelineDragLanding.ts index d463f5f4b9..880c003e7e 100644 --- a/packages/studio/src/player/components/timelineDragLanding.ts +++ b/packages/studio/src/player/components/timelineDragLanding.ts @@ -37,15 +37,11 @@ export function layoutAfterTrackInsert( } | null { const { elements, trackOrder } = deps; const editKey = keyOf(element); - // Expanded-child rows are synthetic host lanes, not source-file topology. - if (element.expandedParentStart != null) return null; const targetTrack = insertTrackValue(trackOrder, insertRow); // Foreign display rows and the opposite zone must not affect this topology. const writableZone = classifyZone(element); const writable = (src: TimelineElement): boolean => - sameSourceFile(src, element) && - classifyZone(src) === writableZone && - src.expandedParentStart == null; + sameSourceFile(src, element) && classifyZone(src) === writableZone; const topologyOrder = [...new Set(elements.filter(writable).map((e) => e.track))].sort( (a, b) => a - b, ); diff --git a/packages/studio/src/player/components/timelineGaps.test.ts b/packages/studio/src/player/components/timelineGaps.test.ts index 7dcf220d8f..4fd435414f 100644 --- a/packages/studio/src/player/components/timelineGaps.test.ts +++ b/packages/studio/src/player/components/timelineGaps.test.ts @@ -183,39 +183,9 @@ describe("trackHasGaps", () => { }); }); -describe("lane floor (expanded sub-comp children)", () => { - const child = (id: string, start: number, duration: number): TimelineElement => ({ - ...el(id, start, duration), - expandedParentStart: 16, - sourceFile: "scene.html", - }); - - it("laneGapFloor is 0 for ordinary lanes and the host window start for child lanes", () => { +describe("lane floor", () => { + it("laneGapFloor is always 0", () => { expect(laneGapFloor([el("a", 0, 2)])).toBe(0); - expect(laneGapFloor([child("c1", 16.5, 2), child("c2", 20, 2)])).toBe(16); - }); - - it("compaction lands the first child at the HOST window start, never absolute 0", () => { - const lane = [child("c1", 18, 2), child("c2", 22, 2)]; - expect(resolveAllTrackGaps(lane, undefined, laneGapFloor(lane))).toEqual([ - { key: "c1", newStart: 16 }, - { key: "c2", newStart: 18 }, - ]); - }); - - it("the leading gap starts at the floor for both close-one and the highlight intervals", () => { - const lane = [child("c1", 18, 2)]; - const floor = laneGapFloor(lane); - expect(resolveTrackGapAt(lane, 17, undefined, floor)).toEqual({ - gapStart: 16, - gapEnd: 18, - followingKeys: ["c1"], - }); - expect(resolveAllGapIntervals(lane, undefined, floor)).toEqual([{ start: 16, end: 18 }]); - }); - - it("a child lane contiguous from its host start has no gaps", () => { - const lane = [child("c1", 16, 2), child("c2", 18, 2)]; - expect(trackHasGaps(lane, undefined, laneGapFloor(lane))).toBe(false); + expect(laneGapFloor([])).toBe(0); }); }); diff --git a/packages/studio/src/player/components/timelineGaps.ts b/packages/studio/src/player/components/timelineGaps.ts index aef99c1671..6f51afd122 100644 --- a/packages/studio/src/player/components/timelineGaps.ts +++ b/packages/studio/src/player/components/timelineGaps.ts @@ -17,16 +17,9 @@ export const TRACK_GAP_EPSILON_S = 1e-3; const keyOf = (e: TimelineElement) => e.key ?? e.id; -/** - * The lane's time ORIGIN — the earliest start a clip on this lane may take. - * 0 for ordinary lanes; for a lane of expanded sub-comp children (post- - * collision-fix a lane is always single-origin) it is the children's host - * window start (`expandedParentStart`): display times are host-absolute, so - * compacting toward absolute 0 would drag a child BEFORE its host's window - * and persist a wrong (even negative) local time. - */ -export function laneGapFloor(elements: readonly TimelineElement[]): number { - return Math.max(0, ...elements.map((e) => e.expandedParentStart ?? 0)); +/** The lane's time ORIGIN — the earliest start a clip on this lane may take. */ +export function laneGapFloor(_elements: readonly TimelineElement[]): number { + return 0; } export const round3 = (v: number) => Math.round(v * 1000) / 1000; const endOf = (e: TimelineElement) => e.start + e.duration; diff --git a/packages/studio/src/player/components/timelineZones.test.ts b/packages/studio/src/player/components/timelineZones.test.ts index a4654088be..4b218f94a5 100644 --- a/packages/studio/src/player/components/timelineZones.test.ts +++ b/packages/studio/src/player/components/timelineZones.test.ts @@ -67,11 +67,6 @@ describe("isMainTrackElement", () => { it("is false for an audio clip even on track 0 (audio-only project has no main track)", () => { expect(isMainTrackElement(el("m", "audio", 0))).toBe(false); }); - - it("is false for an inline-expanded sub-composition child on track 0", () => { - const child: TimelineElement = { ...el("c", "video", 0), expandedParentStart: 4 }; - expect(isMainTrackElement(child)).toBe(false); - }); }); describe("normalizeToZones — CapCut-stable lanes follow the track-index (never z)", () => { diff --git a/packages/studio/src/player/components/timelineZones.ts b/packages/studio/src/player/components/timelineZones.ts index 01518f272b..9e8fc0d9e0 100644 --- a/packages/studio/src/player/components/timelineZones.ts +++ b/packages/studio/src/player/components/timelineZones.ts @@ -16,10 +16,9 @@ export function classifyZone(el: TimelineElement): TrackZone { } /** The "main track" is a convention, not a schema field: the first - * visual-zone display lane, matched only when it actually holds a visual - * clip and isn't an inline-expanded sub-composition child. */ + * visual-zone display lane, matched only when it actually holds a visual clip. */ export function isMainTrackElement(el: TimelineElement): boolean { - return el.track === 0 && classifyZone(el) === "visual" && el.expandedParentStart == null; + return el.track === 0 && classifyZone(el) === "visual"; } const keyOf = (el: TimelineElement) => el.key ?? el.id; diff --git a/packages/studio/src/telemetry/events.test.ts b/packages/studio/src/telemetry/events.test.ts index 099e5bef1d..fcb428ec45 100644 --- a/packages/studio/src/telemetry/events.test.ts +++ b/packages/studio/src/telemetry/events.test.ts @@ -12,6 +12,7 @@ const { trackPreviewFirstFrame, trackStudioRenderStart, trackStudioRazorSplit, + trackStudioKeyframeLaneExpand, trackStudioSegmentEaseEdit, trackStudioFeedback, trackStudioTimelinePerformance, @@ -105,6 +106,11 @@ describe("studio telemetry events", () => { expect(trackEvent).toHaveBeenCalledWith("studio_razor_split", { mode: "all", count: 3 }); }); + it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => { + trackStudioKeyframeLaneExpand({ expanded: true }); + expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true }); + }); + it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => { trackStudioSegmentEaseEdit({ action: "commit", ease: "power2.out" }); expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", { diff --git a/packages/studio/src/telemetry/events.ts b/packages/studio/src/telemetry/events.ts index f788a11d25..59994a46d8 100644 --- a/packages/studio/src/telemetry/events.ts +++ b/packages/studio/src/telemetry/events.ts @@ -84,6 +84,11 @@ export function trackStudioRazorSplit(props: { mode: "single" | "all"; count: nu }); } +// Adoption signal for the per-clip keyframe-lane caret toggle. +export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void { + trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded }); +} + // Adoption signal for opening and committing the per-segment ease editor. export function trackStudioSegmentEaseEdit(props: { action: "open" | "commit"; diff --git a/scripts/check-no-main-deletions.mjs b/scripts/check-no-main-deletions.mjs index ff6c2152c5..45c49dbf24 100644 --- a/scripts/check-no-main-deletions.mjs +++ b/scripts/check-no-main-deletions.mjs @@ -41,16 +41,8 @@ const STORYBOARD_VIEW_REASON = export const ALLOWED_DELETIONS = new Map([ [ - "packages/studio/src/player/components/useAutoExpandKeyframedClips.test.tsx", - "the inline expansion auto-expand feature is removed, so its dedicated tests are removed", - ], - [ - "packages/studio/src/player/components/useAutoExpandKeyframedClips.ts", - "the timeline no longer auto-expands keyframed clips into child rows", - ], - [ - "packages/studio/src/player/components/useTimelineClipDisclosure.ts", - "the timeline no longer exposes inline clip disclosure controls", + "packages/studio/src/components/nle/TimelinePane.test.ts", + "its only subject, the expandedParentStart rebase wrappers, is dead code now removed", ], [ "scripts/test-reachability-baseline.json",