@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
10
10
  import { useElementLifecycleOps } from "./useElementLifecycleOps";
11
11
  import { useTimelineEditing } from "./useTimelineEditing";
12
12
 
13
+ vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
14
+ const actual =
15
+ await importOriginal<typeof import("../components/editor/manualEditingAvailability")>();
16
+ return {
17
+ ...actual,
18
+ STUDIO_SDK_CUTOVER_ENABLED: true,
19
+ STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]),
20
+ STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
21
+ };
22
+ });
23
+
13
24
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
14
25
 
15
26
  type ZIndexEntry = {
@@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: {
108
119
  }) => Promise<void>;
109
120
  reloadPreview?: () => void;
110
121
  sdkSession?: Awaited<ReturnType<typeof openComposition>> | null;
122
+ publishSdkSession?: NonNullable<Parameters<typeof useTimelineEditing>[0]["publishSdkSession"]>;
111
123
  forceReloadSdkSession?: () => void;
124
+ invalidateGsapCache?: () => void;
112
125
  showToast?: (message: string, kind?: string) => void;
113
126
  }): {
114
127
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
@@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: {
140
153
  pendingTimelineEditPathRef: { current: new Set<string>() },
141
154
  uploadProjectFiles: vi.fn(),
142
155
  sdkSession: input.sdkSession,
156
+ publishSdkSession: input.publishSdkSession,
143
157
  forceReloadSdkSession: input.forceReloadSdkSession,
158
+ invalidateGsapCache: input.invalidateGsapCache,
144
159
  handleDomZIndexReorderCommitRef: commitRef,
145
160
  });
146
161
  move = hook.handleTimelineElementMove;
@@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: {
163
178
  type TimelineRecordEdit = NonNullable<
164
179
  Parameters<typeof renderTimelineEditingHook>[0]["recordEdit"]
165
180
  >;
181
+ type TimelinePublishSdkSession = NonNullable<
182
+ Parameters<typeof renderTimelineEditingHook>[0]["publishSdkSession"]
183
+ >;
166
184
 
167
185
  function renderTimelineEditingHookWithLifecycle(input: {
168
186
  timelineElements: TimelineElement[];
@@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise<void> {
227
245
  * with `gsapBody`. Returns the mock for call inspection.
228
246
  */
229
247
  function stubProjectFetch(files: string | Record<string, string>, gsapBody?: unknown) {
230
- // Keep this test server's capability, file-read, and mutation routes together;
231
- // splitting the fixture would obscure the request sequence asserted by callers.
232
- // fallow-ignore-next-line complexity
233
- const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
234
- const url = requestUrl(input);
235
- if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) {
236
- return jsonResponse({ atomicOwnershipPairs: true });
237
- }
238
- if (url.includes("/api/projects/p1/files/")) {
239
- if (typeof files === "string") return jsonResponse({ content: files });
240
- const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html");
241
- return jsonResponse({ content: files[path] });
242
- }
243
- if (url.includes("/api/projects/p1/gsap-mutations/")) {
244
- const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html");
245
- const content = typeof files === "string" ? files : (files[path] ?? "");
246
- return jsonResponse(
247
- gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
248
- );
249
- }
250
- throw new Error(`Unexpected fetch: ${url}`);
251
- });
248
+ const pathAfter = (url: string, marker: string) =>
249
+ decodeURIComponent(url.split(marker)[1] ?? "index.html");
250
+ const fileContent = (path: string) => (typeof files === "string" ? files : files[path]);
251
+ // One handler per route, so the mock itself stays a lookup: the request
252
+ // sequence callers assert on is still readable top to bottom.
253
+ const routes: Array<[marker: string, respond: (url: string) => Response]> = [
254
+ [
255
+ "/api/projects/p1/gsap-mutation-capabilities",
256
+ () => jsonResponse({ atomicOwnershipPairs: true }),
257
+ ],
258
+ [
259
+ "/api/projects/p1/files/",
260
+ (url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }),
261
+ ],
262
+ [
263
+ "/api/projects/p1/gsap-mutations/",
264
+ (url) => {
265
+ const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? "";
266
+ return jsonResponse(
267
+ gsapBody ?? { mutated: false, scriptText: null, before: content, after: content },
268
+ );
269
+ },
270
+ ],
271
+ ];
272
+ const fetchMock = vi.fn(
273
+ async (
274
+ input: Parameters<typeof fetch>[0],
275
+ _init?: Parameters<typeof fetch>[1],
276
+ ): Promise<Response> => {
277
+ const url = requestUrl(input);
278
+ const route = routes.find(([marker]) => url.includes(marker));
279
+ if (!route) throw new Error(`Unexpected fetch: ${url}`);
280
+ return route[1](url);
281
+ },
282
+ );
252
283
  vi.stubGlobal("fetch", fetchMock);
253
284
  return fetchMock;
254
285
  }
