@hyperframes/studio 0.8.47 → 0.8.49

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 (123) hide show
  1. package/dist/assets/hyperframes-player-CqON2Rzo.js +464 -0
  2. package/dist/assets/index-B9LfWNyM.css +1 -0
  3. package/dist/assets/{index-ryL7q1QG.js → index-BGovDksE.js} +1 -1
  4. package/dist/assets/{index-j-4hCrZU.js → index-Bgmx2h7W.js} +208 -208
  5. package/dist/assets/{index-B50xQfSG.js → index-mIgSsKqs.js} +1 -1
  6. package/dist/index.d.ts +70 -26
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1452 -918
  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/propertyPanelAutomation.test.ts +82 -1
  19. package/src/components/editor/propertyPanelAutomation.ts +15 -0
  20. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +35 -0
  21. package/src/components/editor/propertyPanelFlatMediaSection.tsx +23 -9
  22. package/src/components/editor/useVolumeAutomation.test.tsx +81 -10
  23. package/src/components/editor/useVolumeAutomation.ts +23 -2
  24. package/src/components/panels/VariablesPanel.tsx +5 -8
  25. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +59 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +15 -0
  27. package/src/components/sidebar/LeftSidebar.masterComposition.test.tsx +47 -0
  28. package/src/components/sidebar/LeftSidebar.tsx +7 -0
  29. package/src/contexts/FileManagerContext.tsx +0 -3
  30. package/src/hooks/appHotkeysDispatch.ts +256 -0
  31. package/src/hooks/domEditDeleteMembers.test.ts +30 -0
  32. package/src/hooks/domEditDeleteMembers.ts +19 -0
  33. package/src/hooks/useActiveComposition.ts +69 -0
  34. package/src/hooks/useAppHotkeys.test.ts +23 -1
  35. package/src/hooks/useAppHotkeys.ts +6 -239
  36. package/src/hooks/useAutoOpenRootComposition.test.tsx +151 -0
  37. package/src/hooks/useAutoOpenRootComposition.ts +33 -0
  38. package/src/hooks/useBlockHandlers.ts +7 -2
  39. package/src/hooks/useDomEditCommits.ts +13 -15
  40. package/src/hooks/useDomEditSession.test.tsx +90 -1
  41. package/src/hooks/useDomEditSession.ts +31 -34
  42. package/src/hooks/useDomSelectionTypes.ts +7 -2
  43. package/src/hooks/useElementLifecycleOps.ts +0 -8
  44. package/src/hooks/useFileManager.projectOwnership.test.tsx +0 -1
  45. package/src/hooks/useFileManager.ts +0 -2
  46. package/src/hooks/useFileTree.test.tsx +214 -0
  47. package/src/hooks/useFileTree.ts +66 -24
  48. package/src/hooks/useGroupCommits.test.ts +98 -0
  49. package/src/hooks/useGroupCommits.ts +66 -9
  50. package/src/hooks/useResetSelectionOnProjectSwitch.test.tsx +234 -0
  51. package/src/hooks/useResetSelectionOnProjectSwitch.ts +34 -0
  52. package/src/hooks/useTimelineAssetDropOps.test.tsx +87 -0
  53. package/src/hooks/useTimelineAssetDropOps.ts +24 -18
  54. package/src/hooks/useTimelineDeleteOps.test.tsx +119 -0
  55. package/src/hooks/useTimelineDeleteOps.ts +103 -9
  56. package/src/hooks/useTimelineEditing.ts +1 -0
  57. package/src/hooks/useTimelineGroupEditing.test.tsx +74 -0
  58. package/src/hooks/useTimelineGroupEditing.ts +19 -5
  59. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  60. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  61. package/src/index.ts +10 -0
  62. package/src/player/components/Timeline.test.ts +0 -4
  63. package/src/player/components/Timeline.tsx +0 -1
  64. package/src/player/components/TimelineAutomationLaneSlot.tsx +4 -2
  65. package/src/player/components/TimelineClipDiamonds.test.tsx +13 -1
  66. package/src/player/components/TimelineDiamondConnectors.test.ts +16 -0
  67. package/src/player/components/TimelineDiamondConnectors.tsx +17 -1
  68. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  69. package/src/player/components/automationLaneData.test.ts +11 -3
  70. package/src/player/components/automationLaneData.ts +5 -4
  71. package/src/player/components/automationLaneGeometry.ts +15 -0
  72. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  73. package/src/player/components/timelineClipDragCommit.ts +18 -49
  74. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  75. package/src/player/components/timelineClipDragPreview.ts +8 -1
  76. package/src/player/components/timelineCollision.test.ts +23 -0
  77. package/src/player/components/timelineCollision.ts +27 -0
  78. package/src/player/components/timelineDragDrop.test.tsx +21 -4
  79. package/src/player/components/timelineDragDrop.ts +9 -25
  80. package/src/player/components/timelineDragLanding.ts +108 -0
  81. package/src/player/components/timelineDropReveal.test.ts +34 -0
  82. package/src/player/components/timelineDropReveal.ts +12 -0
  83. package/src/player/components/timelineGapCommit.test.ts +96 -0
  84. package/src/player/components/timelineGapCommit.ts +56 -11
  85. package/src/player/components/timelineGaps.ts +2 -2
  86. package/src/player/components/timelineLayout.test.ts +0 -1
  87. package/src/player/components/timelineLayout.ts +1 -6
  88. package/src/player/components/timelineZones.test.ts +20 -1
  89. package/src/player/components/timelineZones.ts +10 -3
  90. package/src/player/components/useAutomationLaneGestures.ts +2 -10
  91. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  92. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  93. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  94. package/src/player/components/useTimelineGeometry.ts +7 -2
  95. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  96. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +7 -6
  98. package/src/player/hooks/usePlaybackKeyboard.test.ts +34 -0
  99. package/src/player/hooks/usePlaybackKeyboard.ts +13 -0
  100. package/src/player/lib/timelineElementHelpers.ts +8 -0
  101. package/src/player/lib/timelineIframeHelpers.test.ts +22 -0
  102. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  103. package/src/player/store/playerStore.ts +16 -21
  104. package/src/player/store/readinessSlice.ts +4 -3
  105. package/src/utils/blockInstaller.test.ts +4 -0
  106. package/src/utils/blockInstaller.ts +9 -3
  107. package/src/utils/razorSplitTransaction.test.ts +10 -0
  108. package/src/utils/razorSplitTransaction.ts +5 -0
  109. package/src/utils/retryBudget.ts +20 -0
  110. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  111. package/src/utils/sdkCutover.test.ts +51 -0
  112. package/src/utils/sdkCutover.ts +7 -4
  113. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  114. package/src/utils/sdkCutoverEligibility.ts +34 -11
  115. package/src/utils/studioHelpers.test.ts +18 -0
  116. package/src/utils/studioHelpers.ts +41 -5
  117. package/src/utils/studioUiPreferences.test.ts +14 -0
  118. package/src/utils/studioUiPreferences.ts +6 -0
  119. package/src/utils/studioUrlState.ts +15 -7
  120. package/src/utils/timelineCompositionInsert.ts +2 -1
  121. package/src/utils/timelineInspector.ts +10 -0
  122. package/dist/assets/hyperframes-player-B7fsB8C2.js +0 -464
  123. package/dist/assets/index-DnRfAiK2.css +0 -1
