@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
@@ -74,9 +74,7 @@ function mountBeatStrip(renderTimeRange?: { start: number; end: number }) {
74
74
  }
75
75
 
76
76
  function firstBeat(): HTMLDivElement {
77
- const beat = document.querySelector<HTMLDivElement>(
78
- '[title="Drag to move · double-click to delete"]',
79
- );
77
+ const beat = document.querySelector<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]');
80
78
  if (!beat) throw new Error("Expected a beat handle");
81
79
  return beat;
82
80
  }
@@ -256,9 +254,7 @@ describe("BeatStrip gesture ownership", () => {
256
254
  );
257
255
  });
258
256
 
259
- expect(
260
- document.querySelectorAll('[title="Drag to move · double-click to delete"]'),
261
- ).toHaveLength(2);
257
+ expect(document.querySelectorAll('[title="Drag to move · ⌥-click to delete"]')).toHaveLength(2);
262
258
  expect(commitBeatEditsSpy).not.toHaveBeenCalled();
263
259
 
264
260
  act(() => {
@@ -297,10 +293,10 @@ describe("BeatStrip gesture ownership", () => {
297
293
  });
298
294
 
299
295
  const lefts = Array.from(
300
- document.querySelectorAll<HTMLDivElement>('[title="Drag to move · double-click to delete"]'),
296
+ document.querySelectorAll<HTMLDivElement>('[title="Drag to move · ⌥-click to delete"]'),
301
297
  (beat) => beat.style.left,
302
298
  );
303
- expect(lefts).toContain("134px");
299
+ expect(lefts).toContain("128px");
304
300
 
305
301
  releaseBeatDrag(140);
306
302
  expectCommittedBeatAt(1.4);
@@ -310,7 +306,7 @@ describe("BeatStrip gesture ownership", () => {
310
306
  mountBeatStrip();
311
307
  startBeatDrag();
312
308
  const beats = document.querySelectorAll<HTMLDivElement>(
313
- '[title="Drag to move · double-click to delete"]',
309
+ '[title="Drag to move · ⌥-click to delete"]',
314
310
  );
315
311
  act(() => {
316
312
  beats[1]?.dispatchEvent(
@@ -14,7 +14,7 @@ import {
14
14
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
15
15
 
16
16
  export const BEAT_BAND_H = 14; // dark band height at top of track
17
- const BEAT_HIT_W = 12; // grab width per beat (px)
17
+ const BEAT_HIT_W = 24; // grab width per beat (px) — ≥24px pointer target
18
18
 
19
19
  interface BeatDragActor {
20
20
  readonly pointerId: number;
@@ -355,8 +355,9 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
355
355
 
356
356
  /**
357
357
  * Green beat dots on the music track's row. Drag a dot to move its beat,
358
- * double-click to delete; both scrub the audio. Dot size/brightness scale with
359
- * beat loudness (gamma-curved for contrast).
358
+ * ⌥-click to delete (kept off double-click so a stuttered drag can't destroy
359
+ * a beat); both scrub the audio. Dot size/brightness scale with beat loudness
360
+ * (gamma-curved for contrast). Deletes remain undoable via ⌘Z.
360
361
  */
361
362
  export const BeatStrip = memo(function BeatStrip({
362
363
  beatTimes,
@@ -412,7 +413,7 @@ export const BeatStrip = memo(function BeatStrip({
412
413
  <div
413
414
  key={`${t}-${i}`}
414
415
  className="absolute select-none"
415
- title="Drag to move · double-click to delete"
416
+ title="Drag to move · ⌥-click to delete"
416
417
  draggable={false}
417
418
  style={{
418
419
  left: x - BEAT_HIT_W / 2,
@@ -428,9 +429,15 @@ export const BeatStrip = memo(function BeatStrip({
428
429
  // selection (which otherwise "selects" the whole panel mid-drag).
429
430
  e.preventDefault();
430
431
  e.stopPropagation();
432
+ // ⌥ starts no drag: the delete lands on release below, so a
433
+ // slipped ⌥-drag abandons instead of destroying the beat.
434
+ if (e.altKey) return;
431
435
  beginBeatDrag(e, t, pps);
432
436
  }}
433
- onDoubleClick={(e) => {
437
+ onClick={(e) => {
438
+ if (!e.altKey) return;
439
+ // ⌥-click deletes. Deliberately NOT double-click: a stuttered drag
440
+ // attempt reads as a double-click and would destroy the beat.
434
441
  e.stopPropagation();
435
442
  deleteBeatAtCompositionTime(t);
436
443
  usePlayerStore.getState().requestSeek(Math.max(0, t)); // park scrubber at deleted beat
@@ -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 && (