@hyperframes/studio 0.7.107 → 0.7.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/assets/{hyperframes-player-BLrfwq5o.js → hyperframes-player-QqavTWRK.js} +1 -1
  2. package/dist/assets/{index-BA9yhzfR.js → index-BQjXA1pz.js} +1 -1
  3. package/dist/assets/{index-BSBk5srs.js → index-BhHHMprP.js} +1 -1
  4. package/dist/assets/index-DNkh9mbV.css +1 -0
  5. package/dist/assets/index-GqONLcOl.js +428 -0
  6. package/dist/index.d.ts +85 -2
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +17696 -12512
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +18 -1
  12. package/src/components/TimelineToolbar.test.tsx +46 -0
  13. package/src/components/TimelineToolbar.tsx +17 -0
  14. package/src/components/editor/PropertyPanel.test.tsx +5 -1
  15. package/src/components/editor/PropertyPanel.tsx +7 -23
  16. package/src/components/editor/PropertyPanelFlat.tsx +25 -0
  17. package/src/components/editor/audioFxSummary.test.ts +75 -0
  18. package/src/components/editor/audioFxSummary.ts +36 -0
  19. package/src/components/editor/audioFxTelemetry.test.ts +129 -0
  20. package/src/components/editor/audioFxTelemetry.ts +230 -0
  21. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
  22. package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
  23. package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
  24. package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
  25. package/src/components/editor/propertyPanelAutomation.ts +97 -0
  26. package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
  27. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  28. package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
  29. package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
  30. package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
  31. package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
  32. package/src/components/editor/propertyPanelFxControls.tsx +356 -0
  33. package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
  34. package/src/components/editor/propertyPanelFxFamily.ts +83 -0
  35. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
  36. package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
  37. package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
  38. package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
  39. package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
  40. package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
  41. package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
  42. package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
  43. package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
  44. package/src/components/editor/propertyPanelFxSection.tsx +550 -0
  45. package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
  46. package/src/components/editor/propertyPanelTypes.ts +5 -0
  47. package/src/components/editor/useFxAudition.ts +95 -0
  48. package/src/components/editor/useFxCarve.ts +0 -0
  49. package/src/components/editor/useFxChainObserved.ts +83 -0
  50. package/src/components/editor/useFxLevelling.ts +235 -0
  51. package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
  52. package/src/components/editor/useVolumeAutomation.ts +54 -0
  53. package/src/contexts/DomEditContext.tsx +11 -0
  54. package/src/hooks/useAppHotkeys.test.ts +198 -0
  55. package/src/hooks/useAppHotkeys.ts +30 -3
  56. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
  57. package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
  58. package/src/hooks/useDomEditAttributeCommits.ts +90 -10
  59. package/src/hooks/useDomEditCommits.test.tsx +38 -0
  60. package/src/hooks/useDomEditCommits.ts +2 -0
  61. package/src/hooks/useDomEditSession.ts +2 -0
  62. package/src/hooks/useDomEditTextCommits.ts +2 -0
  63. package/src/hooks/useElementLifecycleOps.ts +3 -0
  64. package/src/hooks/useLivePlayheadTime.ts +49 -0
  65. package/src/hooks/usePreviewPersistence.ts +7 -0
  66. package/src/hooks/useTimelineEditing.ts +3 -0
  67. package/src/player/components/AutomationSelectionMenu.tsx +76 -0
  68. package/src/player/components/AutomationValueInput.tsx +51 -0
  69. package/src/player/components/LayerDisclosureRow.tsx +5 -4
  70. package/src/player/components/Timeline.test.ts +84 -0
  71. package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
  72. package/src/player/components/TimelineAutomationLane.tsx +674 -0
  73. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
  74. package/src/player/components/TimelineLanes.test.tsx +24 -5
  75. package/src/player/components/TimelineLanes.tsx +142 -154
  76. package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
  77. package/src/player/components/TimelineTrackHeader.tsx +166 -6
  78. package/src/player/components/TimelineTrackRow.tsx +8 -1
  79. package/src/player/components/automationClipboard.test.ts +94 -0
  80. package/src/player/components/automationClipboard.ts +136 -0
  81. package/src/player/components/automationGestureKeys.test.ts +49 -0
  82. package/src/player/components/automationGestureKeys.ts +49 -0
  83. package/src/player/components/automationLaneData.test.ts +270 -0
  84. package/src/player/components/automationLaneData.ts +258 -0
  85. package/src/player/components/automationLaneDragMath.ts +194 -0
  86. package/src/player/components/automationLaneGeometry.test.ts +239 -0
  87. package/src/player/components/automationLaneGeometry.ts +287 -0
  88. package/src/player/components/automationLaneHeight.ts +15 -0
  89. package/src/player/components/automationLanePointer.ts +20 -0
  90. package/src/player/components/automationLaneSelection.test.ts +183 -0
  91. package/src/player/components/automationLaneSelection.ts +135 -0
  92. package/src/player/components/automationShapes.test.ts +82 -0
  93. package/src/player/components/automationShapes.ts +70 -0
  94. package/src/player/components/automationSimplify.test.ts +48 -0
  95. package/src/player/components/automationSimplify.ts +51 -0
  96. package/src/player/components/timelineClipGestureHandlers.ts +223 -0
  97. package/src/player/components/timelineLaneProps.ts +19 -0
  98. package/src/player/components/timelineLayout.ts +11 -5
  99. package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
  100. package/src/player/components/useAutomationEdgeStretch.ts +269 -0
  101. package/src/player/components/useAutomationLaneGestures.ts +537 -0
  102. package/src/player/components/useAutomationLanes.test.tsx +112 -0
  103. package/src/player/components/useAutomationLanes.ts +170 -0
  104. package/src/player/components/useAutomationRangeDrag.ts +114 -0
  105. package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
  106. package/src/player/components/useTimelineTrackLayout.ts +83 -7
  107. package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
  108. package/src/player/hooks/useTimelinePlayer.ts +13 -1
  109. package/src/player/lib/automationStoreSync.test.ts +83 -0
  110. package/src/player/lib/automationStoreSync.ts +56 -0
  111. package/src/player/lib/timelineDOM.test.ts +33 -0
  112. package/src/player/lib/timelineDOM.ts +12 -0
  113. package/src/player/store/automationSelectionSlice.test.ts +30 -0
  114. package/src/player/store/automationSelectionSlice.ts +54 -0
  115. package/src/player/store/playerStore.test.ts +15 -0
  116. package/src/player/store/playerStore.ts +54 -65
  117. package/src/player/store/timelineElement.ts +78 -0
  118. package/src/styles/studio.css +89 -0
  119. package/src/telemetry/agentRuntime.test.ts +45 -0
  120. package/src/telemetry/agentRuntime.ts +62 -0
  121. package/src/telemetry/system.ts +8 -0
  122. package/src/utils/studioFileHistory.test.ts +49 -0
  123. package/src/utils/studioFileHistory.ts +17 -1
  124. package/src/utils/studioTelemetry.ts +16 -10
  125. package/dist/assets/index-dF-CmLZu.js +0 -428
  126. package/dist/assets/index-zQ4JFwwB.css +0 -1
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Writes for the timeline's audio automation lanes.
3
+ *
4
+ * Kept out of TimelineLanes so that component does not grow another concern.
5
+ * Reading lives in `automationLaneData`, shared with the row layout, which needs
6
+ * the lane count to reserve height.
7
+ *
8
+ * Edits go to the *selected* element, because that is what the attribute commit
9
+ * path targets. An unselected clip still draws its envelopes — they are just
10
+ * read only, which is also what stops a stray drag from editing the wrong track.
11
+ */
12
+
13
+ import { useCallback, useMemo, useRef } from "react";
14
+ import {
15
+ HF_AUDIO_AUTOMATION_ATTR,
16
+ serializeAutomation,
17
+ type HfAutomation,
18
+ type HfAutomationLane,
19
+ } from "@hyperframes/core/audio-automation";
20
+ import type { HfAudioFxChain } from "@hyperframes/core/audio-fx";
21
+ import {
22
+ useDomEditActionsContextOptional,
23
+ useDomEditSelectionContextOptional,
24
+ } from "../../contexts/DomEditContext";
25
+ import { resolveTimelineIdForSelection } from "../../utils/studioHelpers";
26
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
27
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
28
+ import type { AutomationSelection } from "../store/automationSelectionSlice";
29
+ import { elementAutomation, elementFxChain } from "./automationLaneData";
30
+ import { createAutomationGestureKeys } from "./automationGestureKeys";
31
+
32
+ export interface AutomationLaneBinding {
33
+ automation: HfAutomation;
34
+ /** One entry per lane, in draw order — each gets its own row. */
35
+ lanes: HfAutomationLane[];
36
+ chain: HfAudioFxChain | null;
37
+ /** Continuous write while dragging; does not persist. */
38
+ onPreview(next: HfAutomation): void;
39
+ /** Gesture-end write; this is the one that persists and lands in undo. */
40
+ onCommit(next: HfAutomation): void;
41
+ /**
42
+ * Select this clip, which is what makes its lanes editable. A lane calls this
43
+ * instead of writing when it is read-only — pressing the lane is the only
44
+ * route in, since lanes sit below the clip bar where the timeline's own
45
+ * selection handler never sees them.
46
+ */
47
+ onSelect(): void;
48
+ readOnly: boolean;
49
+ /**
50
+ * The timeline clip `onCommit`/`onPreview` will ACTUALLY persist to, which is
51
+ * whatever the dom-edit layer has selected — not necessarily the element this
52
+ * binding was made for (see `onSelect` below). Null outside an edit session or
53
+ * when the dom-edit selection maps to no clip. Resolved exactly the way
54
+ * applyDomSelection resolves it, so in a settled selection it equals the bound
55
+ * element's key; it lags only in the window a non-gesture caller can hit.
56
+ */
57
+ commitTargetKey: string | null;
58
+ /** This element's active selection box, or null if none / it belongs to a
59
+ * different element. */
60
+ selection: AutomationSelection | null;
61
+ /** Live write while dragging a selection box on the given lane; does not
62
+ * persist — the selection is ephemeral store state, not part of the
63
+ * composition. */
64
+ onRangeSelect(target: string, t0: number, t1: number, v0: number, v1: number): void;
65
+ onRangeClear(): void;
66
+ }
67
+
68
+ export interface UseAutomationLanesResult {
69
+ bind(element: TimelineElement, isSelected: boolean): AutomationLaneBinding;
70
+ }
71
+
72
+ export function useAutomationLanes(): UseAutomationLanesResult {
73
+ // One per hook instance, held across renders: a gesture spans many commits and
74
+ // they all have to record under the same key for undo to take the whole drag.
75
+ const gestureKeys = useRef(createAutomationGestureKeys());
76
+ // Optional: the player also runs outside Studio, where there is no edit
77
+ // session. There the lanes render read-only, which is the right fallback.
78
+ const domEdit = useDomEditActionsContextOptional();
79
+ const domEditSelection = useDomEditSelectionContextOptional()?.domEditSelection ?? null;
80
+ const elements = usePlayerStore((s) => s.elements);
81
+ const automationSelection = usePlayerStore((s) => s.automationSelection);
82
+ const setAutomationSelection = usePlayerStore((s) => s.setAutomationSelection);
83
+ const clearAutomationSelection = usePlayerStore((s) => s.clearAutomationSelection);
84
+
85
+ // Read from the SAME render as the commit handlers below: both contexts update
86
+ // in one commit, so a handler and this key can never describe different
87
+ // moments. activeCompPath is not needed — resolveTimelineIdForSelection only
88
+ // uses it as a fallback for a selection with no sourceFile of its own, and
89
+ // DomEditSelection always carries one.
90
+ const commitTargetKey = useMemo(
91
+ () =>
92
+ domEditSelection ? resolveTimelineIdForSelection(domEditSelection, elements, null) : null,
93
+ [domEditSelection, elements],
94
+ );
95
+
96
+ const bind = useCallback(
97
+ (element: TimelineElement, isSelected: boolean): AutomationLaneBinding => {
98
+ const chain = elementFxChain(element);
99
+ const automation = elementAutomation(element);
100
+ const elementKey = getTimelineElementIdentity(element);
101
+
102
+ const write = (next: HfAutomation, persist: boolean): void => {
103
+ if (!domEdit || !isSelected) return;
104
+ const value = next.lanes.length > 0 ? serializeAutomation(next) : "";
105
+ // Every write of one gesture under one key, so undo takes back the whole
106
+ // drag rather than the last fragment history happened to keep.
107
+ const coalesce = persist ? gestureKeys.current.commit() : gestureKeys.current.live();
108
+ // Quiet, not the refreshing commit: releasing a dragged point used to
109
+ // reload the preview, which restarts every playing track — the same chop
110
+ // the live write during the drag exists to avoid. Quiet still persists
111
+ // and still resyncs the selection, so the next edit sees this one.
112
+ if (persist) {
113
+ void domEdit.handleDomAttributeQuietCommit(HF_AUDIO_AUTOMATION_ATTR, value, coalesce);
114
+ }
115
+ // Dragging a point writes live: no preview refresh, so the composition
116
+ // does not reload and restart playback on every pixel.
117
+ else {
118
+ // Preview only, because a gesture writes on every pointermove: the
119
+ // preview and the running audio follow the pointer, and the release
120
+ // below is the one write that reaches the file and the undo stack.
121
+ void domEdit.handleDomAttributeLiveCommit(
122
+ HF_AUDIO_AUTOMATION_ATTR,
123
+ value || null,
124
+ undefined,
125
+ {
126
+ coalesce,
127
+ previewOnly: true,
128
+ },
129
+ );
130
+ }
131
+ };
132
+
133
+ return {
134
+ automation,
135
+ lanes: automation.lanes,
136
+ chain,
137
+ onPreview: (next) => write(next, false),
138
+ onCommit: (next) => write(next, true),
139
+ // Deliberately not awaited before an edit: the commit handlers close
140
+ // over the selection as it was when they were built, so writing in the
141
+ // same tick would land on whichever element was selected before.
142
+ // Selecting is its own gesture; the lane goes live after it.
143
+ onSelect: () => void domEdit?.handleTimelineElementSelect(element),
144
+ readOnly: !domEdit || !isSelected,
145
+ commitTargetKey: domEdit ? commitTargetKey : null,
146
+ selection: automationSelection?.elementKey === elementKey ? automationSelection : null,
147
+ // Not gated on `isSelected`, unlike the writes above. A selection is
148
+ // ephemeral store state, and the drag that draws one on a read-only lane is
149
+ // the same press that selects the clip — refusing it here meant the first
150
+ // drag on a lane silently did nothing and the author had to drag again.
151
+ // Nothing can be written through it while the lane is read-only: every
152
+ // consumer resolves the binding again and finds `readOnly`.
153
+ onRangeSelect: (target, t0, t1, v0, v1) => {
154
+ if (!domEdit) return;
155
+ setAutomationSelection({ elementKey, target, t0, t1, v0, v1 });
156
+ },
157
+ onRangeClear: () => clearAutomationSelection(),
158
+ };
159
+ },
160
+ [
161
+ domEdit,
162
+ commitTargetKey,
163
+ automationSelection,
164
+ setAutomationSelection,
165
+ clearAutomationSelection,
166
+ ],
167
+ );
168
+
169
+ return useMemo(() => ({ bind }), [bind]);
170
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Drawing a new selection box over an automation lane's empty background.
3
+ *
4
+ * Its own module for the same reason useAutomationEdgeStretch is: a sixth
5
+ * mutually-exclusive gesture on a hook already at the file's complexity
6
+ * budget, and one with no use for anything the others snapshot.
7
+ */
8
+
9
+ import { useCallback, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
10
+ import { snapLaneTime } from "./automationLaneGeometry";
11
+ import { capturePointer } from "./automationLanePointer";
12
+ import { SNAP_SEC } from "./automationLaneDragMath";
13
+
14
+ export interface UseAutomationRangeDragInput {
15
+ pointAt(clientX: number, clientY: number): { t: number; v: number };
16
+ xOf(t: number): number;
17
+ yOf(v: number): number;
18
+ duration: number;
19
+ snapTimes?: readonly number[] | undefined;
20
+ onRangeSelect?: ((t0: number, t1: number, v0: number, v1: number) => void) | undefined;
21
+ onRangeClear?: (() => void) | undefined;
22
+ }
23
+
24
+ export interface UseAutomationRangeDragResult {
25
+ /** Whether a box is being drawn right now. */
26
+ dragging: boolean;
27
+ /** Arms a new box at the press, or declines when no caller wants to hear
28
+ * about one (a read-only lane). */
29
+ arm(e: ReactPointerEvent<SVGSVGElement>): void;
30
+ move(e: ReactPointerEvent<SVGSVGElement>): void;
31
+ /** A sub-threshold press clears the selection rather than leaving a
32
+ * zero-width one behind. */
33
+ finish(): void;
34
+ }
35
+
36
+ /**
37
+ * A corner of the selection box under the pointer.
38
+ *
39
+ * Time is clamped to the clip and snaps to the grid, because the box's span is
40
+ * also what a shape insert or a paste acts over and those want beat-aligned
41
+ * edges. The value is taken as it lies: there is no grid on a parameter axis,
42
+ * and rounding a dB bound would move which points the box catches.
43
+ */
44
+ function boxCornerAt(
45
+ e: ReactPointerEvent<SVGSVGElement>,
46
+ input: Pick<UseAutomationRangeDragInput, "pointAt" | "duration" | "snapTimes">,
47
+ ): { t: number; v: number } {
48
+ const raw = input.pointAt(e.clientX, e.clientY);
49
+ const clamped = Math.min(input.duration, Math.max(0, raw.t));
50
+ return {
51
+ t: e.altKey ? clamped : snapLaneTime(clamped, input.snapTimes ?? [], SNAP_SEC),
52
+ v: raw.v,
53
+ };
54
+ }
55
+
56
+ export function useAutomationRangeDrag({
57
+ pointAt,
58
+ xOf,
59
+ yOf,
60
+ duration,
61
+ snapTimes,
62
+ onRangeSelect,
63
+ onRangeClear,
64
+ }: UseAutomationRangeDragInput): UseAutomationRangeDragResult {
65
+ const [box, setBox] = useState<{
66
+ from: { t: number; v: number };
67
+ to: { t: number; v: number };
68
+ } | null>(null);
69
+ /** Whether the live drag has crossed the pixel threshold that turns a press
70
+ * into an actual box, rather than a click that should just clear one. */
71
+ const crossed = useRef(false);
72
+
73
+ const arm = useCallback(
74
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
75
+ if (!onRangeSelect) return;
76
+ e.preventDefault();
77
+ capturePointer(e);
78
+ const corner = boxCornerAt(e, { pointAt, duration, snapTimes });
79
+ crossed.current = false;
80
+ setBox({ from: corner, to: corner });
81
+ },
82
+ [onRangeSelect, pointAt, duration, snapTimes],
83
+ );
84
+
85
+ const move = useCallback(
86
+ (e: ReactPointerEvent<SVGSVGElement>): void => {
87
+ if (box === null) return;
88
+ const to = boxCornerAt(e, { pointAt, duration, snapTimes });
89
+ const { from } = box;
90
+ setBox({ from, to });
91
+ const travelled = Math.max(
92
+ Math.abs(xOf(to.t) - xOf(from.t)),
93
+ Math.abs(yOf(to.v) - yOf(from.v)),
94
+ );
95
+ if (travelled <= 3) return;
96
+ crossed.current = true;
97
+ onRangeSelect?.(
98
+ Math.min(from.t, to.t),
99
+ Math.max(from.t, to.t),
100
+ Math.min(from.v, to.v),
101
+ Math.max(from.v, to.v),
102
+ );
103
+ },
104
+ [box, pointAt, duration, snapTimes, xOf, yOf, onRangeSelect],
105
+ );
106
+
107
+ const finish = useCallback((): void => {
108
+ if (!crossed.current) onRangeClear?.();
109
+ crossed.current = false;
110
+ setBox(null);
111
+ }, [onRangeClear]);
112
+
113
+ return { dragging: box !== null, arm, move, finish };
114
+ }
@@ -6,8 +6,9 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it } from "vitest";
7
7
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
8
  import { LANE_H, TRACK_H } from "./timelineLayout";
