@hyperframes/studio 0.7.103 → 0.7.105

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 (71) hide show
  1. package/dist/assets/{hyperframes-player-DUB-xtR5.js → hyperframes-player-Jx07OQgw.js} +1 -1
  2. package/dist/assets/{index-DHc-xHLb.js → index-CryZ0PGi.js} +1 -1
  3. package/dist/assets/{index-Cez_yvk5.js → index-Dqi2GmOp.js} +1 -1
  4. package/dist/assets/{index-CxC2vn2-.js → index-DuxKGsHZ.js} +214 -214
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +779 -294
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/App.tsx +6 -2
  10. package/src/captions/hooks/useCaptionSync.ts +2 -1
  11. package/src/components/editor/DomEditOverlay.test.ts +89 -2
  12. package/src/components/editor/DomEditOverlay.tsx +40 -23
  13. package/src/components/editor/domEditOverlayGeometry.test.ts +94 -0
  14. package/src/components/editor/domEditOverlayGeometry.ts +59 -2
  15. package/src/components/editor/domEditOverlayGestures.ts +42 -0
  16. package/src/components/editor/domEditOverlayStartGesture.ts +17 -0
  17. package/src/components/editor/groupDragMove.ts +110 -0
  18. package/src/components/editor/groupDropKeepsSelection.test.ts +70 -0
  19. package/src/components/editor/manualEditsDom.ts +15 -13
  20. package/src/components/editor/manualOffsetDrag.test.ts +92 -5
  21. package/src/components/editor/manualOffsetDrag.ts +22 -17
  22. package/src/components/editor/reapplyBoxSizeAfterSeek.test.ts +64 -0
  23. package/src/components/editor/snapEngageTravel.test.ts +65 -0
  24. package/src/components/editor/snapEngine.ts +21 -1
  25. package/src/components/editor/useDomEditOverlayGestures.ts +40 -53
  26. package/src/components/editor/useDomEditOverlayRects.ts +4 -2
  27. package/src/hooks/domSelectionTimelineMirror.ts +73 -0
  28. package/src/hooks/gestureTransaction.test.ts +7 -12
  29. package/src/hooks/gestureTransaction.ts +7 -20
  30. package/src/hooks/gsapRuntimePatch.test.ts +43 -0
  31. package/src/hooks/gsapRuntimePatch.ts +11 -1
  32. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  33. package/src/hooks/gsapScriptCommitTypes.ts +20 -0
  34. package/src/hooks/keyframeCacheAstLoad.test.ts +108 -0
  35. package/src/hooks/keyframeCacheAstLoad.ts +29 -1
  36. package/src/hooks/timelineTimingSync.ts +4 -1
  37. package/src/hooks/useDomEditCommits.ts +2 -1
  38. package/src/hooks/useDomEditCommitsHelpers.ts +2 -1
  39. package/src/hooks/useDomEditPreviewSync.ts +23 -0
  40. package/src/hooks/useDomEditSession.test.tsx +43 -62
  41. package/src/hooks/useDomEditSession.ts +10 -2
  42. package/src/hooks/useDomEditWiring.ts +6 -0
  43. package/src/hooks/useDomSelection.test.ts +133 -3
  44. package/src/hooks/useDomSelection.ts +67 -40
  45. package/src/hooks/useDomSelectionSelectionGuards.test.ts +6 -2
  46. package/src/hooks/useElementLifecycleOps.ts +2 -1
  47. package/src/hooks/useExternalFileChangeCoordinator.ts +16 -3
  48. package/src/hooks/useFileManager.ts +2 -8
  49. package/src/hooks/useGroupCommits.ts +2 -1
  50. package/src/hooks/useGsapAnimationFetchFallback.ts +6 -1
  51. package/src/hooks/useGsapAwareEditing.test.tsx +36 -0
  52. package/src/hooks/useGsapAwareEditing.ts +88 -15
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -5
  54. package/src/hooks/useGsapScriptCommits.test.tsx +193 -7
  55. package/src/hooks/useGsapScriptCommits.ts +52 -15
  56. package/src/hooks/useStudioUrlState.ts +130 -7
  57. package/src/hooks/useTimelineEditing.ts +2 -1
  58. package/src/hooks/useTimelineSelectionPreviewSync.ts +13 -0
  59. package/src/player/hooks/useTimelinePlayer.ts +2 -0
  60. package/src/utils/dragDebug.ts +98 -0
  61. package/src/utils/razorSplitTransaction.test.ts +15 -3
  62. package/src/utils/razorSplitTransaction.ts +2 -0
  63. package/src/utils/reloadDebug.ts +10 -0
  64. package/src/utils/resizeDebug.ts +19 -29
  65. package/src/utils/selectDebug.ts +9 -0
  66. package/src/utils/studioDebug.test.ts +32 -0
  67. package/src/utils/studioDebug.ts +30 -0
  68. package/src/utils/studioFileVersion.ts +16 -1
  69. package/src/utils/studioUrlState.test.ts +163 -1
  70. package/src/utils/studioUrlState.ts +60 -3
  71. package/src/utils/timelineCompositionInsert.ts +2 -1
