@hyperframes/studio 0.6.122-alpha.0 → 0.7.1

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 (100) hide show
  1. package/dist/assets/{index-i-MLqhmm.js → index-CnYOrmoA.js} +1 -1
  2. package/dist/assets/index-DzWIinxk.css +1 -0
  3. package/dist/assets/index-Z4bA3Fi0.js +374 -0
  4. package/dist/assets/{index-DSLrl2tB.js → index-wWkKTkIq.js} +24 -24
  5. package/dist/index.d.ts +8 -0
  6. package/dist/index.html +3 -3
  7. package/dist/index.js +3768 -2245
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/App.tsx +1 -0
  11. package/src/components/StudioHeader.tsx +2 -3
  12. package/src/components/StudioPreviewArea.tsx +12 -0
  13. package/src/components/TimelineToolbar.tsx +29 -16
  14. package/src/components/editor/DomEditOverlay.test.ts +0 -13
  15. package/src/components/editor/DomEditOverlay.tsx +3 -10
  16. package/src/components/editor/KeyframeNavigation.test.ts +36 -0
  17. package/src/components/editor/KeyframeNavigation.tsx +34 -4
  18. package/src/components/editor/MotionPathNode.tsx +98 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +481 -0
  20. package/src/components/editor/SnapToolbar.tsx +28 -3
  21. package/src/components/editor/SourceEditor.tsx +21 -9
  22. package/src/components/editor/domEditOverlayGeometry.ts +12 -0
  23. package/src/components/editor/domEditOverlayGestures.ts +5 -1
  24. package/src/components/editor/domEditOverlayStartGesture.ts +5 -1
  25. package/src/components/editor/domEditingDom.ts +24 -9
  26. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  27. package/src/components/editor/manualEditingAvailability.ts +1 -11
  28. package/src/components/editor/manualEdits.ts +1 -0
  29. package/src/components/editor/manualEditsDom.ts +68 -41
  30. package/src/components/editor/manualEditsDomGsap.test.ts +82 -0
  31. package/src/components/editor/manualOffsetDrag.test.ts +12 -0
  32. package/src/components/editor/manualOffsetDrag.ts +79 -4
  33. package/src/components/editor/motionPathCommit.test.ts +130 -0
  34. package/src/components/editor/motionPathCommit.ts +83 -0
  35. package/src/components/editor/motionPathGeometry.test.ts +127 -0
  36. package/src/components/editor/motionPathGeometry.ts +116 -0
  37. package/src/components/editor/motionPathSelection.ts +33 -0
  38. package/src/components/editor/useDomEditOverlayGestures.ts +36 -18
  39. package/src/components/editor/useDomEditOverlayRects.ts +6 -0
  40. package/src/components/editor/useMotionPathData.ts +135 -0
  41. package/src/components/nle/NLELayout.tsx +17 -6
  42. package/src/components/panels/SlideshowPanel.tsx +2 -1
  43. package/src/components/panels/SlideshowSubPanels.tsx +2 -1
  44. package/src/components/renders/RenderQueue.tsx +1 -1
  45. package/src/components/renders/useRenderQueue.ts +3 -2
  46. package/src/hooks/draggedGsapPosition.ts +47 -0
  47. package/src/hooks/gsapDragCommit.test.ts +435 -0
  48. package/src/hooks/gsapDragCommit.ts +278 -290
  49. package/src/hooks/gsapDragPositionCommit.ts +410 -0
  50. package/src/hooks/gsapRuntimeBridge.test.ts +147 -0
  51. package/src/hooks/gsapRuntimeBridge.ts +86 -69
  52. package/src/hooks/gsapRuntimeKeyframes.test.ts +129 -1
  53. package/src/hooks/gsapRuntimeKeyframes.ts +183 -11
  54. package/src/hooks/gsapRuntimePatch.test.ts +462 -0
  55. package/src/hooks/gsapRuntimePatch.ts +159 -0
  56. package/src/hooks/gsapScriptCommitTypes.ts +11 -1
  57. package/src/hooks/gsapShared.test.ts +58 -0
  58. package/src/hooks/gsapShared.ts +28 -10
  59. package/src/hooks/gsapTargetCache.ts +0 -3
  60. package/src/hooks/useDomEditPreviewSync.ts +3 -0
  61. package/src/hooks/useDomEditSession.ts +0 -4
  62. package/src/hooks/useDomEditWiring.ts +1 -0
  63. package/src/hooks/useDomSelection.ts +12 -5
  64. package/src/hooks/useEnableKeyframes.test.ts +130 -0
  65. package/src/hooks/useEnableKeyframes.ts +298 -45
  66. package/src/hooks/useGestureCommit.ts +121 -53
  67. package/src/hooks/useGestureRecording.ts +61 -42
  68. package/src/hooks/useGsapAnimationFetchFallback.test.ts +36 -0
  69. package/src/hooks/useGsapAnimationFetchFallback.ts +65 -9
  70. package/src/hooks/useGsapAnimationOps.ts +1 -1
  71. package/src/hooks/useGsapAwareEditing.ts +38 -40
  72. package/src/hooks/useGsapScriptCommits.test.tsx +366 -0
  73. package/src/hooks/useGsapScriptCommits.ts +102 -6
  74. package/src/hooks/useGsapTweenCache.ts +12 -11
  75. package/src/hooks/useRazorSplit.ts +21 -2
  76. package/src/hooks/useSafeGsapCommitMutation.test.tsx +86 -0
  77. package/src/hooks/useSafeGsapCommitMutation.ts +8 -4
  78. package/src/hooks/useServerConnection.ts +4 -1
  79. package/src/hooks/useStudioContextValue.ts +3 -2
  80. package/src/hooks/useStudioUrlState.ts +97 -37
  81. package/src/player/components/ShortcutsPanel.tsx +8 -0
  82. package/src/player/components/TimelineClipDiamonds.tsx +10 -12
  83. package/src/player/hooks/useExpandedTimelineElements.test.ts +34 -0
  84. package/src/player/hooks/useExpandedTimelineElements.ts +20 -2
  85. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -1
  86. package/src/player/lib/mediaProbe.ts +7 -1
  87. package/src/player/store/playerStore.ts +38 -1
  88. package/src/telemetry/config.ts +3 -2
  89. package/src/utils/editDebugLog.ts +16 -0
  90. package/src/utils/generateId.ts +7 -0
  91. package/src/utils/gsapSoftReload.test.ts +217 -13
  92. package/src/utils/gsapSoftReload.ts +228 -50
  93. package/src/utils/studioHelpers.test.ts +31 -1
  94. package/src/utils/studioHelpers.ts +24 -0
  95. package/src/utils/studioPreviewHelpers.test.ts +26 -1
  96. package/src/utils/studioPreviewHelpers.ts +30 -6
  97. package/src/utils/studioTelemetry.ts +3 -1
  98. package/dist/assets/index-Baz2T-9U.js +0 -269
  99. package/dist/assets/index-BvEhf7SS.css +0 -1
  100. package/src/player/components/TimelinePropertyRows.tsx +0 -126
