@hyperframes/studio 0.8.6 → 0.8.8

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 (155) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-DYTqZd0d.js} +1 -1
  2. package/dist/assets/{index-bv8b5eiQ.js → index-B69X8UsY.js} +1 -1
  3. package/dist/assets/index-Ba9zbpT9.css +1 -0
  4. package/dist/assets/{index-Ci7Uy8fR.js → index-D6CV2x61.js} +1 -1
  5. package/dist/assets/index-ng4tq6YE.js +428 -0
  6. package/dist/index.d.ts +108 -18
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10335 -7201
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +3 -5
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +253 -1
  89. package/src/hooks/timelineTrackVisibility.ts +207 -8
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +8 -8
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +8 -1
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.test.tsx +2 -1
  124. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  125. package/src/player/components/TimelineTrackPlainHeader.test.tsx +161 -0
  126. package/src/player/components/TimelineTrackPlainHeader.tsx +126 -0
  127. package/src/player/components/VideoThumbnail.tsx +6 -1
  128. package/src/player/components/menuKeyboardNav.ts +47 -0
  129. package/src/player/components/timelineCallbacks.ts +40 -1
  130. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  131. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  132. package/src/player/components/timelineLaneProps.ts +3 -0
  133. package/src/player/components/timelineLayout.ts +1 -1
  134. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  135. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  136. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  137. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  138. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  139. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  140. package/src/player/components/useTimelineTrackDerivations.test.ts +103 -0
  141. package/src/player/components/useTimelineTrackDerivations.ts +143 -6
  142. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  143. package/src/player/hooks/previewMessageRouter.ts +127 -0
  144. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  145. package/src/player/lib/timelineDOM.ts +51 -0
  146. package/src/player/store/audioSoloSlice.test.ts +113 -0
  147. package/src/player/store/audioSoloSlice.ts +43 -0
  148. package/src/player/store/editingModeSlice.ts +45 -0
  149. package/src/player/store/groupLevels.ts +33 -0
  150. package/src/player/store/keyframeSlice.ts +26 -0
  151. package/src/player/store/playerStore.ts +21 -22
  152. package/src/player/store/timelineElement.ts +10 -0
  153. package/src/telemetry/events.ts +12 -0
  154. package/dist/assets/index-Cy6fGN1u.js +0 -428
  155. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ /**
4
+ * The visibility control's accessible contract.
5
+ *
6
+ * The audio wording ran behind the `audio-track-mute` canary at 0%, so it had
7
+ * never rendered in any suite: it returned "Muted" / "Mute", which named the
8
+ * CURRENT state rather than the action, and dropped the track suffix so every
9
+ * audio row shared one accessible name. Music plus VO is the ordinary case, so
10
+ * that is two identical buttons. Pinned here because the label and the icon are
11
+ * the whole identity of this control.
12
+ */
13
+
14
+ import React, { act } from "react";
15
+ import { createRoot } from "react-dom/client";
16
+ import { afterEach, describe, expect, it, vi } from "vitest";
17
+ import { PlainTrackHeader, VisibilityButton } from "./TimelineTrackPlainHeader";
18
+
19
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
20
+
21
+ afterEach(() => {
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ function renderButton(props: {
26
+ hidden: boolean;
27
+ isAudioTrack?: boolean;
28
+ trackDisplayNumber: number | null;
29
+ }): { host: HTMLElement; unmount: () => void; onToggle: ReturnType<typeof vi.fn> } {
30
+ const host = document.createElement("div");
31
+ document.body.append(host);
32
+ const root = createRoot(host);
33
+ const onToggle = vi.fn();
34
+ act(() =>
35
+ root.render(
36
+ React.createElement(VisibilityButton, {
37
+ hidden: props.hidden,
38
+ trackNumber: 7,
39
+ trackDisplayNumber: props.trackDisplayNumber,
40
+ visible: true,
41
+ isAudioTrack: props.isAudioTrack,
42
+ onToggle,
43
+ }),
44
+ ),
45
+ );
46
+ return { host, unmount: () => act(() => root.unmount()), onToggle };
47
+ }
48
+
49
+ const labelOf = (host: HTMLElement) => host.querySelector("button")?.getAttribute("aria-label");
50
+
51
+ describe("VisibilityButton", () => {
52
+ it("names the action, not the state, on an audible audio track", () => {
53
+ const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
54
+ expect(labelOf(view.host)).toBe("Mute track 2");
55
+ view.unmount();
56
+ });
57
+
58
+ // The half that was wrong: a muted row read "Muted", so nothing told a
59
+ // screen-reader user that activating it would unmute.
60
+ it("promises the un-mute when the audio track is already muted", () => {
61
+ const view = renderButton({ hidden: true, isAudioTrack: true, trackDisplayNumber: 2 });
62
+ expect(labelOf(view.host)).toBe("Unmute track 2");
63
+ view.unmount();
64
+ });
65
+
66
+ it("keeps each audio row's name unique, so two tracks are distinguishable", () => {
67
+ const first = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 1 });
68
+ const second = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 3 });
69
+ expect(labelOf(first.host)).toBe("Mute track 1");
70
+ expect(labelOf(second.host)).toBe("Mute track 3");
71
+ expect(labelOf(first.host)).not.toBe(labelOf(second.host));
72
+ first.unmount();
73
+ second.unmount();
74
+ });
75
+
76
+ it("still says Hide/Show on a visual track", () => {
77
+ const shown = renderButton({ hidden: false, trackDisplayNumber: 2 });
78
+ expect(labelOf(shown.host)).toBe("Hide track 2");
79
+ shown.unmount();
80
+ const hiddenRow = renderButton({ hidden: true, trackDisplayNumber: 2 });
81
+ expect(labelOf(hiddenRow.host)).toBe("Show track 2");
82
+ hiddenRow.unmount();
83
+ });
84
+
85
+ // The callback acts on the REAL track key; the display row rides along so the
86
+ // undo-history label announces the same row this button just did, instead of
87
+ // re-deriving it from an ordering that has no group anchors in it.
88
+ it("toggles the real track number and passes the row it announced", () => {
89
+ const view = renderButton({ hidden: false, isAudioTrack: true, trackDisplayNumber: 2 });
90
+ view.host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
91
+ expect(view.onToggle).toHaveBeenCalledWith(7, true, 2);
92
+ view.unmount();
93
+ });
94
+ });
95
+
96
+ describe("PlainTrackHeader", () => {
97
+ function renderHeader(overrides: Partial<Parameters<typeof PlainTrackHeader>[0]> = {}) {
98
+ const host = document.createElement("div");
99
+ document.body.append(host);
100
+ const root = createRoot(host);
101
+ const onToggleSolo = vi.fn();
102
+ act(() =>
103
+ root.render(
104
+ React.createElement(PlainTrackHeader, {
105
+ trackNumber: 0,
106
+ trackDisplayNumber: 1,
107
+ trackLabel: "Voiceover",
108
+ clipCount: 1,
109
+ isTrackHidden: false,
110
+ isAudioTrack: true,
111
+ onToggleTrackHidden: vi.fn(),
112
+ showTrackLabel: true,
113
+ isGroupMuted: false,
114
+ isSoloed: false,
115
+ onToggleSolo,
116
+ ...overrides,
117
+ }),
118
+ ),
119
+ );
120
+ return { host, unmount: () => act(() => root.unmount()), onToggleSolo };
121
+ }
122
+
123
+ const soloButton = (host: HTMLElement) =>
124
+ host.querySelector('button[aria-label="Hear only this"]');
125
+ const labelSpan = (host: HTMLElement) => host.querySelector("span.min-w-0");
126
+
127
+ it("offers solo on an audio track and reports its pressed state", () => {
128
+ const view = renderHeader({ isSoloed: true });
129
+ expect(soloButton(view.host)?.getAttribute("aria-pressed")).toBe("true");
130
+ view.unmount();
131
+ });
132
+
133
+ it("withholds solo from a visual track", () => {
134
+ const view = renderHeader({ isAudioTrack: false });
135
+ expect(soloButton(view.host)).toBeNull();
136
+ view.unmount();
137
+ });
138
+
139
+ // A group-muted member is silent without being hidden itself, so the strike
140
+ // is the only thing that says so — and the title has to explain why, since a
141
+ // user who never touched THIS row's mute is looking for the reason.
142
+ it("strikes the label through when the row's own mute is on", () => {
143
+ const view = renderHeader({ isTrackHidden: true });
144
+ expect(labelSpan(view.host)?.className).toContain("line-through");
145
+ view.unmount();
146
+ });
147
+
148
+ it("strikes it through for a group mute too, and says which", () => {
149
+ const view = renderHeader({ isGroupMuted: true });
150
+ expect(labelSpan(view.host)?.className).toContain("line-through");
151
+ expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover (group muted)");
152
+ view.unmount();
153
+ });
154
+
155
+ it("leaves an audible row unstruck", () => {
156
+ const view = renderHeader();
157
+ expect(labelSpan(view.host)?.className).not.toContain("line-through");
158
+ expect(labelSpan(view.host)?.getAttribute("title")).toBe("Voiceover");
159
+ view.unmount();
160
+ });
161
+ });
@@ -0,0 +1,126 @@
1
+ import { Eye, EyeSlash, SpeakerHigh, SpeakerSlash } from "@phosphor-icons/react";
2
+ import { Music } from "../../icons/SystemIcons";
3
+ import { TimelineSoloButton } from "./TimelineSoloButton";
4
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
5
+ import { TrackClipCount } from "./TrackClipCount";
6
+ import { trackDisplaySuffix } from "./timelineTrackDisplay";
7
+
8
+ /**
9
+ * Audio tracks say "Mute", not "Hide" — the eye IS mute for sound-only rows.
10
+ *
11
+ * Action-phrased and per-track, like the visual branch: `hidden` here is the
12
+ * CURRENT state, so the name has to promise the opposite. It read "Muted" /
13
+ * "Mute" until the rollout, which named the state instead of the action (a
14
+ * screen-reader user could not tell that activating an already-muted row would
15
+ * unmute it) and dropped `suffix`, so every audio row shared one accessible
16
+ * name — music plus VO being the ordinary case. The wording matches the undo
17
+ * entry `timelineTrackVisibility` writes for the same click, where `hidden` is
18
+ * the INCOMING state and the two therefore read inverted.
19
+ */
20
+ function visibilityButtonLabel(showAsMute: boolean, hidden: boolean, suffix: string): string {
21
+ if (showAsMute) return hidden ? `Unmute track${suffix}` : `Mute track${suffix}`;
22
+ return hidden ? `Show track${suffix}` : `Hide track${suffix}`;
23
+ }
24
+
25
+ function visibilityButtonIcon(showAsMute: boolean, hidden: boolean) {
26
+ const Icon = showAsMute ? (hidden ? SpeakerSlash : SpeakerHigh) : hidden ? EyeSlash : Eye;
27
+ return <Icon size={14} weight="bold" aria-hidden="true" />;
28
+ }
29
+
30
+ export function VisibilityButton({
31
+ hidden,
32
+ trackNumber,
33
+ trackDisplayNumber,
34
+ visible,
35
+ isAudioTrack,
36
+ onToggle,
37
+ }: {
38
+ hidden: boolean;
39
+ trackNumber: number;
40
+ trackDisplayNumber: number | null;
41
+ visible: boolean;
42
+ isAudioTrack?: boolean;
43
+ onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
44
+ }) {
45
+ if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
46
+ // Display number in the text, real key in the callback. The two must not be
47
+ // conflated in either direction.
48
+ const suffix = trackDisplaySuffix(trackDisplayNumber);
49
+ const showAsMute = Boolean(isAudioTrack);
50
+ const label = visibilityButtonLabel(showAsMute, hidden, suffix);
51
+ return (
52
+ <button
53
+ type="button"
54
+ aria-label={label}
55
+ title={label}
56
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-[-1px] focus-visible:outline-[#3CE6AC] ${
57
+ hidden ? "text-[#3CE6AC] hover:text-white" : "text-white/35 hover:text-white/75"
58
+ }`}
59
+ onPointerDown={(event) => event.stopPropagation()}
60
+ onClick={(event) => {
61
+ event.stopPropagation();
62
+ void onToggle?.(trackNumber, !hidden, trackDisplayNumber);
63
+ }}
64
+ >
65
+ {visibilityButtonIcon(showAsMute, hidden)}
66
+ </button>
67
+ );
68
+ }
69
+
70
+ // The header a track gets when it has no keyframe clip to disclose: label, clip
71
+ // count, eye. Not deprecated — it is the live path for every track without lanes.
72
+ export function PlainTrackHeader({
73
+ trackNumber,
74
+ trackDisplayNumber,
75
+ trackLabel,
76
+ clipCount,
77
+ showTrackLabel,
78
+ isTrackHidden,
79
+ isAudioTrack,
80
+ isGroupMuted,
81
+ isSoloed,
82
+ onToggleSolo,
83
+ onToggleTrackHidden,
84
+ }: {
85
+ trackNumber: number;
86
+ trackDisplayNumber: number | null;
87
+ trackLabel: string;
88
+ clipCount: number;
89
+ isTrackHidden: boolean;
90
+ isAudioTrack: boolean;
91
+ onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
92
+ showTrackLabel: boolean;
93
+ isGroupMuted: boolean;
94
+ isSoloed: boolean;
95
+ onToggleSolo?: (options?: { add?: boolean }) => void;
96
+ }) {
97
+ return (
98
+ <>
99
+ {isAudioTrack && (
100
+ <Music size={12} weight="fill" aria-hidden="true" className="text-white/35" />
101
+ )}
102
+ {showTrackLabel && (
103
+ <span
104
+ className={`min-w-0 flex-1 truncate text-[11px] ${
105
+ isAudioTrack && (isTrackHidden || isGroupMuted) ? "line-through" : ""
106
+ }`}
107
+ title={isGroupMuted && !isTrackHidden ? `${trackLabel} (group muted)` : trackLabel}
108
+ >
109
+ {trackLabel}
110
+ </span>
111
+ )}
112
+ {showTrackLabel && <TrackClipCount clipCount={clipCount} />}
113
+ <VisibilityButton
114
+ hidden={isTrackHidden}
115
+ trackNumber={trackNumber}
116
+ trackDisplayNumber={trackDisplayNumber}
117
+ visible
118
+ isAudioTrack={isAudioTrack}
119
+ onToggle={onToggleTrackHidden}
120
+ />
121
+ {isAudioTrack && onToggleSolo && (
122
+ <TimelineSoloButton isSoloed={isSoloed} onToggle={onToggleSolo} />
123
+ )}
124
+ </>
125
+ );
126
+ }
@@ -110,13 +110,18 @@ export const VideoThumbnail = memo(function VideoThumbnail({
110
110
  )}
111
111
  {snapshot.status === "loading" && urls.length === 0 && (
112
112
  <div
113
- className="absolute inset-0 animate-pulse"
113
+ className="absolute inset-0 animate-pulse motion-reduce:animate-none"
114
114
  style={{
115
115
  background:
116
116
  "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
117
117
  }}
118
118
  />
119
119
  )}
120
+ {snapshot.status === "error" && (
121
+ <div className="absolute inset-0 flex items-center justify-center bg-neutral-900/60">
122
+ <span className="rounded bg-black/50 px-1 text-[8px] text-neutral-500">no preview</span>
123
+ </div>
124
+ )}
120
125
  {label && (
121
126
  <div
122
127
  className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
@@ -0,0 +1,47 @@
1
+ import { useEffect, type RefObject } from "react";
2
+
3
+ /**
4
+ * APG menu keyboard basics for the timeline context menus: focuses the first
5
+ * menu item on open, moves focus with ArrowUp/ArrowDown/Home/End, and restores
6
+ * focus to the previously focused element when the menu unmounts. Pair with
7
+ * `role="menu"` on the container and `role="menuitem"` on the buttons
8
+ * (dismiss/Escape handling stays in useContextMenuDismiss).
9
+ */
10
+ export function useMenuKeyboardNav(menuRef: RefObject<HTMLDivElement | null>): void {
11
+ useEffect(() => {
12
+ const menu = menuRef.current;
13
+ if (!menu) return;
14
+ const previouslyFocused = document.activeElement;
15
+
16
+ const items = () =>
17
+ Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')).filter(
18
+ (el) => !el.disabled,
19
+ );
20
+ items()[0]?.focus();
21
+
22
+ const onKeyDown = (e: KeyboardEvent) => {
23
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
24
+ return;
25
+ }
26
+ const list = items();
27
+ if (list.length === 0) return;
28
+ e.preventDefault();
29
+ const idx = list.findIndex((el) => el === document.activeElement);
30
+ let next: number;
31
+ if (e.key === "ArrowDown") next = idx < 0 ? 0 : (idx + 1) % list.length;
32
+ else if (e.key === "ArrowUp")
33
+ next = idx < 0 ? list.length - 1 : (idx - 1 + list.length) % list.length;
34
+ else if (e.key === "Home") next = 0;
35
+ else next = list.length - 1;
36
+ list[next]?.focus();
37
+ };
38
+ menu.addEventListener("keydown", onKeyDown);
39
+
40
+ return () => {
41
+ menu.removeEventListener("keydown", onKeyDown);
42
+ if (previouslyFocused instanceof HTMLElement && document.contains(previouslyFocused)) {
43
+ previouslyFocused.focus();
44
+ }
45
+ };
46
+ }, [menuRef]);
47
+ }
@@ -67,7 +67,46 @@ export interface TimelineEditCallbacks {
67
67
  }>,
68
68
  options?: { coalesceKey?: string },
69
69
  ) => Promise<void> | void;
70
- onToggleTrackHidden?: (track: number, hidden: boolean) => Promise<void> | void;
70
+ /**
71
+ * `displayNumber` is the row the CLICKED control announced. It travels with
72
+ * the click because the header and the undo-history label derive the row from
73
+ * two different orderings: the header's comes from the group-aware row list
74
+ * (synthetic anchor rows, members pulled contiguous), the history's from a
75
+ * plain ascending sort of element-bearing keys. Once a group exists those
76
+ * disagree, so the same click said "Mute track 2" and recorded "Mute track 1".
77
+ * Passing the rendered number keeps one answer instead of two derivations.
78
+ */
79
+ onToggleTrackHidden?: (
80
+ track: number,
81
+ hidden: boolean,
82
+ displayNumber?: number | null,
83
+ ) => Promise<void> | void;
84
+ /** B7's bus strip: live-write the group's own attribute while dragging. */
85
+ onSetAudioGroupAttributeLive?: (groupId: string, attr: string, value: string | null) => void;
86
+ /** ...and persist one undo entry on release. */
87
+ onSetAudioGroupAttributeQuiet?: (
88
+ groupId: string,
89
+ attr: string,
90
+ value: string | null,
91
+ label: string,
92
+ ) => Promise<void>;
93
+ /** C1's ungrouped-track FX pointer: "Group these clips" — write
94
+ * `data-audio-group` on every one of them, atomically. Same shape B6's
95
+ * carve auto-grouping uses. */
96
+ onGroupClips?: (clipIds: readonly string[], groupId: string) => Promise<void>;
97
+ /** C1's single-clip FX write: addressed by the clip itself rather than the
98
+ * current selection, mirroring `onSetAudioGroupAttributeLive/Quiet`. */
99
+ onSetElementAttributeLive?: (
100
+ element: TimelineElement,
101
+ attr: string,
102
+ value: string | null,
103
+ ) => void;
104
+ onSetElementAttributeQuiet?: (
105
+ element: TimelineElement,
106
+ attr: string,
107
+ value: string | null,
108
+ label: string,
109
+ ) => Promise<void>;
71
110
  onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
72
111
  onSplitElement?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
73
112
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
@@ -58,6 +58,10 @@ function model(overrides: Partial<Parameters<typeof buildTimelineLogicalRows>[0]
58
58
  selectedElementId: "active",
59
59
  selectedElementIds: new Set(),
60
60
  expandedClipIds: new Set(["active"]),
61
+ expandedGroupIds: new Set(),
62
+ expandedLaneOwnerIds: new Set(),
63
+ groups: [],
64
+ trackGroupOf: new Map(),
61
65
  gsapAnimations: new Map([
62
66
  [
63
67
  "active",
@@ -244,6 +248,10 @@ describe("resolveTimelineNavigationTarget", () => {
244
248
  selectedElementId: null,
245
249
  selectedElementIds: new Set(),
246
250
  expandedClipIds: new Set(),
251
+ expandedGroupIds: new Set(),
252
+ expandedLaneOwnerIds: new Set(),
253
+ groups: [],
254
+ trackGroupOf: new Map(),
247
255
  gsapAnimations: new Map(),
248
256
  });
249
257
 
@@ -1,6 +1,7 @@
1
1
  import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
2
2
  import type { TimelineElement } from "../store/playerStore";
3
3
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
4
+ import { groupAutomationLanes } from "./automationLaneData";
4
5
  import {
5
6
  timelineKeyframeSelectionKey,
6
7
  type TimelineKeyframeTarget,
@@ -8,11 +9,13 @@ import {
8
9
  import {
9
10
  timelineClipFocusId,
10
11
  timelineEaseFocusId,
12
+ timelineGroupRowId,
11
13
  timelineKeyframeFocusId,
12
14
  timelinePropertyRowId,
13
15
  timelineTrackRowId,
14
16
  } from "./timelineNavigationIdentity";
15
17
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
18
+ import type { TimelineTrackGroupInfo } from "./useTimelineTrackDerivations";
16
19
 
17
20
  export type TimelineNavigationKey =
18
21
  | "ArrowLeft"
@@ -54,9 +57,11 @@ export interface TimelineLogicalRow {
54
57
  kind: "row";
55
58
  physicalTrackKey: number;
56
59
  logicalIndex: number;
57
- level: 1 | 2;
60
+ level: 1 | 2 | 3;
58
61
  parentId: string | null;
59
62
  elementId: string | null;
63
+ /** Set only on a group's own row (level 1, no clips of its own). */
64
+ groupId?: string;
60
65
  expandable: boolean;
61
66
  expanded: boolean;
62
67
  propertyGroup?: PropertyGroupName;
@@ -65,13 +70,19 @@ export interface TimelineLogicalRow {
65
70
 
66
71
  export type TimelineLogicalTarget = TimelineLogicalRow | TimelineLogicalItem;
67
72
 
68
- interface BuildTimelineLogicalRowsInput {
73
+ export interface BuildTimelineLogicalRowsInput {
69
74
  tracks: readonly (readonly [number, readonly TimelineElement[]])[];
70
75
  displayTrackOrder: readonly number[];
71
76
  laneCounts: ReadonlyMap<string, number>;
72
77
  selectedElementId: string | null;
73
78
  selectedElementIds: ReadonlySet<string>;
74
79
  expandedClipIds: ReadonlySet<string>;
80
+ /** Groups whose member rows the caret has shown (structural, not lanes). */
81
+ expandedGroupIds: ReadonlySet<string>;
82
+ /** Rows (clip id or group id) whose automation-lane rows the `∿` button opened. */
83
+ expandedLaneOwnerIds: ReadonlySet<string>;
84
+ groups: readonly TimelineTrackGroupInfo[];
85
+ trackGroupOf: ReadonlyMap<number, TimelineTrackGroupInfo>;
75
86
  gsapAnimations: ReadonlyMap<string, readonly GsapAnimation[]>;
76
87
  }
77
88
 
@@ -106,6 +117,35 @@ function clipItems(rowId: string, elements: readonly TimelineElement[]): Timelin
106
117
  });
107
118
  }
108
119
 
120
+ /** A track's active clip (if any), its element id, and its automation lanes. */
121
+ function resolveActiveTrackClip(
122
+ elements: readonly TimelineElement[],
123
+ laneCounts: BuildTimelineLogicalRowsInput["laneCounts"],
124
+ selectedElementId: string | null,
125
+ selectedElementIds: ReadonlySet<string>,
126
+ gsapAnimations: BuildTimelineLogicalRowsInput["gsapAnimations"],
127
+ ): {
128
+ activeClip: TimelineElement | null;
129
+ activeId: string | null;
130
+ lanes: ReturnType<typeof getTimelinePropertyLanes>;
131
+ } {
132
+ const activeClip = resolveTrackKeyframeClip(
133
+ elements,
134
+ laneCounts,
135
+ selectedElementId,
136
+ selectedElementIds,
137
+ );
138
+ const activeId = activeClip ? elementId(activeClip) : null;
139
+ const lanes = activeClip
140
+ ? getTimelinePropertyLanes(
141
+ gsapAnimations.get(elementId(activeClip)) ?? [],
142
+ activeClip.start,
143
+ activeClip.duration,
144
+ )
145
+ : [];
146
+ return { activeClip, activeId, lanes };
147
+ }
148
+
109
149
  function keyframeTarget(
110
150
  keyframe: ReturnType<typeof getTimelinePropertyLanes>[number]["keyframes"][number],
111
151
  ): TimelineKeyframeTarget {
@@ -167,6 +207,42 @@ function propertyItems(
167
207
  return items;
168
208
  }
169
209
 
210
+ /** A clip's lanes are visible when either the caret or the `∿` button opened it. */
211
+ function isRowOpen(
212
+ activeId: string | null,
213
+ expandedClipIds: ReadonlySet<string>,
214
+ expandedLaneOwnerIds: ReadonlySet<string>,
215
+ ): boolean {
216
+ if (activeId === null) return false;
217
+ return expandedClipIds.has(activeId) || expandedLaneOwnerIds.has(activeId);
218
+ }
219
+
220
+ /** A single automation-lane row, one level deeper than the track/group row that owns it. */
221
+ function buildLaneRow(
222
+ track: number,
223
+ logicalIndex: number,
224
+ activeId: string,
225
+ activeClip: TimelineElement,
226
+ lane: ReturnType<typeof getTimelinePropertyLanes>[number],
227
+ level: 2 | 3,
228
+ parentId: string,
229
+ ): TimelineLogicalRow {
230
+ const laneRowId = timelinePropertyRowId(activeId, lane.group);
231
+ return {
232
+ id: laneRowId,
233
+ kind: "row",
234
+ physicalTrackKey: track,
235
+ logicalIndex,
236
+ level,
237
+ parentId,
238
+ elementId: activeId,
239
+ expandable: false,
240
+ expanded: false,
241
+ propertyGroup: lane.group,
242
+ items: propertyItems(laneRowId, activeClip, lane.keyframes),
243
+ };
244
+ }
245
+
170
246
  /** Canonical model of the treegrid, independent of which virtual rows or clips are mounted. */
171
247
  export function buildTimelineLogicalRows({
172
248
  tracks,
@@ -175,57 +251,98 @@ export function buildTimelineLogicalRows({
175
251
  selectedElementId,
176
252
  selectedElementIds,
177
253
  expandedClipIds,
254
+ expandedGroupIds,
255
+ expandedLaneOwnerIds,
256
+ groups,
257
+ trackGroupOf,
178
258
  gsapAnimations,
179
259
  }: BuildTimelineLogicalRowsInput): TimelineLogicalRow[] {
180
260
  const trackMap = new Map(tracks);
261
+ const groupByAnchor = new Map(groups.map((group) => [group.anchorKey, group]));
181
262
  const rows: TimelineLogicalRow[] = [];
182
- for (const track of displayTrackOrder) {
263
+
264
+ // A real track's own row (level 1 ungrouped, level 2 under a group) plus,
265
+ // when its clip's lanes are open, the lane rows one level deeper.
266
+ function emitTrack(track: number, level: 1 | 2, parentId: string | null): void {
183
267
  const elements = trackMap.get(track) ?? [];
184
268
  const trackId = timelineTrackRowId(track);
185
- const activeClip = resolveTrackKeyframeClip(
269
+ const { activeClip, activeId, lanes } = resolveActiveTrackClip(
186
270
  elements,
187
271
  laneCounts,
188
272
  selectedElementId,
189
273
  selectedElementIds,
274
+ gsapAnimations,
190
275
  );
191
- const activeId = activeClip ? elementId(activeClip) : null;
192
- const lanes = activeClip
193
- ? getTimelinePropertyLanes(
194
- gsapAnimations.get(elementId(activeClip)) ?? [],
195
- activeClip.start,
196
- activeClip.duration,
197
- )
198
- : [];
199
- const expanded = activeId !== null && expandedClipIds.has(activeId) && lanes.length > 0;
276
+ const expanded = isRowOpen(activeId, expandedClipIds, expandedLaneOwnerIds) && lanes.length > 0;
200
277
  rows.push({
201
278
  id: trackId,
202
279
  kind: "row",
203
280
  physicalTrackKey: track,
204
281
  logicalIndex: rows.length,
205
- level: 1,
206
- parentId: null,
282
+ level,
283
+ parentId,
207
284
  elementId: activeId,
208
285
  expandable: lanes.length > 0,
209
286
  expanded,
210
287
  items: clipItems(trackId, elements),
211
288
  });
212
- if (!expanded || !activeClip) continue;
289
+ if (!expanded || !activeClip || !activeId) return;
213
290
  for (const lane of lanes) {
214
- const rowId = timelinePropertyRowId(activeId, lane.group);
215
- rows.push({
216
- id: rowId,
217
- kind: "row",
218
- physicalTrackKey: track,
219
- logicalIndex: rows.length,
220
- level: 2,
221
- parentId: trackId,
222
- elementId: activeId,
223
- expandable: false,
224
- expanded: false,
225
- propertyGroup: lane.group,
226
- items: propertyItems(rowId, activeClip, lane.keyframes),
227
- });
291
+ rows.push(
292
+ buildLaneRow(track, rows.length, activeId, activeClip, lane, level === 1 ? 2 : 3, trackId),
293
+ );
294
+ }
295
+ }
296
+
297
+ // A group's own row (level 1) plus, when its `∿` is open, its own
298
+ // automation-lane rows (level 2) — structural content deferred to whatever
299
+ // step wires group automation editing; this reserves the rows and their
300
+ // count.
301
+ function emitGroup(group: TimelineTrackGroupInfo): void {
302
+ const groupRowId = timelineGroupRowId(group.id);
303
+ const groupExpanded = expandedGroupIds.has(group.id);
304
+ rows.push({
305
+ id: groupRowId,
306
+ kind: "row",
307
+ physicalTrackKey: group.anchorKey,
308
+ logicalIndex: rows.length,
309
+ level: 1,
310
+ parentId: null,
311
+ elementId: null,
312
+ groupId: group.id,
313
+ expandable: group.memberTracks.length > 0,
314
+ expanded: groupExpanded,
315
+ items: [],
316
+ });
317
+ if (expandedLaneOwnerIds.has(group.id)) {
318
+ const memberElements = group.memberTracks.flatMap((track) => trackMap.get(track) ?? []);
319
+ for (const laneGroup of groupAutomationLanes(memberElements)) {
320
+ rows.push({
321
+ id: `${groupRowId}::${laneGroup.key}`,
322
+ kind: "row",
323
+ physicalTrackKey: group.anchorKey,
324
+ logicalIndex: rows.length,
325
+ level: 2,
326
+ parentId: groupRowId,
327
+ elementId: null,
328
+ expandable: false,
329
+ expanded: false,
330
+ items: [],
331
+ });
332
+ }
333
+ }
334
+ if (!groupExpanded) return;
335
+ for (const track of group.memberTracks) emitTrack(track, 2, groupRowId);
336
+ }
337
+
338
+ for (const key of displayTrackOrder) {
339
+ const group = groupByAnchor.get(key);
340
+ if (group) {
341
+ emitGroup(group);
342
+ continue;
228
343
  }
344
+ if (trackGroupOf.has(key)) continue; // emitted above, under its group
345
+ emitTrack(key, 1, null);
229
346
  }
230
347
  return rows;
231
348
  }