@hyperframes/studio 0.7.109 → 0.7.111

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 (30) hide show
  1. package/dist/assets/{hyperframes-player-BYgqAPib.js → hyperframes-player-DDIlvdLL.js} +1 -1
  2. package/dist/assets/index-8lDGbNjx.css +1 -0
  3. package/dist/assets/{index-CfBGQdrX.js → index-B6B3bUGG.js} +132 -132
  4. package/dist/assets/{index-CIRxodzK.js → index-COUW8ONf.js} +1 -1
  5. package/dist/assets/{index-Blxia-Lt.js → index-DB9iM545.js} +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3916 -3834
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioRightPanel.tsx +8 -50
  12. package/src/components/StudioRightPanel.types.ts +55 -0
  13. package/src/player/components/PlayerControls.tsx +7 -56
  14. package/src/player/components/TimelineAutomationLane.tsx +0 -175
  15. package/src/player/components/TimelineAutomationLaneSlot.test.tsx +1 -1
  16. package/src/player/components/TimelineAutomationLaneSlot.tsx +187 -0
  17. package/src/player/components/TimelineLanes.tsx +1 -1
  18. package/src/player/components/VolumeControl.styles.test.ts +22 -0
  19. package/src/player/components/VolumeControl.test.tsx +86 -0
  20. package/src/player/components/VolumeControl.tsx +104 -0
  21. package/src/player/hooks/useTimelinePlayer.ts +9 -3
  22. package/src/player/lib/playbackScrub.ts +1 -1
  23. package/src/player/lib/timelineIframeHelpers.test.ts +40 -2
  24. package/src/player/lib/timelineIframeHelpers.ts +23 -3
  25. package/src/player/store/playerStore.test.ts +17 -1
  26. package/src/player/store/playerStore.ts +8 -0
  27. package/src/styles/studio.css +69 -0
  28. package/src/utils/studioUiPreferences.test.ts +3 -0
  29. package/src/utils/studioUiPreferences.ts +9 -0
  30. package/dist/assets/index-DNkh9mbV.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.109",
3
+ "version": "0.7.111",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.109",
51
- "@hyperframes/parsers": "0.7.109",
52
- "@hyperframes/studio-server": "0.7.109",
53
- "@hyperframes/sdk": "0.7.109",
54
- "@hyperframes/player": "0.7.109"
50
+ "@hyperframes/core": "0.7.111",
51
+ "@hyperframes/parsers": "0.7.111",
52
+ "@hyperframes/player": "0.7.111",
53
+ "@hyperframes/sdk": "0.7.111",
54
+ "@hyperframes/studio-server": "0.7.111"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.7.109"
71
+ "@hyperframes/producer": "0.7.111"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -1,19 +1,19 @@
1
- import { useCallback, useEffect, useRef, type MutableRefObject } from "react";
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type { StudioRightPanelProps } from "./StudioRightPanel.types";
3
+
4
+ export type { StudioRightPanelProps };
2
5
  import { PropertyPanel } from "./editor/PropertyPanel";
3
6
  import { LayersPanel } from "./editor/LayersPanel";
4
7
  import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
5
8
  import { BlockParamsPanel } from "./editor/BlockParamsPanel";
6
9
  import { RenderQueue } from "./renders/RenderQueue";
7
10
  import { SlideshowPanel } from "./panels/SlideshowPanel";
8
- import { VariablesPanel, type StudioEditPersistenceProps } from "./panels/VariablesPanel";
11
+ import { VariablesPanel } from "./panels/VariablesPanel";
9
12
  import { PanelTabButton } from "./PanelTabButton";
10
13
  import { usePreviewVariablesStore } from "../hooks/previewVariablesStore";
11
14
  import type { RenderJob } from "./renders/useRenderQueue";
