@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  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 +2 -0
  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 +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  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 +7 -7
  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 +5 -0
  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.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
4
  import { canSplitElement } from "../../utils/timelineElementSplit";
5
5
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
6
+ import { useMenuKeyboardNav } from "./menuKeyboardNav";
6
7
 
7
8
  interface ClipContextMenuProps {
8
9
  x: number;
@@ -24,6 +25,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
24
25
  onDelete,
25
26
  }: ClipContextMenuProps) {
26
27
  const menuRef = useContextMenuDismiss(onClose);
28
+ useMenuKeyboardNav(menuRef);
27
29
 
28
30
  const menuWidth = 200;
29
31
  const menuHeight = 80;
@@ -44,6 +46,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
44
46
  return createPortal(
45
47
  <div
46
48
  ref={menuRef}
49
+ role="menu"
50
+ aria-label="Clip actions"
47
51
  className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
48
52
  style={{ left: adjustedX, top: adjustedY }}
49
53
  >
@@ -51,7 +55,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
51
55
  <>
52
56
  <button
53
57
  type="button"
54
- className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
58
+ role="menuitem"
59
+ className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left outline-none focus-visible:bg-neutral-800 ${
55
60
  canSplit
56
61
  ? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
57
62
  : "text-neutral-600 cursor-not-allowed"
@@ -73,7 +78,8 @@ export const ClipContextMenu = memo(function ClipContextMenu({
73
78
 
74
79
  <button
75
80
  type="button"
76
- className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
81
+ role="menuitem"
82
+ className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left"
77
83
  onClick={() => {
78
84
  onDelete(element);
79
85
  onClose();
@@ -13,17 +13,44 @@ interface EditPopoverProps {
13
13
  onClose: () => void;
14
14
  }
15
15
 
16
+ function draftKey(start: number, end: number): string {
17
+ return `hf-edit-draft:${start.toFixed(2)}:${end.toFixed(2)}`;
18
+ }
19
+
20
+ function readDraft(key: string): string {
21
+ try {
22
+ return sessionStorage.getItem(key) ?? "";
23
+ } catch {
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function writeDraft(key: string, value: string): void {
29
+ try {
30
+ if (value) sessionStorage.setItem(key, value);
31
+ else sessionStorage.removeItem(key);
32
+ } catch {
33
+ /* storage unavailable — draft persistence degrades gracefully */
34
+ }
35
+ }
36
+
16
37
  export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
17
38
  const elements = usePlayerStore((s) => s.elements);
18
- const [prompt, setPrompt] = useState("");
39
+ const start = Math.min(rangeStart, rangeEnd);
40
+ const end = Math.max(rangeStart, rangeEnd);
41
+ // Persist the typed prompt per range so Escape/outside-click doesn't destroy it.
42
+ const storageKey = draftKey(start, end);
43
+ const [prompt, setPromptState] = useState(() => readDraft(storageKey));
44
+ const setPrompt = (value: string) => {
45
+ setPromptState(value);
46
+ writeDraft(storageKey, value);
47
+ };
19
48
  const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
20
49
  const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
50
+ const [copyError, setCopyError] = useState(false);
21
51
  const popoverRef = useRef<HTMLDivElement>(null);
22
52
  const textareaRef = useRef<HTMLTextAreaElement>(null);
23
53
 
24
- const start = Math.min(rangeStart, rangeEnd);
25
- const end = Math.max(rangeStart, rangeEnd);
26
-
27
54
  const elementsInRange = useMemo(() => {
28
55
  return elements.filter((el) => {
29
56
  const elEnd = el.start + el.duration;
@@ -64,19 +91,28 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
64
91
 
65
92
  const handleCopy = useCallback(async () => {
66
93
  const copied = await copyTextToClipboard(buildClipboardText());
67
- if (!copied) return;
94
+ if (!copied) {
95
+ setCopyError(true);
96
+ return;
97
+ }
98
+ setCopyError(false);
99
+ writeDraft(storageKey, "");
68
100
  setCopiedAgentPrompt(true);
69
101
  setTimeout(() => {
70
102
  setCopiedAgentPrompt(false);
71
103
  onClose();
72
104
  }, 800);
73
- }, [buildClipboardText, onClose]);
105
+ }, [buildClipboardText, onClose, storageKey]);
74
106
 
75
107
  const handleCopyPrompt = useCallback(async () => {
76
108
  const promptText = buildPromptCopyText(prompt);
77
109
  if (!promptText) return;
78
110
  const copied = await copyTextToClipboard(promptText);
79
- if (!copied) return;
111
+ if (!copied) {
112
+ setCopyError(true);
113
+ return;
114
+ }
115
+ setCopyError(false);
80
116
  setCopiedPromptOnly(true);
81
117
  setTimeout(() => {
82
118
  setCopiedPromptOnly(false);
@@ -137,6 +173,11 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
137
173
  </div>
138
174
 
139
175
  {/* Action */}
176
+ {copyError && (
177
+ <p className="px-3 pb-2 text-[10px] text-red-400" role="alert">
178
+ Copy failed — check clipboard permissions and try again.
179
+ </p>
180
+ )}
140
181
  <div className="grid grid-cols-2 gap-2 px-3 pb-3">
141
182
  <button
142
183
  onClick={handleCopyPrompt}
@@ -1,5 +1,7 @@
1
+ import { useState } from "react";
1
2
  import { createPortal } from "react-dom";
2
3
  import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
4
+ import { useMenuKeyboardNav } from "./menuKeyboardNav";
3
5
  import type { TimelineElement } from "../store/playerStore";
4
6
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
5
7
 
@@ -25,45 +27,83 @@ interface KeyframeDiamondContextMenuProps {
25
27
  * worse than no entry. */
26
28
  onDelete?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
27
29
  onDeleteAll: (element: TimelineElement, animationId?: string) => void;
30
+ /** Focus this keyframe's ease segment in the inspector. Omitted when the
31
+ * keyframe carries no tween identity to focus. */
32
+ onEditEase?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
33
+ /** Copy the keyframe's properties to the clipboard; resolves false on failure. */
34
+ onCopyProperties?: (
35
+ elementId: string,
36
+ keyframe: TimelineKeyframeTarget,
37
+ ) => Promise<boolean> | boolean | void;
28
38
  /** Retime the keyframe to the current playhead, preserving its value + ease. */
29
39
  onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
30
40
  }
31
41
 
42
+ const ITEM_CLS =
43
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
44
+ const DESTRUCTIVE_ITEM_CLS =
45
+ "w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-none cursor-pointer text-left";
46
+
32
47
  export function KeyframeDiamondContextMenu({
33
48
  state,
34
49
  onClose,
35
50
  onDelete,
36
51
  onDeleteAll,
52
+ onEditEase,
53
+ onCopyProperties,
37
54
  onMoveToPlayhead,
38
55
  }: KeyframeDiamondContextMenuProps) {
39
56
  const menuRef = useContextMenuDismiss(onClose);
40
- // The clicked diamond's identity, built once: the menu's two mutating entries
41
- // both act on it, and they must not disagree about which keyframe was clicked.
57
+ // The clicked diamond's identity, built once: the menu's mutating entries
58
+ // all act on it, and they must not disagree about which keyframe was clicked.
42
59
  const keyframe: TimelineKeyframeTarget = {
43
60
  percentage: state.percentage,
44
61
  tweenPercentage: state.tweenPercentage,
45
62
  propertyGroup: state.propertyGroup,
46
63
  animationId: state.animationId,
47
64
  };
65
+ useMenuKeyboardNav(menuRef);
66
+ const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
67
+
68
+ const handleCopyProperties = async () => {
69
+ if (!onCopyProperties) return;
70
+ const result = await onCopyProperties(state.elementId, keyframe);
71
+ if (result === false) {
72
+ setCopyStatus("failed");
73
+ setTimeout(() => setCopyStatus("idle"), 1500);
74
+ return;
75
+ }
76
+ setCopyStatus("copied");
77
+ setTimeout(onClose, 700);
78
+ };
48
79
 
49
80
  const menuWidth = 200;
50
81
  // Measured off the rendered rows, so the flip-up test below stays right as
51
- // optional entries drop out.
52
- const menuHeight = 10 + (1 + (onMoveToPlayhead ? 1 : 0) + (onDelete ? 1 : 0)) * 30;
82
+ // optional entries drop out. The separator counts as roughly a third of a row.
83
+ const rows =
84
+ 1 +
85
+ (onMoveToPlayhead ? 1 : 0) +
86
+ (onEditEase ? 1 : 0) +
87
+ (onCopyProperties ? 1 : 0) +
88
+ (onDelete ? 1 : 0);
89
+ const menuHeight = 10 + rows * 30 + 9;
53
90
  const overflowY = state.y + menuHeight - window.innerHeight;
54
91
  const adjustedX = state.x + menuWidth > window.innerWidth ? state.x - menuWidth : state.x;
55
- const adjustedY = overflowY > 0 ? state.y - overflowY - 8 : state.y;
92
+ const adjustedY = Math.max(8, overflowY > 0 ? state.y - overflowY - 8 : state.y);
56
93
 
57
94
  return createPortal(
58
95
  <div
59
96
  ref={menuRef}
60
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
61
- style={{ left: adjustedX, top: adjustedY }}
97
+ role="menu"
98
+ aria-label="Keyframe actions"
99
+ className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
100
+ style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
62
101
  >
63
102
  {onMoveToPlayhead && (
64
103
  <button
65
104
  type="button"
66
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-neutral-200 hover:bg-neutral-800 cursor-pointer text-left"
105
+ role="menuitem"
106
+ className={ITEM_CLS}
67
107
  onClick={() => {
68
108
  // Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-%
69
109
  // and returns the tween-% for the mutation. Passing tween-% here would
@@ -76,11 +116,44 @@ export function KeyframeDiamondContextMenu({
76
116
  </button>
77
117
  )}
78
118
 
119
+ {onEditEase && (
120
+ <button
121
+ type="button"
122
+ role="menuitem"
123
+ className={`${ITEM_CLS} justify-between`}
124
+ onClick={() => {
125
+ onEditEase(state.elementId, keyframe);
126
+ onClose();
127
+ }}
128
+ >
129
+ <span>Edit Ease…</span>
130
+ <span className="text-[10px] text-neutral-500">{state.currentEase ?? "default"}</span>
131
+ </button>
132
+ )}
133
+
134
+ {onCopyProperties && (
135
+ <button
136
+ type="button"
137
+ role="menuitem"
138
+ className={ITEM_CLS}
139
+ onClick={() => {
140
+ void handleCopyProperties();
141
+ }}
142
+ >
143
+ {copyStatus === "copied"
144
+ ? "Copied!"
145
+ : copyStatus === "failed"
146
+ ? "Copy failed — check permissions"
147
+ : "Copy Properties"}
148
+ </button>
149
+ )}
150
+
79
151
  {/* Delete */}
80
152
  {onDelete && (
81
153
  <button
82
154
  type="button"
83
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
155
+ role="menuitem"
156
+ className={DESTRUCTIVE_ITEM_CLS}
84
157
  onClick={() => {
85
158
  onDelete(state.elementId, keyframe);
86
159
  onClose();
@@ -90,9 +163,16 @@ export function KeyframeDiamondContextMenu({
90
163
  </button>
91
164
  )}
92
165
 
166
+ {/* Deleting every keyframe sat adjacent to the single delete and styled
167
+ identically. Separate and mark it so the two cannot be misread. */}
168
+ <div className="my-1 border-t border-neutral-700/60" role="separator" />
169
+
170
+ <div className="my-1 border-t border-neutral-700/60" role="separator" />
171
+
93
172
  <button
94
173
  type="button"
95
- className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
174
+ role="menuitem"
175
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-red-950/40 focus-visible:bg-red-950/40 outline-none cursor-pointer text-left"
96
176
  onClick={() => {
97
177
  onDeleteAll(state.element, state.animationId);
98
178
  onClose();
@@ -1,9 +1,11 @@
1
- import { CaretRight } from "@phosphor-icons/react";
2
1
  import { TRACK_H } from "./timelineLayout";
3
2
  import { TrackClipCount } from "./TrackClipCount";
4
3
 
5
- // Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives
6
- // here, not on the clip bar, and re-expands a collapsed layer.
4
+ // Layer row (Figma order: disclosure ∿, diamond, name) — the disclosure lives
5
+ // here, not on the clip bar, and re-expands a collapsed layer. `∿` (not a
6
+ // caret) because a group's own row keeps the caret for its structural
7
+ // disclosure (member rows) — this button only ever means "show this row's
8
+ // lanes", so it needs its own distinct glyph.
7
9
  export function LayerDisclosureRow({
8
10
  name,
9
11
  clipCount,
@@ -49,23 +51,20 @@ export function LayerDisclosureRow({
49
51
  tabIndex={-1}
50
52
  aria-expanded={isExpanded}
51
53
  aria-controls={lanesId}
52
- aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
53
- title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
54
- // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The caret glyph stays 11px;
54
+ aria-label={`${isExpanded ? "Hide" : "Show"} ${name} lanes`}
55
+ title={`${isExpanded ? "Hide" : "Show"} lanes`}
56
+ // h-6 w-6 = the 24x24 WCAG 2.2 minimum target. The glyph stays 11px;
55
57
  // only the hit box grows.
56
- className="flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
58
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] leading-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
59
+ isExpanded ? "text-[#3CE6AC]" : "text-white/55 hover:text-white"
60
+ }`}
57
61
  onPointerDown={(event) => event.stopPropagation()}
58
62
  onClick={(event) => {
59
63
  event.stopPropagation();
60
64
  onToggleClipExpanded();
61
65
  }}
62
66
  >
63
- <CaretRight
64
- size={11}
65
- weight="bold"
66
- aria-hidden="true"
67
- style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}
68
- />
67
+ <span aria-hidden="true">∿</span>
69
68
  </button>
70
69
  {/* Decorative: the disclosure button above already names the row's keyframe
71
70
  state, and aria-label on a plain span is not exposed reliably anyway. */}
@@ -135,6 +135,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
135
135
  const [assetsLoading, setAssetsLoading] = useState(false);
136
136
  const [assetOverlayVisible, setAssetOverlayVisible] = useState(false);
137
137
  const [assetOverlayFading, setAssetOverlayFading] = useState(false);
138
+ const [assetWaitLong, setAssetWaitLong] = useState(false);
138
139
  const [shaderTransitionLoading, setShaderTransitionLoading] = useState(false);
139
140
  const [compositionLoading, setCompositionLoading] = useState(true);
140
141
  const [compositionOverlayDeferred, setCompositionOverlayDeferred] = useState(true);
@@ -236,6 +237,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
236
237
  attempts += 1;
237
238
  lastUnloaded = hasUnloadedAssets(iframe, lastUnloaded);
238
239
  if (!lastUnloaded || attempts > 100) {
240
+ if (lastUnloaded && attempts > 100) {
241
+ console.debug(
242
+ "[studio] asset readiness poll hit the 10s cap — continuing with unloaded assets",
243
+ );
244
+ }
239
245
  if (assetPollRef.current) clearInterval(assetPollRef.current);
240
246
  assetPollRef.current = null;
241
247
  setAssetsLoading(false);
@@ -327,6 +333,17 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
327
333
  };
328
334
  });
329
335
 
336
+ // Surface a "Continue anyway" escape hatch once the asset wait drags on.
337
+ // eslint-disable-next-line no-restricted-syntax
338
+ useEffect(() => {
339
+ if (!assetsLoading) {
340
+ setAssetWaitLong(false);
341
+ return;
342
+ }
343
+ const timer = setTimeout(() => setAssetWaitLong(true), 3000);
344
+ return () => clearTimeout(timer);
345
+ }, [assetsLoading]);
346
+
330
347
  useEffect(() => {
331
348
  if (assetFadeRef.current) {
332
349
  clearTimeout(assetFadeRef.current);
@@ -354,12 +371,20 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
354
371
  };
355
372
  }, [assetsLoading]);
356
373
 
374
+ const handleContinueAnyway = () => {
375
+ if (assetPollRef.current) {
376
+ clearInterval(assetPollRef.current);
377
+ assetPollRef.current = null;
378
+ }
379
+ setAssetsLoading(false);
380
+ };
381
+
357
382
  const showCompositionOverlay =
358
383
  !suppressLoadingOverlay &&
359
384
  !compositionOverlayDeferred &&
360
385
  shouldShowCompositionLoadingOverlay(compositionLoading);
361
386
  const showAssetOverlay =
362
- assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay;
387
+ assetOverlayVisible && !shaderTransitionLoading && !showCompositionOverlay && !previewError;
363
388
 
364
389
  useEffect(() => {
365
390
  onCompositionLoadingChange?.(showCompositionOverlay || showAssetOverlay);
@@ -396,17 +421,27 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
396
421
  style={{
397
422
  opacity: assetOverlayFading ? 0 : 1,
398
423
  pointerEvents: assetOverlayFading ? "none" : "auto",
399
- transition: "opacity 240ms ease-out",
424
+ transition: "opacity 180ms ease-in",
400
425
  }}
401
426
  onDragStart={(event) => event.preventDefault()}
402
427
  onMouseDown={(event) => event.preventDefault()}
403
- onPointerDown={(event) => event.preventDefault()}
404
428
  >
405
- <HyperframesLoader
406
- title="Preparing preview assets"
407
- detail="Waiting for media and motion assets before playback starts."
408
- size={56}
409
- />
429
+ <div className="flex flex-col items-center gap-3">
430
+ <HyperframesLoader
431
+ title="Preparing preview assets"
432
+ detail="Waiting for media and motion assets before playback starts."
433
+ size={56}
434
+ />
435
+ {assetWaitLong && (
436
+ <button
437
+ type="button"
438
+ onClick={handleContinueAnyway}
439
+ className="px-3 py-1.5 text-[11px] rounded-md border border-neutral-700 text-neutral-300 hover:border-neutral-500 hover:bg-neutral-800 transition-colors"
440
+ >
441
+ Continue anyway
442
+ </button>
443
+ )}
444
+ </div>
410
445
  </div>
411
446
  )}
412
447
  {previewError && (
@@ -6,6 +6,7 @@ import { liveTime, usePlayerStore } from "../store/playerStore";
6
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
7
7
  import { Tooltip } from "../../components/ui";
8
8
  import { useMountEffect } from "../../hooks/useMountEffect";
9
+ import { useSoloBannerText } from "../../hooks/useAudioSoloBridge";
9
10
  import { ShortcutsPanel } from "./ShortcutsPanel";
10
11
  import { SpeedMenu } from "./SpeedMenu";
11
12
  import { VolumeControl } from "./VolumeControl";
@@ -155,12 +156,47 @@ const FullscreenButton = memo(function FullscreenButton({
155
156
 
156
157
  /* ── Main component ──────────────────────────────────────────────── */
157
158
 
159
+ /**
160
+ * "Hearing only this" notice. Solo is a PREVIEW-only gate — it never touches an
161
+ * attribute and never reaches the export — so the state has to say so out loud,
162
+ * or a soloed session reads as a broken mix.
163
+ */
164
+ const SoloBanner = memo(function SoloBanner({
165
+ previewIframeRef,
166
+ }: {
167
+ previewIframeRef: { current: HTMLIFrameElement | null };
168
+ }) {
169
+ const bannerText = useSoloBannerText(previewIframeRef);
170
+ const clearSolo = usePlayerStore.getState().clearSolo;
171
+ if (bannerText === null) return null;
172
+ return (
173
+ <div
174
+ role="status"
175
+ className="flex h-7 items-center justify-center gap-2 border-b border-neutral-800 bg-neutral-900/90 px-3 text-[11px] text-neutral-300"
176
+ >
177
+ <span>
178
+ Hearing only <span className="font-medium text-neutral-100">{bannerText}</span> — your
179
+ export is not affected
180
+ </span>
181
+ <button
182
+ type="button"
183
+ onClick={() => clearSolo()}
184
+ className="rounded px-1.5 py-0.5 font-medium text-studio-accent transition-colors hover:text-white"
185
+ >
186
+ Clear
187
+ </button>
188
+ </div>
189
+ );
190
+ });
191
+
158
192
  interface PlayerControlsProps {
159
193
  onTogglePlay: () => void;
160
194
  onSeek: (time: number) => void;
161
195
  disabled?: boolean;
162
196
  isFullscreen?: boolean;
163
197
  onToggleFullscreen?: () => void;
198
+ /** Needed to read the soloed clips' labels out of the preview document. */
199
+ previewIframeRef?: { current: HTMLIFrameElement | null };
164
200
  }
165
201
 
166
202
  export const PlayerControls = memo(function PlayerControls({
@@ -169,6 +205,7 @@ export const PlayerControls = memo(function PlayerControls({
169
205
  disabled = false,
170
206
  isFullscreen = false,
171
207
  onToggleFullscreen,
208
+ previewIframeRef,
172
209
  }: PlayerControlsProps) {
173
210
  const isPlaying = usePlayerStore((s) => s.isPlaying);
174
211
  const duration = usePlayerStore((s) => s.duration);
@@ -220,73 +257,80 @@ export const PlayerControls = memo(function PlayerControls({
220
257
  });
221
258
 
222
259
  return (
223
- <div
224
- className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
225
- aria-disabled={disabled || undefined}
226
- style={{
227
- paddingBottom: "env(safe-area-inset-bottom)",
228
- }}
229
- >
230
- <Tooltip
231
- label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
260
+ <div>
261
+ {previewIframeRef && <SoloBanner previewIframeRef={previewIframeRef} />}
262
+ <div
263
+ className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
264
+ aria-disabled={disabled || undefined}
265
+ style={{
266
+ paddingBottom: "env(safe-area-inset-bottom)",
267
+ }}
232
268
  >
233
- <button
234
- type="button"
235
- onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
236
- disabled={disabled}
237
- className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
269
+ <Tooltip
270
+ label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
238
271
  >
239
- <span ref={timeDisplayRef}>{formatTime(0)}</span>
240
- {timeDisplayMode === "time" ? (
241
- <>
242
- <span className="mx-0.5 text-neutral-700">/</span>
243
- <span className="text-neutral-600">{formatTime(duration)}</span>
244
- </>
245
- ) : null}
246
- </button>
247
- </Tooltip>
272
+ <button
273
+ type="button"
274
+ onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
275
+ disabled={disabled}
276
+ className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
277
+ >
278
+ <span ref={timeDisplayRef}>{formatTime(0)}</span>
279
+ {timeDisplayMode === "time" ? (
280
+ <>
281
+ <span className="mx-0.5 text-neutral-700">/</span>
282
+ <span className="text-neutral-600">{formatTime(duration)}</span>
283
+ </>
284
+ ) : null}
285
+ </button>
286
+ </Tooltip>
248
287
 
249
- <Tooltip label={isPlaying ? "Pause" : "Play"}>
250
- <button
251
- type="button"
252
- aria-label={isPlaying ? "Pause" : "Play"}
253
- onClick={() => {
254
- trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
255
- onTogglePlay();
256
- }}
257
- disabled={controlsDisabled}
258
- className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
259
- >
260
- <PlayPauseMorphIcon playing={isPlaying} />
261
- </button>
262
- </Tooltip>
288
+ <Tooltip label={isPlaying ? "Pause" : "Play"}>
289
+ <button
290
+ type="button"
291
+ aria-label={isPlaying ? "Pause" : "Play"}
292
+ onClick={() => {
293
+ trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
294
+ onTogglePlay();
295
+ }}
296
+ disabled={controlsDisabled}
297
+ className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
298
+ >
299
+ <PlayPauseMorphIcon playing={isPlaying} />
300
+ </button>
301
+ </Tooltip>
263
302
 
264
- <div className="flex min-w-0 items-center justify-self-end">
265
- <VolumeControl
266
- audioMuted={audioMuted}
267
- audioVolume={audioVolume}
268
- disabled={controlsDisabled}
269
- setAudioMuted={setAudioMuted}
270
- setAudioVolume={setAudioVolume}
271
- />
272
- <SpeedMenu
273
- playbackRate={playbackRate}
274
- setPlaybackRate={setPlaybackRate}
275
- disabled={disabled}
276
- />
277
- <LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
278
- {onToggleFullscreen && (
279
- <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
280
- )}
281
- <ShortcutsPanel
282
- disabled={disabled}
283
- duration={duration}
284
- inPoint={inPoint}
285
- outPoint={outPoint}
286
- setInPoint={setInPoint}
287
- setOutPoint={setOutPoint}
288
- onSeek={onSeek}
289
- />
303
+ <div className="flex min-w-0 items-center justify-self-end">
304
+ <VolumeControl
305
+ audioMuted={audioMuted}
306
+ audioVolume={audioVolume}
307
+ disabled={controlsDisabled}
308
+ setAudioMuted={setAudioMuted}
309
+ setAudioVolume={setAudioVolume}
310
+ />
311
+ <SpeedMenu
312
+ playbackRate={playbackRate}
313
+ setPlaybackRate={setPlaybackRate}
314
+ disabled={disabled}
315
+ />
316
+ <LoopButton
317
+ loopEnabled={loopEnabled}
318
+ disabled={disabled}
319
+ setLoopEnabled={setLoopEnabled}
320
+ />
321
+ {onToggleFullscreen && (
322
+ <FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
323
+ )}
324
+ <ShortcutsPanel
325
+ disabled={disabled}
326
+ duration={duration}
327
+ inPoint={inPoint}
328
+ outPoint={outPoint}
329
+ setInPoint={setInPoint}
330
+ setOutPoint={setOutPoint}
331
+ onSeek={onSeek}
332
+ />
333
+ </div>
290
334
  </div>
291
335
  </div>
292
336
  );