@hyperframes/studio 0.8.127 → 0.8.129

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 (119) hide show
  1. package/dist/assets/{hyperframes-player-uqzDaxVL.js → hyperframes-player-DfEcBl9r.js} +1 -1
  2. package/dist/assets/{index-DXSYlqmV.js → index-9ZaZFCMZ.js} +1 -1
  3. package/dist/assets/{index-CLBNYlDY.js → index-Bmy-KVib.js} +1 -1
  4. package/dist/assets/{index-COhbL1xq.js → index-C9YkEyN4.js} +189 -189
  5. package/dist/{chunk-3F47EFCT.js → chunk-7UQWVN52.js} +17 -10
  6. package/dist/chunk-7UQWVN52.js.map +1 -0
  7. package/dist/{chunk-246VB2G3.js → chunk-DRVWEPO5.js} +35 -7
  8. package/dist/chunk-DRVWEPO5.js.map +1 -0
  9. package/dist/{chunk-5EKWJZLM.js → chunk-TDWGXBUT.js} +1 -1
  10. package/dist/chunk-TDWGXBUT.js.map +1 -0
  11. package/dist/{domEditingLayers-OL7GLSS4.js → domEditingLayers-IEVLO54Z.js} +3 -3
  12. package/dist/index.d.ts +8 -9
  13. package/dist/index.html +1 -1
  14. package/dist/index.js +1396 -917
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/package.json +8 -8
  18. package/src/components/TimelineToolbar.tsx +6 -7
  19. package/src/components/editor/commitPreflightHandles.test.tsx +32 -0
  20. package/src/components/editor/domEditingLayers.test.ts +45 -18
  21. package/src/components/editor/domEditingLayers.ts +11 -9
  22. package/src/components/editor/domEditingTypes.ts +1 -0
  23. package/src/components/editor/keyframeRetime.ts +11 -4
  24. package/src/components/editor/probeSourceElement.ts +17 -1
  25. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +4 -14
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +0 -7
  27. package/src/components/nle/useTimelineEditCallbacks.test.tsx +27 -42
  28. package/src/components/nle/useTimelineEditCallbacks.ts +3 -2
  29. package/src/hooks/gsapDragCommit.ts +8 -5
  30. package/src/hooks/gsapDragPositionCommit.ts +90 -17
  31. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +50 -0
  32. package/src/hooks/gsapKeyframeCacheHelpers.ts +21 -2
  33. package/src/hooks/gsapMutationClient.ts +35 -7
  34. package/src/hooks/gsapOvershootEase.test.ts +67 -0
  35. package/src/hooks/gsapParsedTween.ts +5 -6
  36. package/src/hooks/gsapPositionDetection.ts +5 -2
  37. package/src/hooks/gsapRuntimeBridge.test.ts +35 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +80 -6
  39. package/src/hooks/gsapRuntimeKeyframes.ts +19 -59
  40. package/src/hooks/gsapRuntimeMotionPath.ts +47 -0
  41. package/src/hooks/gsapRuntimeTweenIndex.ts +53 -0
  42. package/src/hooks/gsapShared.test.ts +48 -35
  43. package/src/hooks/gsapShared.ts +30 -32
  44. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +93 -3
  45. package/src/hooks/gsapValueAtPlayhead.test.ts +13 -0
  46. package/src/hooks/gsapValueAtPlayhead.ts +3 -4
  47. package/src/hooks/timelineEditingHelpers.test.ts +21 -0
  48. package/src/hooks/timelineEditingHelpers.ts +40 -14
  49. package/src/hooks/timelineLinkEditPlan.ts +6 -4
  50. package/src/hooks/timelineMoveAdapter.ts +3 -5
  51. package/src/hooks/timelineTimingSync.test.ts +83 -125
  52. package/src/hooks/timelineTimingSync.ts +82 -63
  53. package/src/hooks/useAnimatedPropertyCommit.ts +6 -6
  54. package/src/hooks/useBlockedTimelineEditToast.ts +3 -12
  55. package/src/hooks/useCommitPreflightCapabilities.ts +42 -7
  56. package/src/hooks/useDomEditPreviewSync.ts +8 -2
  57. package/src/hooks/useDomSelection.test.ts +27 -0
  58. package/src/hooks/useDomSelection.ts +12 -13
  59. package/src/hooks/useDomSelectionTypes.ts +1 -0
  60. package/src/hooks/useEditHistoryActions.test.tsx +54 -3
  61. package/src/hooks/useEditHistoryActions.ts +9 -6
  62. package/src/hooks/useEnableKeyframes.test.ts +20 -15
  63. package/src/hooks/useEnableKeyframes.ts +54 -71
  64. package/src/hooks/useGestureCommit.ts +2 -1
  65. package/src/hooks/useGsapTweenCache.ts +2 -17
  66. package/src/hooks/usePersistentEditHistory.test.ts +45 -0
  67. package/src/hooks/usePersistentEditHistory.ts +13 -2
  68. package/src/hooks/usePreviewPersistence.history.test.tsx +83 -0
  69. package/src/hooks/usePreviewPersistence.ts +26 -4
  70. package/src/hooks/useTimelineClipTimingEditing.ts +343 -0
  71. package/src/hooks/useTimelineDeleteOps.test.tsx +20 -1
  72. package/src/hooks/useTimelineDeleteOps.ts +5 -13
  73. package/src/hooks/useTimelineEditing.test.tsx +63 -4
  74. package/src/hooks/useTimelineEditing.ts +21 -295
  75. package/src/hooks/useTimelineGroupEditing.test.tsx +91 -0
  76. package/src/hooks/useTimelineGroupEditing.ts +15 -38
  77. package/src/player/components/Timeline.test.ts +12 -4
  78. package/src/player/components/TimelineClip.tsx +3 -0
  79. package/src/player/components/TimelineCompactDiamonds.tsx +4 -0
  80. package/src/player/components/TimelineLanes.test.tsx +61 -5
  81. package/src/player/components/TimelineLanes.tsx +20 -28
  82. package/src/player/components/TimelinePropertyLanes.tsx +10 -1
  83. package/src/player/components/timelineCallbacks.ts +1 -4
  84. package/src/player/components/timelineClipDragCommit.test.ts +31 -0
  85. package/src/player/components/timelineClipDragCommit.ts +39 -28
  86. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  87. package/src/player/components/timelineClipGestureHandlers.test.ts +6 -34
  88. package/src/player/components/timelineClipGestureHandlers.ts +2 -20
  89. package/src/player/components/timelineEditing.ts +1 -3
  90. package/src/player/components/timelineGroupResizeCommit.ts +10 -9
  91. package/src/player/components/timelineLinkSelection.ts +1 -35
  92. package/src/player/components/timelineOptimisticRevision.ts +7 -4
  93. package/src/player/components/trackHeaderLaneState.ts +9 -2
  94. package/src/player/components/trackHeaderLaneValues.test.ts +89 -0
  95. package/src/player/components/trackHeaderLaneValues.ts +54 -13
  96. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +4 -6
  97. package/src/player/components/useTimelineClipDrag.resize.test.tsx +4 -7
  98. package/src/player/components/useTimelineClipDrag.ts +0 -3
  99. package/src/player/components/useTimelineKeyframeHandlers.ts +17 -1
  100. package/src/player/store/batchElementUpdates.test.ts +43 -0
  101. package/src/player/store/batchElementUpdates.ts +9 -0
  102. package/src/player/store/elementPatchQueue.ts +35 -0
  103. package/src/player/store/playerStore.ts +5 -6
  104. package/src/utils/domEditHelpers.ts +5 -0
  105. package/src/utils/globalTimeCompiler.test.ts +0 -4
  106. package/src/utils/globalTimeCompiler.ts +6 -4
  107. package/src/utils/gsapKeyframeEases.ts +79 -0
  108. package/src/utils/gsapLiveRetime.realGsap.test.ts +544 -0
  109. package/src/utils/gsapLiveRetime.ts +286 -0
  110. package/src/utils/gsapSoftReload.ts +16 -7
  111. package/src/utils/gsapUndoRestore.test.ts +43 -0
  112. package/src/utils/gsapUndoRestore.ts +6 -3
  113. package/src/utils/sameData.ts +9 -0
  114. package/src/utils/studioFileHistory.ts +1 -0
  115. package/src/utils/studioPendingEdits.ts +15 -1
  116. package/dist/chunk-246VB2G3.js.map +0 -1
  117. package/dist/chunk-3F47EFCT.js.map +0 -1
  118. package/dist/chunk-5EKWJZLM.js.map +0 -1
  119. /package/dist/{domEditingLayers-OL7GLSS4.js.map → domEditingLayers-IEVLO54Z.js.map} +0 -0