@@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: {
285
316
  return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook };
286
317
  }
287
318
 
319
+ const SDK_KEYFRAMED_SOURCE = [
320
+ `<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
321
+ ` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
322
+ `</div>`,
323
+ `<script>`,
324
+ `const tl = gsap.timeline({ paused: true });`,
325
+ `tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
326
+ `window.__timelines = [tl];`,
327
+ `</script>`,
328
+ ].join("\n");
329
+
330
+ async function setupSdkKeyframedClipHarness() {
331
+ const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
332
+ const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
333
+ const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
334
+ const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
335
+ const invalidateGsapCache = vi.fn();
336
+ const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
337
+ usePlayerStore.getState().setDuration(10);
338
+ const hook = renderTimelineEditingHook({
339
+ timelineElements: [clip],
340
+ iframe,
341
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
342
+ projectId: "p1",
343
+ writeProjectFile,
344
+ recordEdit: vi.fn(async () => {}),
345
+ sdkSession,
346
+ publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
347
+ invalidateGsapCache,
348
+ });
349
+ return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
350
+ }
351
+
288
352
  /** Assert a lane write landed in both the live iframe DOM and the persisted file. */
289
353
  function expectLanePersisted(
290
354
  iframe: HTMLIFrameElement,
@@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => {
710
774
  h.unmount();
711
775
  });
712
776
 
777
+ it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
778
+ const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
779
+ await setupSdkKeyframedClipHarness();
780
+
781
+ await act(async () => {
782
+ await hook.move(clip, { start: 2.25, track: clip.track });
783
+ });
784
+
785
+ expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
786
+ const mutationCall = fetchMock.mock.calls.find((call) =>
787
+ requestUrl(call[0]).includes("/gsap-mutations/"),
788
+ );
789
+ expect(mutationCall).toBeDefined();
790
+ const init = mutationCall?.[1] as RequestInit | undefined;
791
+ expect(JSON.parse(String(init?.body))).toEqual({
792
+ type: "shift-positions",
793
+ targetSelector: "#clip",
794
+ delta: 1.25,
795
+ });
796
+ expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
797
+
798
+ hook.unmount();
799
+ });
800
+
801
+ it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
802
+ const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
803
+ await setupSdkKeyframedClipHarness();
804
+
805
+ await act(async () => {
806
+ await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
807
+ });
808
+
809
+ expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
810
+ expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
811
+ const mutationCall = fetchMock.mock.calls.find((call) =>
812
+ requestUrl(call[0]).includes("/gsap-mutations/"),
813
+ );
814
+ expect(mutationCall).toBeDefined();
815
+ const init = mutationCall?.[1] as RequestInit | undefined;
816
+ expect(JSON.parse(String(init?.body))).toEqual({
817
+ type: "scale-positions",
818
+ targetSelector: "#clip",
819
+ oldStart: 1,
820
+ oldDuration: 2,
821
+ newStart: 2,
822
+ newDuration: 4,
823
+ });
824
+ expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
825
+
826
+ hook.unmount();
827
+ });
828
+
713
829
  it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
714
830
  // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
715
831
  // pure lane change routed through onMoveElement (no onMoveElements wired)
@@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => {
821
937
  unmount();
822
938
  });
823
939
 
940
+ it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
941
+ const source = [
942
+ `<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
943
+ ` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
944
+ ` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
945
+ `</div>`,
946
+ `<script>`,
947
+ `const tl = gsap.timeline({ paused: true });`,
948
+ `tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
949
+ `tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
950
+ `window.__timelines = [tl];`,
951
+ `</script>`,
952
+ ].join("\n");
953
+ const { iframe, a, b } = makeTwoClipPair();
954
+ const sdkSession = await openComposition(source);
955
+ const fetchMock = stubProjectFetch(source);
956
+ const invalidateGsapCache = vi.fn();
957
+ usePlayerStore.getState().setDuration(10);
958
+ const hook = renderTimelineEditingHook({
959
+ timelineElements: [a, b],
960
+ iframe,
961
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
962
+ projectId: "p1",
963
+ writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
964
+ recordEdit: vi.fn(async () => {}),
965
+ sdkSession,
966
+ publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
967
+ invalidateGsapCache,
968
+ });
969
+
970
+ await act(async () => {
971
+ await hook.groupMove([
972
+ { element: a, start: 1 },
973
+ { element: b, start: 2 },
974
+ ]);
975
+ });
976
+
977
+ const mutations = fetchMock.mock.calls
978
+ .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
979
+ .map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
980
+ expect(mutations).toEqual([
981
+ { type: "shift-positions", targetSelector: "#a", delta: 1 },
982
+ { type: "shift-positions", targetSelector: "#b", delta: 1 },
983
+ ]);
984
+ expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
985
+
986
+ hook.unmount();
987
+ });
988
+
824
989
  it("partitions a group move by source file while keeping one undo entry", async () => {
825
990
  const files: Record<string, string> = {
826
991
  "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
@@ -58,6 +58,7 @@ export function useTimelineEditing({
58
58
  sdkSession,
59
59
  publishSdkSession,
60
60
  forceReloadSdkSession,
61
+ invalidateGsapCache,
61
62
  handleDomZIndexReorderCommitRef,
62
63
  }: UseTimelineEditingOptions) {
63
64
  const projectIdRef = useRef(projectId);
@@ -118,6 +119,7 @@ export function useTimelineEditing({
118
119
  domEditSaveTimestampRef,
119
120
  editQueueRef,
120
121
  forceReloadSdkSession,
122
+ invalidateGsapCache,
121
123
  isRecordingRef,
122
124
  pendingTimelineEditPathRef,
123
125
  previewIframeRef,
@@ -184,21 +186,24 @@ export function useTimelineEditing({
184
186
  );
185
187
  };
186
188
  const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
189
+ const finishMoveGsapSync = () =>
190
+ // Every timing writer converges the same GSAP positions after its
191
+ // durable clip-start commit. The SDK owns the attribute write; this
192
+ // sync owns only the dependent animation rewrite and preview refresh.
193
+ finishClipTimingFallback({
194
+ iframe: previewIframeRef.current,
195
+ reloadPreview,
196
+ projectId: projectIdRef.current,
197
+ targetPath,
198
+ domId: element.domId,
199
+ label: "Move timeline clip",
200
+ coalesceKey,
201
+ recordEdit,
202
+ edit: { kind: "shift", delta: updates.start - element.start },
203
+ }).finally(() => invalidateGsapCache?.());
187
204
  const moveFallback = () =>
188
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
189
- // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched
190
- // DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview().
191
- finishClipTimingFallback({
192
- iframe: previewIframeRef.current,
193
- reloadPreview,
194
- projectId: projectIdRef.current,
195
- targetPath,
196
- domId: element.domId,
197
- label: "Move timeline clip",
198
- coalesceKey,
199
- recordEdit,
200
- edit: { kind: "shift", delta: updates.start - element.start },
201
- }),
205
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
206
+ finishMoveGsapSync,
202
207
  );
203
208
  return reorderDone
204
209
  .then(() => {
@@ -221,9 +226,10 @@ export function useTimelineEditing({
221
226
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
222
227
  publishSession: publishSdkSession,
223
228
  },
224
- { label: "Move timeline clip", coalesceKey },
229
+ { label: "Move timeline clip", coalesceKey, skipRefresh: true },
225
230
  ).then((result) => {
226
231
  if (!cutoverCommittedOrThrow(result)) return moveFallback();
232
+ return finishMoveGsapSync();
227
233
  });
228
234
  }
229
235
  return moveFallback();
@@ -250,6 +256,7 @@ export function useTimelineEditing({
250
256
  timelineElements,
251
257
  handleDomZIndexReorderCommitRef,
252
258
  showToast,
259
+ invalidateGsapCache,
253
260
  ],
254
261
  );
255
262
 
@@ -287,23 +294,25 @@ export function useTimelineEditing({
287
294
  // script (timing-only resize) — same no-flash path as move; full reload is
288
295
  // the fallback.
289
296
  const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
297
+ const finishResizeGsapSync = () =>
298
+ finishClipTimingFallback({
299
+ iframe: previewIframeRef.current,
300
+ reloadPreview,
301
+ projectId: projectIdRef.current,
302
+ targetPath,
303
+ domId: element.domId,
304
+ label: "Resize timeline clip",
305
+ coalesceKey,
306
+ recordEdit,
307
+ edit: {
308
+ kind: "scale",
309
+ from: { start: element.start, duration: element.duration },
310
+ to: { start: updates.start, duration: updates.duration },
311
+ },
312
+ }).finally(() => invalidateGsapCache?.());
290
313
  const resizeFallback = () =>
291
- enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
292
- finishClipTimingFallback({
293
- iframe: previewIframeRef.current,
294
- reloadPreview,
295
- projectId: projectIdRef.current,
296
- targetPath,
297
- domId: element.domId,
298
- label: "Resize timeline clip",
299
- coalesceKey,
300
- recordEdit,
301
- edit: {
302
- kind: "scale",
303
- from: { start: element.start, duration: element.duration },
304
- to: { start: updates.start, duration: updates.duration },
305
- },
306
- }),
314
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
315
+ finishResizeGsapSync,
307
316
  );
308
317
  const persistDone =
309
318
  sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
@@ -323,9 +332,10 @@ export function useTimelineEditing({
323
332
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
324
333
  publishSession: publishSdkSession,
325
334
  },
326
- { label: "Resize timeline clip", coalesceKey },
335
+ { label: "Resize timeline clip", coalesceKey, skipRefresh: true },
327
336
  ).then((result) => {
328
337
  if (!cutoverCommittedOrThrow(result)) return resizeFallback();
338
+ return finishResizeGsapSync();
329
339
  })
330
340
  : resizeFallback();
331
341
  return persistDone.catch((error) => {
@@ -346,6 +356,7 @@ export function useTimelineEditing({
346
356
  reloadPreview,
347
357
  domEditSaveTimestampRef,
348
358
  showToast,
359
+ invalidateGsapCache,
349
360
  ],
350
361
  );
351
362
 
@@ -46,6 +46,8 @@ export interface UseTimelineEditingOptions {
46
46
  publishSdkSession?: PublishSdkSession;
47
47
  /** Resync the SDK session after a server-authoritative timeline write. */
48
48
  forceReloadSdkSession?: () => void;
49
+ /** Reparse authored animations after a timing rewrite changes their positions. */
50
+ invalidateGsapCache?: () => void;
49
51
  handleDomZIndexReorderCommitRef?: MutableRefObject<TimelineZIndexReorderCommit | null>;
50
52
  }
51
53
 
@@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions {
54
54
  domEditSaveTimestampRef: MutableRefObject<number>;
55
55
  editQueueRef: MutableRefObject<Promise<unknown>>;
56
56
  forceReloadSdkSession?: () => void;
57
+ invalidateGsapCache?: () => void;
57
58
  isRecordingRef?: RefObject<boolean>;
58
59
  pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
59
60
  previewIframeRef: RefObject<HTMLIFrameElement | null>;
@@ -110,6 +111,7 @@ export function useTimelineGroupEditing({
110
111
  domEditSaveTimestampRef,
111
112
  editQueueRef,
112
113
  forceReloadSdkSession,
114
+ invalidateGsapCache,
113
115
  isRecordingRef,
114
116
  pendingTimelineEditPathRef,
115
117
  previewIframeRef,
@@ -212,7 +214,12 @@ export function useTimelineGroupEditing({
212
214
  readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path),
213
215
  publishSession: publishSdkSession,
214
216
  },
215
- { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs },
217
+ {
218
+ label: input.label,
219
+ coalesceKey: input.coalesceKey,
220
+ coalesceMs: input.coalesceMs,
221
+ skipRefresh: true,
222
+ },
216
223
  );
217
224
  return cutoverCommittedOrThrow(result);
218
225
  },
@@ -282,47 +289,55 @@ export function useTimelineGroupEditing({
282
289
  coalesceKey,
283
290
  coalesceMs,
284
291
  });
285
- if (handledBySdk) return;
286
-
287
- await persistServerBatch(
288
- projectId,
289
- "Move timeline clips",
290
- changes.map((change) => ({
291
- element: change.element,
292
- buildPatches: (original, target) =>
293
- buildTimelineMoveTimingPatch(
294
- original,
295
- target,
296
- change.start,
297
- change.element.duration,
298
- change.track,
299
- ),
300
- })),
301
- coalesceKey,
302
- coalesceMs,
303
- );
292
+ if (!handledBySdk) {
293
+ await persistServerBatch(
294
+ projectId,
295
+ "Move timeline clips",
296
+ changes.map((change) => ({
297
+ element: change.element,
298
+ buildPatches: (original, target) =>
299
+ buildTimelineMoveTimingPatch(
300
+ original,
301
+ target,
302
+ change.start,
303
+ change.element.duration,
304
+ change.track,
305
+ ),
306
+ })),
307
+ coalesceKey,
308
+ coalesceMs,
309
+ );
310
+ }
304
311
  // Track-only: no timing delta → no GSAP positions to shift and no
305
312
  // reload (see the trackOnly doc above). Mixed batches (any start
306
313
  // change) keep the full fallback below.
307
314
  if (trackOnly) return;
308
- await finishGroupTimingGsapFallback({
309
- projectId,
310
- iframe: previewIframeRef.current,
311
- reloadPreview,
312
- label: "Move timeline clips",
313
- errorLabel: "Failed to shift GSAP positions",
314
- coalesceKey,
315
- recordEdit,
316
- activeCompPath,
317
- changes,
318
- resolveChangePath: (element) => targetPathFor(element, activeCompPath),
319
- mutateChange: (change, changePath) => {
320
- const delta = change.start - change.element.start;
321
- const domId = change.element.domId;
322
- if (delta === 0 || !domId) return null;
323
- return shiftGsapPositions(projectId, changePath, domId, delta);
324
- },
325
- });
315
+ // The timing persist above already committed to disk, so the cached
316
+ // GSAP read is stale whether or not the position rewrite succeeded —
317
+ // invalidate on the error path too (matches the single-element path's
318
+ // `.finally`), or a failed rewrite leaves the editor reading old tweens.
319
+ try {
320
+ await finishGroupTimingGsapFallback({
321
+ projectId,
322
+ iframe: previewIframeRef.current,
323
+ reloadPreview,
324
+ label: "Move timeline clips",
325
+ errorLabel: "Failed to shift GSAP positions",
326
+ coalesceKey,
327
+ recordEdit,
328
+ activeCompPath,
329
+ changes,
330
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
331
+ mutateChange: (change, changePath) => {
332
+ const delta = change.start - change.element.start;
333
+ const domId = change.element.domId;
334
+ if (delta === 0 || !domId) return null;
335
+ return shiftGsapPositions(projectId, changePath, domId, delta);
336
+ },
337
+ });
338
+ } finally {
339
+ invalidateGsapCache?.();
340
+ }
326
341
  }).catch((error) => {
327
342
  // Failed persist: revert the optimistic duration readout + live root
328
343
  // alongside the gesture owner's store rollback.
@@ -340,6 +355,7 @@ export function useTimelineGroupEditing({
340
355
  reloadPreview,
341
356
  trySdkBatchPersist,
342
357
  showToast,
358
+ invalidateGsapCache,
343
359
  ],
344
360
  );
345
361
 
@@ -384,50 +400,57 @@ export function useTimelineGroupEditing({
384
400
  coalesceKey,
385
401
  coalesceMs,
386
402
  });
387
- if (handledBySdk) return;
388
-
389
- await persistServerBatch(
390
- projectId,
391
- "Resize timeline clips",
392
- changes.map((change) => ({
393
- element: change.element,
394
- buildPatches: (original, target) =>
395
- buildTimelineResizeTimingPatch(original, target, change.element, {
396
- start: change.start,
397
- duration: change.duration,
398
- playbackStart: change.playbackStart,
399
- }),
400
- })),
401
- coalesceKey,
402
- coalesceMs,
403
- );
404
- await finishGroupTimingGsapFallback({
405
- projectId,
406
- iframe: previewIframeRef.current,
407
- reloadPreview,
408
- label: "Resize timeline clips",
409
- errorLabel: "Failed to scale GSAP positions",
410
- coalesceKey,
411
- recordEdit,
412
- activeCompPath,
413
- changes,
414
- resolveChangePath: (element) => targetPathFor(element, activeCompPath),
415
- mutateChange: (change, changePath) => {
416
- const domId = change.element.domId;
417
- const timingChanged =
418
- change.start !== change.element.start || change.duration !== change.element.duration;
419
- if (!timingChanged || !domId) return null;
420
- return scaleGsapPositions(
421
- projectId,
422
- changePath,
423
- domId,
424
- change.element.start,
425
- change.element.duration,
426
- change.start,
427
- change.duration,
428
- );
429
- },
430
- });
403
+ if (!handledBySdk) {
404
+ await persistServerBatch(
405
+ projectId,
406
+ "Resize timeline clips",
407
+ changes.map((change) => ({
408
+ element: change.element,
409
+ buildPatches: (original, target) =>
410
+ buildTimelineResizeTimingPatch(original, target, change.element, {
411
+ start: change.start,
412
+ duration: change.duration,
413
+ playbackStart: change.playbackStart,
414
+ }),
415
+ })),
416
+ coalesceKey,
417
+ coalesceMs,
418
+ );
419
+ }
420
+ // See the move path: the timing persist is already on disk, so the GSAP
421
+ // cache must be invalidated even when the position rewrite throws.
422
+ try {
423
+ await finishGroupTimingGsapFallback({
424
+ projectId,
425
+ iframe: previewIframeRef.current,
426
+ reloadPreview,
427
+ label: "Resize timeline clips",
428
+ errorLabel: "Failed to scale GSAP positions",
429
+ coalesceKey,
430
+ recordEdit,
431
+ activeCompPath,
432
+ changes,
433
+ resolveChangePath: (element) => targetPathFor(element, activeCompPath),
434
+ mutateChange: (change, changePath) => {
435
+ const domId = change.element.domId;
436
+ const timingChanged =
437
+ change.start !== change.element.start ||
438
+ change.duration !== change.element.duration;
439
+ if (!timingChanged || !domId) return null;
440
+ return scaleGsapPositions(
441
+ projectId,
442
+ changePath,
443
+ domId,
444
+ change.element.start,
445
+ change.element.duration,
446
+ change.start,
447
+ change.duration,
448
+ );
449
+ },
450
+ });
451
+ } finally {
452
+ invalidateGsapCache?.();
453
+ }
431
454
  }).catch((error) => {
432
455
  // Failed persist: revert the optimistic duration readout + live root
433
456
  // alongside the gesture owner's store rollback.
@@ -445,6 +468,7 @@ export function useTimelineGroupEditing({
445
468
  reloadPreview,
446
469
  trySdkBatchPersist,
447
470
  showToast,
471
+ invalidateGsapCache,
448
472
  ],
449
473
  );
450
474