@@ -0,0 +1,366 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Mock the two preview-sync primitives so we can assert which path runCommit took.
7
+ // `patchRuntimeTweenInPlace` is the instant in-place patch; `applySoftReload` is
8
+ // the existing fallback. `extractGsapScriptText` is re-exported from the same
9
+ // module and used elsewhere in the hook — keep it a harmless stub.
10
+ const patchRuntimeTweenInPlace = vi.fn<(...args: unknown[]) => boolean>();
11
+ const applySoftReload = vi.fn<(...args: unknown[]) => string>();
12
+ const trackStudioEvent = vi.fn();
13
+
14
+ vi.mock("./gsapRuntimePatch", () => ({
15
+ patchRuntimeTweenInPlace: (...args: unknown[]) => patchRuntimeTweenInPlace(...args),
16
+ }));
17
+ vi.mock("../utils/gsapSoftReload", () => ({
18
+ applySoftReload: (...args: unknown[]) => applySoftReload(...args),
19
+ extractGsapScriptText: () => "",
20
+ }));
21
+ vi.mock("../utils/studioTelemetry", () => ({
22
+ trackStudioEvent: (...args: unknown[]) => trackStudioEvent(...args),
23
+ }));
24
+
25
+ // Tell React this is an act-capable environment so act(...) flushes effects
26
+ // without warning (React reads this global at call time).
27
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
28
+
29
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
30
+ import type { MutationResult } from "./gsapScriptCommitTypes";
31
+ import { applyPreviewSync, useGsapScriptCommits } from "./useGsapScriptCommits";
32
+
33
+ // ── applyPreviewSync (pure preview-sync decision) ────────────────────────────
34
+
35
+ const FAKE_IFRAME = {} as HTMLIFrameElement;
36
+
37
+ function result(over: Partial<MutationResult> = {}): MutationResult {
38
+ return { ok: true, scriptText: "tl.set('#a',{})", ...over };
39
+ }
40
+
41
+ describe("applyPreviewSync", () => {
42
+ beforeEach(() => {
43
+ patchRuntimeTweenInPlace.mockReset();
44
+ applySoftReload.mockReset();
45
+ trackStudioEvent.mockReset();
46
+ });
47
+
48
+ it("instantPatch + patch succeeds: skips both soft reload and full reload", () => {
49
+ patchRuntimeTweenInPlace.mockReturnValue(true);
50
+ const reloadPreview = vi.fn();
51
+
52
+ applyPreviewSync(
53
+ FAKE_IFRAME,
54
+ result(),
55
+ {
56
+ label: "drag",
57
+ softReload: true,
58
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
59
+ },
60
+ reloadPreview,
61
+ );
62
+
63
+ expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
64
+ kind: "set",
65
+ props: { x: 10 },
66
+ });
67
+ expect(applySoftReload).not.toHaveBeenCalled();
68
+ expect(reloadPreview).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it("instantPatch + patch fails: falls back to the soft reload, passing onAsyncFailure", () => {
72
+ patchRuntimeTweenInPlace.mockReturnValue(false);
73
+ applySoftReload.mockReturnValue("applied");
74
+ const reloadPreview = vi.fn();
75
+
76
+ applyPreviewSync(
77
+ FAKE_IFRAME,
78
+ result({ scriptText: "SCRIPT" }),
79
+ {
80
+ label: "drag",
81
+ softReload: true,
82
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
83
+ },
84
+ reloadPreview,
85
+ );
86
+
87
+ // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
88
+ // CDN load failure escalates to a full reload — but it is NOT called eagerly.
89
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
90
+ expect(reloadPreview).not.toHaveBeenCalled();
91
+ // A successful instant patch is the fast path; here it missed → fallback event.
92
+ expect(trackStudioEvent).toHaveBeenCalledWith(
93
+ "gsap_instant_patch_fallback",
94
+ expect.objectContaining({ selector: "#a" }),
95
+ );
96
+ });
97
+
98
+ it('instantPatch + patch fails + soft reload "verify-failed": transient, does NOT escalate (U4)', () => {
99
+ patchRuntimeTweenInPlace.mockReturnValue(false);
100
+ applySoftReload.mockReturnValue("verify-failed");
101
+ const reloadPreview = vi.fn();
102
+
103
+ applyPreviewSync(
104
+ FAKE_IFRAME,
105
+ result({ scriptText: "SCRIPT" }),
106
+ {
107
+ label: "drag",
108
+ softReload: true,
109
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
110
+ },
111
+ reloadPreview,
112
+ );
113
+
114
+ // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
115
+ // is correct, so we must NOT escalate to a full reload.
116
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
117
+ expect(reloadPreview).not.toHaveBeenCalled();
118
+ // Telemetry records the suppressed transient (escalated: false).
119
+ expect(trackStudioEvent).toHaveBeenCalledWith(
120
+ "gsap_soft_reload_outcome",
121
+ expect.objectContaining({
122
+ origin: "preview_sync",
123
+ result: "verify-failed",
124
+ escalated: false,
125
+ }),
126
+ );
127
+ });
128
+
129
+ it('instantPatch + patch fails + soft reload "cannot-soft-reload": escalates to full reload', () => {
130
+ patchRuntimeTweenInPlace.mockReturnValue(false);
131
+ applySoftReload.mockReturnValue("cannot-soft-reload");
132
+ const reloadPreview = vi.fn();
133
+
134
+ applyPreviewSync(
135
+ FAKE_IFRAME,
136
+ result({ scriptText: "SCRIPT" }),
137
+ {
138
+ label: "drag",
139
+ softReload: true,
140
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
141
+ },
142
+ reloadPreview,
143
+ );
144
+
145
+ // Structural failure: the preview is genuinely stale/broken → full reload.
146
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
147
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
148
+ expect(trackStudioEvent).toHaveBeenCalledWith(
149
+ "gsap_soft_reload_outcome",
150
+ expect.objectContaining({
151
+ origin: "preview_sync",
152
+ result: "cannot-soft-reload",
153
+ escalated: true,
154
+ }),
155
+ );
156
+ });
157
+
158
+ it("no instantPatch + softReload + scriptText: soft reloads, passing onAsyncFailure", () => {
159
+ applySoftReload.mockReturnValue("applied");
160
+ const reloadPreview = vi.fn();
161
+
162
+ applyPreviewSync(
163
+ FAKE_IFRAME,
164
+ result({ scriptText: "SCRIPT" }),
165
+ { label: "x", softReload: true },
166
+ reloadPreview,
167
+ );
168
+
169
+ expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
170
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
171
+ expect(reloadPreview).not.toHaveBeenCalled();
172
+ // "applied" emits no telemetry (only the failure paths do).
173
+ expect(trackStudioEvent).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it('no instantPatch + softReload "verify-failed": transient, does NOT escalate (U4)', () => {
177
+ applySoftReload.mockReturnValue("verify-failed");
178
+ const reloadPreview = vi.fn();
179
+
180
+ applyPreviewSync(
181
+ FAKE_IFRAME,
182
+ result({ scriptText: "SCRIPT" }),
183
+ { label: "x", softReload: true },
184
+ reloadPreview,
185
+ );
186
+
187
+ // onAsyncFailure is wired, but the transient result does not trigger it.
188
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
189
+ expect(reloadPreview).not.toHaveBeenCalled();
190
+ expect(trackStudioEvent).toHaveBeenCalledWith(
191
+ "gsap_soft_reload_outcome",
192
+ expect.objectContaining({ result: "verify-failed", escalated: false }),
193
+ );
194
+ });
195
+
196
+ it('no instantPatch + softReload "cannot-soft-reload": escalates to full reload', () => {
197
+ applySoftReload.mockReturnValue("cannot-soft-reload");
198
+ const reloadPreview = vi.fn();
199
+
200
+ applyPreviewSync(
201
+ FAKE_IFRAME,
202
+ result({ scriptText: "SCRIPT" }),
203
+ { label: "x", softReload: true },
204
+ reloadPreview,
205
+ );
206
+
207
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
208
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
209
+ expect(trackStudioEvent).toHaveBeenCalledWith(
210
+ "gsap_soft_reload_outcome",
211
+ expect.objectContaining({ result: "cannot-soft-reload", escalated: true }),
212
+ );
213
+ });
214
+
215
+ it("no instantPatch + no softReload: full reload (today's behavior)", () => {
216
+ const reloadPreview = vi.fn();
217
+
218
+ applyPreviewSync(FAKE_IFRAME, result(), { label: "x" }, reloadPreview);
219
+
220
+ expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
221
+ expect(applySoftReload).not.toHaveBeenCalled();
222
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
223
+ });
224
+ });
225
+
226
+ // ── runCommit (full hook path: persist + preview sync) ───────────────────────
227
+
228
+ type HookApi = ReturnType<typeof useGsapScriptCommits>;
229
+
230
+ let cleanup: (() => void) | null = null;
231
+
232
+ function renderCommitHook() {
233
+ const reloadPreview = vi.fn();
234
+ const onCacheInvalidate = vi.fn();
235
+ const forceReloadSdkSession = vi.fn();
236
+ const recordEdit = vi.fn(async () => {});
237
+ const showToast = vi.fn();
238
+
239
+ const captured: { api: HookApi | null } = { api: null };
240
+ function Probe() {
241
+ captured.api = useGsapScriptCommits({
242
+ projectIdRef: { current: "proj-1" },
243
+ activeCompPath: "index.html",
244
+ previewIframeRef: { current: FAKE_IFRAME },
245
+ editHistory: { recordEdit },
246
+ domEditSaveTimestampRef: { current: 0 },
247
+ reloadPreview,
248
+ onCacheInvalidate,
249
+ onFileContentChanged: undefined,
250
+ showToast,
251
+ sdkSession: null,
252
+ writeProjectFile: undefined,
253
+ forceReloadSdkSession,
254
+ });
255
+ return null;
256
+ }
257
+
258
+ const container = document.createElement("div");
259
+ const root = createRoot(container);
260
+ act(() => {
261
+ root.render(<Probe />);
262
+ });
263
+ cleanup = () => act(() => root.unmount());
264
+ const hookApi = captured.api;
265
+ if (!hookApi) throw new Error("hook did not initialize");
266
+ return {
267
+ api: hookApi,
268
+ reloadPreview,
269
+ onCacheInvalidate,
270
+ forceReloadSdkSession,
271
+ recordEdit,
272
+ showToast,
273
+ };
274
+ }
275
+
276
+ const selection: DomEditSelection = { id: "a", selector: "#a" } as DomEditSelection;
277
+
278
+ function mockFetchResult(over: Partial<MutationResult> = {}): void {
279
+ const body: MutationResult = {
280
+ ok: true,
281
+ changed: true,
282
+ before: "BEFORE",
283
+ after: "AFTER",
284
+ scriptText: "SCRIPT",
285
+ ...over,
286
+ };
287
+ vi.stubGlobal(
288
+ "fetch",
289
+ vi.fn(async () => ({ ok: true, json: async () => body }) as unknown as Response),
290
+ );
291
+ }
292
+
293
+ describe("runCommit — instantPatch wiring", () => {
294
+ beforeEach(() => {
295
+ patchRuntimeTweenInPlace.mockReset();
296
+ applySoftReload.mockReset();
297
+ trackStudioEvent.mockReset();
298
+ });
299
+ afterEach(() => {
300
+ cleanup?.();
301
+ cleanup = null;
302
+ vi.unstubAllGlobals();
303
+ });
304
+
305
+ it("instantPatch succeeds: persists, invalidates cache, NO reload", async () => {
306
+ patchRuntimeTweenInPlace.mockReturnValue(true);
307
+ mockFetchResult();
308
+ const deps = renderCommitHook();
309
+
310
+ await act(async () => {
311
+ await deps.api.commitMutation(
312
+ selection,
313
+ { x: 10 },
314
+ {
315
+ label: "drag",
316
+ softReload: true,
317
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
318
+ },
319
+ );
320
+ });
321
+
322
+ expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
323
+ expect(deps.recordEdit).toHaveBeenCalledTimes(1);
324
+ expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
325
+ expect(applySoftReload).not.toHaveBeenCalled();
326
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
327
+ });
328
+
329
+ it("instantPatch fails: persists AND falls back to soft reload", async () => {
330
+ patchRuntimeTweenInPlace.mockReturnValue(false);
331
+ applySoftReload.mockReturnValue("applied");
332
+ mockFetchResult();
333
+ const deps = renderCommitHook();
334
+
335
+ await act(async () => {
336
+ await deps.api.commitMutation(
337
+ selection,
338
+ { x: 10 },
339
+ {
340
+ label: "drag",
341
+ softReload: true,
342
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
343
+ },
344
+ );
345
+ });
346
+
347
+ expect(fetch).toHaveBeenCalledTimes(1);
348
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
349
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
350
+ expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
351
+ });
352
+
353
+ it("no instantPatch: identical to today — soft reload when softReload+scriptText", async () => {
354
+ applySoftReload.mockReturnValue("applied");
355
+ mockFetchResult();
356
+ const deps = renderCommitHook();
357
+
358
+ await act(async () => {
359
+ await deps.api.commitMutation(selection, { x: 10 }, { label: "drag", softReload: true });
360
+ });
361
+
362
+ expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
363
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
364
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
365
+ });
366
+ });
@@ -1,9 +1,13 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
+ import { editLog } from "../utils/editDebugLog";
2
3
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
6
+ import type { SoftReloadResult } from "../utils/gsapSoftReload";
7
+ import { trackStudioEvent } from "../utils/studioTelemetry";
5
8
  import type { CutoverDeps } from "../utils/sdkCutover";
6
9
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
10
+ import { patchRuntimeTweenInPlace } from "./gsapRuntimePatch";
7
11
  import { createKeyedSerializer } from "./serializeByKey";
8
12
  import {
9
13
  GsapMutationHttpError,
@@ -43,6 +47,77 @@ async function mutateGsapScript(
43
47
  return result;
44
48
  }
45
49
 
50
+ /**
51
+ * Apply a soft reload and enforce the U4 invariant via the richer
52
+ * `SoftReloadResult`, with telemetry on every non-success path so the invariant
53
+ * is observable in production, not just asserted in tests:
54
+ *
55
+ * - `"cannot-soft-reload"` (PERMANENT/STRUCTURAL: no gsap runtime, no rebind
56
+ * hook, no scopable key, no script element, or the sync re-run threw) →
57
+ * escalate to a full `reloadPreview()`; the preview is genuinely stale/broken.
58
+ * - `"verify-failed"` (TRANSIENT: re-run happened, `__timelines` momentarily
59
+ * empty) → do NOT escalate; the live `gsap.set` already shows the correct value
60
+ * and a remount would re-flash the WebGL context + revert subcomp keyframes.
61
+ * - `"applied"` → success (or deferred to async plugin load; `onAsyncFailure`
62
+ * covers the CDN-error escalation).
63
+ */
64
+ function softReloadOrEscalate(
65
+ iframe: HTMLIFrameElement | null,
66
+ scriptText: string,
67
+ reloadPreview: () => void,
68
+ origin: "preview_sync" | "sdk_refresh",
69
+ ): void {
70
+ const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview);
71
+ if (result === "applied") return;
72
+ trackStudioEvent("gsap_soft_reload_outcome", {
73
+ origin,
74
+ result,
75
+ escalated: result === "cannot-soft-reload",
76
+ });
77
+ // PERMANENT failure: the preview can't be soft-updated → full reload. TRANSIENT
78
+ // "verify-failed" is suppressed (live state is correct).
79
+ if (result === "cannot-soft-reload") reloadPreview();
80
+ }
81
+
82
+ /**
83
+ * Sync the preview after a persisted commit. For a value-only edit
84
+ * (`options.instantPatch`), try the in-place runtime patch first: on success the
85
+ * preview is already correct, so we skip the reload entirely (instant). On `false`
86
+ * — or when no `instantPatch` is supplied — fall back to the existing soft/full
87
+ * reload. Pure (no React) so `runCommit`'s preview-sync decision is unit-testable.
88
+ */
89
+ export function applyPreviewSync(
90
+ iframe: HTMLIFrameElement | null,
91
+ result: MutationResult,
92
+ options: CommitMutationOptions,
93
+ reloadPreview: () => void,
94
+ ): void {
95
+ if (options.instantPatch) {
96
+ const patched = patchRuntimeTweenInPlace(
97
+ iframe,
98
+ options.instantPatch.selector,
99
+ options.instantPatch.change,
100
+ );
101
+ // Patched in place — element is already correct on screen; no reload needed.
102
+ if (patched) return;
103
+ // The instant path couldn't patch in place — record the fallback so we can
104
+ // track how often the fast path misses before the soft/full reload below.
105
+ trackStudioEvent("gsap_instant_patch_fallback", { selector: options.instantPatch.selector });
106
+ // Fall through to the soft/full reload path below.
107
+ }
108
+ if (options.softReload && result.scriptText) {
109
+ // A soft-reloadable edit escalates to a full iframe remount ONLY on the
110
+ // PERMANENT "cannot-soft-reload" result (the preview is genuinely stale/
111
+ // broken). The TRANSIENT "verify-failed" does NOT escalate — the value is
112
+ // already correct on screen, and a remount re-flashes the WebGL context AND
113
+ // re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
114
+ // load failure escalates separately via `onAsyncFailure`.
115
+ softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync");
116
+ } else {
117
+ reloadPreview();
118
+ }
119
+ }
120
+
46
121
  // oxfmt-ignore
47
122
  // fallow-ignore-next-line complexity
48
123
  export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession, writeProjectFile, forceReloadSdkSession }: GsapScriptCommitsParams) {
@@ -55,6 +130,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
55
130
  const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
56
131
  const pid = projectIdRef.current;
57
132
  if (!pid) return;
133
+ editLog("gsap-commit", {
134
+ type: mutation.type,
135
+ id: selection.id,
136
+ file: selection.sourceFile || activeCompPath,
137
+ label: options.label,
138
+ });
58
139
  const unsafeFields = findUnsafeMutationValues(mutation);
59
140
  if (unsafeFields.length > 0) {
60
141
  showToast?.("Couldn't read element layout — try again at a different playhead time", "error");
@@ -82,12 +163,13 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
82
163
  if (options.skipReload) return;
83
164
  if (result.parsed?.animations) updateKeyframeCacheFromParsed(result.parsed.animations, targetPath, selection.id ?? undefined, mutation);
84
165
  options.beforeReload?.();
85
- if (options.softReload && result.scriptText) {
86
- if (!applySoftReload(previewIframeRef.current, result.scriptText)) reloadPreview();
87
- } else {
88
- reloadPreview();
89
- }
166
+ applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
90
167
  onCacheInvalidate();
168
+ editLog("gsap-commit:done", {
169
+ type: mutation.type,
170
+ changed: result.changed,
171
+ instant: Boolean(options.instantPatch),
172
+ });
91
173
  }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, forceReloadSdkSession]);
