@hyperframes/studio 0.8.46 → 0.8.48

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 (112) hide show
  1. package/dist/assets/hyperframes-player-CtVX6jL_.js +464 -0
  2. package/dist/assets/{index-CbchRscN.js → index-B3-orIjW.js} +1 -1
  3. package/dist/assets/index-B9LfWNyM.css +1 -0
  4. package/dist/assets/index-C9gJKpqQ.js +441 -0
  5. package/dist/assets/{index-BywY2OM3.js → index-D_j-0PD3.js} +1 -1
  6. package/dist/index.d.ts +83 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1118 -661
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +17 -26
  12. package/src/components/AskAgentModal.tsx +9 -7
  13. package/src/components/TimelineToolbar.tsx +26 -1
  14. package/src/components/editor/PropertyPanelFlat.tsx +8 -8
  15. package/src/components/editor/domEditing.ts +7 -1
  16. package/src/components/editor/domEditingAgentPrompt.test.ts +199 -0
  17. package/src/components/editor/domEditingAgentPrompt.ts +136 -34
  18. package/src/components/editor/manualEditingAvailability.test.ts +19 -0
  19. package/src/components/editor/manualEditingAvailability.ts +9 -5
  20. package/src/components/editor/propertyPanelAutomation.test.ts +82 -1
  21. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  22. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  23. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  24. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  25. package/src/components/editor/useVolumeAutomation.ts +23 -2
  26. package/src/components/panels/VariablesPanel.tsx +5 -8
  27. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  29. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  30. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  31. package/src/contexts/FileManagerContext.tsx +0 -3
  32. package/src/hooks/appHotkeysDispatch.ts +256 -0
  33. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  34. package/src/hooks/domEditDeleteMembers.ts +19 -0
  35. package/src/hooks/useActiveComposition.ts +69 -0
  36. package/src/hooks/useAppHotkeys.test.ts +23 -1
  37. package/src/hooks/useAppHotkeys.ts +6 -239
  38. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  39. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  40. package/src/hooks/useDomEditCommits.ts +13 -15
  41. package/src/hooks/useDomEditSession.test.tsx +90 -1
  42. package/src/hooks/useDomEditSession.ts +31 -34
  43. package/src/hooks/useDomSelectionTypes.ts +7 -2
  44. package/src/hooks/useElementLifecycleOps.ts +0 -8
  45. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  46. package/src/hooks/useFileManager.ts +0 -2
  47. package/src/hooks/useFileTree.test.tsx +214 -0
  48. package/src/hooks/useFileTree.ts +66 -24
  49. package/src/hooks/useGroupCommits.test.ts +98 -0
  50. package/src/hooks/useGroupCommits.ts +66 -9
  51. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  52. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  53. package/src/hooks/useSdkSession.lifecycle.test.tsx +76 -0
  54. package/src/hooks/useSdkSession.ts +21 -3
  55. package/src/hooks/useTimelineAssetDropOps.test.tsx +72 -0
  56. package/src/hooks/useTimelineAssetDropOps.ts +20 -16
  57. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  58. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  59. package/src/hooks/useTimelineEditing.ts +1 -0
  60. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  61. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  62. package/src/index.ts +10 -0
  63. package/src/player/components/Timeline.test.ts +0 -4
  64. package/src/player/components/Timeline.tsx +0 -1
  65. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  66. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  67. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  68. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  69. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  70. package/src/player/components/automationLaneData.test.ts +11 -3
  71. package/src/player/components/automationLaneData.ts +5 -4
  72. package/src/player/components/automationLaneGeometry.ts +15 -0
  73. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  74. package/src/player/components/timelineDragDrop.ts +9 -25
  75. package/src/player/components/timelineGapCommit.test.ts +96 -0
  76. package/src/player/components/timelineGapCommit.ts +56 -11
  77. package/src/player/components/timelineGaps.ts +2 -2
  78. package/src/player/components/timelineLayout.test.ts +0 -1
  79. package/src/player/components/timelineLayout.ts +1 -6
  80. package/src/player/components/timelineZones.test.ts +20 -1
  81. package/src/player/components/timelineZones.ts +10 -3
  82. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  83. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  84. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  85. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  86. package/src/player/hooks/useTimelinePlayer.ts +9 -4
  87. package/src/player/hooks/useTimelineSyncCallbacks.ts +7 -4
  88. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  89. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  90. package/src/player/store/playerStore.test.ts +42 -0
  91. package/src/player/store/playerStore.ts +25 -28
  92. package/src/player/store/readinessSlice.ts +42 -0
  93. package/src/utils/razorSplitTransaction.test.ts +10 -0
  94. package/src/utils/razorSplitTransaction.ts +5 -0
  95. package/src/utils/sdkCutover.gate.test.ts +5 -4
  96. package/src/utils/sdkCutover.test.ts +93 -0
  97. package/src/utils/sdkCutover.ts +46 -25
  98. package/src/utils/sdkCutoverEligibility.ts +21 -0
  99. package/src/utils/sdkCutoverPolicy.test.ts +11 -2
  100. package/src/utils/sdkCutoverPolicy.ts +5 -3
  101. package/src/utils/sdkEditTransaction.decline.test.ts +46 -0
  102. package/src/utils/sdkEditTransaction.ts +22 -1
  103. package/src/utils/sdkResolverShadow.ts +10 -1
  104. package/src/utils/studioHelpers.test.ts +18 -0
  105. package/src/utils/studioHelpers.ts +41 -5
  106. package/src/utils/studioUiPreferences.test.ts +14 -0
  107. package/src/utils/studioUiPreferences.ts +6 -0
  108. package/src/utils/studioUrlState.ts +15 -7
  109. package/src/utils/timelineInspector.ts +10 -0
  110. package/dist/assets/hyperframes-player-CsV2rywT.js +0 -464
  111. package/dist/assets/index-DCblmTX3.js +0 -441
  112. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../player";