9
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
9
10
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
10
- import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
11
+ import { resolveTrackKeyframeClip, useTimelineTrackLayout } from "./useTimelineTrackLayout";
11
12
 
12
13
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
13
14
 
@@ -92,3 +93,108 @@ describe("useTimelineTrackLayout", () => {
92
93
  unmount();
93
94
  });
94
95
  });
96
+ const audioClip = (id: string, over: Partial<TimelineElement> = {}): TimelineElement => ({
97
+ id,
98
+ key: id,
99
+ tag: "audio",
100
+ start: 0,
101
+ duration: 10,
102
+ track: 10,
103
+ ...over,
104
+ });
105
+
106
+ /**
107
+ * Clips sharing a row share a lane row per property, so the height they reserve
108
+ * is the track's grouped count — and the row is open when ANY of them is
109
+ * expanded, or clicking a sibling collapsed it.
110
+ */
111
+ describe("a track several clips share", () => {
112
+ const peaking = (gain: number) =>
113
+ JSON.stringify({
114
+ version: 1,
115
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }],
116
+ });
117
+ const lanes = (...targets: string[]) =>
118
+ JSON.stringify({
119
+ version: 1,
120
+ lanes: targets.map((target) => ({ target, points: [{ t: 0, v: 1 }] })),
121
+ });
122
+ const narration1 = audioClip("narration-1", {
123
+ fxChain: peaking(-3),
124
+ automation: lanes("fx.n1.gain"),
125
+ });
126
+ const narration2 = audioClip("narration-2", {
127
+ start: 10,
128
+ fxChain: peaking(-6),
129
+ automation: lanes("fx.n1.gain", "volume"),
130
+ });
131
+
132
+ /** Reserved height for the row, with only narration-1 ever expanded. */
133
+ function rowHeight(selectedElementId: string | null): number {
134
+ usePlayerStore.setState({ expandedClipIds: new Set(["narration-1"]) });
135
+ let height = 0;
136
+ function Probe() {
137
+ height =
138
+ useTimelineTrackLayout([narration1, narration2], new Map(), selectedElementId, new Set())
139
+ .rowHeights[0] ?? 0;
140
+ return null;
141
+ }
142
+ const root = createRoot(document.createElement("div"));
143
+ act(() => root.render(React.createElement(Probe)));
144
+ act(() => root.unmount());
145
+ return height;
146
+ }
147
+
148
+ it("reserves one row per property, not per clip's lane", () => {
149
+ // Two properties across the two clips — a shared 1 kHz peaking gain and a
150
+ // volume envelope on one of them — so two rows, not three.
151
+ expect(rowHeight("narration-1")).toBe(TRACK_H + 2 * AUTOMATION_LANE_H);
152
+ });
153
+
154
+ it("stays open at the same height when the selection moves to a sibling", () => {
155
+ // Expansion is stored per clip but reads as the row's: asking only about the
156
+ // active clip collapsed the row the moment another was clicked.
157
+ expect(rowHeight("narration-2")).toBe(rowHeight("narration-1"));
158
+ });
159
+ });
160
+
161
+ describe("resolveTrackKeyframeClip", () => {
162
+ const none = new Map<string, number>();
163
+
164
+ it("picks an audio clip that has only automation, no tweens", () => {
165
+ // Gating on tweens alone left an audio clip's envelopes unreachable: no
166
+ // clip resolved, so the track got no caret, no height and no lanes.
167
+ const bgm = audioClip("bgm");
168
+ const picked = resolveTrackKeyframeClip([bgm], none, null, new Set(), () => 1);
169
+ expect(picked).toBe(bgm);
170
+ });
171
+
172
+ it("still resolves nothing when a clip has neither", () => {
173
+ expect(resolveTrackKeyframeClip([audioClip("bgm")], none, null, new Set(), () => 0)).toBeNull();
174
+ });
175
+
176
+ it("prefers the selected clip over the one with more to show", () => {
177
+ const a = audioClip("a");
178
+ const b = audioClip("b");
179
+ const picked = resolveTrackKeyframeClip([a, b], new Map([["b", 4]]), "a", new Set(), (e) =>
180
+ e.id === "a" ? 1 : 0,
181
+ );
182
+ expect(picked).toBe(a);
183
+ });
184
+
185
+ it("counts tweens and automation together when breaking a tie", () => {
186
+ const a = audioClip("a");
187
+ const b = audioClip("b");
188
+ const picked = resolveTrackKeyframeClip(
189
+ [a, b],
190
+ new Map([
191
+ ["a", 1],
192
+ ["b", 1],
193
+ ]),
194
+ null,
195
+ new Set(),
196
+ (e) => (e.id === "b" ? 3 : 0),
197
+ );
198
+ expect(picked).toBe(b);
199
+ });
200
+ });
@@ -1,6 +1,8 @@
1
1
  import { useMemo, useRef } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { animationLaneGroups } from "./TimelinePropertyLanes";