92
174
  // Every GSAP-script commit is a read-modify-write of one file. Overlapping
93
175
  // commits to the SAME file (any op type, any animation) interleave server-side,
@@ -109,8 +191,22 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
109
191
  // the playhead) and invalidates the panel cache, matching the server path.
110
192
  const sdkRefresh = useCallback(
111
193
  (after: string) => {
194
+ // extractGsapScriptText returns null when zero/multiple GSAP scripts are
195
+ // present — that's an ambiguous/structural change that genuinely needs a full
196
+ // reload. But a SINGLE-script soft-reloadable edit must not escalate to a full
197
+ // remount even if applySoftReload reports failure (same U4 invariant as
198
+ // applyPreviewSync): the live state is already correct, and a remount re-inlines
199
+ // subcomps + reverts their keyframes.
112
200
  const script = extractGsapScriptText(after);
113
- if (!(script && applySoftReload(previewIframeRef.current, script))) reloadPreview();
201
+ if (script) {
202
+ // Soft-reload in place. reloadPreview is the ASYNC-failure escalation — a
203
+ // plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
204
+ // synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
205
+ // but a "cannot-soft-reload" (structural failure) does.
206
+ softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
207
+ } else {
208
+ reloadPreview();
209
+ }
114
210
  onCacheInvalidate();
115
211
  },
116
212
  [previewIframeRef, reloadPreview, onCacheInvalidate],
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useRef, useState, useCallback } from "react";
2
2
  import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