@@ -93,9 +93,14 @@ export function useTimelineGeometry({
93
93
  useEffect(() => {
94
94
  if (zoomMode !== "manual" || isDragging.current) return;
95
95
  const el = scrollRef.current;
96
- const target = lastScrollLeftRef.current;
97
- if (!el || target <= 0) return;
96
+ if (!el) return;
97
+ // Read the ref inside the frame, not here: a sibling effect in the same
98
+ // commit (a fresh reveal) can still update lastScrollLeftRef before this
99
+ // frame runs, and a value captured now would fight that reveal with a
100
+ // stale target.
98
101
  const raf = requestAnimationFrame(() => {
102
+ const target = lastScrollLeftRef.current;
103
+ if (target <= 0) return;
99
104
  const max = Math.max(0, el.scrollWidth - el.clientWidth);
100
105
  const next = Math.min(target, max);
101
106
  if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
@@ -0,0 +1,184 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useMemo, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { createTimelineRowGeometry } from "./timelineLayout";
8
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
+ import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
10
+ import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
11
+ import { useTimelineGeometry } from "./useTimelineGeometry";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
16
+
17
+ const oldElement = { id: "old", tag: "div", start: 0, duration: 1, track: 1 };
18
+ const heroElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
19
+ const clipId = timelineClipFocusId("hero");
20
+ const rowId = timelineTrackRowId(1);
21
+ const beforeRows: readonly TimelineLogicalRow[] = [
22
+ {
23
+ id: rowId,
24
+ kind: "row",
25
+ physicalTrackKey: 1,
26
+ logicalIndex: 0,
27
+ level: 1,
28
+ parentId: null,
29
+ elementId: null,
30
+ expandable: false,
31
+ expanded: false,
32
+ items: [],
33
+ },
34
+ ];
35
+ const afterRows: readonly TimelineLogicalRow[] = [
36
+ {
37
+ id: rowId,
38
+ kind: "row",
39
+ physicalTrackKey: 1,
40
+ logicalIndex: 0,
41
+ level: 1,
42
+ parentId: null,
43
+ elementId: null,
44
+ expandable: false,
45
+ expanded: false,
46
+ items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
47
+ },
48
+ ];
49
+
50
+ // Renders the two real production hooks side by side, in a caller-chosen call
51
+ // order, to test whether their interaction actually depends on that order —
52
+ // as opposed to simulating one hook's effect by hand.
53
+ function OrderHarness({
54
+ order,
55
+ expandedElements,
56
+ logicalRows,
57
+ lastScrollLeftRef,
58
+ }: {
59
+ order: "geometry-first" | "coordinator-first";
60
+ expandedElements: (typeof oldElement)[];
61
+ logicalRows: readonly TimelineLogicalRow[];
62
+ lastScrollLeftRef: React.RefObject<number>;
63
+ }) {
64
+ const scrollRef = useRef<HTMLDivElement>(null);
65
+ const isDragging = useRef(false);
66
+ const ppsRef = useRef(0);
67
+ const fitPpsRef = useRef(0);
68
+ const rowGeometry = useMemo(() => createTimelineRowGeometry([1], [48]), []);
69
+
70
+ const runGeometry = () =>
71
+ useTimelineGeometry({
72
+ viewportWidth: 300,
73
+ effectiveDuration: 60,
74
+ zoomMode: "manual",
75
+ manualZoomPercent: 100,
76
+ ppsRef,
77
+ fitPpsRef,
78
+ draggedClip: null,
79
+ resizingClip: null,
80
+ expandedElements,
81
+ isDragging,
82
+ scrollRef,
83
+ lastScrollLeftRef,
84
+ contentOrigin: 32,
85
+ });
86
+ const runFocus = () =>
87
+ useTimelineFocusCoordinator({
88
+ scrollRef,
89
+ logicalRows,
90
+ elements: expandedElements,
91
+ rowGeometry,
92
+ pixelsPerSecond: 100,
93
+ contentOrigin: 32,
94
+ allowHorizontal: true,
95
+ viewportVersion: logicalRows.length,
96
+ projectId: "project-a",
97
+ sessionEpoch: 1,
98
+ syncScrollViewport: () => {},
99
+ lastScrollLeftRef,
100
+ });
101
+
102
+ // `order` is fixed for the lifetime of one mounted harness, so this call
103
+ // order is stable across that instance's renders.
104
+ if (order === "geometry-first") {
105
+ runGeometry();
106
+ runFocus();
107
+ } else {
108
+ runFocus();
109
+ runGeometry();
110
+ }
111
+
112
+ return (
113
+ <div
114
+ ref={(node) => {
115
+ scrollRef.current = node;
116
+ if (node) {
117
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
118
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
119
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: 20_000 });
120
+ }
121
+ }}
122
+ />
123
+ );
124
+ }
125
+
126
+ let host: HTMLDivElement;
127
+ let root: Root;
128
+ beforeEach(() => {
129
+ usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
130
+ host = document.createElement("div");
131
+ document.body.append(host);
132
+ root = createRoot(host);
133
+ });
134
+ afterEach(() => {
135
+ act(() => root.unmount());
136
+ usePlayerStore.getState().reset();
137
+ document.body.replaceChildren();
138
+ });
139
+
140
+ describe.each(["geometry-first", "coordinator-first"] as const)(
141
+ "useTimelineGeometry + useTimelineFocusCoordinator, %s",
142
+ (order) => {
143
+ it("a same-commit reveal survives the geometry restore effect regardless of hook registration order", async () => {
144
+ const lastScrollLeftRef = { current: 132 };
145
+ await act(async () => {
146
+ root.render(
147
+ <OrderHarness
148
+ order={order}
149
+ expandedElements={[oldElement]}
150
+ logicalRows={beforeRows}
151
+ lastScrollLeftRef={lastScrollLeftRef}
152
+ />,
153
+ );
154
+ });
155
+ const scroll = host.firstElementChild as HTMLDivElement;
156
+ scroll.scrollLeft = 132;
157
+ await act(async () => {
158
+ await nextFrame();
159
+ });
160
+ expect(scroll.scrollLeft).toBe(132);
161
+
162
+ // One commit: the drop both adds the element (expandedElements changes,
163
+ // arming the geometry restore effect) and resolves the focus request
164
+ // (arming the coordinator's reveal), exactly as Timeline.tsx renders them.
165
+ usePlayerStore.getState().requestTimelineFocus(clipId);
166
+ await act(async () => {
167
+ root.render(
168
+ <OrderHarness
169
+ order={order}
170
+ expandedElements={[oldElement, heroElement]}
171
+ logicalRows={afterRows}
172
+ lastScrollLeftRef={lastScrollLeftRef}
173
+ />,
174
+ );
175
+ });
176
+ await act(async () => {
177
+ await nextFrame();
178
+ });
179
+
180
+ expect(scroll.scrollLeft).toBe(1_944);
181
+ expect(lastScrollLeftRef.current).toBe(1_944);
182
+ });
183
+ },
184
+ );
@@ -63,6 +63,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
63
63
  projectId,
64
64
  sessionEpoch: input.sessionEpoch,
65
65
  syncScrollViewport: input.syncScrollViewport,
66
+ lastScrollLeftRef: input.lastScrollLeftRef,
66
67
  });
67
68
  const rows = useTimelineRowVirtualization({
68
69
  scrollRef: input.scrollRef,
@@ -1,7 +1,7 @@
1
1
  import { useMemo, useRef } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { animationLaneGroups } from "./TimelinePropertyLanes";
4
- import { isAudioTimelineElement } from "../../utils/timelineInspector";
4
+ import { isAudioOrVideoTimelineElement } from "../../utils/timelineInspector";
5
5
  import { elementAutomationLanes, groupAutomationLanes } from "./automationLaneData";
6
6
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -44,14 +44,15 @@ export function trackShowsBeatStrip(
44
44
  }
45
45
 
46
46
  /**
47
- * Automation lanes on one clip, or 0 for anything that is not audio.
47
+ * Automation lanes on one clip.
48
48
  *
49
- * An audio clip can be worth expanding without carrying a single tween, so this
50
- * counts toward whether a track has anything to disclose. A function rather than
51
- * a map so every caller reads the same cached parse and none can drift.
49
+ * A clip can be worth expanding without carrying a single tween, so this counts
50
+ * toward whether a track has anything to disclose. Gated on
51
+ * `isAudioOrVideoTimelineElement`, matching `groupAutomationLanes`. A function
52
+ * rather than a map so every caller reads the same cached parse and none can drift.
52
53
  */
53
54
  function automationLaneCountOf(element: TimelineElement): number {
54
- return isAudioTimelineElement(element) ? elementAutomationLanes(element).length : 0;
55
+ return isAudioOrVideoTimelineElement(element) ? elementAutomationLanes(element).length : 0;
55
56
  }
56
57
 
57
58
  /**
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
+ // fallow-ignore-file code-duplication
2
3
 
3
4
  import React, { act, useEffect } from "react";
4
5
  import { createRoot } from "react-dom/client";
@@ -171,6 +172,39 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
171
172
 
172
173
  expect(spies.play).toHaveBeenCalledTimes(1);
173
174
  });
175
+
176
+ it("'A' does not seek to the in-point while the razor tool is armed, so it's free for the razor's own return-to-select binding", () => {
177
+ const { dispatch, spies } = setupHook();
178
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
179
+
180
+ act(() => {
181
+ dispatch(keydown({ code: "KeyA", key: "a" }));
182
+ });
183
+
184
+ expect(spies.seek).not.toHaveBeenCalled();
185
+ });
186
+
187
+ it("'A' still seeks to the in-point when the razor tool isn't armed", () => {
188
+ const { dispatch, spies } = setupHook();
189
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "select" });
190
+
191
+ act(() => {
192
+ dispatch(keydown({ code: "KeyA", key: "a" }));
193
+ });
194
+
195
+ expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
196
+ });
197
+
198
+ it("Shift+A still seeks to the in-point while the razor is armed, since only plain A exits the razor", () => {
199
+ const { dispatch, spies } = setupHook();
200
+ usePlayerStore.setState({ inPoint: 1.5, activeTool: "razor" });
201
+
202
+ act(() => {
203
+ dispatch(keydown({ code: "KeyA", key: "a", shiftKey: true }));
204
+ });
205
+
206
+ expect(spies.seek).toHaveBeenCalledWith(1.5, { keepPlaying: true });
207
+ });
174
208
  });
175
209
 
176
210
  describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
@@ -26,6 +26,14 @@ interface UsePlaybackKeyboardParams {
26
26
  seek: (time: number, options?: { keepPlaying?: boolean }) => void;
27
27
  }
28
28
 
29
+ // The razor tool owns plain "a" (return to select) while it's armed; this
30
+ // can't rely on defaultPrevented since both listeners register on window and
31
+ // fire in an order this hook doesn't control. Shift+A and Alt+A are left for
32
+ // the in-point seek below, since only plain A means "exit the razor".
33
+ function shouldRazorClaimPlainA(e: KeyboardEvent): boolean {
34
+ return !e.shiftKey && !e.altKey && usePlayerStore.getState().activeTool === "razor";
35
+ }
36
+
29
37
  export function usePlaybackKeyboard({
30
38
  iframeRef,
31
39
  shuttleDirectionRef,
@@ -79,6 +87,10 @@ export function usePlaybackKeyboard({
79
87
  }
80
88
  }, [play, pause]);
81
89
 
90
+ // Pre-existing dispatcher, already over the complexity gate before this
91
+ // branch's one-line "a" fix touched it. A real fix is a dispatch-table
92
+ // rewrite, out of scope here; the branch this PR changed is its own function.
93
+ // fallow-ignore-next-line complexity
82
94
  const handlePlaybackKeyDown = useCallback(
83
95
  (e: KeyboardEvent) => {
84
96
  if (e.defaultPrevented) return;
@@ -161,6 +173,7 @@ export function usePlaybackKeyboard({
161
173
  return;
162
174
  }
163
175
  if (key === "a") {
176
+ if (shouldRazorClaimPlainA(e)) return;
164
177
  e.preventDefault();
165
178
  seek(usePlayerStore.getState().inPoint ?? 0, { keepPlaying: true });
166
179
  return;
@@ -392,6 +392,14 @@ export function deriveTimelineStoreKey(params: {
392
392
  return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
393
393
  }
394
394
 
395
+ /**
396
+ * {@link deriveTimelineStoreKey} for a caller that already has a DOM id in
397
+ * hand (e.g. one it just minted), so the undefined branch never applies.
398
+ */
399
+ export function deriveTimelineStoreKeyForDomId(domId: string, sourceFile?: string): string {
400
+ return deriveTimelineStoreKey({ domId, sourceFile })!;
401
+ }
402
+
395
403
  // ---------------------------------------------------------------------------
396
404
  // DOM node querying
397
405
  // ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment jsdom
2
+ // fallow-ignore-file code-duplication
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
4
5
  applyPreviewAudioFlags,
@@ -54,6 +55,27 @@ describe("buildMissingCompositionElements — hfId (R7)", () => {
54
55
  expect(entry).toBeDefined();
55
56
  expect(entry?.hfId).toBeUndefined();
56
57
  });
58
+
59
+ it("carries the resolved track onto authoredTrack, so splitting a recovered composition host can't drift to a new row", () => {
60
+ const doc = makeDoc(`
61
+ <div data-composition-id="root">
62
+ <div
63
+ data-composition-id="scene-c"
64
+ data-composition-src="scenes/c.html"
65
+ data-track-index="2"
66
+ data-start="0"
67
+ data-duration="5"
68
+ ></div>
69
+ </div>
70
+ `);
71
+
72
+ const { missing } = buildMissingCompositionElements(doc, window as IframeWindow, [], 10);
73
+ const entry = missing[0];
74
+
75
+ expect(entry).toBeDefined();
76
+ expect(entry?.track).toBe(2);
77
+ expect(entry?.authoredTrack).toBe(2);
78
+ });
57
79
  });
58
80
 
59
81
  describe("setPreviewMediaVolume", () => {
@@ -452,6 +452,7 @@ function buildMissingCompositionEntry(params: {
452
452
  start: window.start,
453
453
  duration: window.duration,
454
454
  track: timing.trackIndex,
455
+ authoredTrack: timing.trackIndex,
455
456
  domId: optionalNonEmpty(element.id),
456
457
  hfId: optionalNonEmpty(element.getAttribute("data-hf-id")),
457
458
  selector,
@@ -108,14 +108,14 @@ interface PlayerState extends PlayerStoreSlices {
108
108
  /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
109
109
  timelineSnapEnabled: boolean;
110
110
  setTimelineSnapEnabled: (enabled: boolean) => void;
111
+ /** Keeps the main track gapless on delete; distinct from the magnet above. */
112
+ rippleEditEnabled: boolean;
113
+ setRippleEditEnabled: (enabled: boolean) => void;
111
114
  /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
112
115
  timeDisplayMode: TimelineTimeDisplayMode;
113
116
  setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
114
- /**
115
- * Pin the timeline zoom to its current visual scale before a duration-changing
116
- * edit, so a subsequent duration change (which recomputes fit-pps) stops
117
- * rescaling every clip. No-op once already pinned (mode is "manual").
118
- */
117
+ /** Pin the timeline zoom to its current scale before a duration change, so
118
+ * it stops rescaling every clip. No-op once already pinned. */
119
119
  pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
120
120
  /** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
121
121
  timelinePps: number;
@@ -149,26 +149,16 @@ interface PlayerState extends PlayerStoreSlices {
149
149
  /** Clears project data without creating a new hard-project session. */
150
150
  reset: () => void;
151
151
 
152
- /**
153
- * Request a seek from outside the player loop (e.g. Layers panel).
154
- * useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify().
155
- */
152
+ /** Request a seek from outside the player loop (e.g. Layers panel);
153
+ * useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify(). */
156
154
  requestedSeekTime: number | null;
157
155
  requestSeek: (time: number) => void;
158
156
  clearSeekRequest: () => void;
159
157
 
160
- /**
161
- * Request the transport start or stop from outside the player loop.
162
- *
163
- * The FX rack auditions a preset by writing it to the running graph, which is
164
- * silent while the transport is paused — so hovering one has to start
165
- * playback, and leaving has to put the playhead back where it was. Hovering is
166
- * not an edit and must not cost the author their place.
167
- *
168
- * A nonce rather than a bare boolean: two hovers in a row both want play, and
169
- * without it the second request is indistinguishable from the first having
170
- * already been served.
171
- */
158
+ /** Request the transport start or stop from outside the player loop: the FX
159
+ * rack starts playback to audition a preset (silent while paused) and
160
+ * restores the playhead on leave, without costing the author their place.
161
+ * A nonce, not a bare boolean, so two hovers in a row both register. */
172
162
  playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
173
163
  requestPlayback: (playing: boolean, returnTo?: number | null) => void;
174
164
  clearPlaybackRequest: () => void;
@@ -475,6 +465,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
475
465
  writeStudioUiPreferences({ timelineSnapEnabled: enabled });
476
466
  set({ timelineSnapEnabled: enabled });
477
467
  },
468
+ rippleEditEnabled: readStudioUiPreferences().rippleEditEnabled ?? true, // default on
469
+ setRippleEditEnabled: (enabled) => {
470
+ writeStudioUiPreferences({ rippleEditEnabled: enabled });
471
+ set({ rippleEditEnabled: enabled });
472
+ },
478
473
  timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
479
474
  setTimeDisplayMode: (mode) => {
480
475
  writeStudioUiPreferences({ timeDisplayMode: mode });
@@ -1,7 +1,8 @@
1
1
  /** Gates `timelineReady` on the composition's declared readiness inputs
2
- * (media, for now) instead of just a known duration. The generation counter
3
- * is module-scope, not store state: it guards an in-flight settlement, not
4
- * something a component reads, so bumping it shouldn't trigger a render. */
2
+ * (media, compute, and the paint-and-idle default) instead of just a known
3
+ * duration. The generation counter is module-scope, not store state: it
4
+ * guards an in-flight settlement, not something a component reads, so
5
+ * bumping it shouldn't trigger a render. */
5
6
  import type { StoreApi } from "zustand";
6
7
  import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
7
8
 
@@ -63,5 +63,9 @@ describe("addBlockToProject", () => {
63
63
  expect(source).toContain('data-start="2.5"');
64
64
  expect(source).toContain('data-duration="4.25"');
65
65
  expect(source).toContain('data-track-index="3"');
66
+ // hostKey addresses the new element the same way the timeline selects
67
+ // clips (sourceFile#domId), so the caller can select and reveal it.
68
+ expect(result?.hostKey).toBe("compositions/scene.html#camcorder-hud");
69
+ expect(source).toContain('id="camcorder-hud"');
66
70
  });
67
71
  });
@@ -11,6 +11,7 @@ import { formatTimelineAttributeNumber } from "../player/components/timelineEdit
11
11
  import { saveProjectFilesWithHistory } from "./studioFileHistory";
12
12
  import type { EditHistoryKind } from "./editHistory";
13
13
  import { extendRootDurationInSource } from "./rootDuration";
14
+ import { deriveTimelineStoreKeyForDomId } from "../player/lib/timelineElementHelpers";
14
15
 
15
16
  function getMaxZIndexFromIframe(iframe: HTMLIFrameElement | null): number {
16
17
  try {
@@ -174,7 +175,7 @@ function buildSubCompositionHtml({
174
175
 
175
176
  export async function addBlockToProject(
176
177
  opts: AddBlockOptions,
177
- ): Promise<{ block: RegistryItem; compositionPath: string } | null> {
178
+ ): Promise<{ block: RegistryItem; compositionPath: string; hostKey: string } | null> {
178
179
  const {
179
180
  projectId,
180
181
  blockName,
@@ -213,8 +214,9 @@ export async function addBlockToProject(
213
214
  currentTime: opts.currentTime ?? 0,
214
215
  });
215
216
  const { width, height } = resolveTimelineAssetCompositionSize(originalContent);
217
+ const hostId = buildUniqueCompositionId(block.name, collectHtmlIds(originalContent));
216
218
  const subComposition = buildSubCompositionHtml({
217
- id: buildUniqueCompositionId(block.name, collectHtmlIds(originalContent)),
219
+ id: hostId,
218
220
  compositionFile,
219
221
  start,
220
222
  duration,
@@ -242,7 +244,11 @@ export async function addBlockToProject(
242
244
  await refreshFileTree();
243
245
  reloadPreview();
244
246
 
245
- return { block, compositionPath: compositionFile };
247
+ return {
248
+ block,
249
+ compositionPath: compositionFile,
250
+ hostKey: deriveTimelineStoreKeyForDomId(hostId, targetPath),
251
+ };
246
252
  } catch (error) {
247
253
  const message = error instanceof Error ? error.message : "Failed to add block";
248
254
  showToast(message);
@@ -37,6 +37,16 @@ describe("buildAtomicCutIntents", () => {
37
37
  expect(intents[0].targets.map((target) => target.originalId)).toEqual(["host-a", "host-b"]);
38
38
  });
39
39
 
40
+ it("carries the element's authored track onto the cut target, so both split halves stay pinned to it server-side", () => {
41
+ const intents = buildAtomicCutIntents([element({ authoredTrack: 2 })], 2, "index.html");
42
+ expect(intents[0].targets[0].track).toBe(2);
43
+ });
44
+
45
+ it("falls back to the resolved track when the element has no authoredTrack", () => {
46
+ const intents = buildAtomicCutIntents([element({ track: 3 })], 2, "index.html");
47
+ expect(intents[0].targets[0].track).toBe(3);
48
+ });
49
+
40
50
  it("rebases each nested target into its own source-file coordinates", () => {
41
51
  const intents = buildAtomicCutIntents(
42
52
  [element({ start: 8, duration: 4, expandedParentStart: 6, sourceFile: "scene.html" })],
@@ -4,6 +4,7 @@ import { buildPatchTarget } from "./timelineElementSplit";
4
4
  import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
5
5
  import { buildProjectApiPath } from "./projectRouting";
6
6
  import { markStudioWriteToken } from "./studioFileVersion";
7
+ import { resolveElementTrack } from "./studioHelpers";
7
8
 
8
9
  type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
9
10
 
@@ -16,6 +17,7 @@ interface CutTarget {
16
17
  playbackStart?: number;
17
18
  playbackRate?: number;
18
19
  isComposition?: boolean;
20
+ track?: number;
19
21
  }
20
22
 
21
23
  interface CutFileIntent {
@@ -69,6 +71,9 @@ function buildCutTarget(
69
71
  ...(element.playbackStart != null ? { playbackStart: element.playbackStart } : {}),
70
72
  ...(element.playbackRate != null ? { playbackRate: element.playbackRate } : {}),
71
73
  ...(element.kind === "composition" ? { isComposition: true } : {}),
74
+ // Pin both halves to the current track: unstamped, the runtime's positional
75
+ // fallback (parseAuthoredTrack) renumbers the new sibling onto a new row.
76
+ track: resolveElementTrack(element),
72
77
  };
73
78
  }
74
79
 
@@ -0,0 +1,20 @@
1
+ import type { MutableRefObject } from "react";
2
+
3
+ export interface RetryBudgetState<K> {
4
+ id: K;
5
+ count: number;
6
+ }
7
+
8
+ // A target still resolving (a just-dropped/pasted/duplicated element, or a
9
+ // preview still reloading) heals within a few retries; one that never will
10
+ // would otherwise retry forever. A new id resets the count.
11
+ export function recordRetryAttempt<K>(
12
+ ref: MutableRefObject<RetryBudgetState<K>>,
13
+ id: K,
14
+ maxRetries: number,
15
+ ): boolean {
16
+ const previous = ref.current;
17
+ const count = previous.id === id ? previous.count + 1 : 1;
18
+ ref.current = { id, count };
19
+ return count <= maxRetries;
20
+ }
@@ -41,6 +41,10 @@ describe("SDK family gate mapping", () => {
41
41
  {} as never,
42
42
  deps,
43
43
  ),
44
- ).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" });
44
+ // The dom family is off, so the gate declines on the FLAG, not on the op
45
+ // batch (which is a perfectly eligible inline-style edit). Before the
46
+ // reason split this reported `ineligible_operation`, which read as "the
47
+ // SDK cannot express this edit" when the truth was "this family is off".
48
+ ).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" });
45
49
  });
46
50
  });
@@ -649,6 +649,57 @@ window.__timelines = { main: tl };</script></div>
649
649
  for (const candidate of published) candidate.dispose();
650
650
  });
651
651
 
652
+ it("does not resurrect an element a prior REST write already deleted, even with a stale live session", async () => {
653
+ // A delete persists via the server REST path, then a same-gesture ripple
654
+ // move reaches an SDK-eligible batch persist while `live` was never
655
+ // reloaded. Prove the candidate rebases on the post-delete disk bytes.
656
+ const twoElementHtml = `<!DOCTYPE html><html data-composition-variables='[]'><body>
657
+ <div data-hf-id="hf-stage" data-hf-root>
658
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
659
+ <div data-hf-id="hf-b" data-start="5" data-duration="2"></div>
660
+ </div>
661
+ </body></html>`;
662
+ const live = await openComposition(twoElementHtml, { history: false });
663
+
664
+ // Disk already reflects hf-b's deletion — a separate write that completed
665
+ // before this persist started, exactly like the delete's own REST call
666
+ // that `handleTimelineElementsDelete` awaits before the ripple begins.
667
+ const postDelete = await openComposition(twoElementHtml, { history: false });
668
+ postDelete.removeElement("hf-b");
669
+ let disk = postDelete.serialize();
670
+ postDelete.dispose();
671
+ expect(disk).not.toContain("hf-b");
672
+
673
+ const writeProjectFile = vi.fn(async (_path: string, content: string) => {
674
+ disk = content;
675
+ });
676
+ const deps = {
677
+ editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
678
+ writeProjectFile,
679
+ readProjectFile: vi.fn(async () => disk),
680
+ reloadPreview: vi.fn(),
681
+ publishSession: vi.fn().mockReturnValue("published"),
682
+ };
683
+
684
+ // The ripple: only survivors move, mirroring resolveShiftedElements — this
685
+ // never touches hf-b, which `live` (unlike disk) still believes exists.
686
+ const result = await persistSdkCandidateMutation(
687
+ live,
688
+ "/comp.html",
689
+ twoElementHtml,
690
+ deps,
691
+ (candidate) => candidate.setTiming("hf-a", { start: 3 }),
692
+ );
693
+
694
+ expect(result.status).toBe("committed");
695
+ expect(disk).not.toContain("hf-b");
696
+ const written = await openComposition(disk, { history: false });
697
+ expect(written.getElement("hf-a")?.start).toBe(3);
698
+ expect(written.getElement("hf-b")).toBeNull();
699
+ written.dispose();
700
+ live.dispose();
701
+ });
702
+
652
703
  it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
653
704
  const live = await openComposition(html, { history: false });
654
705
  let disk = html;