@@ -568,6 +568,19 @@ describe("planValueAtPlayhead", () => {
568
568
  });
569
569
  });
570
570
 
571
+ it("refuses an inner node of a motionPath tween, which GSAP runs power1.out", () => {
572
+ const path = kf([
573
+ { percentage: 0, properties: { x: 0 } },
574
+ { percentage: 50, properties: { x: 100 } },
575
+ { percentage: 100, properties: { x: 300 } },
576
+ ]);
577
+ const anim = { ...path, keyframes: { ...path.keyframes!, fromMotionPath: true as const } };
578
+ expect(plan({ anim, at: { time: 2 }, values: { x: 5 } })).toEqual({
579
+ ok: false,
580
+ reason: "eased-keyframes",
581
+ });
582
+ });
583
+
571
584
  it("changes an inner keyframe picked by its percentage, which the ease does not move", () => {
572
585
  const anim = {
573
586
  ...kf([
@@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
4
  import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
5
+ import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
5
6
  import { roundTo3 } from "../utils/rounding";
6
7
  import {
7
8
  materializeIfDynamic,
@@ -66,10 +67,8 @@ export type PlayheadEditPlan =
66
67
  }
67
68
  | { ok: false; reason: PlayheadEditRefusal };
68
69
 
69
- // GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear.
70
- const PERCENTAGE_SEGMENT_EASE = "power1.inOut";
71
- const ARRAY_STEP_EASE = "none";
72
70
  const roundPct = (pct: number) => Math.round(pct * 1000) / 1000;
71
+
73
72
  const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear";
74
73
 
75
74
  interface Normalized {
@@ -92,7 +91,7 @@ function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefus
92
91
  const blocked = stepListBlock(anim);
93
92
  if (blocked) return { reason: blocked };
94
93
  return {
95
- runEase: data.ease ?? anim.ease,
94
+ runEase: runEaseOf(anim),
96
95
  keyframes: data.keyframes.map((kf) => ({
97
96
  ...kf,
98
97
  properties: { ...kf.properties },
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import {
4
+ applyTimelineMoveAttributes,
4
5
  applyTimelineStackingReorder,
5
6
  buildTimelineMoveTimingPatch,
6
7
  buildTimelineResizeTimingPatch,
@@ -316,6 +317,26 @@ describe("persistTimelineBatchEdit", () => {
316
317
  vi.unstubAllGlobals();
317
318
  });
318
319
 
320
+ it("syncs each file's root duration once, landing where per-member sync would", async () => {
321
+ const source = `<div id="root" data-composition-id="main" data-duration="4"><video id="a" class="clip" data-start="1" data-duration="1"></video><video id="b" class="clip" data-start="2" data-duration="1"></video><video id="c" class="clip" data-start="3" data-duration="1"></video></div>`;
322
+ const members = ["a", "b", "c"].map((id, i) => ({
323
+ element: el({ id, tag: "video", domId: id, start: i + 1, duration: 1 }),
324
+ buildPatches: (original: string, target: Parameters<typeof applyTimelineMoveAttributes>[1]) =>
325
+ applyTimelineMoveAttributes(original, target, i + 6, 1),
326
+ }));
327
+ stubReadFileContent(source);
328
+ const writes: Array<[string, string]> = [];
329
+ await persistTimelineBatchEdit(batchInput(members, writes));
330
+
331
+ const perMember = members.reduce(
332
+ (current, { element }, i) =>
333
+ buildTimelineMoveTimingPatch(current, { id: element.id }, i + 6, 1),
334
+ source,
335
+ );
336
+ expect(writes).toEqual([["index.html", perMember]]);
337
+ expect(perMember).toContain('data-duration="9"');
338
+ });
339
+
319
340
  it("skips no-op members instead of aborting the batch (track-insert renumber)", async () => {
320
341
  // A track-insert renumber can include a member whose attributes already
321
342
  // hold the target values — its patch is string-identical. The batch must
@@ -225,7 +225,7 @@ function resolveResizePlaybackStart(
225
225
  };
226
226
  }
227
227
 
228
- export function buildTimelineMoveTimingPatch(
228
+ export function applyTimelineMoveAttributes(
229
229
  original: string,
230
230
  target: PatchTarget,
231
231
  start: number,
@@ -235,7 +235,7 @@ export function buildTimelineMoveTimingPatch(
235
235
  ): string {
236
236
  if (!Number.isFinite(start) || !Number.isFinite(duration)) {
237
237
  console.warn(
238
- `[Timeline] buildTimelineMoveTimingPatch: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
238
+ `[Timeline] applyTimelineMoveAttributes: non-finite timing (start=${start}, duration=${duration}) — patch skipped`,
239
239
  );
240
240
  return original;
241
241
  }
@@ -257,15 +257,31 @@ export function buildTimelineMoveTimingPatch(
257
257
  property: "data-audio-group",
258
258
  value: null,
259
259
  });
260
- // Content-driven duration: sync data-duration to the furthest clip end read
261
- // from the PATCHED SOURCE (raw data-duration), so it grows if a clip moved
262
- // past the end and shrinks if the furthest clip moved left. Measured from the
263
- // source, NOT the store — store durations are runtime-truncated to the current
264
- // comp length, which would ratchet the duration down every move.
265
- return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
260
+ return patched;
266
261
  }
267
262
 
268
- export function buildTimelineResizeTimingPatch(
263
+ // Root data-duration follows the furthest clip end in the source; the store's durations are
264
+ // truncated to the current length, so reading them would ratchet the duration down every edit.
265
+ export function syncCompositionDurationToContent(source: string): string {
266
+ return setCompositionDurationToContent(source, furthestClipEndFromSource(source));
267
+ }
268
+
269
+ export function buildTimelineMoveTimingPatch(
270
+ original: string,
271
+ target: PatchTarget,
272
+ start: number,
273
+ duration: number,
274
+ track?: number,
275
+ audioGroup?: null,
276
+ ): string {
277
+ const patched = applyTimelineMoveAttributes(original, target, start, duration, track, audioGroup);
278
+ // A non-finite timing skips the patch above; skip the duration sync with it.
279
+ return !(Number.isFinite(start) && Number.isFinite(duration))
280
+ ? patched
281
+ : syncCompositionDurationToContent(patched);
282
+ }
283
+
284
+ export function applyTimelineResizeAttributes(
269
285
  original: string,
270
286
  target: PatchTarget,
271
287
  element: TimelineElement,
@@ -289,10 +305,13 @@ export function buildTimelineResizeTimingPatch(
289
305
  value: formatTimelineMediaOffset(pbs.value),
290
306
  });
291
307
  }
292
- // Content-driven duration from the PATCHED SOURCE (raw data-duration) —
293
- // grows/shrinks to the furthest clip end. Not from the store, whose
294
- // durations are runtime-truncated.
295
- return setCompositionDurationToContent(patched, furthestClipEndFromSource(patched));
308
+ return patched;
309
+ }
310
+
311
+ export function buildTimelineResizeTimingPatch(
312
+ ...args: Parameters<typeof applyTimelineResizeAttributes>
313
+ ): string {
314
+ return syncCompositionDurationToContent(applyTimelineResizeAttributes(...args));
296
315
  }
297
316
 
298
317
  export interface PersistTimelineEditInput {
@@ -305,6 +324,7 @@ export interface PersistTimelineEditInput {
305
324
  recordEdit: (input: RecordEditInput) => Promise<void>;
306
325
  pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
307
326
  coalesceKey?: string;
327
+ coalesceMs?: number;
308
328
  }
309
329
 
310
330
  export async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
@@ -319,6 +339,7 @@ export async function persistTimelineEdit(input: PersistTimelineEditInput): Prom
319
339
  projectId: input.projectId,
320
340
  label: input.label,
321
341
  coalesceKey: input.coalesceKey,
342
+ coalesceMs: input.coalesceMs,
322
343
  files: {
323
344
  [targetPath]: (current) => {
324
345
  const patched = input.buildPatches(current, patchTarget);
@@ -391,7 +412,12 @@ export async function persistTimelineBatchEdit(
391
412
  changesByPath.set(targetPath, [...(changesByPath.get(targetPath) ?? []), change]);
392
413
  }
393
414
  const buildFile = (targetPath: string) => (original: string) => {
394
- const next = patchTimelineChangesInSource(original, targetPath, changesByPath.get(targetPath)!);
415
+ const patched = patchTimelineChangesInSource(
416
+ original,
417
+ targetPath,
418
+ changesByPath.get(targetPath)!,
419
+ );
420
+ const next = syncCompositionDurationToContent(patched);
395
421
  if (next !== original) input.pendingTimelineEditPathRef.current.add(targetPath);
396
422
  return next;
397
423
  };
@@ -1,12 +1,14 @@
1
1
  import type { TimelineElement } from "../player";
2
2
  import type { TimelineLinkEdit } from "../player/components/timelineCallbacks";
3
- import { buildPatchTarget, type PatchTarget } from "./timelineEditingHelpers";
3
+ import {
4
+ buildPatchTarget,
5
+ syncCompositionDurationToContent,
6
+ type PatchTarget,
7
+ } from "./timelineEditingHelpers";
4
8
  import { sharesLinkGroup } from "../player/components/audioClipLink";
5
9
  import { toAuthoredStart } from "../player/store/timelineElement";
6
10
  import { formatTimelineMediaOffset } from "../player/components/timelineEditing";
7
11
  import { applyPatchByTarget } from "../utils/sourcePatcher";
8
- import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
9
- import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
10
12
  import {
11
13
  detachAudioInSource,
12
14
  linkInSource,
@@ -64,7 +66,7 @@ function planSyncEdit(edit: SyncEdit): LinkEditPlan | null {
64
66
  property: "start",
65
67
  value,
66
68
  });
67
- return setCompositionDurationToContent(moved, furthestClipEndFromSource(moved));
69
+ return syncCompositionDurationToContent(moved);
68
70
  },
69
71
  };
70
72
  }
@@ -27,7 +27,6 @@ export type TimelineMoveEditsHandler = (
27
27
  edits: TimelineAtomicMoveEdit[],
28
28
  coalesceKey?: string,
29
29
  operation?: TimelineMoveOperation,
30
- coalesceMs?: number,
31
30
  ) => Promise<void>;
32
31
 
33
32
  export function persistTimelineMoveEditsAtomically(
@@ -35,7 +34,6 @@ export function persistTimelineMoveEditsAtomically(
35
34
  coalesceKey: string | undefined,
36
35
  operation: TimelineMoveOperation,
37
36
  deps: AtomicMoveDeps,
38
- coalesceMs?: number,
39
37
  ): Promise<void> {
40
38
  return deps.handleTimelineGroupMove(
41
39
  edits.map(({ element, updates }) => ({
@@ -48,7 +46,7 @@ export function persistTimelineMoveEditsAtomically(
48
46
  // ("timing") omit it so they stay eligible for the SDK fast path.
49
47
  track: operation === "timing" ? undefined : updates.track,
50
48
  })),
51
- { coalesceKey, coalesceMs },
49
+ { coalesceKey },
52
50
  );
53
51
  }
54
52
 
@@ -56,9 +54,9 @@ export function useTimelineMoveEditsHandler(
56
54
  handleTimelineGroupMove: AtomicMoveDeps["handleTimelineGroupMove"],
57
55
  ): TimelineMoveEditsHandler {
58
56
  return useCallback(
59
- async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => {
57
+ async (edits, coalesceKey, operation: TimelineMoveOperation = "timing") => {
60
58
  const deps = { handleTimelineGroupMove };
61
- await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs);
59
+ await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps);
62
60
  },
63
61
  [handleTimelineGroupMove],
64
62
  );
@@ -7,6 +7,8 @@ import {
7
7
  captureDurationRollback,
8
8
  finishClipTimingFallback,
9
9
  finishGroupTimingGsapFallback,
10
+ finishTimelineTimingFallback,
11
+ foldGsapMutationIntoHistory,
10
12
  readFileContent,
11
13
  shiftGsapPositions,
12
14
  } from "./timelineTimingSync";
@@ -509,7 +511,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
509
511
  changes: [{ element }, { element }],
510
512
  resolveChangePath: () => "index.html",
511
513
  // No domId → nothing to rewrite for ANY change (the gap-close blink path).
512
- mutateChange: () => null,
514
+ mutationFor: () => null,
513
515
  });
514
516
 
515
517
  expect(reloadPreview).not.toHaveBeenCalled();
@@ -539,7 +541,7 @@ describe("nothing-to-rewrite timing edits rebind in place (no script re-executio
539
541
  { element: { sourceFile: "scenes/intro.html" } as TimelineElement },
540
542
  ],
541
543
  resolveChangePath: (el) => el.sourceFile ?? "index.html",
542
- mutateChange: () => null,
544
+ mutationFor: () => null,
543
545
  });
544
546
 
545
547
  expect(reloadPreview).toHaveBeenCalledTimes(1);
@@ -658,32 +660,53 @@ function ownedMutation(contents: Map<string, string>, path: string, before: stri
658
660
  return { mutated: true, scriptText: null, before, after };
659
661
  }
660
662
 
661
- describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
662
- const element = { sourceFile: "index.html" } as TimelineElement;
663
+ type OwnedStep = () => Promise<ReturnType<typeof ownedMutation>>;
663
664
 
665
+ /** Runs `steps` as one owned same-file transaction, wired the way a group edit wires it. */
666
+ function runOwnedChain(
667
+ steps: readonly OwnedStep[],
668
+ options: {
669
+ reloadPreview?: () => void;
670
+ recordEdit?: (edit: never) => Promise<void>;
671
+ } = {},
672
+ ) {
673
+ const onGsapError = (err: unknown) =>
674
+ console.error("[Timeline] Failed to shift GSAP positions", err);
675
+ return finishTimelineTimingFallback({
676
+ iframe: buildLivePreviewIframe().iframe,
677
+ projectId: "p1",
678
+ reloadPreview: options.reloadPreview ?? vi.fn(),
679
+ gsapMutation: () =>
680
+ foldGsapMutationIntoHistory({
681
+ writeFile: writeProjectFile,
682
+ paths: ["index.html"],
683
+ projectId: "p1",
684
+ label: "Move timeline clips",
685
+ recordEdit: (options.recordEdit ?? vi.fn(async () => {})) as never,
686
+ gsapMutation: async (runOwnedMutation) => {
687
+ let status = { mutated: false, scriptText: null } as Awaited<ReturnType<OwnedStep>>;
688
+ for (const step of steps) status = await runOwnedMutation("index.html", step);
689
+ return status;
690
+ },
691
+ onRollbackError: onGsapError,
692
+ }),
693
+ onGsapError,
694
+ rebindWhenUnmutated: true,
695
+ });
696
+ }
697
+
698
+ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
664
699
  it("reverse-rolls back every successful same-file step after a late failure", async () => {
665
700
  const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
666
701
  const server = installOwnedFileServer(contents);
667
- let clipIndex = 0;
668
702
 
669
- await finishGroupTimingGsapFallback({
670
- writeProjectFile,
671
- projectId: "p1",
672
- iframe: buildLivePreviewIframe().iframe,
673
- reloadPreview: vi.fn(),
674
- label: "Move timeline clips",
675
- errorLabel: "Failed to shift GSAP positions",
676
- recordEdit: vi.fn(async () => {}) as never,
677
- activeCompPath: "index.html",
678
- changes: [{ element }, { element }, { element }],
679
- resolveChangePath: () => "index.html",
680
- mutateChange: async () => {
681
- clipIndex += 1;
682
- if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
683
- if (clipIndex === 2) return ownedMutation(contents, "index.html", "STEP-1", "STEP-2");
703
+ await runOwnedChain([
704
+ async () => ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1"),
705
+ async () => ownedMutation(contents, "index.html", "STEP-1", "STEP-2"),
706
+ async () => {
684
707
  throw new Error("late failure");
685
708
  },
686
- });
709
+ ]);
687
710
 
688
711
  expect(server.rollbacks).toEqual([
689
712
  { path: "index.html", expected: "STEP-2", restore: "STEP-1", conflict: false },
@@ -695,27 +718,13 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
695
718
  it("uses the endpoint's atomic before when a foreign write precedes mutation", async () => {
696
719
  const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
697
720
  const server = installOwnedFileServer(contents);
698
- let clipIndex = 0;
699
721
 
700
- await finishGroupTimingGsapFallback({
701
- writeProjectFile,
702
- projectId: "p1",
703
- iframe: buildLivePreviewIframe().iframe,
704
- reloadPreview: vi.fn(),
705
- label: "Move timeline clips",
706
- errorLabel: "Failed to shift GSAP positions",
707
- recordEdit: vi.fn(async () => {}) as never,
708
- activeCompPath: "index.html",
709
- changes: [{ element }, { element }],
710
- resolveChangePath: () => "index.html",
711
- mutateChange: async () => {
712
- clipIndex += 1;
713
- if (clipIndex === 1) {
714
- return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED");
715
- }
722
+ await runOwnedChain([
723
+ async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED"),
724
+ async () => {
716
725
  throw new Error("late failure");
717
726
  },
718
- });
727
+ ]);
719
728
 
720
729
  expect(server.readCount()).toBe(0);
721
730
  expect(server.rollbacks[0]).toEqual({
@@ -732,26 +741,17 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
732
741
  const server = installOwnedFileServer(contents, {
733
742
  beforeRollback: () => contents.set("index.html", "SUCCESSOR"),
734
743
  });
735
- let clipIndex = 0;
736
-
737
744
  const reloadPreview = vi.fn();
738
- await finishGroupTimingGsapFallback({
739
- writeProjectFile,
740
- projectId: "p1",
741
- iframe: buildLivePreviewIframe().iframe,
742
- reloadPreview,
743
- label: "Move timeline clips",
744
- errorLabel: "Failed to shift GSAP positions",
745
- recordEdit: vi.fn(async () => {}) as never,
746
- activeCompPath: "index.html",
747
- changes: [{ element }, { element }],
748
- resolveChangePath: () => "index.html",
749
- mutateChange: async () => {
750
- clipIndex += 1;
751
- if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
752
- throw new Error("late failure");
753
- },
754
- });
745
+
746
+ await runOwnedChain(
747
+ [
748
+ async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
749
+ async () => {
750
+ throw new Error("late failure");
751
+ },
752
+ ],
753
+ { reloadPreview },
754
+ );
755
755
 
756
756
  expect(server.rollbacks[0]?.conflict).toBe(true);
757
757
  expect(contents.get("index.html")).toBe("SUCCESSOR");
@@ -763,25 +763,16 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
763
763
  installOwnedFileServer(contents, { failRollback: true });
764
764
  const reloadPreview = vi.fn();
765
765
  const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
766
- let clipIndex = 0;
767
766
 
768
- await finishGroupTimingGsapFallback({
769
- writeProjectFile,
770
- projectId: "p1",
771
- iframe: buildLivePreviewIframe().iframe,
772
- reloadPreview,
773
- label: "Move timeline clips",
774
- errorLabel: "Failed to shift GSAP positions",
775
- recordEdit: vi.fn(async () => {}) as never,
776
- activeCompPath: "index.html",
777
- changes: [{ element }, { element }],
778
- resolveChangePath: () => "index.html",
779
- mutateChange: async () => {
780
- clipIndex += 1;
781
- if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
782
- throw new Error("late failure");
783
- },
784
- });
767
+ await runOwnedChain(
768
+ [
769
+ async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
770
+ async () => {
771
+ throw new Error("late failure");
772
+ },
773
+ ],
774
+ { reloadPreview },
775
+ );
785
776
 
786
777
  expect(contents.get("index.html")).toBe("OWNED");
787
778
  expect(reloadPreview).toHaveBeenCalledTimes(1);
@@ -796,23 +787,13 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
796
787
  const server = installOwnedFileServer(contents);
797
788
  const recordEdit = vi.fn(async () => {});
798
789
 
799
- await finishGroupTimingGsapFallback({
800
- writeProjectFile,
801
- projectId: "p1",
802
- iframe: buildLivePreviewIframe().iframe,
803
- reloadPreview: vi.fn(),
804
- label: "Move timeline clips",
805
- errorLabel: "Failed to shift GSAP positions",
806
- recordEdit: recordEdit as never,
807
- activeCompPath: "index.html",
808
- changes: [{ element }, { element }],
809
- resolveChangePath: () => "index.html",
810
- mutateChange: async (_change, _path) => {
811
- const before = contents.get("index.html")!;
812
- const after = before === "FOREIGN" ? "FOREIGN+ONE" : "FOREIGN+ONE+TWO";
813
- return ownedMutation(contents, "index.html", before, after);
814
- },
815
- });
790
+ await runOwnedChain(
791
+ [
792
+ async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+ONE"),
793
+ async () => ownedMutation(contents, "index.html", "FOREIGN+ONE", "FOREIGN+ONE+TWO"),
794
+ ],
795
+ { recordEdit },
796
+ );
816
797
 
817
798
  expect(server.readCount()).toBe(1); // final ownership verification only
818
799
  expect(recordEdit).toHaveBeenCalledWith(
@@ -828,26 +809,15 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
828
809
  const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
829
810
  const server = installOwnedFileServer(contents);
830
811
  const recordEdit = vi.fn(async () => {});
831
- let clipIndex = 0;
832
-
833
812
  const reloadPreview = vi.fn();
834
- await finishGroupTimingGsapFallback({
835
- writeProjectFile,
836
- projectId: "p1",
837
- iframe: buildLivePreviewIframe().iframe,
838
- reloadPreview,
839
- label: "Move timeline clips",
840
- errorLabel: "Failed to shift GSAP positions",
841
- recordEdit: recordEdit as never,
842
- activeCompPath: "index.html",
843
- changes: [{ element }, { element }],
844
- resolveChangePath: () => "index.html",
845
- mutateChange: async () => {
846
- clipIndex += 1;
847
- if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
848
- return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2");
849
- },
850
- });
813
+
814
+ await runOwnedChain(
815
+ [
816
+ async () => ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1"),
817
+ async () => ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2"),
818
+ ],
819
+ { recordEdit, reloadPreview },
820
+ );
851
821
 
852
822
  expect(recordEdit).not.toHaveBeenCalled();
853
823
  expect(server.rollbacks).toEqual([
@@ -867,19 +837,7 @@ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
867
837
  const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
868
838
  const server = installOwnedFileServer(contents, { failReadAt: 1 });
869
839
 
870
- await finishGroupTimingGsapFallback({
871
- writeProjectFile,
872
- projectId: "p1",
873
- iframe: buildLivePreviewIframe().iframe,
874
- reloadPreview: vi.fn(),
875
- label: "Move timeline clips",
876
- errorLabel: "Failed to shift GSAP positions",
877
- recordEdit: vi.fn(async () => {}) as never,
878
- activeCompPath: "index.html",
879
- changes: [{ element }],
880
- resolveChangePath: () => "index.html",
881
- mutateChange: async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
882
- });
840
+ await runOwnedChain([async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED")]);
883
841
 
884
842
  expect(server.rollbacks[0]).toEqual({
885
843
  path: "index.html",