@@ -30,7 +30,6 @@ import {
30
30
  type GroupOverlayItem,
31
31
  type OverlayRect,
32
32
  orientedOverlayRect,
33
- resolveDomEditGroupOverlayRect,
34
33
  } from "./domEditOverlayGeometry";
35
34
  import {
36
35
  BLOCKED_MOVE_THRESHOLD_PX,
@@ -50,8 +49,15 @@ import {
50
49
  startGroupDrag as _startGroupDrag,
51
50
  } from "./domEditOverlayStartGesture";
52
51
  import { hugRectForElement } from "./domEditOverlayCrop";
53
- import { resolveSnapAdjustment, resolveEquidistanceGuides, SNAP_THRESHOLD_PX } from "./snapEngine";
52
+ import {
53
+ resolveSnapAdjustment,
54
+ resolveEquidistanceGuides,
55
+ snapEngagedForTravel,
56
+ SNAP_THRESHOLD_PX,
57
+ } from "./snapEngine";
54
58
  import { logResize, logResizeMove, logResizeSettle } from "../../utils/resizeDebug";
59
+ import { logDrag, logDragSettle, readDragPositions } from "../../utils/dragDebug";
60
+ import { createGroupDragMover } from "./groupDragMove";
55
61
 
56
62
  export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGesturesOptions) {
57
63
  const setDraftOverlayRect = (next: OverlayRect) => {
@@ -91,6 +97,8 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
91
97
  },
92
98
  ) => _startGesture(kind, e, opts, options);
93
99
 
100
+ const moveGroupDrag = createGroupDragMover(opts, setDraftGroupOverlayItems);
101
+
94
102
  // fallow-ignore-next-line complexity
95
103
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
96
104
  const g = opts.gestureRef.current;
@@ -114,55 +122,7 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
114
122
  }
115
123
 
116
124
  if (groupG) {
117
- let dx = e.clientX - groupG.startX;
118
- let dy = e.clientY - groupG.startY;
119
-
120
- const sc = groupG.snapContext;
121
- if (sc?.snapEnabled && sc.targets.length > 0) {
122
- const groupBounds = resolveDomEditGroupOverlayRect(
123
- groupG.originItems.map((item) => item.rect),
124
- );
125
- if (groupBounds) {
126
- const allTargets = sc.compositionTarget
127
- ? [...sc.targets, sc.compositionTarget]
128
- : sc.targets;
129
- const snap = resolveSnapAdjustment({
130
- movingRect: groupBounds,
131
- proposedDx: dx,
132
- proposedDy: dy,
133
- targets: allTargets,
134
- gridEdges: sc.gridEdges ?? undefined,
135
- threshold: SNAP_THRESHOLD_PX,
136
- disabled: e.altKey,
137
- });
138
- dx = snap.dx;
139
- dy = snap.dy;
140
- const movedRect = {
141
- left: groupBounds.left + dx,
142
- top: groupBounds.top + dy,
143
- width: groupBounds.width,
144
- height: groupBounds.height,
145
- };
146
- const spacingGuides = e.altKey
147
- ? []
148
- : resolveEquidistanceGuides({
149
- movingRect: movedRect,
150
- targets: allTargets,
151
- threshold: SNAP_THRESHOLD_PX,
152
- });
153
- opts.snapGuidesRef.current = { guides: snap.guides, spacingGuides };
154
- }
155
- }
156
- groupG.lastSnappedDx = dx;
157
- groupG.lastSnappedDy = dy;
158
-
159
- setDraftGroupOverlayItems(
160
- groupG.originItems.map((item) => ({
161
- ...item,
162
- rect: { ...item.rect, left: item.rect.left + dx, top: item.rect.top + dy },
163
- })),
164
- );
165
- for (const member of groupG.members) applyManualOffsetDragDraft(member, dx, dy);
125
+ moveGroupDrag(groupG, e);
166
126
  return;
167
127
  }
168
128
 
@@ -215,6 +175,9 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
215
175
  movingRect,
216
176
  proposedDx: dx,
217
177
  proposedDy: dy,
178
+ // Same reason as the group path: a snap on a drag that has not travelled
179
+ // yet moves the element while the pointer is still.
180
+ disabledForTravel: !snapEngagedForTravel(dx, dy),
218
181
  targets: allTargets,
