@hyperframes/studio 0.8.98 → 0.8.100

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 (63) hide show
  1. package/dist/assets/{hyperframes-player-C6muFtfk.js → hyperframes-player-fWt87gyh.js} +1 -1
  2. package/dist/assets/{index-B6vU39NM.js → index-D34Oy5DN.js} +1 -1
  3. package/dist/assets/{index-itc1XziP.js → index-D6TjRAvz.js} +171 -171
  4. package/dist/assets/{index-BxnoLgxG.js → index-DofdzdxJ.js} +1 -1
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +718 -438
  7. package/dist/index.js.map +1 -1
  8. package/package.json +9 -9
  9. package/src/components/editor/DomEditCropHandles.test.tsx +34 -66
  10. package/src/components/editor/DomEditCropHandles.tsx +30 -62
  11. package/src/components/editor/DomEditOverlay.test.ts +2 -2
  12. package/src/components/editor/PropertyPanel.tsx +3 -3
  13. package/src/components/editor/cropResize.test.ts +129 -0
  14. package/src/components/editor/cropResize.ts +143 -0
  15. package/src/components/editor/domEditOverlayCrop.ts +65 -15
  16. package/src/components/editor/domEditOverlayCropLift.test.ts +44 -0
  17. package/src/components/editor/domEditOverlayGestures.ts +2 -5
  18. package/src/components/editor/domEditOverlayStartGesture.test.ts +83 -0
  19. package/src/components/editor/domEditOverlayStartGesture.ts +6 -4
  20. package/src/components/editor/elementPositionOffset.ts +5 -4
  21. package/src/components/editor/manualEdits.test.ts +22 -8
  22. package/src/components/editor/manualEditsDom.ts +27 -11
  23. package/src/components/editor/manualEditsParsing.ts +0 -4
  24. package/src/components/editor/manualEditsSnapshot.ts +3 -0
  25. package/src/components/editor/manualOffsetDrag.test.ts +84 -0
  26. package/src/components/editor/manualOffsetDrag.ts +50 -40
  27. package/src/components/editor/plainTranslate.test.ts +62 -0
  28. package/src/components/editor/plainTranslate.ts +85 -0
  29. package/src/components/editor/propertyPanelTransformCommit.ts +3 -2
  30. package/src/components/editor/useDomEditNudge.ts +1 -1
  31. package/src/hooks/domSelectionTestHarness.ts +8 -0
  32. package/src/hooks/draggedGsapPosition.ts +4 -2
  33. package/src/hooks/elementOffsetStager.ts +33 -2
  34. package/src/hooks/gsapDragCommit.ts +6 -6
  35. package/src/hooks/gsapEditOutcome.ts +6 -0
  36. package/src/hooks/gsapResizeIntercept.ts +5 -5
  37. package/src/hooks/gsapRuntimeBridge.test.ts +11 -0
  38. package/src/hooks/gsapRuntimeBridge.ts +3 -18
  39. package/src/hooks/gsapRuntimeKeyframes.test.ts +7 -0
  40. package/src/hooks/gsapRuntimeKeyframes.ts +69 -21
  41. package/src/hooks/timelineTimingSync.ts +40 -4
  42. package/src/hooks/useAnimatedPropertyCommit.test.tsx +40 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +11 -13
  44. package/src/hooks/useCropOverlay.ts +8 -2
  45. package/src/hooks/useDomEditCommits.test.tsx +18 -4
  46. package/src/hooks/useDomEditCommits.ts +3 -1
  47. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -2
  48. package/src/hooks/useDomEditSession.ts +2 -0
  49. package/src/hooks/useDomEditTextCommits.test.tsx +62 -5
  50. package/src/hooks/useDomEditTextCommits.ts +35 -35
  51. package/src/hooks/useDomGeometryCommit.test.tsx +62 -4
  52. package/src/hooks/useDomGeometryCommit.ts +1 -0
  53. package/src/hooks/useDomGeometryCommits.test.tsx +116 -2
  54. package/src/hooks/useDomGeometryCommits.ts +5 -0
  55. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +3 -1
  56. package/src/hooks/useGsapAwareEditing.test.tsx +147 -23
  57. package/src/hooks/useGsapAwareEditing.ts +48 -34
  58. package/src/hooks/useTimelineEditing.test.tsx +190 -135
  59. package/src/hooks/useTimelineEditing.ts +13 -13
  60. package/src/hooks/useTimelineGroupEditing.ts +13 -9
  61. package/src/player/components/useTimelineVirtualRows.test.tsx +30 -1
  62. package/src/utils/rounding.ts +5 -0
  63. package/src/utils/sdkEditTransaction.ts +10 -3
