@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
@@ -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;
@@ -45,6 +45,7 @@ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../li
45
45
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
46
46
  import { createPreviewMessageHandler } from "./previewMessageRouter";
47
47
  import { timelineElementsChanged } from "./timelinePlayerSync";
48
+ import { safeContentDocument } from "./timelineSyncHydration";
48
49
 
49
50
  export function useTimelinePlayer() {
50
51
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -60,7 +61,7 @@ export function useTimelinePlayer() {
60
61
  const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
61
62
  const staticSeekWarnedRef = useRef(false);
62
63
 
63
- const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
64
+ const { setIsPlaying, setCurrentTime, setDuration, requestTimelineReady, setElements } =
64
65
  usePlayerStore.getState();
65
66
 
66
67
  // The fixture lease belongs at this shared synchronization boundary so every
@@ -101,7 +102,11 @@ export function useTimelinePlayer() {
101
102
  setDuration(nextDuration ?? 0);
102
103
  }
103
104
  if (!state.timelineReady) {
104
- setTimelineReady(true);
105
+ // Same gate as initializeAdapter's own readiness wait: this is the
106
+ // message-based fallback for compositions that report their duration
107
+ // via clip manifest rather than the direct adapter, and it must not
108
+ // enable Play any earlier than that path does.
109
+ requestTimelineReady(safeContentDocument(iframeRef.current));
105
110
  }
106
111
 
107
112
  // Asynchronously enrich media elements still missing sourceDuration
@@ -120,7 +125,7 @@ export function useTimelinePlayer() {
120
125
  },
121
126
  );
122
127
  },
123
- [setElements, setTimelineReady, setDuration],
128
+ [setElements, requestTimelineReady, setDuration],
124
129
  );
125
130
 
126
131
  // Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