219
182
  gridEdges: sc.gridEdges ?? undefined,
220
183
  threshold: SNAP_THRESHOLD_PX,
@@ -319,9 +282,14 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
319
282
  opts.rafPausedRef.current = false;
320
283
  const rawDx = e.clientX - groupG.startX;
321
284
  const rawDy = e.clientY - groupG.startY;
285
+ // The click that trails every pointerup has to be eaten either way. The
286
+ // gesture ref is already cleared above, so by the time it arrives the box
287
+ // no longer looks busy, and handleBoxClick hands it to the canvas as an
288
+ // ordinary click — which lands between the members, resolves to nothing,
289
+ // and deselects the group the drag just moved.
290
+ opts.suppressNextBoxClickRef.current = true;
322
291
  if (Math.hypot(rawDx, rawDy) < BLOCKED_MOVE_THRESHOLD_PX) {
323
292
  restoreGroupPathOffsets(groupG);
324
- opts.suppressNextBoxClickRef.current = true;
325
293
  return;
326
294
  }
327
295
  const dx = groupG.lastSnappedDx ?? rawDx;
@@ -336,6 +304,17 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
336
304
  selection: member.selection,
337
305
  next: applyManualOffsetDragCommit(member, dx, dy),
338
306
  }));
307
+ logDrag("drop", {
308
+ pointer: `${Math.round(rawDx)},${Math.round(rawDy)}`,
309
+ applied: `${Math.round(dx)},${Math.round(dy)}`,
310
+ committed: Object.fromEntries(
311
+ updates.map((update, index) => [
312
+ groupG.members[index]?.key ?? String(index),
313
+ `${Math.round(update.next.x)},${Math.round(update.next.y)}`,
314
+ ]),
315
+ ),
316
+ at: readDragPositions(groupG.members),
317
+ });
339
318
  void Promise.resolve(opts.onGroupPathOffsetCommitRef.current(updates))
340
319
  .catch(() => {
341
320
  for (const member of groupG.members) {
@@ -346,7 +325,15 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu
346
325
  restoreStudioPathOffset(member.element, member.initialPathOffset);
347
326
  }
348
327
  })
349
- .finally(() => endManualOffsetDragMembers(groupG.members));
328
+ .finally(() => {
329
+ logDrag("committed", { at: readDragPositions(groupG.members) });
330
+ endManualOffsetDragMembers(groupG.members);
331
+ // The gesture teardown resumes the paused timelines and re-seeks the
332
+ // player, which re-renders from whatever the preview currently holds.
333
+ // If the reloaded source has not landed yet that is the OLD position,
334
+ // so this is where a snap-back would show.
335
+ logDragSettle("settle", groupG.members);
336
+ });
350
337
  return;
351
338
  }
352
339
 
@@ -17,7 +17,7 @@ import {
17
17
  rectsEqual,
18
18
  resolveElementForOverlay,
19
19
  selectionCacheKey,
20
- toVisibleOverlayRect,
20
+ orientedVisibleOverlayRect,
21
21
  } from "./domEditOverlayGeometry";
22
22
 