6
+ import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
+ import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
+
9
+ installReactActEnvironment();
10
+
11
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
12
+ return { id, tag: "video", start, duration, track, domId: id };
13
+ }
14
+
15
+ describe("applyRippleShifts", () => {
16
+ it("returns survivors unchanged when there are no changes", () => {
17
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
18
+ expect(applyRippleShifts(survivors, null)).toBe(survivors);
19
+ });
20
+
21
+ it("rewrites only the starts named by the changes", () => {
22
+ const survivors = [el("a", 0, 2), el("c", 8, 1)];
23
+ const result = applyRippleShifts(survivors, [{ element: survivors[1], start: 2 }]);
24
+ expect(result).toEqual([el("a", 0, 2), el("c", 2, 1)]);
25
+ expect(result[0]).toBe(survivors[0]);
26
+ });
27
+ });
28
+
29
+ describe("useTimelineDeleteOps: ripple undo label", () => {
30
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
31
+ <div data-hf-id="hf-stage" data-hf-root data-duration="6">
32
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
33
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
34
+ <div data-hf-id="hf-c" data-start="4" data-duration="2"></div>
35
+ </div>
36
+ </body></html>`;
37
+
38
+ beforeEach(() => {
39
+ vi.stubGlobal(
40
+ "fetch",
41
+ vi.fn(async (input: RequestInfo | URL) => {
42
+ const url = String(input);
43
+ if (url.includes("/file-mutations/remove-element/")) {
44
+ return new Response(JSON.stringify({ changed: true, content: html }), { status: 200 });
45
+ }
46
+ return new Response(JSON.stringify({ content: html }), { status: 200 });
47
+ }),
48
+ );
49
+ });
50
+
51
+ afterEach(() => {
52
+ document.body.innerHTML = "";
53
+ vi.unstubAllGlobals();
54
+ });
55
+
56
+ type DeleteOpsOptions = Parameters<typeof useTimelineDeleteOps>[0];
57
+
58
+ function mountDeleteHarness(overrides: {
59
+ handleTimelineGroupMove: DeleteOpsOptions["handleTimelineGroupMove"];
60
+ showToast?: DeleteOpsOptions["showToast"];
61
+ }) {
62
+ const elements = [el("hf-a", 0, 2), el("hf-b", 2, 2), el("hf-c", 4, 2)];
63
+ let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
64
+ function Harness() {
65
+ hook = useTimelineDeleteOps({
66
+ projectIdRef: { current: "test-project" },
67
+ activeCompPath: "index.html",
68
+ timelineElements: elements,
69
+ showToast: overrides.showToast ?? vi.fn(),
70
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
71
+ recordEdit: vi.fn().mockResolvedValue(undefined),
72
+ reloadPreview: vi.fn(),
73
+ previewIframeRef: { current: null },
74
+ handleTimelineGroupMove: overrides.handleTimelineGroupMove,
75
+ });
76
+ return null;
77
+ }
78
+ mountReactHarness(<Harness />);
79
+ return { b: elements[1], getHook: () => hook! };
80
+ }
81
+
82
+ // editHistory.ts's coalescing keeps the LAST recordEdit call's label, so the
83
+ // folded ripple move must carry the delete's label, not its own.
84
+ it("passes the delete's own label to the folded ripple move, not 'Move timeline clips'", async () => {
85
+ const handleTimelineGroupMove = vi.fn().mockResolvedValue(undefined);
86
+ const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
87
+
88
+ await act(async () => {
89
+ await getHook().handleTimelineElementDelete(b);
90
+ });
91
+
92
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
93
+ expect.anything(),
94
+ expect.objectContaining({ label: "Delete timeline clip" }),
95
+ );
96
+ });
97
+
98
+ it("shows exactly one toast when a ripple fails to persist after a committed delete", async () => {
99
+ const handleTimelineGroupMove = vi.fn().mockRejectedValue(new Error("persist failed"));
100
+ const showToast = vi.fn();
101
+ const { b, getHook } = mountDeleteHarness({ handleTimelineGroupMove, showToast });
102
+
103
+ await act(async () => {
104
+ await getHook().handleTimelineElementDelete(b);
105
+ });
106
+
107
+ // The user did one thing (delete); the generic move-failure toast is
108
+ // suppressed on this call so only the specific message reaches them.
109
+ expect(showToast).toHaveBeenCalledTimes(1);
110
+ expect(showToast).toHaveBeenCalledWith(
111
+ "Clip deleted, but the gap could not be closed.",
112
+ "error",
113
+ );
114
+ expect(handleTimelineGroupMove).toHaveBeenCalledWith(
115
+ expect.anything(),
116
+ expect.objectContaining({ suppressFailureToast: true }),
117
+ );
118
+ });
119
+ });
@@ -2,7 +2,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  // Timeline clip deletion: the marquee/multi path and the single-clip wrapper
3
3
  // the context menu uses. Extracted verbatim from useTimelineEditing.ts to keep
4
4
  // it under the studio 600-line cap, following useTimelineAssetDropOps.
5
- import { useCallback, type MutableRefObject, type RefObject } from "react";
5
+ import { useCallback, useRef, type MutableRefObject, type RefObject } from "react";
6
6
  import type { TimelineElement } from "../player";
7
7
  import { usePlayerStore } from "../player";
8
8
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
@@ -12,6 +12,28 @@ import { buildPatchTarget } from "./timelineEditingHelpers";
12
12
  import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
13
13
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
14
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
15
+ import {
16
+ resolveMainTrackDeleteRippleShifts,
17
+ resolveShiftedElements,
18
+ } from "../player/components/timelineGapCommit";
19
+ import type {
20
+ TimelineGroupCommitOptions,
21
+ TimelineGroupMoveChange,
22
+ } from "./useTimelineGroupEditing";
23
+
24
+ /** Apply already-resolved ripple changes to the surviving elements for the
25
+ * optimistic store update after a delete. Pure — no IO. */
26
+ export function applyRippleShifts(
27
+ survivors: TimelineElement[],
28
+ changes: readonly TimelineGroupMoveChange[] | null,
29
+ ): TimelineElement[] {
30
+ if (!changes) return survivors;
31
+ const newStartByElement = new Map(changes.map((c) => [c.element, c.start]));
32
+ return survivors.map((te) => {
33
+ const newStart = newStartByElement.get(te);
34
+ return newStart != null ? { ...te, start: newStart } : te;
35
+ });
36
+ }
15
37
 
16
38
  interface UseTimelineDeleteOpsOptions {
17
39
  projectIdRef: MutableRefObject<string | null>;
@@ -24,8 +46,20 @@ interface UseTimelineDeleteOpsOptions {
24
46
  isRecordingRef?: MutableRefObject<boolean>;
25
47
  forceReloadSdkSession?: () => void;
26
48
  previewIframeRef: RefObject<HTMLIFrameElement | null>;
49
+ /** The same atomic multi-clip move commit the track-gap menu uses, reused
50
+ * to ripple the main track after a delete, folded into the delete's own
51
+ * undo entry via a shared coalesceKey. */
52
+ handleTimelineGroupMove: (
53
+ changes: TimelineGroupMoveChange[],
54
+ options?: TimelineGroupCommitOptions,
55
+ ) => Promise<void>;
27
56
  }
28
57
 
58
+ // Per-gesture-unique coalesce key — a monotonic counter, not Date.now() /
59
+ // Math.random() (determinism rules), mirroring gapCloseGestureSeq in
60
+ // timelineGapCommit.ts.
61
+ let deleteGestureSeq = 0;
62
+
29
63
  export function useTimelineDeleteOps({
30
64
  projectIdRef,
31
65
  activeCompPath,
@@ -37,7 +71,12 @@ export function useTimelineDeleteOps({
37
71
  isRecordingRef,
38
72
  forceReloadSdkSession,
39
73
  previewIframeRef,
74
+ handleTimelineGroupMove,
40
75
  }: UseTimelineDeleteOpsOptions) {
76
+ // First-use-per-session notice for the ripple. Plain info toast: Studio's
77
+ // toast system has no action buttons, so Undo and the toggle location are
78
+ // named in words instead.
79
+ const rippleNoticeShownRef = useRef(false);
41
80
  // fallow-ignore-next-line complexity
42
81
  const handleTimelineElementsDelete = useCallback(
43
82
  // fallow-ignore-next-line complexity
@@ -109,11 +148,17 @@ export function useTimelineDeleteOps({
109
148
  usePlayerStore.getState().setDuration(deleteContentEnd);
110
149
  }
111
150
 
151
+ // Shared with the ripple move below so a folded ripple is one undo
152
+ // step with the delete, not two (editHistory.ts coalesces by key +
153
+ // window across separate recordEdit calls, not by label).
154
+ const coalesceKey = `main-track-ripple-delete:${deleteGestureSeq++}`;
155
+ const deleteHistoryLabel = "Delete timeline clip";
112
156
  try {
113
157
  await saveProjectFilesWithHistory({
114
158
  projectId: pid,
115
- label: "Delete timeline clip",
159
+ label: deleteHistoryLabel,
116
160
  kind: "timeline",
161
+ coalesceKey,
117
162
  files: { [targetPath]: patchedContent },
118
163
  readFile: async () => originalContent,
119
164
  // remove-element already wrote the removal, so disk holds THAT — not the
@@ -128,17 +173,65 @@ export function useTimelineDeleteOps({
128
173
  }
129
174
 
130
175
  const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
131
- usePlayerStore
132
- .getState()
133
- .setElements(timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id)));
176
+ const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
177
+
178
+ // Ripple: close the gap the delete left on the main track. Pure decision
179
+ // first (resolveMainTrackDeleteRippleShifts — off, no main-track clip
180
+ // deleted, already gapless, or a locked survivor all resolve to null),
181
+ // then the one write, folded into the delete's undo entry above.
182
+ const rippleShifts = resolveMainTrackDeleteRippleShifts(
183
+ survivors,
184
+ sameFile,
185
+ usePlayerStore.getState().rippleEditEnabled,
186
+ );
187
+ let rippleApplied: TimelineGroupMoveChange[] | null = null;
188
+ let rippleFailed = false;
189
+ if (rippleShifts) {
190
+ const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
191
+ try {
192
+ await handleTimelineGroupMove(rippleChanges, {
193
+ coalesceKey,
194
+ coalesceMs: Number.POSITIVE_INFINITY,
195
+ // Coalescing keeps the LAST entry's label; without this the undo
196
+ // toast reads "Undid Move timeline clips" after a delete, naming
197
+ // the ripple's mechanics instead of what the user actually did.
198
+ label: deleteHistoryLabel,
199
+ // This call gets its own, more specific failure toast below —
200
+ // the generic one would tell the user about one action twice.
201
+ suppressFailureToast: true,
202
+ });
203
+ rippleApplied = rippleChanges;
204
+ } catch (error) {
205
+ // The delete already committed; a failed ripple leaves the gap the
206
+ // toggle-off behaviour would have left anyway — not worth undoing
207
+ // an otherwise-successful delete over.
208
+ rippleFailed = true;
209
+ console.error("[Timeline] ripple-edit failed to persist after delete", error);
210
+ showToast("Clip deleted, but the gap could not be closed.", "error");
211
+ }
212
+ }
213
+
214
+ usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
134
215
  usePlayerStore.getState().setSelectedElementId(null);
135
216
  usePlayerStore.getState().setSelectedElementIds(new Set());
136
217
  forceReloadSdkSession?.();
137
218
  reloadPreview();
138
- showToast(
139
- `Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
140
- "info",
141
- );
219
+ // A failed ripple already showed its own toast above; the user did one
220
+ // thing (delete), so they get one message, not this generic follow-up too.
221
+ if (!rippleFailed) {
222
+ showToast(
223
+ `Deleted ${label}. Use Undo to restore ${sameFile.length === 1 ? "it" : "them"}.`,
224
+ "info",
225
+ );
226
+ }
227
+ if (rippleApplied && !rippleNoticeShownRef.current) {
228
+ rippleNoticeShownRef.current = true;
229
+ showToast(
230
+ "Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
231
+ "Ripple in the timeline toolbar.",
232
+ "info",
233
+ );
234
+ }
142
235
  } catch (error) {
143
236
  const message = error instanceof Error ? error.message : "Failed to delete timeline clip";
144
237
  showToast(message);
@@ -155,6 +248,7 @@ export function useTimelineDeleteOps({
155
248
  isRecordingRef,
156
249
  forceReloadSdkSession,
157
250
  previewIframeRef,
251
+ handleTimelineGroupMove,
158
252
  ],
159
253
  );
160
254
 
@@ -413,6 +413,7 @@ export function useTimelineEditing({
413
413
  isRecordingRef,
414
414
  forceReloadSdkSession,
415
415
  previewIframeRef,
416
+ handleTimelineGroupMove: groupEditing.handleTimelineGroupMove,
416
417
  });
417
418
 
418
419
  const { handleTimelineAssetDrop, handleTimelineFileDrop, handleTimelineCompositionDrop } =
@@ -0,0 +1,74 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { Root } from "react-dom/client";
6
+ import type { TimelineElement } from "../player";
7
+ import { useTimelineGroupEditing } from "./useTimelineGroupEditing";
8
+ import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
9
+
10
+ installReactActEnvironment();
11
+
12
+ function el(id: string, start: number, duration: number, track = 0): TimelineElement {
13
+ return { id, tag: "video", start, duration, track, domId: id };
14
+ }
15
+
16
+ describe("useTimelineGroupEditing: handleTimelineGroupMove suppressFailureToast", () => {
17
+ let root: Root | null = null;
18
+
19
+ afterEach(() => {
20
+ if (root) act(() => root!.unmount());
21
+ root = null;
22
+ document.body.innerHTML = "";
23
+ });
24
+
25
+ type GroupEditingOptions = Parameters<typeof useTimelineGroupEditing>[0];
26
+
27
+ // No project id makes enqueueGroupOperation reject before any persist runs,
28
+ // exercising the move's real catch block without faking the SDK/server path.
29
+ function mountFailingHarness(showToast: GroupEditingOptions["showToast"]) {
30
+ let hook: ReturnType<typeof useTimelineGroupEditing> | null = null;
31
+ function Harness() {
32
+ hook = useTimelineGroupEditing({
33
+ activeCompPath: "index.html",
34
+ editQueueRef: { current: Promise.resolve() },
35
+ pendingTimelineEditPathRef: { current: new Set() },
36
+ previewIframeRef: { current: null },
37
+ projectIdRef: { current: null },
38
+ recordEdit: vi.fn().mockResolvedValue(undefined),
39
+ reloadPreview: vi.fn(),
40
+ showToast,
41
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
42
+ });
43
+ return null;
44
+ }
45
+ root = mountReactHarness(<Harness />);
46
+ return () => hook!;
47
+ }
48
+
49
+ const change = { element: el("a", 0, 2), start: 2 };
50
+
51
+ it("shows no toast when suppressFailureToast is set on a failed move", async () => {
52
+ const showToast = vi.fn();
53
+ const getHook = mountFailingHarness(showToast);
54
+
55
+ await act(async () => {
56
+ await expect(
57
+ getHook().handleTimelineGroupMove([change], { suppressFailureToast: true }),
58
+ ).rejects.toThrow();
59
+ });
60
+
61
+ expect(showToast).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("shows one toast when suppressFailureToast is not set on a failed move", async () => {
65
+ const showToast = vi.fn();
66
+ const getHook = mountFailingHarness(showToast);
67
+
68
+ await act(async () => {
69
+ await expect(getHook().handleTimelineGroupMove([change])).rejects.toThrow();
70
+ });
71
+
72
+ expect(showToast).toHaveBeenCalledTimes(1);
73
+ });
74
+ });
@@ -47,6 +47,15 @@ export interface TimelineGroupCommitOptions {
47
47
  coalesceKey?: string;
48
48
  /** Per-entry undo coalesce window override (ms) — see EditHistoryEntry.coalesceMs. */
49
49
  coalesceMs?: number;
50
+ /** Overrides the default "Move timeline clips" undo-history label. Coalescing
51
+ * keeps the LAST entry's label (editHistory.ts), so a mechanical follow-up
52
+ * move folded into another gesture's coalesceKey (e.g. the ripple after a
53
+ * delete) should carry that gesture's own label, not its own. */
54
+ label?: string;
55
+ /** Skips this call's own generic failure toast (the error is still logged
56
+ * to the console) — for a caller that shows its own more specific message
57
+ * on the same failure, so the user isn't told about one action twice. */
58
+ suppressFailureToast?: boolean;
50
59
  }
51
60
 
52
61
  interface UseTimelineGroupEditingOptions {
@@ -272,21 +281,22 @@ export function useTimelineGroupEditing({
272
281
  syncPreviewContentDuration(previewIframeRef.current);
273
282
  const coalesceKey = options?.coalesceKey ?? moveCoalesceKey(changes);
274
283
  const coalesceMs = options?.coalesceMs;
275
- return enqueueGroupOperation("Move timeline clips", async (projectId) => {
284
+ const label = options?.label ?? "Move timeline clips";
285
+ return enqueueGroupOperation(label, async (projectId) => {
276
286
  await options?.beforeTiming;
277
287
  const handledBySdk = await trySdkBatchPersist({
278
288
  changes,
279
289
  sdkChanges: toSdkTimingChanges(changes, (change) => ({ start: change.start })),
280
290
  eligible: changes.every((change) => change.track == null),
281
291
  needsExtension,
282
- label: "Move timeline clips",
292
+ label,
283
293
  coalesceKey,
284
294
  coalesceMs,
285
295
  });
286
296
  if (!handledBySdk) {
287
297
  await persistServerBatch(
288
298
  projectId,
289
- "Move timeline clips",
299
+ label,
290
300
  changes.map((change) => ({
291
301
  element: change.element,
292
302
  buildPatches: (original, target) =>
@@ -315,7 +325,7 @@ export function useTimelineGroupEditing({
315
325
  projectId,
316
326
  iframe: previewIframeRef.current,
317
327
  reloadPreview,
318
- label: "Move timeline clips",
328
+ label,
319
329
  errorLabel: "Failed to shift GSAP positions",
320
330
  coalesceKey,
321
331
  recordEdit,
@@ -336,7 +346,11 @@ export function useTimelineGroupEditing({
336
346
  // Failed persist: revert the optimistic duration readout + live root
337
347
  // alongside the gesture owner's store rollback.
338
348
  rollbackDuration();
339
- showToast(getStudioSaveErrorMessage(error), "error");
349
+ if (options?.suppressFailureToast) {
350
+ console.error("[Timeline] group move failed to persist", error);
351
+ } else {
352
+ showToast(getStudioSaveErrorMessage(error), "error");
353
+ }
340
354
  throw error;
341
355
  });
342
356
  },
package/src/index.ts CHANGED
@@ -27,6 +27,16 @@ export { FileTree } from "./components/editor/FileTree";
27
27
  // App
28
28
  export { StudioApp } from "./App";
29
29
 
30
+ // Ask-agent flow
31
+ export { AskAgentModal } from "./components/AskAgentModal";
32
+ export type { AskAgentModalProps } from "./components/AskAgentModal";
33
+ export type { AgentModalAnchorPoint } from "./utils/studioHelpers";
34
+ export {
35
+ buildPickerAgentPrompt,
36
+ buildPickerAgentContextPreview,
37
+ } from "./components/editor/domEditingAgentPrompt";
38
+ export type { AgentPromptElementInfo } from "./components/editor/domEditingAgentPrompt";
39
+
30
40
  // Hooks
31
41
  export { useElementPicker } from "./hooks/useElementPicker";
32
42
  export type { PickedElement } from "./hooks/useElementPicker";
@@ -166,7 +166,6 @@ describe("Timeline provider boundary", () => {
166
166
  scrollTop: 0,
167
167
  contentOrigin: GUTTER,
168
168
  pixelsPerSecond: 100,
169
- duration: 60,
170
169
  trackOrder: [0],
171
170
  },
172
171
  1132,
@@ -282,7 +281,6 @@ describe("Timeline provider boundary", () => {
282
281
  scrollTop: 0,
283
282
  contentOrigin: LABEL_COL_W + GUTTER,
284
283
  pixelsPerSecond: 100,
285
- duration: 60,
286
284
  trackOrder: [0],
287
285
  },
288
286
  100 + LABEL_COL_W + GUTTER + 1000,
@@ -1263,7 +1261,6 @@ describe("resolveTimelineAssetDrop", () => {
1263
1261
  scrollTop: 0,
1264
1262
  contentOrigin: GUTTER,
1265
1263
  pixelsPerSecond: 100,
1266
- duration: 10,
1267
1264
  trackHeight: 72,
1268
1265
  trackOrder: [0, 3, 7],
1269
1266
  },
@@ -1285,7 +1282,6 @@ describe("resolveTimelineAssetDrop", () => {
1285
1282
  scrollTop: 0,
1286
1283
  contentOrigin: GUTTER,
1287
1284
  pixelsPerSecond: 100,
1288
- duration: 10,
1289
1285
  trackHeight: 72,
1290
1286
  trackOrder: [0, 3, 7],
1291
1287
  },
@@ -238,7 +238,6 @@ export const Timeline = memo(function Timeline({
238
238
  const assetDrop = useTimelineAssetDrop({
239
239
  scrollRef,
240
240
  ppsRef,
241
- durationRef,
242
241
  trackOrderRef,
243
242
  rowGeometryRef,
244
243
  contentOrigin,
@@ -13,7 +13,7 @@ import { TimelineAutomationLane } from "./TimelineAutomationLane";
13
13
  import { AUTOMATION_LANE_H } from "./automationLaneHeight";
14
14
  import { getTimelineLaneTop } from "./timelineLayout";
15
15
  import { groupAutomationLanes, isCarveLane } from "./automationLaneData";
16
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
16
+ import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
17
17
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
18
18
  import type { TimelineElement } from "../store/playerStore";
19
19
  import type { UseAutomationLanesResult } from "./useAutomationLanes";
@@ -174,7 +174,9 @@ export function TimelineAutomationLaneSlot({
174
174
  currentTime,
175
175
  beatTimes,
176
176
  }: TimelineAutomationLaneSlotProps) {
177
- const clips = elements.filter(isAudioTimelineElement);
177
+ // Broader than a raw-attribute check — a clip mid-edit (see the stale-
178
+ // selection test) has lanes in its live binding before the attribute commits.
179
+ const clips = elements.filter(isAudioOrVideoTimelineElement);
178
180
  const rowsByClip = new Map<string, ClipLaneRow[]>();
179
181
  groupAutomationLanes(clips).forEach((group, rowIndex) => {
180
182
  for (const entry of group.entries) {
@@ -1275,7 +1275,8 @@ describe("TimelineClipDiamonds", () => {
1275
1275
  expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
1276
1276
  const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
1277
1277
  expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
1278
- expect(ease.classList.contains("opacity-0")).toBe(true);
1278
+ expect(ease.getAttribute("title")).toBe("Edit none easing");
1279
+ expect(ease.classList.contains("opacity-40")).toBe(true);
1279
1280
  act(() => ease.click());
1280
1281
  expect(onSelectSegment).toHaveBeenCalledOnce();
1281
1282
  expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
@@ -1318,6 +1319,17 @@ describe("TimelineClipDiamonds", () => {
1318
1319
  act(() => root.unmount());
1319
1320
  });
1320
1321
 
1322
+ it("keeps the ease button hover-only on a segment too narrow to show it at rest", () => {
1323
+ // Same narrow clip as above: showing the button at rest here would sit on
1324
+ // top of both diamonds instead of clear of them.
1325
+ const { host, root } = renderSegmentLane(false, 40);
1326
+ const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]");
1327
+
1328
+ expect(ease?.classList.contains("opacity-0")).toBe(true);
1329
+ expect(ease?.classList.contains("opacity-40")).toBe(false);
1330
+ act(() => root.unmount());
1331
+ });
1332
+
1321
1333
  it("hides the inline ease button on a segment with no source animation id", () => {
1322
1334
  // A runtime-scanned keyframe has no animationId, so there is no tween to
1323
1335
  // target; the segment ending on it must not render a (dead) ease button.
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasRoomForRestingEase } from "./TimelineDiamondConnectors";
3
+
4
+ describe("hasRoomForRestingEase", () => {
5
+ it("is false just under the threshold", () => {
6
+ expect(hasRoomForRestingEase(23)).toBe(false);
7
+ });
8
+
9
+ it("is true exactly at the threshold", () => {
10
+ expect(hasRoomForRestingEase(24)).toBe(true);
11
+ });
12
+
13
+ it("is true well above the threshold", () => {
14
+ expect(hasRoomForRestingEase(78)).toBe(true);
15
+ });
16
+ });
@@ -13,6 +13,17 @@ export interface TimelineDiamondMarker {
13
13
  visualSize: number;
14
14
  }
15
15
 
16
+ const EASE_BUTTON_PX = 16;
17
+ const EASE_BUTTON_REST_GAP_PX = 4;
18
+
19
+ /** Whether a segment is wide enough to show the ease button at rest (button
20
+ * box plus a clear gap each side) without overlapping either neighboring
21
+ * diamond. 24 coincides with roomForFullTarget's threshold below but is a
22
+ * separate constraint; don't assume changing one keeps the other in sync. */
23
+ export function hasRoomForRestingEase(connectorWidth: number): boolean {
24
+ return connectorWidth >= EASE_BUTTON_PX + EASE_BUTTON_REST_GAP_PX * 2;
25
+ }
26
+
16
27
  /**
17
28
  * The line between each pair of adjacent diamonds, plus the ease control that
18
29
  * sits at the segment's midpoint. Split out of TimelineClipDiamonds only to keep
@@ -96,6 +107,7 @@ export function TimelineDiamondConnectors({
96
107
  // the hit test and steal their clicks at fit zoom. Grow the
97
108
  // target only where the room exists.
98
109
  roomForFullTarget={connectorWidth >= 24}
110
+ restingVisible={hasRoomForRestingEase(connectorWidth)}
99
111
  onSelectSegment={onSelectSegment}
100
112
  />
101
113
  )}
@@ -133,6 +145,7 @@ function SegmentEaseControl({
133
145
  rovingTargetId,
134
146
  afterLabel,
135
147
  roomForFullTarget,
148
+ restingVisible,
136
149
  onSelectSegment,
137
150
  }: {
138
151
  left: number;
@@ -146,6 +159,9 @@ function SegmentEaseControl({
146
159
  /** Time label of the keyframe this segment starts at, for the accessible name. */
147
160
  afterLabel: string;
148
161
  roomForFullTarget: boolean;
162
+ /** Whether the segment is wide enough to show the button at rest; too
163
+ * narrow and it stays hover-only so it doesn't smear over the diamonds. */
164
+ restingVisible: boolean;
149
165
  onSelectSegment: (target: TimelineKeyframeTarget) => void;
150
166
  }) {
151
167
  // The ease button sits dead centre of its segment, which on a two-keyframe clip
@@ -187,7 +203,7 @@ function SegmentEaseControl({
187
203
  // for that overlay the button keeps its 16x16 hit area, which is WCAG's
188
204
  // target-spacing exception: the neighbouring diamonds are themselves the
189
205
  // reason it cannot grow, and stealing their clicks is the worse failure.
190
- className={`absolute flex items-center justify-center rounded opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
206
+ className={`absolute flex items-center justify-center rounded ${restingVisible ? "opacity-40" : "opacity-0"} transition-opacity hover:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 ${roomForFullTarget ? "before:absolute before:left-1/2 before:top-1/2 before:h-6 before:w-6 before:-translate-x-1/2 before:-translate-y-1/2 before:content-['']" : ""}`}
191
207
  style={{
192
208
  left: "50%",
193
209
  top: "50%",
@@ -406,7 +406,7 @@ describe("TimelinePropertyLanes", () => {
406
406
  expect(laneDiamonds(host, "position")).toHaveLength(3);
407
407
  const buttons = laneEaseButtons(host, "position");
408
408
  expect(buttons).toHaveLength(2);
409
- expect(buttons.every((button) => button.classList.contains("opacity-0"))).toBe(true);
409
+ expect(buttons.every((button) => button.classList.contains("opacity-40"))).toBe(true);
410
410
  expect(buttons.every((button) => button.classList.contains("group-hover:opacity-100"))).toBe(
411
411
  true,
412
412
  );
@@ -260,9 +260,17 @@ describe("groupAutomationLanes", () => {
260
260
  expect(groups.map((g) => g.entries.length)).toEqual([1, 1]);
261
261
  });
262
262
 
263
- it("ignores clips that are not audio, the way the reserved height does", () => {
264
- const video = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
265
- expect(groupAutomationLanes([video])).toEqual([]);
263
+ it("draws lanes for a video element, matching the reserved height", () => {
264
+ // A <video>'s volume can be automated from the property panel same as an
265
+ // audio clip's; gating this on `isAudioTimelineElement` alone hid every
266
+ // keyframe the panel wrote for a video clip.
267
+ const video = el({ id: "titles", key: "titles", tag: "video", automation: lanesOf("volume") });
268
+ expect(groupAutomationLanes([video]).map((g) => g.key)).toEqual(["Volume"]);
269
+ });
270
+
271
+ it("ignores a clip that is neither audio nor video, the way the reserved height does", () => {
272
+ const div = el({ id: "titles", key: "titles", tag: "div", automation: lanesOf("volume") });
273
+ expect(groupAutomationLanes([div])).toEqual([]);
266
274
  });
267
275
 
268
276
  it("skips a target that does not resolve against its clip's chain", () => {