@@ -21,6 +21,10 @@ import {
21
21
  resolveTimelineGroupResizeChanges,
22
22
  } from "../player/components/timelineGroupEditing";
23
23
  import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
24
+ import {
25
+ scalePositionsInScript,
26
+ shiftPositionsInScript,
27
+ } from "@hyperframes/core/gsap-writer-acorn";
24
28
 
25
29
  vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
26
30
  const actual =
@@ -134,6 +138,7 @@ function renderTimelineEditingHook(input: {
134
138
  invalidateGsapCache?: () => void;
135
139
  showToast?: (message: string, kind?: string) => void;
136
140
  canEdit?: NonNullable<Parameters<typeof useTimelineEditing>[0]["canEdit"]>;
141
+ activeCompPath?: string;
137
142
  }): {
138
143
  move: ReturnType<typeof useTimelineEditing>["handleTimelineElementMove"];
139
144
  resize: ReturnType<typeof useTimelineEditing>["handleTimelineElementResize"];
@@ -166,7 +171,7 @@ function renderTimelineEditingHook(input: {
166
171
  commitRef.current = input.onZIndexCommit;
167
172
  const hook = useTimelineEditing({
168
173
  projectId: input.projectId ?? null,
169
- activeCompPath: "index.html",
174
+ activeCompPath: input.activeCompPath ?? "index.html",
170
175
  timelineElements: input.timelineElements,
171
176
  showToast: input.showToast ?? vi.fn(),
172
177
  writeProjectFile: input.writeProjectFile ?? vi.fn(),
@@ -372,39 +377,6 @@ function setupSingleClipHarness(options?: {
372
377
  };
373
378
  }
374
379
 
375
- const SDK_KEYFRAMED_SOURCE = [
376
- `<div data-hf-id="hf-stage" data-hf-root data-composition-id="main" data-duration="10">`,
377
- ` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
378
- `</div>`,
379
- `<script>`,
380
- `const tl = gsap.timeline({ paused: true });`,
381
- `tl.to("#clip", { keyframes: [{ x: 0 }, { x: 100 }], duration: 2 }, 1);`,
382
- `window.__timelines = [tl];`,
383
- `</script>`,
384
- ].join("\n");
385
-
386
- async function setupSdkKeyframedClipHarness() {
387
- const iframe = createPreviewIframe([{ id: "clip", track: 0 }]);
388
- const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 });
389
- const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE);
390
- const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
391
- const invalidateGsapCache = vi.fn();
392
- const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE);
393
- usePlayerStore.getState().setDuration(10);
394
- const hook = renderTimelineEditingHook({
395
- timelineElements: [clip],
396
- iframe,
397
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
398
- projectId: "p1",
399
- writeProjectFile,
400
- recordEdit: vi.fn(async () => {}),
401
- sdkSession,
402
- publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
403
- invalidateGsapCache,
404
- });
405
- return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile };
406
- }
407
-
408
380
  /** Assert a lane write landed in both the live iframe DOM and the persisted file. */
409
381
  function expectLanePersisted(
410
382
  iframe: HTMLIFrameElement,
@@ -830,58 +802,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
830
802
  h.unmount();
831
803
  });
832
804
 
833
- it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => {
834
- const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
835
- await setupSdkKeyframedClipHarness();
836
-
837
- await act(async () => {
838
- await hook.move(clip, { start: 2.25, track: clip.track });
839
- });
840
-
841
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"');
842
- const mutationCall = fetchMock.mock.calls.find((call) =>
843
- requestUrl(call[0]).includes("/gsap-mutations/"),
844
- );
845
- expect(mutationCall).toBeDefined();
846
- const init = mutationCall?.[1] as RequestInit | undefined;
847
- expect(JSON.parse(String(init?.body))).toEqual({
848
- type: "shift-positions",
849
- targetSelector: "#clip",
850
- delta: 1.25,
851
- });
852
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
853
-
854
- hook.unmount();
855
- });
856
-
857
- it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => {
858
- const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } =
859
- await setupSdkKeyframedClipHarness();
860
-
861
- await act(async () => {
862
- await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined });
863
- });
864
-
865
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"');
866
- expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"');
867
- const mutationCall = fetchMock.mock.calls.find((call) =>
868
- requestUrl(call[0]).includes("/gsap-mutations/"),
869
- );
870
- expect(mutationCall).toBeDefined();
871
- const init = mutationCall?.[1] as RequestInit | undefined;
872
- expect(JSON.parse(String(init?.body))).toEqual({
873
- type: "scale-positions",
874
- targetSelector: "#clip",
875
- oldStart: 1,
876
- oldDuration: 2,
877
- newStart: 2,
878
- newDuration: 4,
879
- });
880
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
881
-
882
- hook.unmount();
883
- });
884
-
885
805
  it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => {
886
806
  // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a
887
807
  // pure lane change routed through onMoveElement (no onMoveElements wired)
@@ -993,55 +913,6 @@ describe("useTimelineEditing timeline z-index reorder", () => {
993
913
  unmount();
994
914
  });
995
915
 
996
- it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => {
997
- const source = [
998
- `<div data-hf-id="hf-stage" data-hf-root data-duration="10">`,
999
- ` <div id="a" data-hf-id="hf-a" data-start="0" data-duration="1"></div>`,
1000
- ` <div id="b" data-hf-id="hf-b" data-start="1" data-duration="1"></div>`,
1001
- `</div>`,
1002
- `<script>`,
1003
- `const tl = gsap.timeline({ paused: true });`,
1004
- `tl.to("#a", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 0);`,
1005
- `tl.to("#b", { keyframes: [{ x: 0 }, { x: 100 }], duration: 1 }, 1);`,
1006
- `window.__timelines = [tl];`,
1007
- `</script>`,
1008
- ].join("\n");
1009
- const { iframe, a, b } = makeTwoClipPair();
1010
- const sdkSession = await openComposition(source);
1011
- const fetchMock = stubProjectFetch(source);
1012
- const invalidateGsapCache = vi.fn();
1013
- usePlayerStore.getState().setDuration(10);
1014
- const hook = renderTimelineEditingHook({
1015
- timelineElements: [a, b],
1016
- iframe,
1017
- onZIndexCommit: vi.fn().mockResolvedValue(undefined),
1018
- projectId: "p1",
1019
- writeProjectFile: vi.fn<(...args: unknown[]) => Promise<void>>(async () => {}),
1020
- recordEdit: vi.fn(async () => {}),
1021
- sdkSession,
1022
- publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
1023
- invalidateGsapCache,
1024
- });
1025
-
1026
- await act(async () => {
1027
- await hook.groupMove([
1028
- { element: a, start: 1 },
1029
- { element: b, start: 2 },
1030
- ]);
1031
- });
1032
-
1033
- const mutations = fetchMock.mock.calls
1034
- .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
1035
- .map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body)));
1036
- expect(mutations).toEqual([
1037
- { type: "shift-positions", targetSelector: "#a", delta: 1 },
1038
- { type: "shift-positions", targetSelector: "#b", delta: 1 },
1039
- ]);
1040
- expect(invalidateGsapCache).toHaveBeenCalledTimes(1);
1041
-
1042
- hook.unmount();
1043
- });
1044
-
1045
916
  it("partitions a group move by source file while keeping one undo entry", async () => {
1046
917
  const files: Record<string, string> = {
1047
918
  "index.html": '<div id="a" data-start="0" data-duration="1"></div>',
@@ -2353,3 +2224,187 @@ describe("useTimelineEditing: nested rows write composition-local starts", () =>
2353
2224
  hook.unmount();
2354
2225
  });
2355
2226
  });
2227
+
2228
+ describe("clip timing edits sync GSAP exactly once", () => {
2229
+ const SCENE_PATH = "compositions/scene.html";
2230
+ const SCENE_SOURCE = [
2231
+ `<div data-hf-id="hf-stage" data-hf-root data-composition-id="scene" data-duration="10">`,
2232
+ ` <div id="scene" data-hf-id="hf-scene" data-start="1" data-duration="4"><h1 data-hf-id="hf-title">Hi</h1></div>`,
2233
+ ` <div id="side" data-hf-id="hf-side" data-start="0" data-duration="8"></div>`,
2234
+ `</div>`,
2235
+ `<script>`,
2236
+ `const tl = gsap.timeline({ paused: true });`,
2237
+ `tl.to("#scene", { x: 1, duration: 1 }, 1);`,
2238
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 1.5);`,
2239
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2240
+ `window.__timelines = [tl];`,
2241
+ `</script>`,
2242
+ ].join("\n");
2243
+
2244
+ // A project whose files live in memory, with the GSAP route running the real server writer on them.
2245
+ function stubProjectFiles(initial: Record<string, string>) {
2246
+ const files = { ...initial };
2247
+ const pathAfter = (url: string, marker: string) => decodeURIComponent(url.split(marker)[1]!);
2248
+ const applyServerMutation = (path: string, body: Record<string, unknown>) => {
2249
+ const before = files[path]!;
2250
+ const doc = new DOMParser().parseFromString(before, "text/html");
2251
+ const root = doc.querySelector("template")?.content ?? doc;
2252
+ const old = [...root.querySelectorAll("script")]
2253
+ .map((script) => script.textContent ?? "")
2254
+ .find((text) => text.includes("gsap.timeline"))!;
2255
+ const next =
2256
+ body.type === "shift-positions"
2257
+ ? shiftPositionsInScript(old, String(body.targetSelector), Number(body.delta), root)
2258
+ : scalePositionsInScript(
2259
+ old,
2260
+ String(body.targetSelector),
2261
+ Number(body.oldStart),
2262
+ Number(body.oldDuration),
2263
+ Number(body.newStart),
2264
+ Number(body.newDuration),
2265
+ root,
2266
+ );
2267
+ const after = before.replace(old, next);
2268
+ files[path] = after;
2269
+ return {
2270
+ ok: true,
2271
+ mutated: after !== before,
2272
+ changed: after !== before,
2273
+ scriptText: next,
2274
+ before,
2275
+ after,
2276
+ };
2277
+ };
2278
+ const fetchMock = vi.fn(
2279
+ async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
2280
+ const url = requestUrl(input);
2281
+ if (url.includes("/gsap-mutation-capabilities"))
2282
+ return jsonResponse({ atomicOwnershipPairs: true });
2283
+ if (url.includes("/gsap-mutations/")) {
2284
+ return jsonResponse(
2285
+ applyServerMutation(pathAfter(url, "/gsap-mutations/"), JSON.parse(String(init?.body))),
2286
+ );
2287
+ }
2288
+ if (url.includes("/files/"))
2289
+ return jsonResponse({ content: files[pathAfter(url, "/files/")] });
2290
+ throw new Error(`Unexpected fetch: ${url}`);
2291
+ },
2292
+ );
2293
+ vi.stubGlobal("fetch", fetchMock);
2294
+ const writeProjectFile = vi.fn(async (path: string, content: string) => {
2295
+ files[path] = content;
2296
+ });
2297
+ return { files, fetchMock, writeProjectFile };
2298
+ }
2299
+
2300
+ async function setupScene(withSdk: boolean, source = SCENE_SOURCE) {
2301
+ const project = stubProjectFiles({ [SCENE_PATH]: source });
2302
+ const iframe = createPreviewIframe([
2303
+ { id: "scene", track: 0 },
2304
+ { id: "side", track: 1 },
2305
+ ]);
2306
+ const scene = timelineElement({
2307
+ id: "scene",
2308
+ track: 0,
2309
+ zIndex: 0,
2310
+ start: 1,
2311
+ duration: 4,
2312
+ sourceFile: SCENE_PATH,
2313
+ });
2314
+ const side = timelineElement({
2315
+ id: "side",
2316
+ track: 1,
2317
+ zIndex: 0,
2318
+ start: 0,
2319
+ duration: 8,
2320
+ sourceFile: SCENE_PATH,
2321
+ });
2322
+ usePlayerStore.getState().setDuration(10);
2323
+ const hook = renderTimelineEditingHook({
2324
+ timelineElements: [scene, side],
2325
+ iframe,
2326
+ onZIndexCommit: vi.fn().mockResolvedValue(undefined),
2327
+ projectId: "p1",
2328
+ activeCompPath: SCENE_PATH,
2329
+ writeProjectFile: project.writeProjectFile,
2330
+ recordEdit: vi.fn(async () => {}),
2331
+ sdkSession: withSdk ? await openComposition(source) : undefined,
2332
+ publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
2333
+ });
2334
+ const tweens = () =>
2335
+ project.files[SCENE_PATH]!.split("\n").filter((line) => line.startsWith("tl."));
2336
+ return { ...project, hook, scene, side, tweens };
2337
+ }
2338
+
2339
+ it("a move then a stretch through the SDK sync once in a template file with a config script first", async () => {
2340
+ const withConfig = `<template id="scene-template">\n${SCENE_SOURCE.replace(
2341
+ "<script>",
2342
+ "<script>gsap.config({ nullTargetWarn: false });</script>\n<script>",
2343
+ )}\n</template>`;
2344
+ const h = await setupScene(true, withConfig);
2345
+ await act(async () => {
2346
+ await h.hook.move(h.scene, { start: 3, track: h.scene.track });
2347
+ await flushAsyncWork();
2348
+ });
2349
+ const moved = { ...h.scene, start: 3 };
2350
+ await act(async () => {
2351
+ await h.hook.resize(moved, { start: 3, duration: 6, playbackStart: undefined });
2352
+ await flushAsyncWork();
2353
+ });
2354
+ expect(h.tweens()).toEqual([
2355
+ `tl.to("#scene", { x: 1, duration: 1.5 }, 3);`,
2356
+ `tl.from("#scene h1", { y: 20, duration: 1.5 }, 3.75);`,
2357
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2358
+ ]);
2359
+ h.hook.unmount();
2360
+ });
2361
+
2362
+ for (const withSdk of [true, false]) {
2363
+ const via = withSdk ? "through the SDK" : "through the server (no SDK session)";
2364
+
2365
+ it(`a move ${via} lands each tween once`, async () => {
2366
+ const h = await setupScene(withSdk);
2367
+ await act(async () => {
2368
+ await h.hook.move(h.scene, { start: 3, track: h.scene.track });
2369
+ await flushAsyncWork();
2370
+ });
2371
+ expect(h.tweens()).toEqual([
2372
+ `tl.to("#scene", { x: 1, duration: 1 }, 3);`,
2373
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
2374
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2375
+ ]);
2376
+ h.hook.unmount();
2377
+ });
2378
+
2379
+ it(`a resize ${via} scales each tween once`, async () => {
2380
+ const h = await setupScene(withSdk);
2381
+ await act(async () => {
2382
+ await h.hook.resize(h.scene, { start: 1, duration: 8, playbackStart: undefined });
2383
+ await flushAsyncWork();
2384
+ });
2385
+ expect(h.tweens()).toEqual([
2386
+ `tl.to("#scene", { x: 1, duration: 2 }, 1);`,
2387
+ `tl.from("#scene h1", { y: 20, duration: 2 }, 2);`,
2388
+ `tl.to("#side", { x: 5, duration: 1 }, 2);`,
2389
+ ]);
2390
+ h.hook.unmount();
2391
+ });
2392
+
2393
+ it(`a group move ${via} lands each tween once`, async () => {
2394
+ const h = await setupScene(withSdk);
2395
+ await act(async () => {
2396
+ await h.hook.groupMove([
2397
+ { element: h.scene, start: 3 },
2398
+ { element: h.side, start: 1 },
2399
+ ]);
2400
+ await flushAsyncWork();
2401
+ });
2402
+ expect(h.tweens()).toEqual([
2403
+ `tl.to("#scene", { x: 1, duration: 1 }, 3);`,
2404
+ `tl.from("#scene h1", { y: 20, duration: 1 }, 3.5);`,
2405
+ `tl.to("#side", { x: 5, duration: 1 }, 3);`,
2406
+ ]);
2407
+ h.hook.unmount();
2408
+ });
2409
+ }
2410
+ });
@@ -19,6 +19,7 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
19
19
  import {
20
20
  captureDurationRollback,
21
21
  finishClipTimingFallback,
22
+ sdkTimingGsapSync,
22
23
  readFileContent,
23
24
  syncPreviewContentDuration,
24
25
  } from "./timelineTimingSync";
@@ -229,10 +230,8 @@ export function useTimelineEditing({
229
230
  );
230
231
  };
231
232
  const coalesceKey = `timeline-move:${element.hfId ?? element.id}`;
232
- const finishMoveGsapSync = () =>
233
- // Every timing writer converges the same GSAP positions after its
234
- // durable clip-start commit. The SDK owns the attribute write; this
235
- // sync owns only the dependent animation rewrite and preview refresh.
233
+ const finishMoveGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
234
+ // One GSAP sync per edit: the SDK commit's own (sdkGsap), else the server rewrite here.
236
235
  finishClipTimingFallback({
237
236
  iframe: previewIframeRef.current,
238
237
  reloadPreview,
@@ -244,15 +243,15 @@ export function useTimelineEditing({
244
243
  recordEdit,
245
244
  writeProjectFile,
246
245
  edit: { kind: "shift", delta: updates.start - element.start },
246
+ sdkGsap,
247
247
  }).finally(() => invalidateGsapCache?.());
248
248
  const moveFallback = () =>
249
- enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(
250
- finishMoveGsapSync,
249
+ enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() =>
250
+ finishMoveGsapSync(),
251
251
  );
252
252
  return reorderDone
253
253
  .then(() => {
254
- // The SDK setTiming path writes start only — a lane change must take
255
- // the fallback, whose patch builder writes data-track-index too.
254
+ // SDK setTiming writes start only; a lane change needs the fallback's track patch.
256
255
  if (sdkSession && element.hfId && !needsExtension && !trackChanged) {
257
256
  return sdkTimingPersist(
258
257
  element.hfId,
@@ -272,7 +271,7 @@ export function useTimelineEditing({
272
271
  { label: "Move timeline clip", coalesceKey, skipRefresh: true },
273
272
  ).then((result) => {
274
273
  if (!cutoverCommittedOrThrow(result)) return moveFallback();
275
- return finishMoveGsapSync();
274
+ return finishMoveGsapSync(sdkTimingGsapSync(result));
276
275
  });
277
276
  }
278
277
  return moveFallback();
@@ -337,7 +336,7 @@ export function useTimelineEditing({
337
336
  // script (timing-only resize) — same no-flash path as move; full reload is
338
337
  // the fallback.
339
338
  const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`;
340
- const finishResizeGsapSync = () =>
339
+ const finishResizeGsapSync = (sdkGsap?: ReturnType<typeof sdkTimingGsapSync>) =>
341
340
  finishClipTimingFallback({
342
341
  iframe: previewIframeRef.current,
343
342
  reloadPreview,
@@ -353,10 +352,11 @@ export function useTimelineEditing({
353
352
  from: { start: toCompositionTime(element, element.start), duration: element.duration },
354
353
  to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
355
354
  },
355
+ sdkGsap,
356
356
  }).finally(() => invalidateGsapCache?.());
357
357
  const resizeFallback = () =>
358
- enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(
359
- finishResizeGsapSync,
358
+ enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() =>
359
+ finishResizeGsapSync(),
360
360
  );
361
361
  const persistDone =
362
362
  sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension
@@ -378,7 +378,7 @@ export function useTimelineEditing({
378
378
  { label: "Resize timeline clip", coalesceKey, skipRefresh: true },
379
379
  ).then((result) => {
380
380
  if (!cutoverCommittedOrThrow(result)) return resizeFallback();
381
- return finishResizeGsapSync();
381
+ return finishResizeGsapSync(sdkTimingGsapSync(result));
382
382
  })
383
383
  : resizeFallback();
384
384
  return persistDone.catch((error) => {
@@ -24,11 +24,13 @@ import { playbackStartAttributeForElement } from "../player/lib/timelineElementH
24
24
  import {
25
25
  captureDurationRollback,
26
26
  finishGroupTimingGsapFallback,
27
+ sdkTimingGsapSync,
27
28
  readFileContent,
28
29
  scaleGsapPositions,
29
30
  shiftGsapPositions,
30
31
  syncPreviewContentDuration,
31
32
  } from "./timelineTimingSync";
33
+ import type { GsapMutationStatus } from "./gsapMutationClient";
32
34
  import { getStudioSaveErrorMessage } from "../utils/studioSaveDiagnostics";
33
35
 
34
36
  export interface TimelineGroupMoveChange {
@@ -186,8 +188,8 @@ export function useTimelineGroupEditing({
186
188
 
187
189
  // Shared SDK fast path for group move/resize: eligible when nothing needs the
188
190
  // server (no root-duration growth, one shared file, every change SDK-addressable
189
- // and `eligible` per the caller's own gate). Returns whether the SDK handled it;
190
- // false → caller falls through to the server batch persist.
191
+ // and `eligible` per the caller's own gate). Returns the SDK's GSAP sync (null: the
192
+ // server syncs) when it handled the edit; null → caller falls through to the server.
191
193
  const trySdkBatchPersist = useCallback(
192
194
  async (input: {
193
195
  changes: readonly { element: TimelineElement }[];
@@ -200,14 +202,14 @@ export function useTimelineGroupEditing({
200
202
  label: string;
201
203
  coalesceKey: string;
202
204
  coalesceMs?: number;
203
- }): Promise<boolean> => {
205
+ }): Promise<{ sdkGsap: GsapMutationStatus | null } | null> => {
204
206
  const sharedPath = allChangesSharePath(input.changes, activeCompPath);
205
207
  const canUseSdk =
206
208
  !input.needsExtension &&
207
209
  sharedPath !== null &&
208
210
  input.eligible &&
209
211
  input.sdkChanges.every((change) => change !== null);
210
- if (!canUseSdk) return false;
212
+ if (!canUseSdk) return null;
211
213
  const result = await sdkTimingBatchPersist(
212
214
  input.sdkChanges.filter((change): change is NonNullable<typeof change> => change !== null),
213
215
  sharedPath,
@@ -227,7 +229,7 @@ export function useTimelineGroupEditing({
227
229
  skipRefresh: true,
228
230
  },
229
231
  );
230
- return cutoverCommittedOrThrow(result);
232
+ return cutoverCommittedOrThrow(result) ? { sdkGsap: sdkTimingGsapSync(result) } : null;
231
233
  },
232
234
  [
233
235
  activeCompPath,
@@ -289,7 +291,7 @@ export function useTimelineGroupEditing({
289
291
  const label = options?.label ?? "Move timeline clips";
290
292
  return enqueueGroupOperation(label, async (projectId) => {
291
293
  await options?.beforeTiming;
292
- const handledBySdk = await trySdkBatchPersist({
294
+ const sdk = await trySdkBatchPersist({
293
295
  changes,
294
296
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
295
297
  start: toAuthoredStart(change.element, change.start),
@@ -300,7 +302,7 @@ export function useTimelineGroupEditing({
300
302
  coalesceKey,
301
303
  coalesceMs,
302
304
  });
303
- if (!handledBySdk) {
305
+ if (!sdk) {
304
306
  await persistServerBatch(
305
307
  projectId,
306
308
  label,
@@ -339,6 +341,7 @@ export function useTimelineGroupEditing({
339
341
  writeProjectFile,
340
342
  activeCompPath,
341
343
  changes,
344
+ sdkGsap: sdk?.sdkGsap,
342
345
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
343
346
  mutateChange: (change, changePath) => {
344
347
  const delta = change.start - change.element.start;
@@ -408,7 +411,7 @@ export function useTimelineGroupEditing({
408
411
  const coalesceMs = options?.coalesceMs;
409
412
  return enqueueGroupOperation("Resize timeline clips", async (projectId) => {
410
413
  await options?.beforeTiming;
411
- const handledBySdk = await trySdkBatchPersist({
414
+ const sdk = await trySdkBatchPersist({
412
415
  changes,
413
416
  sdkChanges: toSdkTimingChanges(changes, (change) => ({
414
417
  start: toAuthoredStart(change.element, change.start),
@@ -420,7 +423,7 @@ export function useTimelineGroupEditing({
420
423
  coalesceKey,
421
424
  coalesceMs,
422
425
  });
423
- if (!handledBySdk) {
426
+ if (!sdk) {
424
427
  await persistServerBatch(
425
428
  projectId,
426
429
  "Resize timeline clips",
@@ -451,6 +454,7 @@ export function useTimelineGroupEditing({
451
454
  writeProjectFile,
452
455
  activeCompPath,
453
456
  changes,
457
+ sdkGsap: sdk?.sdkGsap,
454
458
  resolveChangePath: (element) => targetPathFor(element, activeCompPath),
455
459
  mutateChange: (change, changePath) => {
456
460
  const domId = change.element.domId;
@@ -2,7 +2,13 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
5
+ import {
6
+ elementScroll,
7
+ observeElementOffset,
8
+ observeElementRect,
9
+ Virtualizer,
10
+ } from "@tanstack/react-virtual";
11
+ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
6
12
  import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
7
13
  import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
8
14
  import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
@@ -203,4 +209,27 @@ describe("useTimelineVirtualRows", () => {
203
209
  expect(rows.some((row) => row.index === 500)).toBe(false);
204
210
  act(() => root.unmount());
205
211
  });
212
+
213
+ // A scroll-end timer left running fires into the torn-down test window ("window is not defined").
214
+ it("gets no virtualizer change after unmount from a scroll just before it", async () => {
215
+ const scroll = createScrollElement(0);
216
+ const onChange = vi.fn();
217
+ const virtualizer = new Virtualizer<HTMLDivElement, Element>({
218
+ count: 100,
219
+ getScrollElement: () => scroll,
220
+ estimateSize: () => 48,
221
+ scrollToFn: elementScroll,
222
+ observeElementRect,
223
+ observeElementOffset,
224
+ onChange,
225
+ });
226
+ const unmount = virtualizer._didMount();
227
+ virtualizer._willUpdate();
228
+ scroll.dispatchEvent(new Event("scroll"));
229
+ unmount();
230
+ onChange.mockClear();
231
+
232
+ await new Promise((settle) => setTimeout(settle, 200));
233
+ expect(onChange).not.toHaveBeenCalled();
234
+ });
206
235
  });
@@ -3,6 +3,11 @@ export function roundTo3(val: number): number {
3
3
  return Math.round(val * 1000) / 1000;
4
4
  }
5
5
 
6
+ /** Whole CSS px: GSAP parses a -50% translate against the integer offsetWidth/Height. */
7
+ export function roundToLayoutPx(val: number): number {
8
+ return Math.round(val);
9
+ }
10
+
6
11
  /** Round to 2 decimal places (centisecond precision for timeline values). */
7
12
  export function roundToCenti(val: number): number {
8
13
  return Math.round(val * 100) / 100;
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  export type CutoverResult =
13
13
  | { status: "declined"; reason: string }
14
- | { status: "committed"; version: string }
14
+ | { status: "committed"; version: string; before: string; after: string }
15
15
  | { status: "failed"; error: Error };
16
16
 
17
17
  export interface SdkSessionPublication {
@@ -143,7 +143,9 @@ export function failedCutover(
143
143
  }
144
144
 
145
145
  /** Only an explicit decline may enter the legacy mutation backend. */
146
- export function cutoverCommittedOrThrow(result: CutoverResult): boolean {
146
+ export function cutoverCommittedOrThrow(
147
+ result: CutoverResult,
148
+ ): result is Extract<CutoverResult, { status: "committed" }> {
147
149
  if (result.status === "failed") throw result.error;
148
150
  return result.status === "committed";
149
151
  }
@@ -300,7 +302,12 @@ async function commitCandidateEdit(
300
302
  });
301
303
  }
302
304
  if (refreshTarget) refreshCommittedEdit(edit.after, deps, options);
303
- return { status: "committed", version: hashContent(edit.after) };
305
+ return {
306
+ status: "committed",
307
+ version: hashContent(edit.after),
308
+ before: originalContent,
309
+ after: edit.after,
310
+ };
304
311
  }
305
312
 
306
313
  export async function persistSdkCandidateMutation(