4
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
5
+ import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
4
6
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
6
8
  import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations";
@@ -14,6 +16,60 @@ import {
14
16
 
15
17
  export { getTrackStyle } from "./timelineIcons";
16
18
 
19
+ /**
20
+ * Whether this track draws the beat-dot strip: only where there are beats to
21
+ * draw, and only on the track the user is working in — the selected clip's, or
22
+ * the music track's when nothing is selected.
23
+ */
24
+ export function trackShowsBeatStrip(
25
+ els: readonly TimelineElement[],
26
+ beatTimes: readonly number[] | undefined,
27
+ ctx: {
28
+ selectedElementId: string | null;
29
+ isMusicTrack(element: TimelineElement): boolean;
30
+ },
31
+ ): boolean {
32
+ if ((beatTimes?.length ?? 0) < 2) return false;
33
+ return ctx.selectedElementId
34
+ ? els.some((e) => (e.key ?? e.id) === ctx.selectedElementId)
35
+ : els.some((e) => ctx.isMusicTrack(e));
36
+ }
37
+
38
+ /**
39
+ * Automation lanes on one clip, or 0 for anything that is not audio.
40
+ *
41
+ * An audio clip can be worth expanding without carrying a single tween, so this
42
+ * counts toward whether a track has anything to disclose. A function rather than
43
+ * a map so every caller reads the same cached parse and none can drift.
44
+ */
45
+ function automationLaneCountOf(element: TimelineElement): number {
46
+ return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
47
+ }
48
+
49
+ /**
50
+ * Automation rows a TRACK reserves: the union over the clips sharing it, since
51
+ * clips on one row share a lane row per property. Counting only the active clip's
52
+ * lanes reserved a height that changed with the selection.
53
+ */
54
+ function trackAutomationLaneCount(elements: readonly TimelineElement[]): number {
55
+ return groupAutomationLanes(elements).length;
56
+ }
57
+
58
+ /**
59
+ * Is this row disclosed? Expansion is stored per clip, but it reads as a property
60
+ * of the ROW: the active clip changes with the selection, so asking only about it
61
+ * collapsed the row the moment you clicked a sibling. Any expanded clip on the
62
+ * track holds the row open — and the caret expands and collapses all of them
63
+ * together (see TimelineLanes), so the two can only disagree on state predating
64
+ * this rule or written by the keyframe auto-expand.
65
+ */
66
+ export function isTrackRowExpanded(
67
+ elements: readonly TimelineElement[],
68
+ expandedClipIds: ReadonlySet<string>,
69
+ ): boolean {
70
+ return elements.some((element) => expandedClipIds.has(element.key ?? element.id));
71
+ }
72
+
17
73
  /**
18
74
  * The single keyframed element whose property lanes a track shows when expanded.
19
75
  * A track can hold several elements (same z-index is common), but keyframes are
@@ -27,10 +83,15 @@ export function resolveTrackKeyframeClip(
27
83
  laneCounts: ReadonlyMap<string, number>,
28
84
  selectedElementId: string | null,
29
85
  selectedElementIds: ReadonlySet<string>,
86
+ automationLaneCount: (element: TimelineElement) => number = automationLaneCountOf,
30
87
  ): TimelineElement | null {
31
- const keyframed = elements.filter(
32
- (element) => (laneCounts.get(element.key ?? element.id) ?? 0) >= 1,
33
- );
88
+ // Automation counts toward "has something to disclose". Without it an audio
89
+ // clip carrying envelopes but no tweens resolved to null, so its track got no
90
+ // caret, no reserved height and no lanes — the automation was unreachable for
91
+ // exactly the tracks the feature is for.
92
+ const disclosable = (element: TimelineElement): number =>
93
+ (laneCounts.get(element.key ?? element.id) ?? 0) + automationLaneCount(element);
94
+ const keyframed = elements.filter((element) => disclosable(element) >= 1);
34
95
  if (keyframed.length === 0) return null;
35
96
  const selected = keyframed.find((element) => {
36
97
  const key = element.key ?? element.id;
@@ -39,8 +100,9 @@ export function resolveTrackKeyframeClip(
39
100
  if (selected) return selected;
40
101
  // Most lanes wins, first one on a tie (same as the old stable sort), but as a
41
102
  // reduce over the already non-empty list so there's no index to assert on.
42
- const lanesOf = (element: TimelineElement) => laneCounts.get(element.key ?? element.id) ?? 0;
43
- return keyframed.reduce((best, element) => (lanesOf(element) > lanesOf(best) ? element : best));
103
+ return keyframed.reduce((best, element) =>
104
+ disclosable(element) > disclosable(best) ? element : best,
105
+ );
44
106
  }
45
107
 
46
108
  /** Lanes per clip: the count of distinct property groups whose tween contributes
@@ -74,8 +136,9 @@ function useTimelineRowHeights(
74
136
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
75
137
  const { laneCounts, rowGeometry } = useMemo(() => {
76
138
  const laneCounts = computeLaneCounts(tracks, gsapAnimations);
77
- // Row height follows only the active keyframe clip, so a track with several
139
+ // Keyframe lanes follow only the active clip, so a track with several
78
140
  // keyframed elements never reserves empty lanes for the ones not shown.
141
+ // Automation lanes follow the whole row: they are shared per property.
79
142
  const heightTracks: TimelineTrackHeightClip[][] = tracks.map(([, elements]) => {
80
143
  const active = resolveTrackKeyframeClip(
81
144
  elements,
@@ -85,7 +148,20 @@ function useTimelineRowHeights(
85
148
  );
86
149
  if (!active) return [];
87
150
  const clipId = active.key ?? active.id;
88
- return [{ clipId, laneCount: laneCounts.get(clipId) ?? 0 }];
151
+ // `trackHeights` gates the reserved lanes on this id being expanded, and the
152
+ // row is expanded when ANY of its clips is — so hand it whichever clip holds
153
+ // the row open, while the lane counts stay the active clip's (keyframes) and
154
+ // the track's (automation, shared across the row).
155
+ const holdingOpen = elements.find((element) =>
156
+ expandedClipIds.has(element.key ?? element.id),
157
+ );
158
+ return [
159
+ {
160
+ clipId: holdingOpen ? (holdingOpen.key ?? holdingOpen.id) : clipId,
161
+ laneCount: laneCounts.get(clipId) ?? 0,
162
+ automationLaneCount: trackAutomationLaneCount(elements),
163
+ },
164
+ ];
89
165
  });
90
166
  const rowHeights = trackHeights(heightTracks, expandedClipIds);
91
167
  return {
@@ -152,6 +152,13 @@ function hostElementState(flat: TimelineElement | undefined): Partial<TimelineEl
152
152
  hidden: flat.hidden,
153
153
  timelineLocked: flat.timelineLocked,
154
154
  timelineRole: flat.timelineRole,
155
+ // Same reason as the three above: these are read off the host element, which
156
+ // an expanded child is built without. Missing them, an audio child inside a
157
+ // sub-composition reserved no automation height and drew no lanes, while the
158
+ // property panel — reading the live DOM selection rather than this row —
159
+ // still showed the chain and its toggles.
160
+ fxChain: flat.fxChain,
161
+ automation: flat.automation,
155
162
  };
156
163
  }
157
164
 
@@ -389,8 +389,20 @@ export function useTimelinePlayer() {
389
389
  seek(state.requestedSeekTime);
390
390
  usePlayerStore.getState().clearSeekRequest();
391
391
  }
392
+ // Play or stop from outside the loop — the FX rack auditioning a preset
393
+ // while paused, which is silent otherwise. `returnTo` puts the playhead
394
+ // back where the request found it: hovering is not an edit.
395
+ const request = state.playbackRequest;
396
+ if (request && request.nonce !== prev.playbackRequest?.nonce) {
397
+ if (request.playing) play();
398
+ else {
399
+ pause();
400
+ if (request.returnTo !== null) seek(request.returnTo);
401
+ }
402
+ usePlayerStore.getState().clearPlaybackRequest();
403
+ }
392
404
  });
393
- }, [seek]);
405
+ }, [seek, play, pause]);
394
406
  const { playbackKeyDownRef, playbackKeyUpRef, attachIframeShortcutListeners, togglePlay } =
395
407
  usePlaybackKeyboard({
396
408
  iframeRef,
@@ -0,0 +1,83 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { syncStoredAutomationFromPreview } from "./automationStoreSync";
4
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
+
6
+ const TWO_POINTS = '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1}]}]}';
7
+ const RESTORED =
8
+ '{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":1},{"t":4,"v":0}]}]}';
9
+
10
+ const el = (over: Partial<TimelineElement> = {}): TimelineElement => ({
11
+ id: "bgm",
12
+ key: "bgm",
13
+ tag: "audio",
14
+ start: 0,
15
+ duration: 10,
16
+ track: 1,
17
+ domId: "bgm",
18
+ ...over,
19
+ });
20
+
21
+ /** A stand-in preview document carrying the attributes an undo would have written. */
22
+ function previewWith(attrs: Record<string, string>): Document {
23
+ const doc = document.implementation.createHTMLDocument("preview");
24
+ const audio = doc.createElement("audio");
25
+ audio.id = "bgm";
26
+ for (const [name, value] of Object.entries(attrs)) audio.setAttribute(name, value);
27
+ doc.body.append(audio);
28
+ return doc;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ usePlayerStore.getState().reset();
33
+ });
34
+
35
+ describe("syncStoredAutomationFromPreview", () => {
36
+ it("reads back an envelope an undo restored on the preview", () => {
37
+ // The bug: a soft undo patches the preview document and re-runs the timeline, but
38
+ // the store keeps its own copy of the attributes and that copy is what a lane
39
+ // draws — so an undone delete stayed invisible until the page was reloaded.
40
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
41
+ syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
42
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
43
+ });
44
+
45
+ it("reads back an undone FX chain too, since a lane's targets come from it", () => {
46
+ const chain = '{"version":1,"nodes":[{"type":"lowpass","id":"n1","params":{}}]}';
47
+ usePlayerStore.setState({ elements: [el()] });
48
+ syncStoredAutomationFromPreview(previewWith({ "data-fx-chain": chain }));
49
+ expect(usePlayerStore.getState().elements[0]?.fxChain).toBe(chain);
50
+ });
51
+
52
+ it("clears an attribute the undo removed", () => {
53
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
54
+ syncStoredAutomationFromPreview(previewWith({}));
55
+ expect(usePlayerStore.getState().elements[0]?.automation).toBeUndefined();
56
+ });
57
+
58
+ it("finds the node by data-hf-id when the dom id does not match", () => {
59
+ // Studio stamps hf-ids; an element discovered under a suffixed dom id still has
60
+ // to resolve, or the sync silently skips it.
61
+ const doc = document.implementation.createHTMLDocument("preview");
62
+ const audio = doc.createElement("audio");
63
+ audio.setAttribute("data-hf-id", "hf-snao");
64
+ audio.setAttribute("data-automation", RESTORED);
65
+ doc.body.append(audio);
66
+ usePlayerStore.setState({ elements: [el({ domId: "bgm-2", hfId: "hf-snao" })] });
67
+ syncStoredAutomationFromPreview(doc);
68
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(RESTORED);
69
+ });
70
+
71
+ it("keeps the same array when nothing changed, so nothing re-renders", () => {
72
+ usePlayerStore.setState({ elements: [el({ automation: RESTORED })] });
73
+ const before = usePlayerStore.getState().elements;
74
+ syncStoredAutomationFromPreview(previewWith({ "data-automation": RESTORED }));
75
+ expect(usePlayerStore.getState().elements).toBe(before);
76
+ });
77
+
78
+ it("does nothing without a preview document", () => {
79
+ usePlayerStore.setState({ elements: [el({ automation: TWO_POINTS })] });
80
+ syncStoredAutomationFromPreview(null);
81
+ expect(usePlayerStore.getState().elements[0]?.automation).toBe(TWO_POINTS);
82
+ });
83
+ });