@hyperframes/studio 0.6.99 → 0.6.101

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 (42) hide show
  1. package/dist/assets/index-BITwbxi-.css +1 -0
  2. package/dist/assets/{index-C52IT_lp.js → index-CQ3n6Y9q.js} +1 -1
  3. package/dist/assets/index-CTiqZ7XQ.js +296 -0
  4. package/dist/assets/{index-DOh7E1uj.js → index-DvttAtOD.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +5 -4
  7. package/src/App.tsx +13 -13
  8. package/src/components/editor/PropertyPanel.tsx +24 -16
  9. package/src/components/editor/manualEditingAvailability.ts +12 -1
  10. package/src/components/nle/NLELayout.tsx +89 -1
  11. package/src/components/renders/useRenderQueue.ts +12 -8
  12. package/src/hooks/gsapScriptCommitHelpers.ts +8 -5
  13. package/src/hooks/gsapScriptCommitTypes.ts +3 -0
  14. package/src/hooks/gsapTargetCache.ts +65 -0
  15. package/src/hooks/useAppHotkeys.ts +10 -0
  16. package/src/hooks/useDomEditCommits.ts +12 -14
  17. package/src/hooks/useDomEditSession.ts +13 -0
  18. package/src/hooks/useDomGeometryCommits.ts +1 -36
  19. package/src/hooks/useElementLifecycleOps.ts +5 -0
  20. package/src/hooks/useGsapAnimationOps.ts +26 -1
  21. package/src/hooks/useGsapScriptCommits.ts +5 -2
  22. package/src/hooks/useRazorSplit.ts +3 -0
  23. package/src/hooks/useSdkSelectionSync.ts +25 -0
  24. package/src/hooks/useSdkSession.test.ts +20 -0
  25. package/src/hooks/useSdkSession.ts +101 -0
  26. package/src/hooks/useTimelineEditing.ts +23 -3
  27. package/src/player/components/Timeline.tsx +31 -18
  28. package/src/player/components/TimelineClip.tsx +3 -3
  29. package/src/player/components/useResolvedTimelineEditCallbacks.ts +30 -0
  30. package/src/player/hooks/useExpandedTimelineElements.test.ts +91 -0
  31. package/src/player/hooks/useExpandedTimelineElements.ts +153 -0
  32. package/src/player/hooks/useTimelineSyncCallbacks.ts +22 -0
  33. package/src/player/store/playerStore.ts +22 -8
  34. package/src/telemetry/events.test.ts +16 -1
  35. package/src/telemetry/events.ts +15 -0
  36. package/src/utils/blockCategories.ts +2 -2
  37. package/src/utils/sdkShadow.test.ts +246 -0
  38. package/src/utils/sdkShadow.ts +404 -0
  39. package/src/utils/studioHelpers.test.ts +25 -1
  40. package/src/utils/studioHelpers.ts +54 -28
  41. package/dist/assets/index-B62bDCQv.css +0 -1
  42. package/dist/assets/index-DrwSRbsl.js +0 -252
@@ -1 +1 @@
1
- import{g as P}from"./index-DrwSRbsl.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
1
+ import{g as P}from"./index-CTiqZ7XQ.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-DrwSRbsl.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-B62bDCQv.css">
8
+ <script type="module" crossorigin src="/assets/index-CTiqZ7XQ.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.99",
3
+ "version": "0.6.101",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,8 +33,9 @@
33
33
  "@phosphor-icons/react": "^2.1.10",
34
34
  "bpm-detective": "^2.0.5",
35
35
  "mediabunny": "^1.45.3",
36
- "@hyperframes/core": "0.6.99",
37
- "@hyperframes/player": "0.6.99"
36
+ "@hyperframes/core": "0.6.101",
37
+ "@hyperframes/player": "0.6.101",
38
+ "@hyperframes/sdk": "0.6.101"
38
39
  },
39
40
  "devDependencies": {
40
41
  "@types/react": "19",
@@ -48,7 +49,7 @@
48
49
  "vite": "^6.4.2",
49
50
  "vitest": "^3.2.4",
50
51
  "zustand": "^5.0.0",
51
- "@hyperframes/producer": "0.6.99"
52
+ "@hyperframes/producer": "0.6.101"
52
53
  },
