@hyperframes/studio 0.7.107 → 0.7.109

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-BYgqAPib.js} +1 -1
  2. package/dist/assets/{index-BSBk5srs.js → index-Blxia-Lt.js} +1 -1
  3. package/dist/assets/{index-BA9yhzfR.js → index-CIRxodzK.js} +1 -1
  4. package/dist/assets/index-CfBGQdrX.js +428 -0
  5. package/dist/assets/index-DNkh9mbV.css +1 -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
@@ -9,7 +9,8 @@ import { TimelineTrackHeader } from "./TimelineTrackHeader";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import type { TimelineElement } from "../store/playerStore";
11
11
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
12
- import { LABEL_COL_W } from "./timelineLayout";
12
+ import { getTimelineLaneTop, LABEL_COL_W } from "./timelineLayout";
13
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
13
14
 
14
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
16
 
@@ -61,6 +62,8 @@ const OPACITY = animation("opacity-tween", "visual", [
61
62
 
62
63
  interface RenderHeaderOptions {
63
64
  keyframeClip?: TimelineElement;
65
+ /** Every clip on the track; defaults to just the keyframe clip. */
66
+ trackElements?: readonly TimelineElement[];
64
67
  animations?: GsapAnimation[];
65
68
  clipCount?: number;
66
69
  currentTime?: number;
@@ -68,6 +71,7 @@ interface RenderHeaderOptions {
68
71
  onSeek?: (time: number) => void;
69
72
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
70
73
  onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
74
+ onRemoveAutomationLane?: (target: string) => void;
71
75
  }
72
76
 
73
77
  function renderHeader(options: RenderHeaderOptions = {}): {
@@ -90,6 +94,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
90
94
  lanesId="timeline-lanes-track-0"
91
95
  contentOrigin={LABEL_COL_W}
92
96
  keyframeClip={next.keyframeClip ?? ELEMENT}
97
+ trackElements={next.trackElements ?? [next.keyframeClip ?? ELEMENT]}
93
98
  clipCount={next.clipCount ?? 1}
94
99
  isExpanded={next.expanded !== false}
95
100
  animations={next.animations ?? [POSITION, OPACITY]}
@@ -100,6 +105,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
100
105
  onToggleClipExpanded={vi.fn()}
101
106
  onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
102
107
  onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
108
+ onRemoveAutomationLane={next.onRemoveAutomationLane}
103
109
  onSeek={next.onSeek}
104
110
  />,
105
111
  );
@@ -414,4 +420,209 @@ describe("TimelineTrackHeader", () => {
414
420
  assertAligned([POSITION, OPACITY]);
415
421
  act(() => view.root.unmount());
416
422
  });
423
+
424
+ /**
425
+ * Automation lanes are named in the label column, on the same tree as the
426
+ * keyframe rows — not painted inside the lane, where the name sat on top of
427
+ * the envelope it belonged to and scrolled away from its own row.
428
+ */
429
+ describe("audio automation rows", () => {
430
+ const BED: TimelineElement = {
431
+ id: "bed",
432
+ label: "Music Bed",
433
+ tag: "audio",
434
+ start: 0,
435
+ duration: 10,
436
+ track: 0,
437
+ fxChain: JSON.stringify({
438
+ version: 1,
439
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 1600, gain: -6, q: 1.4 } }],
440
+ }),
441
+ automation: JSON.stringify({
442
+ version: 1,
443
+ lanes: [
444
+ {
445
+ target: "volume",
446
+ points: [
447
+ { t: 0, v: 1 },
448
+ { t: 5, v: 0.4 },
449
+ ],
450
+ },
451
+ {
452
+ target: "fx.n1.gain",
453
+ points: [
454
+ { t: 0, v: 0 },
455
+ { t: 5, v: -6 },
456
+ ],
457
+ },
458
+ ],
459
+ }),
460
+ } as TimelineElement;
461
+
462
+ it("names every envelope in the label column", () => {
463
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
464
+ const rows = Array.from(host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"));
465
+ // The attribute is the ROW's identity, which is the label: a row can hold
466
+ // several clips' envelopes, whose lane targets differ from each other.
467
+ expect(rows.map((r) => r.getAttribute("data-automation-lane-label"))).toEqual([
468
+ "Peaking EQ 1.6 kHz · Gain",
469
+ "Volume",
470
+ ]);
471
+ // A band is named by its frequency: with several of them, "Peaking EQ" says
472
+ // nothing about which is which. Bands sit above the level lanes.
473
+ // Two lines per row: what the effect is, then which knob the envelope
474
+ // drives. One line truncated mid-word in a column this narrow.
475
+ expect(rows.map((r) => r.querySelector("[data-automation-lane-name]")?.textContent)).toEqual([
476
+ "Peaking EQ 1.6 kHz",
477
+ "Volume",
478
+ ]);
479
+ expect(rows.map((r) => r.querySelector("[data-automation-lane-param]")?.textContent)).toEqual(
480
+ [
481
+ "Gain",
482
+ // Volume has no effect behind it, so it has no second line at all.
483
+ undefined,
484
+ ],
485
+ );
486
+ act(() => root.unmount());
487
+ });
488
+
489
+ it("hides them when the track is collapsed", () => {
490
+ const { host, root } = renderHeader({
491
+ keyframeClip: BED,
492
+ animations: [],
493
+ expanded: false,
494
+ });
495
+ expect(host.querySelectorAll("[data-automation-lane-label]")).toHaveLength(0);
496
+ act(() => root.unmount());
497
+ });
498
+
499
+ it("removes just that envelope from the label column", () => {
500
+ // The panel's automate toggle can only reach a parameter it still shows; a
501
+ // carve's own lanes are not in it at all, so without this an envelope could
502
+ // be created and never deleted.
503
+ const onRemoveAutomationLane = vi.fn();
504
+ const { host, root } = renderHeader({
505
+ keyframeClip: BED,
506
+ animations: [],
507
+ onRemoveAutomationLane,
508
+ });
509
+ const button = host.querySelector<HTMLButtonElement>(
510
+ 'button[aria-label="Remove Peaking EQ 1.6 kHz · Gain automation"]',
511
+ );
512
+ expect(button).not.toBeNull();
513
+ act(() => button?.click());
514
+ expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
515
+ act(() => root.unmount());
516
+ });
517
+
518
+ it("offers no remove button when the lanes are read-only", () => {
519
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [] });
520
+ expect(host.querySelectorAll('button[aria-label$="automation"]')).toHaveLength(0);
521
+ act(() => root.unmount());
522
+ });
523
+
524
+ it("stacks each envelope's row where its lane is drawn", () => {
525
+ // Same rhythm the canvas uses: automation begins below the keyframe lanes
526
+ // and steps by its own taller row height.
527
+ const { host, root } = renderHeader({ keyframeClip: BED, animations: [OPACITY] });
528
+ const tops = Array.from(
529
+ host.querySelectorAll<HTMLElement>("[data-automation-lane-label]"),
530
+ ).map((r) => r.style.top);
531
+ const base = getTimelineLaneTop(1);
532
+ expect(tops).toEqual([`${base}px`, `${base + AUTOMATION_LANE_H}px`]);
533
+ act(() => root.unmount());
534
+ });
535
+ });
536
+
537
+ /**
538
+ * Several clips on one row share a lane row per property, so the label column
539
+ * has to name the row for the property and the header for the track — not for
540
+ * whichever clip happens to be selected.
541
+ */
542
+ describe("a track several clips share", () => {
543
+ const clip = (id: string, over: Partial<TimelineElement>): TimelineElement =>
544
+ ({
545
+ id,
546
+ key: id,
547
+ label: id,
548
+ tag: "audio",
549
+ start: 0,
550
+ duration: 4,
551
+ track: 0,
552
+ ...over,
553
+ }) as TimelineElement;
554
+ const PEAKING = (gain: number) =>
555
+ JSON.stringify({
556
+ version: 1,
557
+ nodes: [{ type: "peaking", id: "n1", params: { frequency: 1000, gain, q: 1.4 } }],
558
+ });
559
+ const NARRATION_1 = clip("narration-1", {
560
+ fxChain: PEAKING(-3),
561
+ automation: JSON.stringify({
562
+ version: 1,
563
+ lanes: [{ target: "fx.n1.gain", points: [{ t: 0, v: -3 }] }],
564
+ }),
565
+ });
566
+ const NARRATION_2 = clip("narration-2", {
567
+ start: 4,
568
+ fxChain: PEAKING(-6),
569
+ automation: JSON.stringify({
570
+ version: 1,
571
+ lanes: [
572
+ { target: "fx.n1.gain", points: [{ t: 0, v: -6 }] },
573
+ { target: "volume", points: [{ t: 0, v: 1 }] },
574
+ ],
575
+ }),
576
+ });
577
+ const ROW = { trackElements: [NARRATION_1, NARRATION_2], clipCount: 2, animations: [] };
578
+
579
+ it("lists every clip's envelopes, whichever clip is selected", () => {
580
+ const labels = (host: HTMLElement) =>
581
+ Array.from(host.querySelectorAll("[data-automation-lane-label]")).map((r) =>
582
+ r.getAttribute("data-automation-lane-label"),
583
+ );
584
+ const first = renderHeader({ ...ROW, keyframeClip: NARRATION_1 });
585
+ const second = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
586
+ // narration-1 has no volume envelope, but the row is still there — it is
587
+ // the track's, and it was its sibling's before the selection moved.
588
+ expect(labels(first.host)).toEqual(["Peaking EQ 1 kHz · Gain", "Volume"]);
589
+ expect(labels(second.host)).toEqual(labels(first.host));
590
+ act(() => first.root.unmount());
591
+ act(() => second.root.unmount());
592
+ });
593
+
594
+ it("removes only from the clip it is showing, and offers nothing where it has no lane", () => {
595
+ // A write can only reach the selected clip, so a button on a row that clip
596
+ // is absent from could only remove nothing, or somebody else's envelope.
597
+ const onRemoveAutomationLane = vi.fn();
598
+ const { host, root } = renderHeader({
599
+ ...ROW,
600
+ keyframeClip: NARRATION_1,
601
+ onRemoveAutomationLane,
602
+ });
603
+ expect(
604
+ Array.from(host.querySelectorAll('button[aria-label$="automation"]')).map((b) =>
605
+ b.getAttribute("aria-label"),
606
+ ),
607
+ ).toEqual(["Remove Peaking EQ 1 kHz · Gain automation"]);
608
+ act(() => host.querySelector<HTMLButtonElement>('button[aria-label$="automation"]')?.click());
609
+ expect(onRemoveAutomationLane).toHaveBeenCalledWith("fx.n1.gain");
610
+ act(() => root.unmount());
611
+ });
612
+
613
+ it("names the header for the track, not for one of the clips on it", () => {
614
+ const view = renderHeader({ ...ROW, keyframeClip: NARRATION_2 });
615
+ expect(view.host.textContent).not.toContain("narration-2");
616
+ expect(view.host.querySelector('[title="Track 1"]')?.textContent).toBe("Track 1");
617
+ // Alone on the track it is still named for itself.
618
+ view.rerender({
619
+ ...ROW,
620
+ keyframeClip: NARRATION_2,
621
+ trackElements: [NARRATION_2],
622
+ clipCount: 1,
623
+ });
624
+ expect(view.host.querySelector('[title="narration-2"]')?.textContent).toBe("narration-2");
625
+ act(() => view.root.unmount());
626
+ });
627
+ });
417
628
  });