23
23
  function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean {
@@ -172,7 +172,9 @@ export function useDomEditOverlayRects({
172
172
  for (let i = 0; i < descendants.length; i++) {
173
173
  const child = descendants[i] as HTMLElement;
174
174
  if (!child.getBoundingClientRect) continue;
175
- const r = toVisibleOverlayRect(overlayEl, iframe, child);
175
+ // Oriented, not axis-aligned: a child of a rotated element drew its
176
+ // outline square around the rotated glyphs instead of on them.
177
+ const r = orientedVisibleOverlayRect(overlayEl, iframe, child);
176
178
  if (r && r.width > 2 && r.height > 2) nextChildRects.push(r);
177
179
  }
178
180
  if (!childRectsEqual(childRectsRef.current, nextChildRects)) {
@@ -0,0 +1,73 @@
1
+ import type { SelectElementOptions, TimelineElement } from "../player";
2
+ import { findMatchingTimelineElementId, findTimelineIdByAncestor } from "../utils/studioHelpers";
3
+ import type { DomEditSelection } from "../components/editor/domEditing";
4
+ import { logSelect } from "../utils/selectDebug";
5
+
6
+ interface TimelineMirrorDeps {
7
+ timelineElements: TimelineElement[];
8
+ getTimelineSelectionSet: () => ReadonlySet<string>;
9
+ setSelectedTimelineElementId: (id: string | null, options?: SelectElementOptions) => void;
10
+ setTimelineSelectionSet: (ids: Set<string>) => void;
11
+ }
12
+
13
+ /**
14
+ * Mirror a canvas selection onto the timeline: the whole set first, then the
15
+ * primary as its anchor.
16
+ *
17
+ * The timeline is the source of truth for what is selected and it syncs back —
18
+ * whatever it holds replaces the canvas selection a moment later. Announcing only
19
+ * the primary therefore drops every other member. Worse, anchoring with
20
+ * `preserveSet` on an id the set does not yet contain empties the set outright,
21
+ * and an empty set syncs back as "nothing is selected" — which is how adding a
22
+ * second element, or moving a group, could wipe the selection instead of keeping
23
+ * it. Publishing the members first is what makes the anchor a member, so
24
+ * preserving the set is meaningful rather than destructive.
25
+ */
26
+ export function announceTimelineSelection(
27
+ deps: TimelineMirrorDeps,
28
+ group: DomEditSelection[],
29
+ primary: DomEditSelection | null,
30
+ ): void {
31
+ const {
32
+ timelineElements,
33
+ getTimelineSelectionSet,
34
+ setSelectedTimelineElementId,
35
+ setTimelineSelectionSet,
36
+ } = deps;
37
+ if (!primary) {
38
+ setTimelineSelectionSet(new Set());
39
+ setSelectedTimelineElementId(null);
40
+ return;
41
+ }
42
+ const timelineIdFor = (selection: DomEditSelection) =>
43
+ findMatchingTimelineElementId(selection, timelineElements) ??
44
+ findTimelineIdByAncestor(
45
+ selection.element,
46
+ timelineElements,
47
+ selection.sourceFile || "index.html",
48
+ );
49
+ const members = group.map(timelineIdFor).filter((id): id is string => Boolean(id));
50
+ const anchor = timelineIdFor(primary);
51
+ const publishedMembers = new Set(members);
52
+ if (anchor) publishedMembers.add(anchor);
53
+ const timelineAnchor = anchor ?? members[0] ?? null;
54
+ // A member with no timeline row of its own resolves to null and is dropped here,
55
+ // so a group can announce fewer ids than it has — or none, which reads back as an
56
+ // empty selection and takes the canvas selection with it.
57
+ logSelect("announce", {
58
+ group: group.length,
59
+ published: publishedMembers.size,
60
+ anchor,
61
+ anchorPublished: anchor != null && publishedMembers.has(anchor),
62
+ });
63
+ // A canvas target can be editable without owning a timeline row. Preserve that
64
+ // canvas-only selection when the timeline has nothing truthful to represent.
65
+ if (!timelineAnchor) return;
66
+ // A late async primary that already belongs to the live set must preserve the
67
+ // group. A fresh single click does not belong to it, so publish the singleton
68
+ // first; otherwise `preserveSet` clears the set and sync wipes the canvas.
69
+ if (group.length > 1 || !getTimelineSelectionSet().has(timelineAnchor)) {
70
+ setTimelineSelectionSet(publishedMembers);
71
+ }
72
+ setSelectedTimelineElementId(timelineAnchor, { preserveSet: true });
73
+ }
@@ -36,6 +36,7 @@ function runTwoMutationTransaction(
36
36
  describe("runGestureTransaction", () => {
37
37
  beforeEach(() => {
38
38
  trackStudioEventMock.mockReset();
39
+ localStorage.clear();
39
40
  });
40
41
 
41
42
  it("settles synchronously before persist reaches its first await", async () => {
@@ -249,7 +250,7 @@ describe("runGestureTransaction", () => {
249
250
  .spyOn(element, "getBoundingClientRect")
250
251
  .mockReturnValueOnce(rect(10.04, 20.05, 100.05, 80.05))
251
252
  .mockReturnValueOnce(rect(11.19, 17.89, 100.29, 78.99));
252
- const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
253
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
253
254
  const now = vi.spyOn(performance, "now").mockReturnValueOnce(50).mockReturnValueOnce(58.44);
254
255
 
255
256
  await runGestureTransaction({
@@ -261,13 +262,7 @@ describe("runGestureTransaction", () => {
261
262
  });
262
263
 
263
264
  expect(getRect).toHaveBeenCalledTimes(2);
264
- expect(error).toHaveBeenCalledWith(
265
- "[hf-commit] persist changed pixels",
266
- expect.objectContaining({
267
- label: "Resize layer",
268
- delta: expect.objectContaining({ x: expect.any(Number) }),
269
- }),
270
- );
265
+ expect(log).not.toHaveBeenCalled();
271
266
  expect(trackStudioEventMock).toHaveBeenCalledWith("commit_invariant_violation", {
272
267
  label: "Resize layer",
273
268
  delta_x: 1.2,
@@ -283,13 +278,13 @@ describe("runGestureTransaction", () => {
283
278
  expect.objectContaining({ pixel_asserted: true }),
284
279
  );
285
280
  now.mockRestore();
286
- error.mockRestore();
281
+ log.mockRestore();
287
282
  });
288
283
 
289
284
  it("skips the pixel assertion for live position tweens", async () => {
290
285
  const element = document.createElement("div");
291
286
  const getRect = vi.spyOn(element, "getBoundingClientRect");
292
- const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
287
+ const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
293
288
 
294
289
  await runGestureTransaction({
295
290
  element,
@@ -301,11 +296,11 @@ describe("runGestureTransaction", () => {
301
296
  });
302
297
 
303
298
  expect(getRect).not.toHaveBeenCalled();
304
- expect(error).not.toHaveBeenCalledWith("[hf-commit] persist changed pixels", expect.anything());
299
+ expect(log).not.toHaveBeenCalled();
305
300
  expect(trackStudioEventMock).not.toHaveBeenCalledWith(
306
301
  "commit_invariant_violation",
307
302
  expect.anything(),
308
303
  );
309
- error.mockRestore();
304
+ log.mockRestore();
310
305
  });
311
306
  });
@@ -4,6 +4,7 @@ import type {
4
4
  CommitMutationOptions,
5
5
  } from "./gsapScriptCommitTypes";
6
6
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
+ import { makeStudioDebugLogger } from "../utils/studioDebug";
7
8
 
8
9
  type PixelRect = Pick<DOMRect, "x" | "y" | "width" | "height">;
9
10
 
@@ -108,14 +109,7 @@ async function dispatchBufferedCommits(calls: BufferedCommit[]): Promise<number>
108
109
  return reloadsRequested(calls);
109
110
  }
110
111
 
111
- /**
112
- * Dev-only [hf-commit] lifecycle trace. The production observability lives in
113
- * the trackStudioEvent commit_* events (always on); these console lines are a
114
- * developer aid and stay out of end users' consoles.
115
- */
116
- function traceCommit(stage: string, data: Record<string, unknown>): void {
117
- if (import.meta.env.DEV) console.info(`[hf-commit] ${stage}`, data);
118
- }
112
+ const logCommit = makeStudioDebugLogger("commit");
119
113
 
120
114
  /**
121
115
  * Owns the visual + persistence + history lifecycle for one gesture release.
@@ -127,9 +121,9 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
127
121
  let mutationCount = 0;
128
122
  let reloadCount = 0;
129
123
  const bufferedCommits: BufferedCommit[] = [];
130
- traceCommit("start", { label: tx.label, coalesceKey });
124
+ logCommit("start", { label: tx.label, coalesceKey });
131
125
  tx.settle();
132
- traceCommit("settled", { label: tx.label, coalesceKey });
126
+ logCommit("settled", { label: tx.label, coalesceKey });
133
127
 
134
128
  const before = !tx.skipPixelAssert ? readPixelRect(tx.element) : null;
135
129
  const commit: TxCommit = (commitMutation) => {
@@ -152,19 +146,12 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
152
146
  .then(async () => {
153
147
  reloadCount = await dispatchBufferedCommits(bufferedCommits);
154
148
  const durationMs = Math.round(performance.now() - startedAt);
155
- traceCommit("persisted", { label: tx.label, coalesceKey });
149
+ logCommit("persisted", { label: tx.label, coalesceKey });
156
150
  if (before) {
157
151
  const after = readPixelRect(tx.element);
158
152
  const delta = pixelDelta(before, after);
159
153
  if (exceedsPixelTolerance(delta)) {
160
- if (import.meta.env.DEV) {
161
- console.error("[hf-commit] persist changed pixels", {
162
- label: tx.label,
163
- before,
164
- after,
165
- delta,
166
- });
167
- }
154
+ logCommit("persist-changed-pixels", { label: tx.label, before, after, delta });
168
155
  trackStudioEvent("commit_invariant_violation", {
169
156
  label: tx.label,
170
157
  delta_x: roundToOneDecimal(delta.x),
@@ -193,7 +180,7 @@ export function runGestureTransaction(tx: GestureTransaction): Promise<void> {
193
180
  error_name: error instanceof Error ? error.name : "unknown",
194
181
  restore_ran: true,
195
182
  });
196
- traceCommit("restore", { label: tx.label, coalesceKey });
183
+ logCommit("restore", { label: tx.label, coalesceKey });
197
184
  throw error;
198
185
  });
199
186
  }
@@ -523,3 +523,46 @@ describe("patchRuntimeTweenInPlace — composition isolation", () => {
523
523
  expect(otherTween.invalidate).not.toHaveBeenCalled();
524
524
  });
525
525
  });
526
+
527
+ describe("patchRuntimeTweenInPlace — deferSeek", () => {
528
+ /**
529
+ * A group drag commits one member at a time. Each in-place patch used to seek,
530
+ * and a seek re-renders the WHOLE timeline — so every member still queued behind
531
+ * the current one got repainted from its un-patched tween, back to where it sat
532
+ * before the drag, and stayed there until its own patch landed. That is the jump.
533
+ */
534
+ it("does not seek while a group commit is still writing its other members", () => {
535
+ const a = { id: "a" };
536
+ const rendered = { a: 0, b: 0 };
537
+ const tweenA = makeTween({ vars: { x: 0 }, targetIds: ["a"], duration: 0 }, a);
538
+ const tweenB = makeTween({ vars: { x: 0 }, targetIds: ["b"], duration: 0 }, a);
539
+ const { iframe, seek } = fakeIframe(a, [tweenA, tweenB], {
540
+ onSeek: () => {
541
+ rendered.a = tweenA.vars.x as number;
542
+ rendered.b = tweenB.vars.x as number;
543
+ },
544
+ });
545
+
546
+ const first = patchRuntimeTweenInPlace(
547
+ iframe,
548
+ "#a",
549
+ { kind: "set", props: { x: 500 } },
550
+ undefined,
551
+ true,
552
+ );
553
+
554
+ expect(first).toBe(true);
555
+ expect(tweenA.vars.x).toBe(500);
556
+ // No repaint yet: "b" keeps the transform the gesture left on it instead of
557
+ // being rendered from its own tween, which still holds the pre-drag value.
558
+ expect(seek).not.toHaveBeenCalled();
559
+ expect(rendered).toEqual({ a: 0, b: 0 });
560
+
561
+ tweenB.vars.x = 600;
562
+ const last = patchRuntimeTweenInPlace(iframe, "#a", { kind: "set", props: { x: 500 } });
563
+
564
+ expect(last).toBe(true);
565
+ expect(seek).toHaveBeenCalledTimes(1);
566
+ expect(rendered).toEqual({ a: 500, b: 600 });
567
+ });
568
+ });
@@ -277,12 +277,16 @@ function applyChange(tween: RuntimeTween, change: RuntimeTweenChange): boolean {
277
277
  /**
278
278
  * Edit one tween in `window.__timelines` in place + re-seek to the current playhead.
279
279
  * Returns `true` on a confident patch, `false` otherwise (caller soft-reloads).
280
+ *
281
+ * `deferSeek` skips the re-render, for a caller patching several tweens in a row
282
+ * that will render once after the last one.
280
283
  */
281
284
  export function patchRuntimeTweenInPlace(
282
285
  iframe: HTMLIFrameElement | null,
283
286
  selector: string,
284
287
  change: RuntimeTweenChange,
285
288
  compositionId?: string,
289
+ deferSeek = false,
286
290
  ): boolean {
287
291
  if (!iframe) return false;
288
292
  // A base `gsap.set` has no timeline tween to resolve — apply the value straight
@@ -312,7 +316,13 @@ export function patchRuntimeTweenInPlace(
312
316
  if (change.kind !== "keyframe-rebuild") {
313
317
  tween.invalidate?.();
314
318
  }
315
- seekToCurrent(iframe, timeline);
319
+ // A seek re-renders the WHOLE timeline, not just the tween we patched. Under a
320
+ // multi-element commit that is a visible jump: the members still queued behind
321
+ // this one get repainted from their un-patched tweens, back to where they were
322
+ // before the gesture, and stay there until their own patch lands. Deferring
323
+ // leaves them showing the gesture's own transform, and the caller's last patch
324
+ // seeks once for the whole group.
325
+ if (!deferSeek) seekToCurrent(iframe, timeline);
316
326
  return true;
317
327
  } catch {
318
328
  return false;
@@ -3,6 +3,7 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
 
4
4
  export { PROPERTY_DEFAULTS } from "./gsapShared";
5
5
  import { idSelector, matchesExactlyOne } from "./gsapShared";
6
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
6
7
 
7
8
  /**
8
9
  * The selector to author a NEW tween against, minting an id on the element when
@@ -119,7 +120,7 @@ export async function assignGsapTargetAutoIdIfNeeded({
119
120
  `/api/projects/${encodeURIComponent(projectId)}/file-mutations/patch-element/${encodeURIComponent(targetPath)}`,
120
121
  {
121
122
  method: "POST",
122
- headers: { "Content-Type": "application/json" },
123
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
123
124
  body: JSON.stringify(patchBody),
124
125
  },
125
126
  );
@@ -22,6 +22,18 @@ export interface CommitMutationOptions {
22
22
  coalesceMs?: number;
23
23
  softReload?: boolean;
24
24
  skipReload?: boolean;
25
+ /**
26
+ * Write the source but leave the preview alone; the caller renders once when it
27
+ * is done. For a multi-write action like a group drag, rendering after each
28
+ * write shows a source where the members not yet written still hold their old
29
+ * values, so they snap back until their own write lands. This also defers the
30
+ * in-place runtime patch's seek, which re-renders the whole timeline and repaints
31
+ * the queued members the same way. Unlike `skipReload` this changes nothing about
32
+ * error handling — a failed write still throws.
33
+ */
34
+ deferPreviewSync?: boolean;
35
+ /** Shares an in-place patch miss with the final render of one multi-write action. */
36
+ previewFallbackLatch?: { pending: boolean };
25
37
  beforeReload?: () => void;
26
38
  /**
27
39
  * Serialize this commit against others sharing the same key. Used to chain
@@ -39,6 +51,14 @@ export interface CommitMutationOptions {
39
51
  * existing soft/full reload path. Structural edits omit this and reload as before.
40
52
  */
41
53
  instantPatch?: { selector: string; change: RuntimeTweenChange };
54
+ /**
55
+ * The same fast path for a batched commit: one patch per element the batch
56
+ * wrote, applied in order. All of them must land for the reload to be skipped
57
+ * — one that can't be applied leaves the preview half-patched, so the whole
58
+ * batch falls back to the reload. Only the last patch re-renders (see
59
+ * `deferSeek`), so a ten-element batch repaints once.
60
+ */
61
+ instantPatches?: Array<{ selector: string; change: RuntimeTweenChange }>;
42
62
  }
43
63
 
44
64
  export interface CommitMutationCall {
@@ -0,0 +1,108 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { fetchParsedAnimations } from "./keyframeCacheAstLoad";
3
+
4
+ /**
5
+ * Parsing a composition is a whole-file read + parse on the server, and a
6
+ * multi-element action asks for the same file once per element. Callers that
7
+ * overlap in time share one request; a caller that comes after the last one
8
+ * settled does not, so a parse issued after a write is never served a
9
+ * pre-write answer.
10
+ */
11
+ describe("fetchParsedAnimations — in-flight sharing", () => {
12
+ afterEach(() => {
13
+ vi.unstubAllGlobals();
14
+ });
15
+
16
+ function stubFetch(): { calls: () => number; settle: () => void } {
17
+ let calls = 0;
18
+ const pending: Array<() => void> = [];
19
+ vi.stubGlobal("fetch", () => {
20
+ calls++;
21
+ return new Promise((resolve) => {
22
+ pending.push(() =>
23
+ resolve({
24
+ ok: true,
25
+ json: () => Promise.resolve({ animations: [{ id: "a", targetSelector: "#a" }] }),
26
+ } as Response),
27
+ );
28
+ });
29
+ });
30
+ return {
31
+ calls: () => calls,
32
+ settle: () => {
33
+ for (const release of pending.splice(0, pending.length)) release();
34
+ },
35
+ };
36
+ }
37
+
38
+ it("serves overlapping reads of one file from a single request", async () => {
39
+ const fetchStub = stubFetch();
40
+
41
+ const pending = [
42
+ fetchParsedAnimations("p", "index.html"),
43
+ fetchParsedAnimations("p", "index.html"),
44
+ fetchParsedAnimations("p", "index.html"),
45
+ ];
46
+ fetchStub.settle();
47
+ const results = await Promise.all(pending);
48
+
49
+ expect(fetchStub.calls()).toBe(1);
50
+ expect(results.map((parsed) => parsed?.animations.length)).toEqual([1, 1, 1]);
51
+ });
52
+
53
+ it("does not share across files", async () => {
54
+ const fetchStub = stubFetch();
55
+
56
+ const pending = [
57
+ fetchParsedAnimations("p", "index.html"),
58
+ fetchParsedAnimations("p", "other.html"),
59
+ ];
60
+ fetchStub.settle();
61
+ await Promise.all(pending);
62
+
63
+ expect(fetchStub.calls()).toBe(2);
64
+ });
65
+
66
+ it("re-requests once the previous read has settled", async () => {
67
+ const fetchStub = stubFetch();
68
+
69
+ const first = fetchParsedAnimations("p", "index.html");
70
+ fetchStub.settle();
71
+ await first;
72
+ const second = fetchParsedAnimations("p", "index.html");
73
+ fetchStub.settle();
74
+ await second;
75
+
76
+ expect(fetchStub.calls()).toBe(2);
77
+ });
78
+
79
+ it("supersedes an in-flight pre-write parse with a fresh post-write read", async () => {
80
+ const releases: Array<(response: Response) => void> = [];
81
+ const fetch = vi.fn(
82
+ () =>
83
+ new Promise<Response>((resolve) => {
84
+ releases.push(resolve);
85
+ }),
86
+ );
87
+ vi.stubGlobal("fetch", fetch);
88
+ const response = (id: string) =>
89
+ ({
90
+ ok: true,
91
+ json: () => Promise.resolve({ animations: [{ id, targetSelector: `#${id}` }] }),
92
+ }) as Response;
93
+
94
+ const stale = fetchParsedAnimations("p", "index.html");
95
+ const fresh = fetchParsedAnimations("p", "index.html", { fresh: true });
96
+ expect(fetch).toHaveBeenCalledTimes(2);
97
+
98
+ releases[0]?.(response("stale"));
99
+ await stale;
100
+ const overlappingFreshRead = fetchParsedAnimations("p", "index.html");
101
+ expect(fetch).toHaveBeenCalledTimes(2);
102
+
103
+ releases[1]?.(response("fresh"));
104
+ const [freshResult, sharedResult] = await Promise.all([fresh, overlappingFreshRead]);
105
+ expect(freshResult?.animations[0]?.id).toBe("fresh");
106
+ expect(sharedResult?.animations[0]?.id).toBe("fresh");
107
+ });
108
+ });
@@ -42,7 +42,35 @@ function hasAnimations(value: unknown): value is ParsedGsapAnimations {
42
42
  );
43
43
  }
44
44
 
45
- export async function fetchParsedAnimations(
45
+ /**
46
+ * Requests for the same file that overlap in time, keyed `projectId|sourceFile`.
47
+ *
48
+ * Every parse re-reads and re-parses the whole composition server-side, and a
49
+ * multi-element action asks for the same file once per element. Sharing the
50
+ * in-flight promise makes that one request. Only OVERLAPPING calls share: the
51
+ * entry is dropped the moment it settles, so a call made after a write still
52
+ * gets a fresh parse.
53
+ */
54
+ const inFlightParses = new Map<string, Promise<ParsedGsapAnimations | null>>();
55
+
56
+ export function fetchParsedAnimations(
57
+ projectId: string,
58
+ sourceFile: string,
59
+ options: { fresh?: boolean } = {},
60
+ ): Promise<ParsedGsapAnimations | null> {
61
+ const key = `${projectId}|${sourceFile}`;
62
+ if (options.fresh) inFlightParses.delete(key);
63
+ const inFlight = inFlightParses.get(key);
64
+ if (inFlight) return inFlight;
65
+ const request = requestParsedAnimations(projectId, sourceFile).finally(() => {
66
+ // A superseded pre-write request must not evict the fresh post-write one.
67
+ if (inFlightParses.get(key) === request) inFlightParses.delete(key);
68
+ });
69
+ inFlightParses.set(key, request);
70
+ return request;
71
+ }
72
+
73
+ async function requestParsedAnimations(
46
74
  projectId: string,
47
75
  sourceFile: string,
48
76
  ): Promise<ParsedGsapAnimations | null> {
@@ -7,6 +7,7 @@ import { applySoftReload, applySoftReloadFinalization } from "../utils/gsapSoftR
7
7
  import { furthestClipEndFromDocument } from "../player/lib/timelineElementHelpers";
8
8
  import type { RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { patchDocumentRootDuration } from "./timelineEditingGsap";
10
+ import { studioWriteHeaders } from "../utils/studioFileVersion";
10
11
 
11
12
  class GsapPreviewConvergenceError extends Error {}
12
13
  class GsapOwnershipProtocolError extends GsapPreviewConvergenceError {}
@@ -58,6 +59,8 @@ async function rollbackOwnedMutation(
58
59
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutation-rollback/${encodeURIComponent(targetPath)}`,
59
60
  {
60
61
  method: "POST",
62
+ // Deliberately unclaimed: a rollback runs because a mutation did not
63
+ // converge, so let the restored file reload the preview.
61
64
  headers: { "Content-Type": "application/json" },
62
65
  body: JSON.stringify({ expected, restore }),
63
66
  },
@@ -156,7 +159,7 @@ async function postGsapMutation(
156
159
  `/api/projects/${encodeURIComponent(projectId)}/gsap-mutations/${encodeURIComponent(filePath)}`,
157
160
  {
158
161
  method: "POST",
159
- headers: { "Content-Type": "application/json" },
162
+ headers: { "Content-Type": "application/json", ...studioWriteHeaders() },
160
163
  body: JSON.stringify(mutation),
161
164
  },
162
165
  );