53
54
  "peerDependencies": {
54
55
  "react": "19",
package/src/App.tsx CHANGED
@@ -13,6 +13,8 @@ import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
13
13
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
14
14
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
15
15
  import { useDomEditSession } from "./hooks/useDomEditSession";
16
+ import { useSdkSession } from "./hooks/useSdkSession";
17
+ import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
16
18
  import { useBlockHandlers } from "./hooks/useBlockHandlers";
17
19
  import { useAppHotkeys } from "./hooks/useAppHotkeys";
18
20
  import { useClipboard } from "./hooks/useClipboard";
@@ -173,6 +175,7 @@ export function StudioApp() {
173
175
  reloadPreview: () => setRefreshKey((k) => k + 1),
174
176
  pendingTimelineEditPathRef,
175
177
  });
178
+ const sdkSession = useSdkSession(projectId, activeCompPath);
176
179
  const timelineEditing = useTimelineEditing({
177
180
  projectId,
178
181
  activeCompPath,
@@ -186,6 +189,7 @@ export function StudioApp() {
186
189
  pendingTimelineEditPathRef,
187
190
  uploadProjectFiles: fileManager.uploadProjectFiles,
188
191
  isRecordingRef: isGestureRecordingRef,
192
+ sdkSession,
189
193
  });
190
194
  const {
191
195
  activeBlockParams,
@@ -299,6 +303,7 @@ export function StudioApp() {
299
303
  openSourceForSelection: fileManager.openSourceForSelection,
300
304
  selectSidebarTab: selectSidebarTabStable,
301
305
  getSidebarTab: getSidebarTabStable,
306
+ sdkSession,
302
307
  });
303
308
  domEditSelectionBridgeRef.current = domEditSession.domEditSelection;
304
309
  clearDomSelectionRef.current = domEditSession.clearDomSelection;
@@ -314,6 +319,12 @@ export function StudioApp() {
314
319
  domEditSession.handleGsapRemoveKeyframe(a.id, p);
315
320
  }
316
321
  };