@@ -4,6 +4,8 @@ import { Music } from "../../icons/SystemIcons";
4
4
  import type { TimelineElement } from "../store/playerStore";
5
5
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
6
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
7
+ import { groupAutomationLanes } from "./automationLaneData";
8
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
7
9
  import { clipTimingStart } from "../../hooks/gsapShared";
8
10
  import { LayerDisclosureRow } from "./LayerDisclosureRow";
9
11
  import { TrackClipCount } from "./TrackClipCount";
@@ -28,13 +30,19 @@ interface TimelineTrackHeaderProps {
28
30
  * from the label rather than inventing one (see trackDisplayNumber). */
29
31
  trackDisplayNumber: number | null;
30
32
  trackLabel: string;
31
- /** Id of the canvas-side lanes element the disclosure caret expands. Minted by
32
- * TimelineLanes, which is the one place that sees both subtrees. */
33
+ /** Ids of the canvas-side lane regions the disclosure caret expands, space
34
+ * separated as `aria-controls` takes them: the active clip's keyframe lanes
35
+ * and the track's automation lanes are separate elements, one owned by a
36
+ * clip and one by the row. Minted by TimelineLanes, the one place that sees
37
+ * every subtree. */
33
38
  lanesId: string;
34
39
  contentOrigin: number;
35
40
  /** The track's active keyframe clip (selected, else primary) — the one whose
36
41
  * disclosure + property rows this header shows, whether expanded or not. */
37
42
  keyframeClip: TimelineElement | null;
43
+ /** Every clip on this track. Automation rows are the track's, unioned over
44
+ * these, so they stop changing with the selection. */
45
+ trackElements: readonly TimelineElement[];
38
46
  /** Clips on this track, so the header can say how many the row holds. */
39
47
  clipCount: number;
40
48
  isExpanded: boolean;
@@ -47,6 +55,9 @@ interface TimelineTrackHeaderProps {
47
55
  onToggleClipExpanded: () => void;
48
56
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
49
57
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
58
+ /** Drop one envelope. Absent while the lanes are read-only, which is what
59
+ * hides the control rather than offering a button that cannot act. */
60
+ onRemoveAutomationLane?: (target: string) => void;
50
61
  onSeek?: (time: number) => void;
51
62
  }
52
63
 
@@ -291,6 +302,110 @@ function PropertyGroupHeaderRow({
291
302
  );
292
303
  }
293
304
 
305
+ /**
306
+ * One envelope's row in the label column.
307
+ *
308
+ * Named here rather than inside the lane, on the same tree connector the
309
+ * keyframe rows use: an automation lane is a child of its clip exactly as a
310
+ * property group is, and drawing its name over the envelope put the label on top
311
+ * of the curve it describes and scrolled it away from its own row.
312
+ */
313
+ function AutomationLaneHeaderRow({
314
+ target,
315
+ label,
316
+ name,
317
+ param,
318
+ top,
319
+ isLastLane,
320
+ gutterBackground,
321
+ columnWidth,
322
+ onRemove,
323
+ }: {
324
+ /** The lane the ACTIVE clip draws in this row, or null when it draws none —
325
+ * the row belongs to the property, and a clip may be absent from it. */
326
+ target: string | null;
327
+ /** The whole thing on one line: the row's identity, its tooltip, and the
328
+ * remove button's name. */
329
+ label: string;
330
+ /** What the effect is — "Peaking EQ 1.6 kHz". */
331
+ name: string;
332
+ /** Which knob the envelope drives. Empty when there is no second line to draw. */
333
+ param: string;
334
+ top: number;
335
+ isLastLane: boolean;
336
+ gutterBackground: string;
337
+ columnWidth: number;
338
+ onRemove?: (target: string) => void;
339
+ }) {
340
+ return (
341
+ <div
342
+ data-automation-lane-label={label}
343
+ data-timeline-lane-top={top}
344
+ className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
345
+ style={{
346
+ top,
347
+ width: columnWidth,
348
+ height: AUTOMATION_LANE_H,
349
+ background: gutterBackground,
350
+ }}
351
+ >
352
+ {/* Tree connector, as the keyframe rows draw it: spine down the row, branch
353
+ tick at the name's own height. */}
354
+ <span className="relative h-full w-3 shrink-0" aria-hidden="true">
355
+ <span
356
+ className="absolute left-1.5 top-0 w-px bg-white/15"
357
+ style={{ height: isLastLane ? "50%" : "100%" }}
358
+ />
359
+ <span className="absolute left-1.5 top-1/2 h-px w-1.5 bg-white/15" />
360
+ </span>
361
+ {/* Two lines: what the effect is, then which knob the envelope drives. On
362
+ one line a band's own name was the first thing truncated in a column this
363
+ narrow — "Peaking EQ 1.6 k…" — losing exactly the part that tells two
364
+ bands apart. */}
365
+ <span className="flex min-w-0 flex-1 flex-col justify-center leading-tight" title={label}>
366
+ <span data-automation-lane-name="" className="truncate font-mono text-[9px] text-white/70">
367
+ {name}
368
+ </span>
369
+ {param ? (
370
+ <span
371
+ data-automation-lane-param=""
372
+ className="truncate font-mono text-[9px] text-white/40"
373
+ >
374
+ {param}
375
+ </span>
376
+ ) : null}
377
+ </span>
378
+ {/* Beside the name it labels, because that is the only place an envelope is
379
+ named at all: a carve writes its own lanes, and the FX panel's automate
380
+ toggle can only reach a parameter it still lists — so without this an
381
+ envelope could be created and never removed.
382
+
383
+ Only for the clip the header is showing, since that is the only one a
384
+ write can reach: a shared row's other envelopes belong to clips that
385
+ are not selected, and a button that silently removed one of them (or
386
+ none) would be worse than no button. */}
387
+ {onRemove && target !== null && (
388
+ <button
389
+ type="button"
390
+ aria-label={`Remove ${label} automation`}
391
+ title={`Remove ${label} automation`}
392
+ // h-6 w-6 is the 24x24 WCAG 2.2 target; the glyph stays small.
393
+ className="flex h-6 w-6 shrink-0 items-center justify-center border-0 bg-transparent p-0 text-[11px] text-white/35 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
394
+ onPointerDown={(event) => event.stopPropagation()}
395
+ onClick={(event) => {
396
+ // A control in the label column owns its click; it does not also hit
397
+ // the track row behind it and change the selection.
398
+ event.stopPropagation();
399
+ onRemove(target);
400
+ }}
401
+ >
402
+ ×
403
+ </button>
404
+ )}
405
+ </div>
406
+ );
407
+ }
408
+
294
409
  export function TimelineTrackHeader({
295
410
  trackNumber,
296
411
  trackDisplayNumber,
@@ -298,6 +413,7 @@ export function TimelineTrackHeader({
298
413
  lanesId,
299
414
  contentOrigin,
300
415
  keyframeClip,
416
+ trackElements,
301
417
  clipCount,
302
418
  isExpanded,
303
419
  animations,
@@ -308,6 +424,7 @@ export function TimelineTrackHeader({
308
424
  onToggleClipExpanded,
309
425
  onToggleTrackHidden,
310
426
  onTogglePropertyGroupKeyframe,
427
+ onRemoveAutomationLane,
311
428
  onSeek,
312
429
  rovingTargetId = null,
313
430
  }: TimelineTrackHeaderProps) {
@@ -322,7 +439,24 @@ export function TimelineTrackHeader({
322
439
  // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
323
440
  // owns the gutter past it, so a 0% diamond isn't clipped by this panel).
324
441
  const showTrackLabel = contentOrigin >= LABEL_COL_W;
325
- const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
442
+ // One row per automated property across the whole track, in the order the
443
+ // canvas draws them — a name beside the wrong envelope is worse than an awkward
444
+ // order. `target` is the ACTIVE clip's lane in that row, which is the only one
445
+ // the remove button can write to; null when the row belongs to its siblings.
446
+ const activeKey = keyframeClip ? (keyframeClip.key ?? keyframeClip.id) : null;
447
+ const automationRows = groupAutomationLanes(trackElements).map((group) => ({
448
+ key: group.key,
449
+ label: group.key,
450
+ name: group.name,
451
+ param: group.param,
452
+ target:
453
+ group.entries.find((entry) => (entry.element.key ?? entry.element.id) === activeKey)?.lane
454
+ .target ?? null,
455
+ }));
456
+ // Automation counts as something to disclose: gating the caret on tweens alone
457
+ // left an audio clip's envelopes unreachable, since the track could not expand.
458
+ const disclosable = lanes.length > 0 || automationRows.length > 0;
459
+ const isKeyframeLayer = !!keyframeClip && disclosable;
326
460
 
327
461
  return (
328
462
  <div
@@ -341,7 +475,7 @@ export function TimelineTrackHeader({
341
475
  borderRight: `1px solid ${theme.gutterBorder}`,
342
476
  }}
343
477
  >
344
- {!keyframeClip || lanes.length === 0 ? (
478
+ {!keyframeClip || !disclosable ? (
345
479
  <PlainTrackHeader
346
480
  trackNumber={trackNumber}
347
481
  trackDisplayNumber={trackDisplayNumber}
@@ -355,7 +489,15 @@ export function TimelineTrackHeader({
355
489
  ) : (
356
490
  <>
357
491
  <LayerDisclosureRow
358
- keyframeClip={keyframeClip}
492
+ name={
493
+ // A row holding several clips is named for the track, not for
494
+ // whichever of them is selected — the lanes below it are the
495
+ // track's, shared per property, and naming it "Narration 2" read
496
+ // as if they all belonged to that one slice.
497
+ clipCount > 1
498
+ ? `Track${trackDisplaySuffix(trackDisplayNumber)}`
499
+ : (keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id)
500
+ }
359
501
  clipCount={clipCount}
360
502
  isExpanded={isExpanded}
361
503
  gutterBackground={theme.gutterBackground}
@@ -391,7 +533,7 @@ export function TimelineTrackHeader({
391
533
  lanesId={lanesId}
392
534
  lane={lane}
393
535
  laneIndex={laneIndex}
394
- isLastLane={laneIndex === lanes.length - 1}
536
+ isLastLane={laneIndex === lanes.length - 1 && automationRows.length === 0}
395
537
  expandedElement={keyframeClip}
396
538
  currentTime={currentTime}
397
539
  clipPercentage={clipPercentage}
@@ -402,6 +544,24 @@ export function TimelineTrackHeader({
402
544
  rovingTargetId={rovingTargetId}
403
545
  />
404
546
  ))}
547
+ {/* Below the keyframe rows and stepping by its own height, which is how
548
+ TimelineAutomationLaneSlot lays the envelopes out on the canvas. The
549
+ two have to agree or a name labels the wrong curve. */}
550
+ {isExpanded &&
551
+ automationRows.map((row, index) => (
552
+ <AutomationLaneHeaderRow
553
+ key={row.key}
554
+ target={row.target}
555
+ label={row.label}
556
+ name={row.name}
557
+ param={row.param}
558
+ top={getTimelineLaneTop(lanes.length) + index * AUTOMATION_LANE_H}
559
+ isLastLane={index === automationRows.length - 1}
560
+ gutterBackground={theme.gutterBackground}
561
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
562
+ onRemove={onRemoveAutomationLane}
563
+ />
564
+ ))}
405
565
  </>
406
566
  )}
407
567
  </div>
@@ -7,7 +7,13 @@ interface TimelineTrackRowProps {
7
7
  rowKey: number;
8
8
  logicalRow: TimelineLogicalRow;
9
9
  propertyRows: readonly TimelineLogicalRow[];
10
+ /** Names the canvas-side content cell — the active clip's own property lanes,
11
+ * minted with this single id in TimelinePropertyLanes. */
10
12
  lanesId: string;
13
+ /** Names the header cell. Space-separated because the caret it lives under
14
+ * expands two disjoint subtrees (the clip's keyframe lanes AND the track's
15
+ * automation lanes) — see TimelineTrackHeader for why they cannot share one id. */
16
+ headerLanesId: string;
11
17
  top: number;
12
18
  height: number;
13
19
  virtualized: boolean;
@@ -24,6 +30,7 @@ export function TimelineTrackRow({
24
30
  logicalRow,
25
31
  propertyRows,
26
32
  lanesId,
33
+ headerLanesId,
27
34
  top,
28
35
  height,
29
36
  virtualized,
@@ -78,7 +85,7 @@ export function TimelineTrackRow({
78
85
  <div
79
86
  role="rowheader"
80
87
  aria-colindex={1}
81
- aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
88
+ aria-owns={timelineLogicalRowCellId(headerLanesId, row.id, "header")}
82
89
  >
83
90
  {group}
84
91
  </div>
@@ -0,0 +1,94 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import {
3
+ clearAutomationClipboard,
4
+ copyRange,
5
+ pastePoints,
6
+ readClipboard,
7
+ } from "./automationClipboard";
8
+ import { resolveAutomationRange, VOLUME_RANGE } from "@hyperframes/core/audio-automation";
9
+ import type { HfAutomationLane } from "@hyperframes/core/audio-automation";
10
+
11
+ const duck: HfAutomationLane = {
12
+ target: "volume",
13
+ points: [
14
+ { t: 2, v: 1, curve: -0.4 },
15
+ { t: 3, v: 0.25 },
16
+ { t: 4, v: 1 },
17
+ ],
18
+ };
19
+
20
+ beforeEach(clearAutomationClipboard);
21
+
22
+ describe("automation clipboard", () => {
23
+ it("copies the range rebased to zero", () => {
24
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
25
+ const entry = readClipboard("project-a");
26
+ expect(entry?.span).toBe(2);
27
+ expect(entry?.points.map((p) => p.t)).toEqual([0, 1, 2]);
28
+ expect(entry?.points[0]?.curve).toBe(-0.4);
29
+ });
30
+
31
+ it("pastes at a new time on the same axis unchanged", () => {
32
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
33
+ const entry = readClipboard("project-a");
34
+ expect(entry).not.toBeNull();
35
+ if (!entry) return;
36
+ const pts = pastePoints(entry, VOLUME_RANGE, 10);
37
+ expect(pts.map((p) => p.t)).toEqual([10, 11, 12]);
38
+ expect(pts.map((p) => p.v)).toEqual([1, 0.25, 1]);
39
+ });
40
+
41
+ it("maps values through unit space onto a different parameter", () => {
42
+ // fx.n1.frequency (lowpass cutoff) is log-scaled (min:20, max:20000):
43
+ // linear unit math and a literal copy of the source value would both
44
+ // read as a passing test on a range that happens to be numerically
45
+ // identical to VOLUME_RANGE (e.g. fx.r.wet), so this target has to be
46
+ // genuinely log for the test to discriminate real unit-space mapping.
47
+ const frequency = resolveAutomationRange("fx.n1.frequency", {
48
+ version: 1,
49
+ nodes: [{ type: "lowpass", id: "n1", params: {} }],
50
+ });
51
+ expect(frequency).toBeTruthy();
52
+ if (!frequency) return;
53
+ expect(frequency.scale).toBe("log");
54
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
55
+ const entry = readClipboard("project-a");
56
+ if (!entry) return;
57
+ const pts = pastePoints(entry, frequency, 0);
58
+ // volume 1 (unit 1) → frequency max; volume 0.25 (unit 0.25) → a quarter
59
+ // up frequency's LOG axis, i.e. exp(ln(min) + 0.25*(ln(max)-ln(min))) —
60
+ // NOT the naive linear guess (min + 0.25*(max-min)) and nowhere near a
61
+ // literal copy of 0.25.
62
+ expect(pts[0]?.v).toBeCloseTo(frequency.max, 5);
63
+ const expectedLog = Math.exp(
64
+ Math.log(frequency.min) + 0.25 * (Math.log(frequency.max) - Math.log(frequency.min)),
65
+ );
66
+ const naiveLinear = frequency.min + 0.25 * (frequency.max - frequency.min);
67
+ expect(pts[1]?.v).toBeCloseTo(expectedLog, 5);
68
+ expect(pts[1]?.v).not.toBeCloseTo(naiveLinear, 0);
69
+ expect(pts[1]?.v).not.toBeCloseTo(0.25, 0);
70
+ });
71
+
72
+ it("reads null when nothing was copied", () => {
73
+ expect(readClipboard("project-a")).toBeNull();
74
+ });
75
+
76
+ it("does not hand a range copied in one project to another", () => {
77
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
78
+ expect(readClipboard("project-b")).toBeNull();
79
+ });
80
+
81
+ it("drops the entry for good once another project has read past it", () => {
82
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
83
+ readClipboard("project-b");
84
+ // Not merely hidden from B: switching back must not resurrect a shape whose
85
+ // source clip may have been edited or deleted while the project was closed.
86
+ expect(readClipboard("project-a")).toBeNull();
87
+ });
88
+
89
+ it("keeps serving the entry inside its own project", () => {
90
+ copyRange("project-a", duck, VOLUME_RANGE, 2, 4);
91
+ expect(readClipboard("project-a")?.span).toBe(2);
92
+ expect(readClipboard("project-a")?.span).toBe(2);
93
+ });
94
+ });