@@ -425,7 +430,7 @@ export function useTimelinePlayer() {
425
430
  syncTimelineElements,
426
431
  setDuration,
427
432
  setCurrentTime,
428
- setTimelineReady,
433
+ requestTimelineReady,
429
434
  setIsPlaying,
430
435
  attachIframeShortcutListeners,
431
436
  applyPreviewAudioState,
@@ -42,7 +42,7 @@ interface UseTimelineSyncCallbacksParams {
42
42
  syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
43
43
  setDuration: (v: number) => void;
44
44
  setCurrentTime: (v: number) => void;
45
- setTimelineReady: (v: boolean) => void;
45
+ requestTimelineReady: (doc: Document | null) => void;
46
46
  setIsPlaying: (v: boolean) => void;
47
47
  attachIframeShortcutListeners: () => void;
48
48
  applyPreviewAudioState: () => void;
@@ -106,7 +106,7 @@ export function useTimelineSyncCallbacks({
106
106
  syncTimelineElements,
107
107
  setDuration,
108
108
  setCurrentTime,
109
- setTimelineReady,
109
+ requestTimelineReady,
110
110
  setIsPlaying,
111
111
  attachIframeShortcutListeners,
112
112
  applyPreviewAudioState,
@@ -204,7 +204,10 @@ export function useTimelineSyncCallbacks({
204
204
  syncAdapterDuration(adapter, setDuration);
205
205
  setCurrentTime(startTime);
206
206
  if (!isRefreshingRef.current) {
207
- setTimelineReady(true);
207
+ // Enables Play from actual play-readiness, not just a known duration —
208
+ // a click before this resolves used to start the timeline with media,
209
+ // images or fonts still loading and never recover.
210
+ requestTimelineReady(safeContentDocument(iframeRef.current));
208
211
  }
209
212
  isRefreshingRef.current = false;
210
213
  setIsPlaying(false);
@@ -223,7 +226,7 @@ export function useTimelineSyncCallbacks({
223
226
  getAdapter,
224
227
  setDuration,
225
228
  setCurrentTime,
226
- setTimelineReady,
229
+ requestTimelineReady,
227
230
  setIsPlaying,
228
231
  processTimelineMessage,
229
232
  enrichMissingCompositions,
@@ -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,
@@ -1,3 +1,4 @@
1
+ // @vitest-environment happy-dom
1
2
  import { describe, it, expect, beforeEach, vi } from "vitest";
2
3
  import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
4
 
@@ -322,6 +323,47 @@ describe("usePlayerStore", () => {
322
323
  });
323
324
  });
324
325
 
326
+ describe("requestTimelineReady", () => {
327
+ function stalledVideoDoc(): { doc: Document; video: HTMLVideoElement } {
328
+ const doc = document.implementation.createHTMLDocument("composition");
329
+ const video = doc.createElement("video");
330
+ Object.defineProperty(video, "readyState", { value: 0, configurable: true });
331
+ doc.body.appendChild(video);
332
+ return { doc, video };
333
+ }
334
+
335
+ it("resolves timelineReady once the doc's media settles", async () => {
336
+ const { doc, video } = stalledVideoDoc();
337
+ usePlayerStore.getState().requestTimelineReady(doc);
338
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
339
+
340
+ video.dispatchEvent(new Event("canplay"));
341
+ await new Promise((resolve) => setTimeout(resolve, 0));
342
+
343
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
344
+ });
345
+
346
+ it("a stale wait from a project switched away from never marks the new project ready", async () => {
347
+ // beginTimelineSession no-ops when projectId is unchanged, so pin a
348
+ // known starting project first rather than relying on reset()'s
349
+ // leftover timelineProjectId from whatever test ran before this one.
350
+ usePlayerStore.getState().beginTimelineSession("readiness-test-project-a");
351
+ const { doc: docA, video: videoA } = stalledVideoDoc();
352
+ usePlayerStore.getState().requestTimelineReady(docA);
353
+
354
+ // Switches projects mid-wait — beginTimelineSession resets timelineReady
355
+ // to false for project B, whose own media has not been checked at all.
356
+ usePlayerStore.getState().beginTimelineSession("readiness-test-project-b");
357
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
358
+
359
+ // A's wait settles late; it must not resolve into B's readiness.
360
+ videoA.dispatchEvent(new Event("canplay"));
361
+ await new Promise((resolve) => setTimeout(resolve, 0));
362
+
363
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
364
+ });
365
+ });
366
+
325
367
  describe("setElements", () => {
326
368
  it("sets the elements array", () => {
327
369
  const elements: TimelineElement[] = [
@@ -19,6 +19,7 @@ import {
19
19
  import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
20
20
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
21
21
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
22
+ import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
22
23
 
23
24
  export type { KeyframeCacheEntry } from "./keyframeSlice";
24
25
  export { liveTime } from "./liveTime";
@@ -54,12 +55,15 @@ function resolveElementSelection(
54
55
  };
55
56
  }
56
57
 
57
- interface PlayerState
58
- extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, EditingModeSlice {
58
+ type PlayerStoreSlices = KeyframeSlice &
59
+ AutomationSelectionSlice &
60
+ ThumbnailSlice &
61
+ EditingModeSlice &
62
+ ReturnType<typeof createPlaybackReadinessSlice>;
63
+ interface PlayerState extends PlayerStoreSlices {
59
64
  isPlaying: boolean;
60
65
  currentTime: number;
61
66
  duration: number;
62
- timelineReady: boolean;
63
67
  /** Increments exactly once when the Studio switches to a different project. */
64
68
  timelineSessionEpoch: number;
65
69
  /** Project owning the current timeline session; null outside a project-scoped reset. */
@@ -104,14 +108,14 @@ interface PlayerState
104
108
  /** Timeline magnet toggle — when false, clip drags/trims/drops never snap. */
105
109
  timelineSnapEnabled: boolean;
106
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;
107
114
  /** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
108
115
  timeDisplayMode: TimelineTimeDisplayMode;
109
116
  setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
110
- /**
111
- * Pin the timeline zoom to its current visual scale before a duration-changing
112
- * edit, so a subsequent duration change (which recomputes fit-pps) stops
113
- * rescaling every clip. No-op once already pinned (mode is "manual").
114
- */
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. */
115
119
  pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
116
120
  /** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
117
121
  timelinePps: number;
@@ -129,7 +133,6 @@ interface PlayerState
129
133
  setAudioMuted: (muted: boolean) => void;
130
134
  setAudioVolume: (volume: number) => void;
131
135
  setLoopEnabled: (enabled: boolean) => void;
132
- setTimelineReady: (ready: boolean) => void;
133
136
  setBeatDragging: (dragging: boolean) => void;
134
137
  setElements: (elements: TimelineElement[]) => void;
135
138
  setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
@@ -146,26 +149,16 @@ interface PlayerState
146
149
  /** Clears project data without creating a new hard-project session. */
147
150
  reset: () => void;
148
151
 
149
- /**
150
- * Request a seek from outside the player loop (e.g. Layers panel).
151
- * useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify().
152
- */
152
+ /** Request a seek from outside the player loop (e.g. Layers panel);
153
+ * useTimelinePlayer subscribes and calls adapter.seek() + liveTime.notify(). */
153
154
  requestedSeekTime: number | null;
154
155
  requestSeek: (time: number) => void;
155
156
  clearSeekRequest: () => void;
156
157
 
157
- /**
158
- * Request the transport start or stop from outside the player loop.
159
- *
160
- * The FX rack auditions a preset by writing it to the running graph, which is
161
- * silent while the transport is paused — so hovering one has to start
162
- * playback, and leaving has to put the playhead back where it was. Hovering is
163
- * not an edit and must not cost the author their place.
164
- *
165
- * A nonce rather than a bare boolean: two hovers in a row both want play, and
166
- * without it the second request is indistinguishable from the first having
167
- * already been served.
168
- */
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. */
169
162
  playbackRequest: { playing: boolean; returnTo: number | null; nonce: number } | null;
170
163
  requestPlayback: (playing: boolean, returnTo?: number | null) => void;
171
164
  clearPlaybackRequest: () => void;
@@ -260,7 +253,7 @@ export function createTimelineResetState() {
260
253
  isPlaying: false,
261
254
  currentTime: 0,
262
255
  duration: 0,
263
- timelineReady: false,
256
+ ...resetPlaybackReadinessState(),
264
257
  beatDragging: false,
265
258
  elements: [],
266
259
  selectedElementId: null,
@@ -304,7 +297,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
304
297
  isPlaying: false,
305
298
  currentTime: 0,
306
299
  duration: 0,
307
- timelineReady: false,
308
300
  timelineSessionEpoch: 0,
309
301
  timelineProjectId: null,
310
302
  beatDragging: false,
@@ -333,6 +325,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
333
325
 
334
326
  ...createAutomationSelectionSlice(set),
335
327
  ...createEditingModeSlice(set),
328
+ ...createPlaybackReadinessSlice(set),
336
329
 
337
330
  activeKeyframePct: null,
338
331
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -472,6 +465,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
472
465
  writeStudioUiPreferences({ timelineSnapEnabled: enabled });
473
466
  set({ timelineSnapEnabled: enabled });
474
467
  },
468
+ rippleEditEnabled: readStudioUiPreferences().rippleEditEnabled ?? true, // default on
469
+ setRippleEditEnabled: (enabled) => {
470
+ writeStudioUiPreferences({ rippleEditEnabled: enabled });
471
+ set({ rippleEditEnabled: enabled });
472
+ },
475
473
  timeDisplayMode: readStudioUiPreferences().timeDisplayMode ?? "time",
476
474
  setTimeDisplayMode: (mode) => {
477
475
  writeStudioUiPreferences({ timeDisplayMode: mode });
@@ -521,7 +519,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
521
519
  bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
522
520
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
523
521
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
524
- setTimelineReady: (ready) => set({ timelineReady: ready }),
525
522
  setBeatDragging: (dragging) => set({ beatDragging: dragging }),
526
523
  setElements: (elements) => set({ elements }),
527
524
  // A genuine single selection: always collapse the set to just this element. User
@@ -0,0 +1,42 @@
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. */
5
+ import type { StoreApi } from "zustand";
6
+ import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
7
+
8
+ export interface PlaybackReadinessSlice {
9
+ timelineReady: boolean;
10
+ setTimelineReady: (ready: boolean) => void;
11
+ /** Sets timelineReady once doc's readiness inputs settle, or immediately
12
+ * if doc is null. A wait a later call supersedes never wins the race. */
13
+ requestTimelineReady: (doc: Document | null) => void;
14
+ }
15
+
16
+ let timelineReadyGeneration = 0;
17
+
18
+ /** For a full timeline reset: bumps the generation so any requestTimelineReady
19
+ * wait in flight can never resolve into what replaced it. */
20
+ export function resetPlaybackReadinessState(): Pick<PlaybackReadinessSlice, "timelineReady"> {
21
+ timelineReadyGeneration++;
22
+ return { timelineReady: false };
23
+ }
24
+
25
+ export function createPlaybackReadinessSlice(
26
+ set: StoreApi<PlaybackReadinessSlice>["setState"],
27
+ ): PlaybackReadinessSlice {
28
+ return {
29
+ timelineReady: false,
30
+ setTimelineReady: (ready) => {
31
+ timelineReadyGeneration++;
32
+ set({ timelineReady: ready });
33
+ },
34
+ requestTimelineReady: (doc) => {
35
+ const generation = ++timelineReadyGeneration;
36
+ if (!doc) return set({ timelineReady: true });
37
+ settleCompositionReadiness(doc, () => {
38
+ if (generation === timelineReadyGeneration) set({ timelineReady: true });
39
+ });
40
+ },
41
+ };
42
+ }
@@ -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
 
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- // Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
3
+ // Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
4
4
  // persist chokepoint must explicitly decline so the caller takes the legacy server
5
5
  // path — even when a valid SDK session exists (one always does, for
6
- // shadow/selection). This is the contract the prod flag-flip rests on; a future
7
- // refactor of the gate guards that silently re-enables cutover on flag-off
8
- // turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
6
+ // shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that);
7
+ // this file pins that the env override still turns it fully off. A future
8
+ // refactor of the gate guards that ignores the flag turns these red.
9
+ // (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
9
10
  vi.mock("../components/editor/manualEditingAvailability", () => ({
10
11
  STUDIO_SDK_CUTOVER_ENABLED: false,
11
12
  STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
@@ -23,6 +23,8 @@ vi.mock("./studioTelemetry", () => ({
23
23
  trackStudioEvent: vi.fn(),
24
24
  }));
25
25
 
26
+ import { trackStudioEvent } from "./studioTelemetry";
27
+
26
28
  const styleOp = (property: string, value: string): PatchOperation => ({
27
29
  type: "inline-style",
28
30
  property,
@@ -202,6 +204,46 @@ describe("sdkCutoverPersist", () => {
202
204
  expect(result.status).toBe("declined");
203
205
  });
204
206
 
207
+ it("tags target_not_found with resolverDisagreement when dispatch could have resolved it", async () => {
208
+ // getElement is canonical-only for a bare id; resolveSnapshot mirrors what
209
+ // dispatch resolves (bare ids anywhere). An element present under a scoped
210
+ // id is dispatchable, so getElement refusing it is a resolver disagreement
211
+ // — and one the shadow event stays silent about.
212
+ const deps = makeDeps();
213
+ const session = makeSession(false);
214
+ (session as unknown as { getElements: () => unknown[] }).getElements = () => [
215
+ { id: "hf-abc", scopedId: "host/hf-abc" },
216
+ ];
217
+ const sel = { hfId: "hf-abc" } as never;
218
+
219
+ await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
220
+
221
+ expect(trackStudioEvent).toHaveBeenCalledWith(
222
+ "sdk_cutover_declined",
223
+ expect.objectContaining({ reason: "target_not_found", resolverDisagreement: true }),
224
+ );
225
+ });
226
+
227
+ it("does not tag resolverDisagreement when the element is genuinely absent", async () => {
228
+ const deps = makeDeps();
229
+ const session = makeSession(false);
230
+ (session as unknown as { getElements: () => unknown[] }).getElements = () => [
231
+ { id: "hf-other", scopedId: "hf-other" },
232
+ ];
233
+ const sel = { hfId: "hf-abc" } as never;
234
+ vi.mocked(trackStudioEvent).mockClear();
235
+
236
+ await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
237
+
238
+ expect(trackStudioEvent).toHaveBeenLastCalledWith(
239
+ "sdk_cutover_declined",
240
+ expect.objectContaining({ reason: "target_not_found" }),
241
+ );
242
+ expect(vi.mocked(trackStudioEvent).mock.lastCall?.[1]).not.toHaveProperty(
243
+ "resolverDisagreement",
244
+ );
245
+ });
246
+
205
247
  it("dispatches setStyle for inline-style ops", async () => {
206
248
  const deps = makeDeps();
207
249
  const session = makeSession(true);
@@ -607,6 +649,57 @@ window.__timelines = { main: tl };</script></div>
607
649
  for (const candidate of published) candidate.dispose();
608
650
  });
609
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
+
610
703
  it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
611
704
  const live = await openComposition(html, { history: false });
612
705
  let disk = html;