322
+ useSdkSelectionSync(
323
+ sdkSession,
324
+ domEditSession.domEditSelection,
325
+ domEditSession.domEditGroupSelections,
326
+ );
327
+
317
328
  useCaptionDetection({
318
329
  projectId,
319
330
  activeCompPath,
@@ -419,17 +430,6 @@ export function StudioApp() {
419
430
  applyDomSelection: domEditSession.applyDomSelection,
420
431
  initialState: initialUrlStateRef.current,
421
432
  });
422
- const { jobs, isRendering, deleteRender, clearCompleted, startRender } = renderQueue;
423
- const stableRenderQueue = useMemo(
424
- () => ({
425
- jobs,
426
- isRendering,
427
- deleteRender,
428
- clearCompleted,
429
- startRender: startRender as (options: unknown) => Promise<void>,
430
- }),
431
- [jobs, isRendering, deleteRender, clearCompleted, startRender],
432
- );
433
433
  const studioCtxValue = buildStudioContextValue({
434
434
  projectId: projectId!,
435
435
  activeCompPath,
@@ -445,7 +445,7 @@ export function StudioApp() {
445
445
  editHistory,
446
446
  handleUndo: appHotkeys.handleUndo,
447
447
  handleRedo: appHotkeys.handleRedo,
448
- renderQueue: stableRenderQueue,
448
+ renderQueue,
449
449
  compositionDimensions,
450
450
  waitForPendingDomEditSaves: previewPersistence.waitForPendingDomEditSaves,
451
451
  handlePreviewIframeRef,
@@ -485,7 +485,7 @@ export function StudioApp() {
485
485
  refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
486
486
  inspectorButtonActive={inspectorButtonActive}
487
487
  inspectorPanelActive={inspectorPanelActive}
488
- onExport={() => void renderQueue.startRender()}
488
+ onExport={() => void renderQueue.startRender(undefined)}
489
489
  />
490
490
  {previewPersistence.domEditSaveQueuePaused && (
491
491
  <SaveQueuePausedBanner
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useRef, useState } from "react";
1
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
3
3
  import { useStudioShellContext } from "../../contexts/StudioContext";
4
4
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
@@ -111,6 +111,29 @@ export const PropertyPanel = memo(function PropertyPanel({
111
111
  const cacheElementKey = element?.id ?? element?.selector ?? "";
112
112
  const cacheEntry = usePlayerStore((s) => s.keyframeCache.get(cacheElementKey));
113
113
 
114
+ const iframeRef = previewIframeRef ?? { current: null };
115
+ const gsapAnimIdForMemo = element
116
+ ? (gsapAnimations?.find((a: { keyframes?: unknown }) => a.keyframes)?.id ??
117
+ gsapAnimations?.[0]?.id ??
118
+ null)
119
+ : null;
120
+ const gsapRuntimeValues = useMemo(
121
+ () =>
122
+ element
123
+ ? readGsapRuntimeValuesForPanel(gsapAnimIdForMemo, gsapAnimations, element, iframeRef)
124
+ : null,
125
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- iframeRef is stable; currentTime drives re-reads during playback
126
+ [gsapAnimIdForMemo, gsapAnimations, element, currentTime],
127
+ );
128
+ const gsapBorderRadius = useMemo(
129
+ () =>
130
+ element
131
+ ? readGsapBorderRadiusForPanel(gsapRuntimeValues, gsapAnimations, element, iframeRef)
132
+ : null,
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ [gsapRuntimeValues, gsapAnimations, element, currentTime],
135
+ );
136
+
114
137
  if (!element) {
115
138
  return (
116
139
  <div className="flex h-full flex-col bg-neutral-900">
@@ -194,21 +217,6 @@ export const PropertyPanel = memo(function PropertyPanel({
194
217
  return gsapAnimId ?? "";
195
218
  };
196
219
 
197
- // Read ALL GSAP-interpolated values at the current seek time.
198
- const gsapRuntimeValues = readGsapRuntimeValuesForPanel(
199
- gsapAnimId,
200
- gsapAnimations,
201
- element,
202
- previewIframeRef ?? { current: null },
203
- );
204
-
205
- const gsapBorderRadius = readGsapBorderRadiusForPanel(
206
- gsapRuntimeValues,
207
- gsapAnimations,
208
- element,
209
- previewIframeRef ?? { current: null },
210
- );
211
-
212
220
  const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
213
221
  const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
214
222
  const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
@@ -73,7 +73,7 @@ export const STUDIO_KEYFRAMES_ENABLED = resolveStudioBooleanEnvFlag(
73
73
  export const STUDIO_RAZOR_TOOL_ENABLED = resolveStudioBooleanEnvFlag(
74
74
  env,
75
75
  ["VITE_STUDIO_ENABLE_RAZOR_TOOL", "VITE_STUDIO_RAZOR_TOOL_ENABLED"],
76
- false,
76
+ true,
77
77
  );
78
78
 
79
79
  // When disabled (the default), drag/resize/rotate commits always take the CSS
@@ -88,4 +88,15 @@ export const STUDIO_GSAP_DRAG_INTERCEPT_ENABLED = resolveStudioBooleanEnvFlag(
88
88
 
89
89
  export const STUDIO_PREVIEW_SELECTION_ENABLED = STUDIO_INSPECTOR_PANELS_ENABLED;
90
90
 
91
+ // Stage 7 Step 3b: shadow dispatch parity mode — dispatches ops to the SDK
92
+ // session alongside the server patch path and logs mismatches via telemetry.
93
+ // Default on: server stays authoritative (no user-visible change), so we want
94
+ // the sdk_shadow_dispatch parity signal from all traffic. Disable via
95
+ // VITE_STUDIO_SDK_SHADOW_ENABLED=false.
96
+ export const STUDIO_SDK_SHADOW_ENABLED = resolveStudioBooleanEnvFlag(
97
+ env,
98
+ ["VITE_STUDIO_SDK_SHADOW_ENABLED"],
99
+ true,
100
+ );
101
+
91
102
  export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
@@ -10,10 +10,13 @@ import {
10
10
  import { useMountEffect } from "../../hooks/useMountEffect";
11
11
  import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
12
12
  import type { TimelineElement } from "../../player";
13
+ import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
13
14
  import { NLEPreview } from "./NLEPreview";
14
15
  import { CompositionBreadcrumb } from "./CompositionBreadcrumb";
15
16
  import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
16
17
  import { useCompositionStack } from "./useCompositionStack";
18
+ import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
19
+ import { trackStudioExpandedClipEdit } from "../../telemetry/events";
17
20
  import {
18
21
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
19
22
  getTimelineToggleTitle,
@@ -58,6 +61,7 @@ interface NLELayoutProps {
58
61
  blockName: string,
59
62
  position: { left: number; top: number },
60
63
  ) => Promise<void> | void;
64
+ onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
61
65
  onSelectTimelineElement?: (element: TimelineElement | null) => void;
62
66
  /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
63
67
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
@@ -103,6 +107,7 @@ export const NLELayout = memo(function NLELayout({
103
107
  onAssetDrop,
104
108
  onBlockDrop,
105
109
  onPreviewBlockDrop,
110
+ onBlockedEditAttempt,
106
111
  onSelectTimelineElement,
107
112
  onCompIdToSrcChange,
108
113
  timelineVisible,
@@ -175,6 +180,7 @@ export const NLELayout = memo(function NLELayout({
175
180
  const handleDrillDown = useCallback(
176
181
  (element: TimelineElement) => {
177
182
  if (!element.compositionSrc) return;
183
+ usePlayerStore.getState().setSelectedElementId(null);
178
184
  // Check compIdToSrc map first; then scan iframe DOM; then fall through to drillDown
179
185
  const compId = element.id;
180
186
  let resolvedPath = compIdToSrc.get(compId);
@@ -202,6 +208,73 @@ export const NLELayout = memo(function NLELayout({
202
208
  [compIdToSrc, drillDown, iframeRef_],
203
209
  );
204
210
 
211
+ // Move/resize/split come from the timeline edit context, not props — the
212
+ // wrappers below intercept expanded clips and must call the *real* handlers.
213
+ // (Delete is a direct prop; it stays that way.)
214
+ const { onMoveElement, onResizeElement, onSplitElement } = useTimelineEditContext();
215
+
216
+ // An expanded sub-comp child reaches the normal edit handlers in its own
217
+ // local coordinates: addressed by its real DOM id, with timeline time rebased
218
+ // onto the sub-comp it lives in. The handlers then save + reloadPreview exactly
219
+ // as they do for top-level clips — no separate live-DOM path.
220
+ const toLocalElement = useCallback(
221
+ (element: TimelineElement, basis: number): TimelineElement => ({
222
+ ...element,
223
+ id: element.domId ?? element.id,
224
+ start: element.start - basis,
225
+ }),
226
+ [],
227
+ );
228
+
229
+ const handleMoveElement = useCallback(
230
+ (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
231
+ const basis = element.expandedParentStart;
232
+ if (basis === undefined) return onMoveElement?.(element, updates);
233
+ trackStudioExpandedClipEdit({ action: "move" });
234
+ onMoveElement?.(toLocalElement(element, basis), {
235
+ ...updates,
236
+ start: Math.max(0, updates.start - basis),
237
+ });
238
+ },
239
+ [onMoveElement, toLocalElement],
240
+ );
241
+
242
+ const handleResizeElement = useCallback(
243
+ (
244
+ element: TimelineElement,
245
+ updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
246
+ ) => {
247
+ const basis = element.expandedParentStart;
248
+ if (basis === undefined) return onResizeElement?.(element, updates);
249
+ trackStudioExpandedClipEdit({ action: "resize" });
250
+ onResizeElement?.(toLocalElement(element, basis), {
251
+ ...updates,
252
+ start: Math.max(0, updates.start - basis),
253
+ });
254
+ },
255
+ [onResizeElement, toLocalElement],
256
+ );
257
+
258
+ const handleDeleteElement = useCallback(
259
+ (element: TimelineElement) => {
260
+ const basis = element.expandedParentStart;
261
+ if (basis === undefined) return onDeleteElement?.(element);
262
+ trackStudioExpandedClipEdit({ action: "delete" });
263
+ return onDeleteElement?.(toLocalElement(element, basis));
264
+ },
265
+ [onDeleteElement, toLocalElement],
266
+ );
267
+
268
+ const handleSplitElement = useCallback(
269
+ (element: TimelineElement, splitTime: number) => {
270
+ const basis = element.expandedParentStart;
271
+ if (basis === undefined) return onSplitElement?.(element, splitTime);
272
+ trackStudioExpandedClipEdit({ action: "split" });
273
+ return onSplitElement?.(toLocalElement(element, basis), Math.max(0, splitTime - basis));
274
+ },
275
+ [onSplitElement, toLocalElement],
276
+ );
277
+
205
278
  // Composition ID → file path map from raw index.html
206
279
  const compIdToSrcRef = useRef(compIdToSrc);
207
280
  compIdToSrcRef.current = compIdToSrc;
@@ -356,6 +429,17 @@ export const NLELayout = memo(function NLELayout({
356
429
  <div
357
430
  className="flex-1 min-h-0 relative"
358
431
  data-preview-pan-surface="true"
432
+ onPointerDown={(e) => {
433
+ const el = iframeRef.current?.parentElement ?? iframeRef.current;
434
+ if (!el) return;
435
+ const rect = el.getBoundingClientRect();
436
+ const inside =
437
+ e.clientX >= rect.left &&
438
+ e.clientX <= rect.right &&
439
+ e.clientY >= rect.top &&
440
+ e.clientY <= rect.bottom;
441
+ if (!inside) onSelectTimelineElement?.(null);
442
+ }}
359
443
  onDragOver={handlePreviewDragOver}
360
444
  onDragLeave={handlePreviewDragLeave}
361
445
  onDrop={handlePreviewDrop}
@@ -429,9 +513,13 @@ export const NLELayout = memo(function NLELayout({
429
513
  onDrillDown={handleDrillDown}
430
514
  renderClipContent={renderClipContent}
431
515
  onFileDrop={onFileDrop}
432
- onDeleteElement={onDeleteElement}
516
+ onDeleteElement={handleDeleteElement}
433
517
  onAssetDrop={onAssetDrop}
434
518
  onBlockDrop={onBlockDrop}
519
+ onMoveElement={handleMoveElement}
520
+ onResizeElement={handleResizeElement}
521
+ onBlockedEditAttempt={onBlockedEditAttempt}
522
+ onSplitElement={handleSplitElement}
435
523
  onSelectElement={onSelectTimelineElement}
436
524
  />
437
525
  </div>
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { trackStudioRenderStart } from "../../telemetry/events";
3
3
 
4
4
  export interface RenderJob {
@@ -238,11 +238,15 @@ export function useRenderQueue(projectId: string | null) {
238
238
  };
239
239
  }, [projectId]);
240
240
 
241
- return {
242
- jobs,
243
- startRender,
244
- deleteRender,
245
- clearCompleted,
246
- isRendering: jobs.some((j) => j.status === "rendering"),
247
- };
241
+ const isRendering = jobs.some((j) => j.status === "rendering");
242
+ return useMemo(
243
+ () => ({
244
+ jobs,
245
+ isRendering,
246
+ deleteRender,
247
+ clearCompleted,
248
+ startRender: startRender as (options: unknown) => Promise<void>,
249
+ }),
250
+ [jobs, isRendering, deleteRender, clearCompleted, startRender],
251
+ );
248
252
  }
@@ -37,6 +37,13 @@ function isRecord(value: unknown): value is Record<string, unknown> {
37
37
  return typeof value === "object" && value !== null;
38
38
  }
39
39
 
40
+ export function formatFieldsSuffix(rawFields: unknown): string {
41
+ const fields = Array.isArray(rawFields)
42
+ ? rawFields.filter((f): f is string => typeof f === "string")
43
+ : [];
44
+ return fields.length > 0 ? ` (${fields.join(", ")})` : "";
45
+ }
46
+
40
47
  export async function readJsonResponseBody(res: Response): Promise<unknown> {
41
48
  const contentType = res.headers.get("content-type") ?? "";
42
49
  if (!contentType.includes("application/json")) {
@@ -55,14 +62,10 @@ function formatGsapMutationHttpErrorMessage(statusCode: number, body: unknown):
55
62
  export function formatGsapMutationRejectionToast(error: GsapMutationHttpError): string {
56
63
  const body = error.responseBody;
57
64
  if (isRecord(body)) {
58
- const fields = Array.isArray(body.fields)
59
- ? body.fields.filter((field): field is string => typeof field === "string")
60
- : [];
61
- const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
62
65
  return `Couldn't save animation: ${formatGsapMutationHttpErrorMessage(
63
66
  error.statusCode,
64
67
  body,
65
- )}${suffix}`;
68
+ )}${formatFieldsSuffix(body.fields)}`;
66
69
  }
67
70
  return `Couldn't save animation: ${error.message}`;
68
71
  }
@@ -1,4 +1,5 @@
1
1
  import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
+ import type { Composition } from "@hyperframes/sdk";
2
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
4
  import type { EditHistoryKind } from "../utils/editHistory";
4
5
 
@@ -55,4 +56,6 @@ export interface GsapScriptCommitsParams {
55
56
  onCacheInvalidate: () => void;
56
57
  onFileContentChanged?: (path: string, content: string) => void;
57
58
  showToast: (message: string, tone?: "error" | "info") => void;
59
+ /** Stage 7 Step 3b: SDK session for shadow GSAP dispatch (server stays authoritative). */
60
+ sdkSession?: Composition | null;
58
61
  }
@@ -0,0 +1,65 @@
1
+ import { STUDIO_GSAP_DRAG_INTERCEPT_ENABLED } from "../components/editor/manualEditingAvailability";
2
+
3
+ type TimelineLike = { getChildren?: (nested: boolean) => Array<{ targets?: () => Element[] }> };
4
+
5
+ let _gsapCachedTimelines: Record<string, TimelineLike> | undefined;
6
+ let _gsapTargetIds: Set<string> | undefined;
7
+ let _gsapTargetNodes: WeakSet<Element> | undefined;
8
+
9
+ function addTargetsFromTimeline(tl: TimelineLike, ids: Set<string>, nodes: WeakSet<Element>): void {
10
+ const children = tl.getChildren?.(true);
11
+ if (!children) return;
12
+ for (const child of children) {
13
+ const targets = child.targets?.();
14
+ if (!targets) continue;
15
+ for (const t of targets) {
16
+ nodes.add(t);
17
+ if (t.id) ids.add(t.id);
18
+ }
19
+ }
20
+ }
21
+
22
+ function collectGsapTargets(timelines: Record<string, TimelineLike>): {
23
+ ids: Set<string>;
24
+ nodes: WeakSet<Element>;
25
+ } {
26
+ const ids = new Set<string>();
27
+ const nodes = new WeakSet<Element>();
28
+ for (const tl of Object.values(timelines)) {
29
+ if (!tl) continue;
30
+ try {
31
+ addTargetsFromTimeline(tl, ids, nodes);
32
+ } catch {
33
+ /* teardown race */
34
+ }
35
+ }
36
+ return { ids, nodes };
37
+ }
38
+
39
+ function readTimelines(iframe: HTMLIFrameElement | null): Record<string, TimelineLike> | undefined {
40
+ if (!iframe?.contentWindow) return undefined;
41
+ try {
42
+ return (iframe.contentWindow as Window & { __timelines?: Record<string, TimelineLike> })
43
+ .__timelines;
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function isElementGsapTargeted(
50
+ iframe: HTMLIFrameElement | null,
51
+ element: HTMLElement,
52
+ ): boolean {
53
+ if (!STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) return false;
54
+ const timelines = readTimelines(iframe);
55
+ if (!timelines) return false;
56
+
57
+ if (timelines !== _gsapCachedTimelines) {
58
+ const cache = collectGsapTargets(timelines);
59
+ _gsapTargetIds = cache.ids;
60
+ _gsapTargetNodes = cache.nodes;
61
+ _gsapCachedTimelines = timelines;
62
+ }
63
+
64
+ return _gsapTargetNodes!.has(element) || !!(element.id && _gsapTargetIds!.has(element.id));
65
+ }
@@ -136,6 +136,7 @@ interface HotkeyCallbacks {
136
136
  onToggleRecording?: () => void;
137
137
  leftSidebarRef: React.RefObject<LeftSidebarHandle | null>;
138
138
  domEditSelectionRef: React.MutableRefObject<DomEditSelection | null>;
139
+ showToast: (message: string, tone?: "error" | "info") => void;
139
140
  }
140
141
 
141
142
  function dispatchModifierKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks): boolean {
@@ -205,6 +206,14 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
205
206
  void cb.handleTimelineElementSplit(el, currentTime);
206
207
  return;
207
208
  }
209
+ // Expanded sub-comp children carry a qualified `sourceFile#id` selection
210
+ // that isn't in the raw `elements` list, so the s-key can't resolve them.
211
+ // Nudge toward the razor tool instead of failing silently.
212
+ if (!el && selectedElementId.includes("#")) {
213
+ event.preventDefault();
214
+ cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
215
+ return;
216
+ }
208
217
  }
209
218
  }
210
219
 
@@ -376,6 +385,7 @@ export function useAppHotkeys({
376
385
  onToggleRecording,
377
386
  leftSidebarRef,
378
387
  domEditSelectionRef,
388
+ showToast,
379
389
  };
380
390
 
381
391
  // ── Keydown dispatch ──
@@ -9,13 +9,12 @@ import { buildDomEditPatchTarget, type DomEditSelection } from "../components/ed
9
9
  import { fontFamilyFromAssetPath, type ImportedFontAsset } from "../components/editor/fontAssets";
10
10
  import type { EditHistoryKind } from "../utils/editHistory";
11
11
  import type { PersistDomEditOperations } from "./domEditCommitTypes";
12
+ import type { PatchOperation } from "../utils/sourcePatcher";
12
13
  import { useDomEditPositionPatchCommit } from "./useDomEditPositionPatchCommit";
13
14
  import { useDomEditTextCommits } from "./useDomEditTextCommits";
14
15
  import { useDomGeometryCommits } from "./useDomGeometryCommits";
15
16
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
16
-
17
- // Re-export so existing consumers keep their import path
18
- export { GSAP_CSS_FALLBACK_BLOCKED_MESSAGE } from "./useDomGeometryCommits";
17
+ import { formatFieldsSuffix } from "./gsapScriptCommitHelpers";
19
18
 
20
19
  // ── Helpers ──
21
20
 
@@ -33,15 +32,9 @@ async function readErrorResponseBody(
33
32
 
34
33
  function formatPatchRejectionMessage(body: { error?: string; fields?: string[] } | null): string {
35
34
  if (!body?.error) return "Couldn't save edit";
36
- const fields = Array.isArray(body.fields)
37
- ? body.fields.filter((field): field is string => typeof field === "string")
38
- : [];
39
- const suffix = fields.length > 0 ? ` (${fields.join(", ")})` : "";
40
- return `Couldn't save edit: ${body.error}${suffix}`;
35
+ return `Couldn't save edit: ${body.error}${formatFieldsSuffix(body.fields)}`;
41
36
  }
42
37
 
43
- // ── Types ──
44
-
45
38
  interface RecordEditInput {
46
39
  label: string;
47
40
  kind: EditHistoryKind;
@@ -49,8 +42,6 @@ interface RecordEditInput {
49
42
  files: Record<string, { before: string; after: string }>;
50
43
  }
51
44
 
52
- export type { PersistDomEditOperations } from "./domEditCommitTypes";
53
-
54
45
  export interface UseDomEditCommitsParams {
55
46
  activeCompPath: string | null;
56
47
  previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
@@ -77,10 +68,12 @@ export interface UseDomEditCommitsParams {
77
68
  target: HTMLElement,
78
69
  options?: { preferClipAncestor?: boolean },
79
70
  ) => Promise<DomEditSelection | null>;
71
+ /** Stage 7 Step 3b: called after a successful server-side element patch. */
72
+ onDomEditPersisted?: (selection: DomEditSelection, operations: PatchOperation[]) => void;
73
+ /** Stage 7 Step 3b: called after a successful server-side element delete. */
74
+ onElementDeleted?: (selection: DomEditSelection) => void;
80
75
  }
81
76
 
82
- // ── Hook ──
83
-
84
77
  export function useDomEditCommits({
85
78
  activeCompPath,
86
79
  previewIframeRef,
@@ -99,6 +92,8 @@ export function useDomEditCommits({
99
92
  clearDomSelection,
100
93
  refreshDomEditSelectionFromPreview,
101
94
  buildDomSelectionFromTarget,
95
+ onDomEditPersisted,
96
+ onElementDeleted,
102
97
  }: UseDomEditCommitsParams) {
103
98
  const resolveImportedFontAsset = useCallback(
104
99
  (fontFamilyValue: string): ImportedFontAsset | null => {
@@ -220,6 +215,7 @@ export function useDomEditCommits({
220
215
  coalesceKey: options?.coalesceKey,
221
216
  files: { [targetPath]: { before: originalContent, after: finalContent } },
222
217
  });
218
+ onDomEditPersisted?.(selection, operations);
223
219
 
224
220
  if (!options?.skipRefresh) {
225
221
  reloadPreview();
@@ -233,6 +229,7 @@ export function useDomEditCommits({
233
229
  domEditSaveTimestampRef,
234
230
  reloadPreview,
235
231
  showToast,
232
+ onDomEditPersisted,
236
233
  ],
237
234
  );
238
235
 
@@ -293,6 +290,7 @@ export function useDomEditCommits({
293
290
  reloadPreview,
294
291
  clearDomSelection,
295
292
  commitPositionPatchToHtml,
293
+ onElementDeleted,
296
294
  });
297
295
 
298
296
  return {
@@ -1,3 +1,4 @@
1
+ import type { Composition } from "@hyperframes/sdk";
1
2
  import type { TimelineElement } from "../player";
2
3
  import type { ImportedFontAsset } from "../components/editor/fontAssets";
3
4
  import type { EditHistoryKind } from "../utils/editHistory";
@@ -8,6 +9,7 @@ import { useAskAgentModal } from "./useAskAgentModal";
8
9
  import { useDomSelection } from "./useDomSelection";
9
10
  import { usePreviewInteraction } from "./usePreviewInteraction";
10
11
  import { useDomEditCommits } from "./useDomEditCommits";
12
+ import { runShadowDispatch, runShadowDelete } from "../utils/sdkShadow";
11
13
  import { useGsapScriptCommits } from "./useGsapScriptCommits";
12
14
  import { useGsapCacheVersion } from "./useGsapTweenCache";
13
15
  import { useDomEditWiring } from "./useDomEditWiring";
@@ -58,6 +60,8 @@ export interface UseDomEditSessionParams {
58
60
  openSourceForSelection?: (sourceFile: string, target: PatchTarget) => void;
59
61
  selectSidebarTab?: (tab: SidebarTab) => void;
60
62
  getSidebarTab?: () => SidebarTab;
63
+ /** Stage 7 Step 3b: SDK session for shadow dispatch parity tracking. */
64
+ sdkSession?: Composition | null;
61
65
  }
62
66
 
63
67
  // ── Hook ──
@@ -96,6 +100,7 @@ export function useDomEditSession({
96
100
  openSourceForSelection,
97
101
  selectSidebarTab,
98
102
  getSidebarTab,
103
+ sdkSession,
99
104
  }: UseDomEditSessionParams) {
100
105
  void _setRefreshKey;
101
106
  void _readProjectFile;
@@ -189,6 +194,7 @@ export function useDomEditSession({
189
194
  onCacheInvalidate: bumpGsapCache,
190
195
  onFileContentChanged: updateEditingFileContent,
191
196
  showToast,
197
+ sdkSession,
192
198
  });
193
199
 
194
200
  // ── DOM commit handlers ──
@@ -227,6 +233,10 @@ export function useDomEditSession({
227
233
  clearDomSelection,
228
234
  refreshDomEditSelectionFromPreview,
229
235
  buildDomSelectionFromTarget,
236
+ onDomEditPersisted: sdkSession
237
+ ? (sel, ops) => runShadowDispatch(sdkSession, sel, ops)
238
+ : undefined,
239
+ onElementDeleted: sdkSession ? (sel) => runShadowDelete(sdkSession, sel.hfId) : undefined,
230
240
  });
231
241
 
232
242
  // ── Wiring: selection sync, GSAP cache, preview sync, selection handlers ──
@@ -255,6 +265,9 @@ export function useDomEditSession({
255
265
  handleGsapRemoveAllKeyframes,
256
266
  handleResetSelectedElementKeyframes,
257
267
  } = useDomEditWiring({
268
+ // Pre-existing prop-drilling clone (same param set forwarded to
269
+ // useDomEditWiring); surfaced by this PR's adjacent edits, not introduced.
270
+ // fallow-ignore-next-line code-duplication
258
271
  projectId,
259
272
  activeCompPath,
260
273
  domEditSelection,