12
- import type { BlockParam } from "@hyperframes/core/registry";
13
15
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
14
- import type { Composition } from "@hyperframes/sdk";
15
- import type { EditHistoryKind } from "../utils/editHistory";
16
- import { useSlideshowPersist, type UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
16
+ import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
17
17
  import { useSlideshowTabState } from "../hooks/useSlideshowTabState";
18
18
  import { DesignPanelPromoteProvider } from "./DesignPanelPromoteProvider";
19
19
  import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext";
@@ -27,52 +27,10 @@ import {
27
27
  EMPTY_COLOR_GRADING_SCOPE_RESULT,
28
28
  type ColorGradingScope,
29
29
  } from "./studioColorGradingScope";
30
- import type {
31
- AddMediaOverlayHandler,
32
- BackgroundRemovalProgress,
33
- } from "./editor/propertyPanelTypes";
34
- import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
30
+ import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
31
+ import { timelineKeysForSelections } from "../utils/studioHelpers";
35
32
  import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
36
33
 
37
- export interface StudioRightPanelProps extends StudioEditPersistenceProps {
38
- designPanelActive: boolean;
39
- activeBlockParams?: {
40
- blockName: string;
41
- blockTitle: string;
42
- params: BlockParam[];
43
- compositionPath: string;
44
- } | null;
45
- onCloseBlockParams?: () => void;
46
- recordingState?: "idle" | "recording" | "preview";
47
- recordingDuration?: number;
48
- onToggleRecording?: () => void;
49
- /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
50
- sdkSession: Composition | null;
51
- publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
52
- /**
53
- * Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
54
- * opens its own separate SDK session scoped to the selected element's own
55
- * file (needed so promoting inside a sub-composition binds a variable there,
56
- * not on the host) — for a top-level selection that's the SAME file this
57
- * session already has open, so a write through that other session leaves
58
- * this one holding stale in-memory content. The self-write-echo registry
59
- * that normally suppresses redundant reloads is keyed by file path only, not
60
- * by session instance, so it wrongly treats the sibling session's write as
61
- * "our own echo" and never reloads on its own — this must be called
62
- * explicitly after such a write.
63
- */
64
- forceReloadSdkSession?: () => void;
65
- reloadPreview: () => void;
66
- domEditSaveTimestampRef: MutableRefObject<number>;
67
- recordEdit: (entry: {
68
- label: string;
69
- kind: EditHistoryKind;
70
- files: Record<string, { before: string; after: string }>;
71
- }) => Promise<void>;
72
- onToggleElementHidden?: ToggleHiddenHandler;
73
- onAddMediaOverlay?: AddMediaOverlayHandler;
74
- }
75
-
76
34
  // fallow-ignore-next-line complexity
77
35
  export function StudioRightPanel({
78
36
  designPanelActive,
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Props for StudioRightPanel.
3
+ *
4
+ * Kept beside the component rather than inside it: the panel is at the file-size
5
+ * cap, and this block is the part that changes least, so moving it keeps the
6
+ * component's own diffs small and readable.
7
+ */
8
+
9
+ import type { MutableRefObject } from "react";
10
+ import type { StudioEditPersistenceProps } from "./panels/VariablesPanel";
11
+ import type { BlockParam } from "@hyperframes/core/registry";
12
+ import type { Composition } from "@hyperframes/sdk";
13
+ import type { EditHistoryKind } from "../utils/editHistory";
14
+ import type { UseSlideshowPersistParams } from "../hooks/useSlideshowPersist";
15
+ import type { AddMediaOverlayHandler } from "./editor/propertyPanelTypes";
16
+ import type { ToggleHiddenHandler } from "../utils/studioHelpers";
17
+
18
+ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
19
+ designPanelActive: boolean;
20
+ activeBlockParams?: {
21
+ blockName: string;
22
+ blockTitle: string;
23
+ params: BlockParam[];
24
+ compositionPath: string;
25
+ } | null;
26
+ onCloseBlockParams?: () => void;
27
+ recordingState?: "idle" | "recording" | "preview";
28
+ recordingDuration?: number;
29
+ onToggleRecording?: () => void;
30
+ /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
31
+ sdkSession: Composition | null;
32
+ publishSdkSession: NonNullable<UseSlideshowPersistParams["publishSdkSession"]>;
33
+ /**
34
+ * Forces THIS `sdkSession` to re-open from disk. DesignPanelPromoteProvider
35
+ * opens its own separate SDK session scoped to the selected element's own
36
+ * file (needed so promoting inside a sub-composition binds a variable there,
37
+ * not on the host) — for a top-level selection that's the SAME file this
38
+ * session already has open, so a write through that other session leaves
39
+ * this one holding stale in-memory content. The self-write-echo registry
40
+ * that normally suppresses redundant reloads is keyed by file path only, not
41
+ * by session instance, so it wrongly treats the sibling session's write as
42
+ * "our own echo" and never reloads on its own — this must be called
43
+ * explicitly after such a write.
44
+ */
45
+ forceReloadSdkSession?: () => void;
46
+ reloadPreview: () => void;
47
+ domEditSaveTimestampRef: MutableRefObject<number>;
48
+ recordEdit: (entry: {
49
+ label: string;
50
+ kind: EditHistoryKind;
51
+ files: Record<string, { before: string; after: string }>;
52
+ }) => Promise<void>;
53
+ onToggleElementHidden?: ToggleHiddenHandler;
54
+ onAddMediaOverlay?: AddMediaOverlayHandler;
55
+ }
@@ -8,6 +8,7 @@ import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
9
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
10
  import { SpeedMenu } from "./SpeedMenu";
11
+ import { VolumeControl } from "./VolumeControl";
11
12
 
12
13
  /* ── Icon sub-components ─────────────────────────────────────────── */
13
14
 
@@ -55,60 +56,6 @@ function PlayPauseMorphIcon({ playing }: { playing: boolean }) {
55
56
 
56
57
  /* ── Button sub-components ───────────────────────────────────────── */
57
58
 
58
- const MuteButton = memo(function MuteButton({
59
- audioMuted,
60
- controlsDisabled,
61
- setAudioMuted,
62
- }: {
63
- audioMuted: boolean;
64
- controlsDisabled: boolean;
65
- setAudioMuted: (v: boolean) => void;
66
- }) {
67
- const label = audioMuted ? "Unmute audio" : "Mute audio";
68
- return (
69
- <Tooltip label={label}>
70
- <button
71
- type="button"
72
- onClick={() => {
73
- trackStudioEvent("playback", { action: "mute_toggle", muted: !audioMuted });
74
- setAudioMuted(!audioMuted);
75
- }}
76
- disabled={controlsDisabled}
77
- aria-label={label}
78
- aria-pressed={audioMuted}
79
- className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
80
- audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
81
- }`}
82
- >
83
- <svg
84
- width="13"
85
- height="13"
86
- viewBox="0 0 24 24"
87
- fill="none"
88
- stroke="currentColor"
89
- strokeWidth="2"
90
- strokeLinecap="round"
91
- strokeLinejoin="round"
92
- aria-hidden="true"
93
- >
94
- <path d="M11 5 6 9H3v6h3l5 4V5Z" />
95
- {audioMuted ? (
96
- <>
97
- <path d="m19 9-6 6" />
98
- <path d="m13 9 6 6" />
99
- </>
100
- ) : (
101
- <>
102
- <path d="M15.5 8.5a5 5 0 0 1 0 7" />
103
- <path d="M18.5 5.5a9 9 0 0 1 0 13" />
104
- </>
105
- )}
106
- </svg>
107
- </button>
108
- </Tooltip>
109
- );
110
- });
111
-
112
59
  const LoopButton = memo(function LoopButton({
113
60
  loopEnabled,
114
61
  disabled,
@@ -228,9 +175,11 @@ export const PlayerControls = memo(function PlayerControls({
228
175
  const timelineReady = usePlayerStore((s) => s.timelineReady);
229
176
  const playbackRate = usePlayerStore((s) => s.playbackRate);
230
177
  const audioMuted = usePlayerStore((s) => s.audioMuted);
178
+ const audioVolume = usePlayerStore((s) => s.audioVolume);
231
179
  const loopEnabled = usePlayerStore((s) => s.loopEnabled);
232
180
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
233
181
  const setAudioMuted = usePlayerStore.getState().setAudioMuted;
182
+ const setAudioVolume = usePlayerStore.getState().setAudioVolume;
234
183
  const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
235
184
  const inPoint = usePlayerStore((s) => s.inPoint);
236
185
  const outPoint = usePlayerStore((s) => s.outPoint);
@@ -313,10 +262,12 @@ export const PlayerControls = memo(function PlayerControls({
313
262
  </Tooltip>
314
263
 
315
264
  <div className="flex min-w-0 items-center justify-self-end">
316
- <MuteButton
265
+ <VolumeControl
317
266
  audioMuted={audioMuted}
318
- controlsDisabled={controlsDisabled}
267
+ audioVolume={audioVolume}
268
+ disabled={controlsDisabled}
319
269
  setAudioMuted={setAudioMuted}
270
+ setAudioVolume={setAudioVolume}
320
271
  />
321
272
  <SpeedMenu
322
273
  playbackRate={playbackRate}
@@ -27,7 +27,6 @@ import {
27
27
  type MouseEvent as ReactMouseEvent,
28
28
  } from "react";
29
29
  import {
30
- resolveAutomationRange,
31
30
  sampleAutomationLane,
32
31
  type AutomationRange,
33
32
  type HfAutomation,
@@ -42,13 +41,7 @@ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
42
41
  import { generateShape, type AutomationShapeId } from "./automationShapes";
43
42
  import { simplifyPoints } from "./automationSimplify";
44
43
  import { pointInSelection, pointsIn, replaceRange } from "./automationLaneSelection";
45
- import { getTimelineLaneTop } from "./timelineLayout";
46
44
  import { defaultTimelineTheme } from "./timelineTheme";
47
- import { groupAutomationLanes } from "./automationLaneData";
48
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
49
- import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
50
- import type { TimelineElement } from "../store/playerStore";
51
- import type { UseAutomationLanesResult } from "./useAutomationLanes";
52
45
 
53
46
  /**
54
47
  * Drawn radius of a breakpoint.
@@ -504,171 +497,3 @@ export function TimelineAutomationLane({
504
497
  </div>
505
498
  );
506
499
  }
507
-
508
- /** Which shared rows one clip draws into, and with which of its lanes. */
509
- interface ClipLaneRow {
510
- lane: HfAutomationLane;
511
- rowIndex: number;
512
- }
513
-
514
- /**
515
- * One clip's envelopes, each in the shared row its property owns.
516
- *
517
- * Its own component because every clip on the row needs its own binding, its own
518
- * gestures and its own selection box — a shared row is a shared lane track, not a
519
- * shared envelope, and two clips' curves must never drag as one thing. Hooks
520
- * cannot run in a loop, so the loop is over components.
521
- */
522
- function ClipAutomationLanes({
523
- element,
524
- rows,
525
- isSelected,
526
- lanes,
527
- pps,
528
- top,
529
- accentColor,
530
- currentTime,
531
- beatTimes,
532
- }: {
533
- element: TimelineElement;
534
- rows: readonly ClipLaneRow[];
535
- isSelected: boolean;
536
- lanes: UseAutomationLanesResult;
537
- pps: number;
538
- /** y of the first automation row on this track. */
539
- top: number;
540
- accentColor: string;
541
- currentTime: number;
542
- beatTimes?: readonly number[];
543
- }) {
544
- // Beats inside this clip, in the clip's own frame — the lane's times are
545
- // clip-local, and a beat outside the clip can never be snapped to anyway.
546
- const snapTimes = useMemo(
547
- () =>
548
- (beatTimes ?? [])
549
- .filter((t) => t >= element.start && t <= element.start + element.duration)
550
- .map((t) => t - element.start),
551
- [beatTimes, element.start, element.duration],
552
- );
553
- const bound = lanes.bind(element, isSelected);
554
- // Stale-selection guard: the selected lane's target can vanish out from under
555
- // it (e.g. its effect got deleted from the chain, dropping the lane), leaving
556
- // a rectangle selecting nothing. Clear it rather than let it point at a
557
- // target that no longer draws. Above the empty-rows return, because a clip
558
- // that draws nothing is exactly when a selection goes stale.
559
- useEffect(() => {
560
- const target = bound.selection?.target;
561
- if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) {
562
- bound.onRangeClear();
563
- }
564
- }, [bound]);
565
- if (rows.length === 0) return null;
566
- const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
567
- return (
568
- <>
569
- {rows.map(({ lane, rowIndex }) => {
570
- const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
571
- // A lane whose target no longer resolves was already dropped upstream;
572
- // this is belt and braces so a row can never draw on the wrong axis.
573
- if (!range) return null;
574
- return (
575
- <TimelineAutomationLane
576
- key={lane.target}
577
- duration={element.duration}
578
- widthPx={Math.max(element.duration * pps, 4)}
579
- leftPx={element.start * pps}
580
- topPx={top + rowIndex * AUTOMATION_LANE_H}
581
- automation={bound.automation}
582
- target={lane.target}
583
- range={range}
584
- accentColor={accentColor}
585
- playheadSec={inClip ? currentTime - element.start : null}
586
- onPreview={bound.onPreview}
587
- onCommit={bound.onCommit}
588
- onSelect={bound.onSelect}
589
- snapTimes={snapTimes}
590
- readOnly={bound.readOnly}
591
- rangeSelection={
592
- bound.selection?.target === lane.target
593
- ? {
594
- t0: bound.selection.t0,
595
- t1: bound.selection.t1,
596
- v0: bound.selection.v0,
597
- v1: bound.selection.v1,
598
- }
599
- : null
600
- }
601
- onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
602
- onRangeClear={bound.onRangeClear}
603
- />
604
- );
605
- })}
606
- </>
607
- );
608
- }
609
-
610
- export interface TimelineAutomationLaneSlotProps {
611
- /** Every clip on the track, in row order — not just the selected one. */
612
- elements: readonly TimelineElement[];
613
- isSelected: (element: TimelineElement) => boolean;
614
- lanes: UseAutomationLanesResult;
615
- pps: number;
616
- /** Keyframe lanes already stacked above, which automation sits under. */
617
- laneCount: number;
618
- accentColor: string;
619
- /** Composition-time playhead; the slot converts it to clip-local. */
620
- currentTime: number;
621
- /** Composition-time beat grid; the slot converts it to clip-local too. */
622
- beatTimes?: readonly number[];
623
- }
624
-
625
- /**
626
- * Every automated parameter on this TRACK, one lane per row — the way a DAW
627
- * stacks them, so two envelopes can be read and edited without swapping a
628
- * control to see either.
629
- *
630
- * Rows belong to the track, not to a clip: clips sharing a row share a row per
631
- * property (see `groupAutomationLanes`), each drawing over its own span, and a
632
- * clip that does not automate that property leaves its stretch empty. Binding one
633
- * clip at a time is what made the visible envelopes change with the selection.
634
- */
635
- export function TimelineAutomationLaneSlot({
636
- elements,
637
- isSelected,
638
- lanes,
639
- pps,
640
- laneCount,
641
- accentColor,
642
- currentTime,
643
- beatTimes,
644
- }: TimelineAutomationLaneSlotProps) {
645
- const clips = elements.filter(isAudioTimelineElement);
646
- const rowsByClip = new Map<string, ClipLaneRow[]>();
647
- groupAutomationLanes(clips).forEach((group, rowIndex) => {
648
- for (const entry of group.entries) {
649
- const key = getTimelineElementIdentity(entry.element);
650
- const rows = rowsByClip.get(key);
651
- if (rows) rows.push({ lane: entry.lane, rowIndex });
652
- else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
653
- }
654
- });
655
- const top = getTimelineLaneTop(laneCount);
656
- return (
657
- <>
658
- {clips.map((element) => (
659
- <ClipAutomationLanes
660
- key={getTimelineElementIdentity(element)}
661
- element={element}
662
- rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
663
- isSelected={isSelected(element)}
664
- lanes={lanes}
665
- pps={pps}
666
- top={top}
667
- accentColor={accentColor}
668
- currentTime={currentTime}
669
- beatTimes={beatTimes}
670
- />
671
- ))}
672
- </>
673
- );
674
- }
@@ -2,7 +2,7 @@
2
2
  import { act } from "react";
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import { createRoot } from "react-dom/client";
5
- import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
5
+ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
6
6
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
7
7
  import { PAD_X } from "./automationLaneGeometry";
8
8
  import { getTimelineLaneTop } from "./timelineLayout";
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Track-level automation: the stack of shared rows an audio track draws, and
3
+ * one clip's envelopes within them.
4
+ *
5
+ * Split from TimelineAutomationLane.tsx, which owns the single-lane editor this
6
+ * renders many of. The dependency runs one way, slot -> lane, so the editor
7
+ * stays readable on its own and this file keeps the row-layout concern.
8
+ */
9
+
10
+ import { useEffect, useMemo } from "react";
11
+ import { resolveAutomationRange, type HfAutomationLane } from "@hyperframes/core/audio-automation";
12
+ import { TimelineAutomationLane } from "./TimelineAutomationLane";
13
+ import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
+ import { getTimelineLaneTop } from "./timelineLayout";
15
+ import { groupAutomationLanes } from "./automationLaneData";
16
+ import { isAudioTimelineElement } from "../../utils/timelineInspector";
17
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
18
+ import type { TimelineElement } from "../store/playerStore";
19
+ import type { UseAutomationLanesResult } from "./useAutomationLanes";
20
+
21
+ /** Which shared rows one clip draws into, and with which of its lanes. */
22
+ interface ClipLaneRow {
23
+ lane: HfAutomationLane;
24
+ rowIndex: number;
25
+ }
26
+
27
+ /**
28
+ * One clip's envelopes, each in the shared row its property owns.
29
+ *
30
+ * Its own component because every clip on the row needs its own binding, its own
31
+ * gestures and its own selection box — a shared row is a shared lane track, not a
32
+ * shared envelope, and two clips' curves must never drag as one thing. Hooks
33
+ * cannot run in a loop, so the loop is over components.
34
+ */
35
+ function ClipAutomationLanes({
36
+ element,
37
+ rows,
38
+ isSelected,
39
+ lanes,
40
+ pps,
41
+ top,
42
+ accentColor,
43
+ currentTime,
44
+ beatTimes,
45
+ }: {
46
+ element: TimelineElement;
47
+ rows: readonly ClipLaneRow[];
48
+ isSelected: boolean;
49
+ lanes: UseAutomationLanesResult;
50
+ pps: number;
51
+ /** y of the first automation row on this track. */
52
+ top: number;
53
+ accentColor: string;
54
+ currentTime: number;
55
+ beatTimes?: readonly number[];
56
+ }) {
57
+ // Beats inside this clip, in the clip's own frame — the lane's times are
58
+ // clip-local, and a beat outside the clip can never be snapped to anyway.
59
+ const snapTimes = useMemo(
60
+ () =>
61
+ (beatTimes ?? [])
62
+ .filter((t) => t >= element.start && t <= element.start + element.duration)
63
+ .map((t) => t - element.start),
64
+ [beatTimes, element.start, element.duration],
65
+ );
66
+ const bound = lanes.bind(element, isSelected);
67
+ // Stale-selection guard: the selected lane's target can vanish out from under
68
+ // it (e.g. its effect got deleted from the chain, dropping the lane), leaving
69
+ // a rectangle selecting nothing. Clear it rather than let it point at a
70
+ // target that no longer draws. Above the empty-rows return, because a clip
71
+ // that draws nothing is exactly when a selection goes stale.
72
+ useEffect(() => {
73
+ const target = bound.selection?.target;
74
+ if (target !== undefined && !bound.lanes.some((lane) => lane.target === target)) {
75
+ bound.onRangeClear();
76
+ }
77
+ }, [bound]);
78
+ if (rows.length === 0) return null;
79
+ const inClip = currentTime >= element.start && currentTime <= element.start + element.duration;
80
+ return (
81
+ <>
82
+ {rows.map(({ lane, rowIndex }) => {
83
+ const range = resolveAutomationRange(lane.target, bound.chain ?? undefined);
84
+ // A lane whose target no longer resolves was already dropped upstream;
85
+ // this is belt and braces so a row can never draw on the wrong axis.
86
+ if (!range) return null;
87
+ return (
88
+ <TimelineAutomationLane
89
+ key={lane.target}
90
+ duration={element.duration}
91
+ widthPx={Math.max(element.duration * pps, 4)}
92
+ leftPx={element.start * pps}
93
+ topPx={top + rowIndex * AUTOMATION_LANE_H}
94
+ automation={bound.automation}
95
+ target={lane.target}
96
+ range={range}
97
+ accentColor={accentColor}
98
+ playheadSec={inClip ? currentTime - element.start : null}
99
+ onPreview={bound.onPreview}
100
+ onCommit={bound.onCommit}
101
+ onSelect={bound.onSelect}
102
+ snapTimes={snapTimes}
103
+ readOnly={bound.readOnly}
104
+ rangeSelection={
105
+ bound.selection?.target === lane.target
106
+ ? {
107
+ t0: bound.selection.t0,
108
+ t1: bound.selection.t1,
109
+ v0: bound.selection.v0,
110
+ v1: bound.selection.v1,
111
+ }
112
+ : null
113
+ }
114
+ onRangeSelect={(t0, t1, v0, v1) => bound.onRangeSelect(lane.target, t0, t1, v0, v1)}
115
+ onRangeClear={bound.onRangeClear}
116
+ />
117
+ );
118
+ })}
119
+ </>
120
+ );
121
+ }
122
+
123
+ export interface TimelineAutomationLaneSlotProps {
124
+ /** Every clip on the track, in row order — not just the selected one. */
125
+ elements: readonly TimelineElement[];
126
+ isSelected: (element: TimelineElement) => boolean;
127
+ lanes: UseAutomationLanesResult;
128
+ pps: number;
129
+ /** Keyframe lanes already stacked above, which automation sits under. */
130
+ laneCount: number;
131
+ accentColor: string;
132
+ /** Composition-time playhead; the slot converts it to clip-local. */
133
+ currentTime: number;
134
+ /** Composition-time beat grid; the slot converts it to clip-local too. */
135
+ beatTimes?: readonly number[];
136
+ }
137
+
138
+ /**
139
+ * Every automated parameter on this TRACK, one lane per row — the way a DAW
140
+ * stacks them, so two envelopes can be read and edited without swapping a
141
+ * control to see either.
142
+ *
143
+ * Rows belong to the track, not to a clip: clips sharing a row share a row per
144
+ * property (see `groupAutomationLanes`), each drawing over its own span, and a
145
+ * clip that does not automate that property leaves its stretch empty. Binding one
146
+ * clip at a time is what made the visible envelopes change with the selection.
147
+ */
148
+ export function TimelineAutomationLaneSlot({
149
+ elements,
150
+ isSelected,
151
+ lanes,
152
+ pps,
153
+ laneCount,
154
+ accentColor,
155
+ currentTime,
156
+ beatTimes,
157
+ }: TimelineAutomationLaneSlotProps) {
158
+ const clips = elements.filter(isAudioTimelineElement);
159
+ const rowsByClip = new Map<string, ClipLaneRow[]>();
160
+ groupAutomationLanes(clips).forEach((group, rowIndex) => {
161
+ for (const entry of group.entries) {
162
+ const key = getTimelineElementIdentity(entry.element);
163
+ const rows = rowsByClip.get(key);
164
+ if (rows) rows.push({ lane: entry.lane, rowIndex });
165
+ else rowsByClip.set(key, [{ lane: entry.lane, rowIndex }]);
166
+ }
167
+ });
168
+ const top = getTimelineLaneTop(laneCount);
169
+ return (
170
+ <>
171
+ {clips.map((element) => (
172
+ <ClipAutomationLanes
173
+ key={getTimelineElementIdentity(element)}
174
+ element={element}
175
+ rows={rowsByClip.get(getTimelineElementIdentity(element)) ?? []}
176
+ isSelected={isSelected(element)}
177
+ lanes={lanes}
178
+ pps={pps}
179
+ top={top}
180
+ accentColor={accentColor}
181
+ currentTime={currentTime}
182
+ beatTimes={beatTimes}
183
+ />
184
+ ))}
185
+ </>
186
+ );
187
+ }
@@ -3,7 +3,7 @@ import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
4
  import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
5
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
6
- import { TimelineAutomationLaneSlot } from "./TimelineAutomationLane";
6
+ import { TimelineAutomationLaneSlot } from "./TimelineAutomationLaneSlot";
7
7
  import { useAutomationLanes } from "./useAutomationLanes";
8
8
  import { useAutomationSelectionKeyboard } from "../../hooks/useAutomationSelectionKeyboard";
9
9
  import { TimelineTrackHeader } from "./TimelineTrackHeader";