3
3
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
4
+ import { isStudioHoldSet } from "@hyperframes/core/gsap-parser";
4
5
  import { usePlayerStore } from "../player/store/playerStore";
5
6
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
6
7
  import {
@@ -107,7 +108,12 @@ export async function fetchParsedAnimations(
107
108
  const res = await fetch(
108
109
  `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`,
109
110
  );
110
- return res.ok ? ((await res.json()) as ParsedGsap) : null;
111
+ if (!res.ok) return null;
112
+ const parsed = (await res.json()) as ParsedGsap;
113
+ // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they
114
+ // hold an element's first keyframe before its tween). They must not surface as
115
+ // user animations — otherwise they pollute the keyframe cache / timeline diamonds.
116
+ return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) };
111
117
  } catch {
112
118
  return null;
113
119
  }
@@ -131,7 +137,8 @@ export function useGsapAnimationsForElement(
131
137
  const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
132
138
 
133
139
  useEffect(() => {
134
- const fetchKey = `${projectId}:${sourceFile}:${version}`;
140
+ const targetKey = target?.id ?? target?.selector ?? "";
141
+ const fetchKey = `${projectId}:${sourceFile}:${version}:${targetKey}`;
135
142
  if (fetchKey === lastFetchKeyRef.current) return;
136
143
  lastFetchKeyRef.current = fetchKey;
137
144
 
@@ -360,9 +367,7 @@ export function usePopulateKeyframeCacheForFile(
360
367
 
361
368
  const sf = sourceFile;
362
369
  fetchParsedAnimations(projectId, sf).then((parsed) => {
363
- if (!parsed) {
364
- return;
365
- }
370
+ if (!parsed) return;
366
371
  const { setKeyframeCache } = usePlayerStore.getState();
367
372
  clearKeyframeCacheForFile(sf);
368
373
  const { elements } = usePlayerStore.getState();
@@ -370,13 +375,9 @@ export function usePopulateKeyframeCacheForFile(
370
375
  for (const anim of parsed.animations) {
371
376
  const id = extractIdFromSelector(anim.targetSelector);
372
377
  if (!id) continue;
373
- if (anim.hasUnresolvedKeyframes) {
374
- continue;
375
- }
378
+ if (anim.hasUnresolvedKeyframes) continue;
376
379
  const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim);
377
- if (!kfData) {
378
- continue;
379
- }
380
+ if (!kfData) continue;
380
381
  const tweenPos =
381
382
  anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0);
382
383
  const tweenDur = anim.duration ?? 1;
@@ -1,4 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
+ import { editLog } from "../utils/editDebugLog";
2
3
  import type { TimelineElement } from "../player";
3
4
  import { usePlayerStore } from "../player";
4
5
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
@@ -38,13 +39,21 @@ async function splitHtmlElement(
38
39
  patchTarget: NonNullable<ReturnType<typeof buildPatchTarget>>,
39
40
  splitTime: number,
40
41
  newId: string,
42
+ elementStart: number,
43
+ elementDuration: number,
41
44
  ): Promise<{ ok: boolean; changed?: boolean; content?: string }> {
42
45
  const response = await fetch(
43
46
  `/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
44
47
  {
45
48
  method: "POST",
46
49
  headers: { "Content-Type": "application/json" },
47
- body: JSON.stringify({ target: patchTarget, splitTime, newId }),
50
+ body: JSON.stringify({
51
+ target: patchTarget,
52
+ splitTime,
53
+ newId,
54
+ elementStart,
55
+ elementDuration,
56
+ }),
48
57
  },
49
58
  );
50
59
  if (!response.ok) throw new Error("Split request failed");
@@ -111,11 +120,21 @@ async function executeSplit(
111
120
  if (!patchTarget) throw new Error("Clip is missing a patchable target.");
112
121
 
113
122
  const targetPath = element.sourceFile || activeCompPath || "index.html";
123
+ editLog("razor-split", { id: element.domId ?? element.id, splitTime, file: targetPath });
114
124
  const originalContent = await readFileContent(pid, targetPath);
115
125
  const newId = generateSplitId(collectHtmlIds(originalContent), element.domId || "clip");
116
126
 
117
- const splitResult = await splitHtmlElement(pid, targetPath, patchTarget, splitTime, newId);
127
+ const splitResult = await splitHtmlElement(
128
+ pid,
129
+ targetPath,
130
+ patchTarget,
131
+ splitTime,
132
+ newId,
133
+ element.start,
134
+ element.duration,
135
+ );
118
136
  if (!splitResult.ok) throw new Error("Failed to split clip.");
137
+ editLog("razor-split:done", { changed: splitResult.changed, newId });
119
138
  if (!splitResult.changed) {
120
139
  return { targetPath, originalContent, patchedContent: originalContent, changed: false };
121
140
  }