@hyperframes/studio 0.8.113 → 0.8.115

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 (194) hide show
  1. package/dist/assets/{hyperframes-player-DzJbtQVO.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-n5FUn1xv.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-DRhF3v4Z.js → index-CAvZ0AA-.js} +204 -204
  4. package/dist/assets/{index-DgwDQK5G.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/index-t1LH6H5H.css +1 -0
  6. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  7. package/dist/chunk-5EKWJZLM.js.map +1 -0
  8. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  9. package/dist/{chunk-BIUXP3PG.js → chunk-TXZMMOMA.js} +43 -276
  10. package/dist/chunk-TXZMMOMA.js.map +1 -0
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-D7VYFJJD.js} +3 -5
  12. package/dist/index.d.ts +46 -22
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +3899 -3487
  15. package/dist/index.js.map +1 -1
  16. package/dist/player.js +2 -2
  17. package/dist/styles.css +1 -1
  18. package/package.json +10 -8
  19. package/src/App.tsx +84 -86
  20. package/src/components/EditorShell.tsx +3 -3
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/StudioHeader.tsx +4 -4
  23. package/src/components/StudioLeftPanels.tsx +3 -3
  24. package/src/components/StudioOverlays.tsx +4 -4
  25. package/src/components/StudioRightPanels.tsx +14 -9
  26. package/src/components/StudioToast.tsx +1 -1
  27. package/src/components/TimelineToolbar.tsx +3 -3
  28. package/src/components/dock/Dock.tsx +5 -7
  29. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  30. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  31. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  32. package/src/components/editor/DomEditOverlay.tsx +5 -1
  33. package/src/components/editor/FileTreeNodes.tsx +13 -10
  34. package/src/components/editor/InlineTextToolbar.tsx +11 -27
  35. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  37. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  38. package/src/components/editor/SnapToolbar.tsx +43 -5
  39. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  40. package/src/components/editor/audioFxTelemetry.ts +2 -3
  41. package/src/components/editor/colorBrowserCanvas.test.ts +116 -0
  42. package/src/components/editor/colorValue.test.ts +16 -0
  43. package/src/components/editor/colorValue.ts +100 -24
  44. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  45. package/src/components/editor/cropResize.test.ts +11 -0
  46. package/src/components/editor/cropResize.ts +6 -6
  47. package/src/components/editor/domEditingDom.ts +4 -0
  48. package/src/components/editor/domEditingLayers.ts +1 -5
  49. package/src/components/editor/gradientValue.ts +28 -36
  50. package/src/components/editor/gsapAnimatesProperty.ts +0 -54
  51. package/src/components/editor/inlineTextStyleRange.test.ts +31 -1
  52. package/src/components/editor/inlineTextStyleRange.ts +5 -2
  53. package/src/components/editor/inlineTextStyleRead.ts +9 -0
  54. package/src/components/editor/manualEdits.ts +1 -0
  55. package/src/components/editor/manualEditsDom.ts +2 -2
  56. package/src/components/editor/manualEditsParsing.ts +7 -0
  57. package/src/components/editor/manualEditsSeekReapply.ts +2 -2
  58. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  59. package/src/components/editor/marqueeCommit.ts +24 -8
  60. package/src/components/editor/motionPathCommit.test.ts +29 -1
  61. package/src/components/editor/motionPathCommit.ts +21 -9
  62. package/src/components/editor/probeSourceElement.test.ts +45 -0
  63. package/src/components/editor/probeSourceElement.ts +39 -15
  64. package/src/components/editor/propertyPanelColor.tsx +1 -1
  65. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  66. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  67. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  68. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  69. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  70. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  71. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  72. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  73. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  74. package/src/components/editor/propertyPanelTypes.ts +6 -3
  75. package/src/components/editor/reapplyPositionEditsAfterSeek.test.ts +25 -0
  76. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  77. package/src/components/editor/useDomEditNudge.ts +2 -0
  78. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  79. package/src/components/editor/useDomEditZOrder.ts +12 -2
  80. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  81. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  82. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  83. package/src/components/ui/menuStyle.test.ts +61 -0
  84. package/src/components/ui/menuStyle.ts +10 -0
  85. package/src/contexts/DomEditContext.tsx +1 -1
  86. package/src/contexts/TimelineEditContext.tsx +1 -0
  87. package/src/domEditExports.test.tsx +9 -1
  88. package/src/hooks/domEditCommitRunner.ts +1 -1
  89. package/src/hooks/elementOffsetStager.ts +5 -4
  90. package/src/hooks/gsapDragCommit.ts +2 -20
  91. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  92. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  93. package/src/hooks/gsapGestureOutcome.ts +64 -0
  94. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  95. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  96. package/src/hooks/gsapResizeIntercept.ts +1 -0
  97. package/src/hooks/gsapRuntimeKeyframes.ts +1 -1
  98. package/src/hooks/gsapRuntimeReaders.test.ts +48 -0
  99. package/src/hooks/gsapRuntimeReaders.ts +3 -90
  100. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  101. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  102. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  103. package/src/hooks/plainRotation.ts +3 -2
  104. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  105. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  106. package/src/hooks/refreshTimelineRowText.ts +29 -0
  107. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  108. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  109. package/src/hooks/useAppHotkeys.ts +11 -5
  110. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  111. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  112. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  113. package/src/hooks/useDomEditTextCommits.ts +5 -1
  114. package/src/hooks/useDomEditWiring.ts +4 -2
  115. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  116. package/src/hooks/useDomGeometryCommit.ts +7 -4
  117. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  118. package/src/hooks/useDomGeometryCommits.ts +3 -2
  119. package/src/hooks/useDomSelection.test.ts +26 -0
  120. package/src/hooks/useDomSelection.ts +10 -3
  121. package/src/hooks/useDomSelectionTypes.ts +6 -1
  122. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  123. package/src/hooks/useEnableKeyframes.ts +5 -3
  124. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  125. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  126. package/src/hooks/useFrameCapture.ts +15 -8
  127. package/src/hooks/useGestureCommit.test.tsx +225 -55
  128. package/src/hooks/useGestureCommit.ts +90 -56
  129. package/src/hooks/useGestureTrailOverlay.tsx +33 -0
  130. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  131. package/src/hooks/useGsapAwareEditing.ts +96 -195
  132. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  133. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  134. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  135. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  136. package/src/hooks/useGsapScriptCommits.ts +3 -0
  137. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  138. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  139. package/src/hooks/useGsapTweenCache.ts +18 -12
  140. package/src/hooks/usePanelLayout.ts +5 -1
  141. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  142. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  143. package/src/hooks/usePreviewInteraction.ts +2 -0
  144. package/src/hooks/useRenderClipContent.test.ts +18 -0
  145. package/src/hooks/useRenderClipContent.ts +4 -5
  146. package/src/hooks/useShellSlices.ts +53 -0
  147. package/src/hooks/useStableHandlers.test.tsx +77 -0
  148. package/src/hooks/useStableHandlers.ts +44 -0
  149. package/src/hooks/useStudioSdkSessions.ts +2 -1
  150. package/src/hooks/useStudioSidePanels.test.tsx +44 -0
  151. package/src/hooks/useStudioSidePanels.tsx +23 -0
  152. package/src/hooks/useTimelineAddAtPlayhead.ts +16 -8
  153. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  154. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  155. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  156. package/src/hooks/useTimelineEditing.ts +33 -27
  157. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  158. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  159. package/src/player/components/ClipContextMenu.tsx +33 -31
  160. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  161. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  162. package/src/player/components/TextClipContent.test.ts +19 -0
  163. package/src/player/components/TextClipContent.tsx +27 -0
  164. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  165. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  166. package/src/player/components/clipMenuToolItems.tsx +13 -9
  167. package/src/player/components/timelineCallbacks.ts +1 -0
  168. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  169. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  170. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  171. package/src/player/components/timelineEditing.ts +3 -1
  172. package/src/player/components/timelineLinkSelection.ts +35 -1
  173. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  174. package/src/player/components/useTimelineClipDrag.ts +6 -7
  175. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  176. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  177. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  178. package/src/player/lib/timelineElementHelpers.ts +6 -15
  179. package/src/player/lib/timelineText.ts +40 -0
  180. package/src/player/store/timelineElement.ts +12 -0
  181. package/src/styles/theme.css +3 -0
  182. package/src/utils/frameCapture.ts +3 -1
  183. package/src/utils/gsapUndoRestore.browser.test.ts +4 -2
  184. package/src/utils/keyframeUsage.ts +79 -0
  185. package/src/utils/previewFeatureUsage.test.ts +32 -0
  186. package/src/utils/previewFeatureUsage.ts +46 -0
  187. package/src/utils/studioTelemetry.test.ts +107 -0
  188. package/src/utils/studioTelemetry.ts +49 -4
  189. package/src/webmcp/StudioAgentTools.tsx +2 -2
  190. package/dist/assets/index-DbfKVTId.css +0 -1
  191. package/dist/chunk-BIUXP3PG.js.map +0 -1
  192. package/dist/chunk-K47E2ZJA.js.map +0 -1
  193. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  194. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -79,7 +79,7 @@ describe("DOM editing package exports", () => {
79
79
  front.id = "front";
80
80
  parent.append(back, front);
81
81
  document.body.append(parent);
82
- const commitZ = vi.fn(() => Promise.resolve());
82
+ const commitZ = vi.fn(async () => ({ durable: true, changed: true }));
83
83
  const session = {
84
84
  handleDomZIndexReorderCommit: commitZ,
85
85
  } as unknown as Parameters<typeof DomEditProvider>[0]["value"];
@@ -107,6 +107,11 @@ describe("DOM editing package exports", () => {
107
107
  await act(async () => {
108
108
  expect(zOrder?.apply(sel, toFront)).toBe(true);
109
109
  });
110
+ expect(trackStudioEvent).toHaveBeenCalledWith("feature_used", {
111
+ feature: "z_order",
112
+ surface: "preview",
113
+ method: "button",
114
+ });
110
115
  expect(commitZ).toHaveBeenCalledTimes(1);
111
116
  expect(commitZ).toHaveBeenCalledWith(
112
117
  expect.arrayContaining([expect.objectContaining({ element: back, id: "back" })]),
@@ -159,3 +164,6 @@ describe("DOM editing package exports", () => {
159
164
  await act(async () => root.unmount());
160
165
  });
161
166
  });
167
+
168
+ vi.mock("./utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
169
+ import { trackStudioEvent } from "./utils/studioTelemetry";
@@ -94,7 +94,7 @@ export type DomEditCommitDeclineReason =
94
94
  | "persist-failed";
95
95
 
96
96
  export type DomEditCommitOutcome =
97
- | { ok: true; persistence?: DomEditPersistOutcome }
97
+ | { ok: true; persistence?: DomEditPersistOutcome; changed?: boolean }
98
98
  | { ok: false; reason: DomEditCommitDeclineReason };
99
99
 
100
100
  export function domEditCommitDeclined(reason: DomEditCommitDeclineReason): DomEditCommitOutcome {
@@ -1,3 +1,4 @@
1
+ import type { DomEditPersistOutcome } from "./domEditCommitTypes";
1
2
  import { getDomEditTargetKey, type DomEditSelection } from "../components/editor/domEditing";
2
3
  import {
3
4
  applyElementPositionOffset,
@@ -23,7 +24,7 @@ export interface ElementOffsetStagerDeps {
23
24
  selection: DomEditSelection,
24
25
  patches: PatchOperation[],
25
26
  options: { label: string; coalesceKey: string; coalesceMs?: number },
26
- ) => Promise<void>;
27
+ ) => Promise<DomEditPersistOutcome | undefined>;
27
28
  showToast: (message: string, tone?: "error" | "info") => void;
28
29
  readOnlyPreview?: boolean;
29
30
  }
@@ -59,7 +60,7 @@ function stagePlainTranslate(
59
60
  selection: DomEditSelection,
60
61
  next: { x: number; y: number },
61
62
  coalesceKey?: string,
62
- ): { save: () => Promise<void>; rollback: () => void } {
63
+ ): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
63
64
  const el = selection.element;
64
65
  const before = captureStudioPathOffset(el);
65
66
  const patches = writePlainMove(el, next);
@@ -85,9 +86,9 @@ export function stageElementOffset(
85
86
  next: { x: number; y: number },
86
87
  plainTranslate: boolean,
87
88
  coalesceKey?: string,
88
- ): { save: () => Promise<void>; rollback: () => void } {
89
+ ): { save: () => Promise<DomEditPersistOutcome | undefined>; rollback: () => void } {
89
90
  const el = selection.element;
90
- if (readOnlyPreview) return { save: () => Promise.resolve(), rollback: () => undefined };
91
+ if (readOnlyPreview) return { save: () => Promise.resolve(undefined), rollback: () => undefined };
91
92
  if (plainTranslate) {
92
93
  refuseGsapTakeover(el, showToast);
93
94
  return stagePlainTranslate(commitPositionPatchToHtml, selection, next, coalesceKey);
@@ -13,7 +13,7 @@ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeComp
13
13
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
14
14
  import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
15
15
  import { computeDraggedGsapPosition } from "./draggedGsapPosition";
16
- import type { RuntimeTweenChange } from "./gsapRuntimePatch";
16
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
17
17
  import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
18
18
  import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
19
19
  import { GsapEditBlockedError } from "./gsapEditOutcome";
@@ -23,25 +23,7 @@ export {
23
23
  findSizeSetAnimation,
24
24
  } from "./gsapDragStaticSetHelpers";
25
25
  export interface GsapDragCommitCallbacks {
26
- commitMutation: (
27
- selection: DomEditSelection,
28
- mutation: Record<string, unknown>,
29
- options: {
30
- label: string;
31
- coalesceKey?: string;
32
- coalesceMs?: number;
33
- softReload?: boolean;
34
- skipReload?: boolean;
35
- beforeReload?: () => void;
36
- /**
37
- * Value-only fast path: when set, `runCommit` patches the changed tween in
38
- * the preview runtime in place (instant, no re-run) and only falls back to
39
- * the soft reload if the patch can't be safely applied. Attached only to
40
- * value-only `set` commits; structural/keyframe commits omit it.
41
- */
42
- instantPatch?: { selector: string; change: RuntimeTweenChange };
43
- },
44
- ) => Promise<void>;
26
+ commitMutation: CommitMutation;
45
27
  fetchAnimations?: () => Promise<GsapAnimation[]>;
46
28
  }
47
29
 
@@ -81,7 +81,12 @@ async function extendTweenAndAddKeyframe(
81
81
  duration: roundTo3(newDuration),
82
82
  keyframes: remappedKfs,
83
83
  },
84
- { label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
84
+ {
85
+ label: `Move layer (extended keyframe)`,
86
+ keyframeAction: "add",
87
+ softReload: true,
88
+ beforeReload,
89
+ },
85
90
  );
86
91
  }
87
92
 
@@ -339,7 +344,12 @@ export async function commitGsapPositionFromDrag(
339
344
  keyframes: temporalKeyframes,
340
345
  ease: "none",
341
346
  },
342
- { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
347
+ {
348
+ label: "Move layer (new keyframe)",
349
+ keyframeAction: "add",
350
+ softReload: true,
351
+ beforeReload: restoreOffset,
352
+ },
343
353
  );
344
354
  return;
345
355
  }
@@ -449,7 +459,12 @@ export async function commitGsapPositionFromDrag(
449
459
  existingPosAnim
450
460
  ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf }
451
461
  : { type: "add-with-keyframes", ...baseKf },
452
- { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
462
+ {
463
+ label: "Move layer (from extended)",
464
+ keyframeAction: "add",
465
+ softReload: true,
466
+ beforeReload: restoreOffset,
467
+ },
453
468
  );
454
469
  } else {
455
470
  const coalesceKey = `gsap:convert-drag:${anim.id}`;
@@ -0,0 +1,161 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
3
+ import { observeGsapGesture } from "./gsapGestureOutcome";
4
+ import type { CommitMutation, MutationResult } from "./gsapScriptCommitTypes";
5
+ import { trackStudioEvent } from "../utils/studioTelemetry";
6
+
7
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
8
+ beforeEach(() => vi.clearAllMocks());
9
+ const selection = {} as Parameters<CommitMutation>[0];
10
+ const mutation = {
11
+ type: "add-keyframe",
12
+ animationId: "private-target",
13
+ properties: { text: "private-content" },
14
+ };
15
+ function writer(result?: MutationResult): CommitMutation {
16
+ const commit: CommitMutation = async (_selection, _mutation, options) => {
17
+ if (result) options.onResult?.(result);
18
+ };
19
+ commit.batch = async (_calls, options) => {
20
+ if (result) options.onResult?.(result);
21
+ };
22
+ return commit;
23
+ }
24
+
25
+ describe("GSAP gesture usage", () => {
26
+ it("counts a multi-write add gesture once from successful writer results", async () => {
27
+ const observed = observeGsapGesture(
28
+ writer({ ok: true, changed: true, mutationChanges: [true] }),
29
+ );
30
+ const onResult = vi.fn();
31
+ await observed.commit!(
32
+ selection,
33
+ { type: "convert-to-keyframes" },
34
+ { label: "Convert", onResult },
35
+ );
36
+ await observed.commit!(selection, mutation, { label: "Add" });
37
+ expect(trackStudioEvent).not.toHaveBeenCalled();
38
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
39
+ expect(onResult).toHaveBeenCalledWith({ ok: true, changed: true, mutationChanges: [true] });
40
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
41
+ });
42
+
43
+ it("counts a batched insertion once", async () => {
44
+ const observed = observeGsapGesture(
45
+ writer({ ok: true, changed: true, mutationChanges: [true, true] }),
46
+ );
47
+ await observed.commit!.batch!(
48
+ [
49
+ { selection, mutation, options: { label: "Add" } },
50
+ { selection, mutation, options: { label: "Add" } },
51
+ ],
52
+ { label: "Add together" },
53
+ );
54
+ expect(observed.finish().changed).toBe(true);
55
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
56
+ });
57
+
58
+ it("retains insertion metadata from an earlier member of a mixed batch", async () => {
59
+ const observed = observeGsapGesture(
60
+ writer({ ok: true, changed: true, mutationChanges: [true, true] }),
61
+ );
62
+ await observed.commit!.batch!(
63
+ [
64
+ {
65
+ selection,
66
+ mutation: { type: "replace-with-keyframes" },
67
+ options: { label: "Extend", keyframeAction: "add" },
68
+ },
69
+ { selection, mutation: { type: "update-keyframe" }, options: { label: "Update" } },
70
+ ],
71
+ { label: "Update" },
72
+ );
73
+ observed.finish();
74
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
75
+ });
76
+
77
+ it("uses semantic insertion metadata for a replacement", async () => {
78
+ const observed = observeGsapGesture(
79
+ writer({ ok: true, changed: true, mutationChanges: [true] }),
80
+ );
81
+ await observed.commit!(
82
+ selection,
83
+ { type: "replace-with-keyframes" },
84
+ { label: "Replace", keyframeAction: "add" },
85
+ );
86
+ observed.finish();
87
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
88
+ });
89
+
90
+ it.each([undefined, { ok: true }, { ok: true, changed: false }, { ok: false, changed: true }])(
91
+ "does not count resolution without a changed successful write (%j)",
92
+ async (result) => {
93
+ const observed = observeGsapGesture(writer(result));
94
+ await observed.commit!(selection, mutation, { label: "Add" });
95
+ expect(observed.finish()).toEqual({ ok: true, changed: false });
96
+ expect(trackStudioEvent).not.toHaveBeenCalled();
97
+ },
98
+ );
99
+
100
+ it("does not count missing writers or an unfinished group", async () => {
101
+ expect(observeGsapGesture(null).finish()).toEqual({ ok: true, changed: false });
102
+ const observed = observeGsapGesture(writer());
103
+ await observed.commit!(selection, mutation, { label: "Add" });
104
+ expect(observed.finish(true).changed).toBe(false);
105
+ expect(trackStudioEvent).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it("reports a successful plain DOM gesture without inventing keyframes", () => {
109
+ expect(observeGsapGesture(null).finish(true).changed).toBe(true);
110
+ expect(trackStudioEvent).not.toHaveBeenCalled();
111
+ });
112
+ });
113
+
114
+ it.each([
115
+ [[false, true], 0],
116
+ [[true, false], 1],
117
+ [undefined, 0],
118
+ ] as const)(
119
+ "attributes mixed batches only to changed members (%j)",
120
+ async (mutationChanges, count) => {
121
+ const result = {
122
+ ok: true,
123
+ changed: true,
124
+ mutationChanges: mutationChanges && [...mutationChanges],
125
+ };
126
+ const observed = observeGsapGesture(writer(result));
127
+ const mutations = [mutation, { type: "update-property" }];
128
+ await observed.commit!.batch!(
129
+ mutations.map((mutation) => ({ selection, mutation, options: { label: "Edit" } })),
130
+ { label: "Edit" },
131
+ );
132
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
133
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
134
+ if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
135
+ vi.mocked(trackStudioEvent).mockClear();
136
+ trackKeyframeCommit(mutations, result, { label: "Edit" });
137
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
138
+ },
139
+ );
140
+
141
+ it("excludes a suppressed preparation from mixed-batch attribution", async () => {
142
+ const result = { ok: true, changed: true, mutationChanges: [true, false] };
143
+ const calls = [
144
+ {
145
+ selection,
146
+ mutation: { type: "convert-to-keyframes" },
147
+ options: { label: "Prepare", keyframeTelemetry: false },
148
+ },
149
+ { selection, mutation, options: { label: "Add" } },
150
+ ];
151
+ const observed = observeGsapGesture(writer(result));
152
+ await observed.commit!.batch!(calls, { label: "Add" });
153
+ expect(observed.finish()).toEqual({ ok: true, changed: true });
154
+ trackKeyframeCommit(
155
+ calls.map((call) => call.mutation),
156
+ result,
157
+ { label: "Add" },
158
+ calls.map((call) => call.options),
159
+ );
160
+ expect(trackStudioEvent).not.toHaveBeenCalled();
161
+ });
@@ -0,0 +1,64 @@
1
+ import type {
2
+ CommitMutationCall,
3
+ CommitMutation,
4
+ CommitMutationOptions,
5
+ MutationResult,
6
+ } from "./gsapScriptCommitTypes";
7
+ import {
8
+ keyframeUsageActions,
9
+ changedMutationIndices,
10
+ primaryKeyframeAction,
11
+ trackKeyframeUsage,
12
+ type KeyframeUsageAction,
13
+ } from "../utils/keyframeUsage";
14
+ import type { GeometryCommitResult } from "../utils/previewFeatureUsage";
15
+
16
+ export function observeGsapGesture(writer: CommitMutation | null) {
17
+ let changed = false;
18
+ let pendingResults = 0;
19
+ const actions = new Set<KeyframeUsageAction>();
20
+ const observe = (calls: CommitMutationCall[], options: CommitMutationOptions) => {
21
+ pendingResults += 1;
22
+ return {
23
+ ...options,
24
+ keyframeTelemetry: false,
25
+ onResult: (result: MutationResult) => {
26
+ pendingResults -= 1;
27
+ options.onResult?.(result);
28
+ if (!result.ok || result.changed !== true) return;
29
+ changed = true;
30
+ const members = changedMutationIndices(
31
+ result,
32
+ calls.length,
33
+ calls.map((call) => call.options),
34
+ ).map((index) => calls[index]!);
35
+ for (const action of keyframeUsageActions(
36
+ members.map((call) => call.mutation),
37
+ members.map((call) => call.options.keyframeAction),
38
+ ))
39
+ actions.add(action);
40
+ },
41
+ };
42
+ };
43
+ let commit: CommitMutation | null = null;
44
+ if (writer) {
45
+ commit = (selection, mutation, options) =>
46
+ writer(selection, mutation, observe([{ selection, mutation, options }], options));
47
+ if (writer.batch) {
48
+ const batch = writer.batch;
49
+ commit.batch = (calls, options) => batch(calls, observe(calls, options));
50
+ }
51
+ }
52
+ return {
53
+ commit,
54
+ recordDomResult: (result: { changed: boolean } | undefined) => {
55
+ changed ||= result?.changed === true;
56
+ },
57
+ finish: (domChanged = false): GeometryCommitResult => {
58
+ if (pendingResults !== 0) return { ok: true, changed: false };
59
+ const action = primaryKeyframeAction(actions);
60
+ if (action) trackKeyframeUsage(action);
61
+ return { ok: true, changed: changed || domChanged };
62
+ },
63
+ };
64
+ }
@@ -4,23 +4,14 @@ import { absoluteToPercentageForAnimation, findTweenAtTime } from "../utils/glob
4
4
  import { PROPERTY_DEFAULTS, selectorFromSelection, writeTargetSelector } from "./gsapShared";
5
5
  import { roundToCenti } from "../utils/rounding";
6
6
 
7
- type CommitFn = (
8
- selection: DomEditSelection,
9
- mutation: Record<string, unknown>,
10
- options: {
11
- label: string;
12
- coalesceKey?: string;
13
- softReload?: boolean;
14
- skipReload?: boolean;
15
- },
16
- ) => Promise<void>;
7
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
17
8
 
18
9
  export async function commitKeyframeAtTimeImpl(
19
10
  selection: DomEditSelection,
20
11
  absoluteTime: number,
21
12
  animations: GsapAnimation[],
22
13
  properties: Record<string, number | string>,
23
- commitMutation: CommitFn,
14
+ commitMutation: CommitMutation,
24
15
  ): Promise<void> {
25
16
  // Matching an authored tween is a string compare against what the author
26
17
  // wrote, so it keeps using the selection's own selector; the NEW tween below
@@ -38,7 +29,7 @@ export async function commitKeyframeAtTimeImpl(
38
29
  await commitMutation(
39
30
  selection,
40
31
  { type: "convert-to-keyframes", animationId: tween.id },
41
- { label: "Convert to keyframes", skipReload: true },
32
+ { label: "Convert to keyframes", keyframeTelemetry: false, skipReload: true },
42
33
  );
43
34
  }
44
35
 
@@ -3,10 +3,16 @@ import { afterEach, expect, it, vi } from "vitest";
3
3
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
5
5
  import { usePlayerStore } from "../player/store/playerStore";
6
+ import { observeGsapGesture } from "./gsapGestureOutcome";
7
+ import { trackStudioEvent } from "../utils/studioTelemetry";
8
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
6
9
  import { computeCurrentPercentage } from "./gsapDragCommit";
7
10
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
8
11
 
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
13
+
9
14
  afterEach(() => {
15
+ vi.clearAllMocks();
10
16
  vi.restoreAllMocks();
11
17
  usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
12
18
  });
@@ -553,3 +559,30 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
553
559
  expect(handled).toEqual({ status: "element-size" });
554
560
  expect(commitMutation).not.toHaveBeenCalled();
555
561
  });
562
+
563
+ it.each([
564
+ [5, 1],
565
+ [0.5, 1],
566
+ [0.2, 0],
567
+ ])("counts scale resize insertion at %ss", async (time, count) => {
568
+ const el = makeGradedElement();
569
+ const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1 });
570
+ usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
571
+ const writer: CommitMutation = async (_selection, _mutation, options) => {
572
+ options.onResult?.({ ok: true, changed: true });
573
+ };
574
+ const outcome = observeGsapGesture(writer);
575
+ const result = await tryGsapResizeIntercept(
576
+ { id: "clip", selector: "#clip", element: el } as DomEditSelection,
577
+ { width: 700, height: 400 },
578
+ [keyframedScaleFixture()],
579
+ iframe,
580
+ outcome.commit!,
581
+ );
582
+ expect(result.status).toBe("persisted");
583
+ outcome.finish();
584
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
585
+ if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
586
+ el.remove();
587
+ document.querySelector("#__hf_color_grading_clip")?.remove();
588
+ });
@@ -541,6 +541,7 @@ export async function tryGsapResizeIntercept(
541
541
  ? `Resize (extended to ${ct.toFixed(2)}s)`
542
542
  : `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
543
543
  softReload: true,
544
+ ...(!collidingKf ? { keyframeAction: "add" as const } : {}),
544
545
  },
545
546
  );
546
547
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
@@ -428,7 +428,7 @@ export const GSAP_TRANSFORM_KEYS = new Set(
428
428
  );
429
429
 
430
430
  /** Whether a live timeline tween or hold writes any of `channels` on `el`. Sync, no fetch. */
431
- function gsapWritesChannels(el: Element, channels: string[]): boolean {
431
+ export function gsapWritesChannels(el: Element, channels: string[]): boolean {
432
432
  const win = el.ownerDocument.defaultView as { __timelines?: Record<string, RuntimeTimeline> };
433
433
  return Object.values(win?.__timelines ?? {}).some((tl) =>
434
434
  (tl?.getChildren?.(true) ?? []).some(
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
+ import { gsap } from "gsap";
3
4
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
4
5
  import {
5
6
  COLOR_GRADING_SOURCE_HIDDEN_ATTR,
@@ -109,3 +110,50 @@ describe("color-grading opacity truth", () => {
109
110
  expect(readGsapProperty(iframe, "#clip", "opacity")).toBe(0.3);
110
111
  });
111
112
  });
113
+
114
+ describe("readAllAnimatedProperties with a sibling tween", () => {
115
+ it("leaves a sibling tween's rotation to that tween after an opacity keyframe edit", () => {
116
+ const el = document.createElement("div");
117
+ el.id = "clip";
118
+ document.body.appendChild(el);
119
+ const build = (keyframes: Record<string, Record<string, number>>) => {
120
+ const tl = gsap.timeline({ paused: true });
121
+ tl.to(el, { duration: 4, ease: "none", keyframes }, 0);
122
+ tl.to(el, { rotation: 90, duration: 3 }, 0);
123
+ return tl;
124
+ };
125
+ const live = build({ "0%": { opacity: 0 }, "100%": { opacity: 1 } });
126
+ live.seek(2);
127
+ const iframe = {
128
+ contentWindow: { __timelines: { main: live }, gsap },
129
+ contentDocument: document,
130
+ } as unknown as HTMLIFrameElement;
131
+ const anim = {
132
+ id: "#clip-to-0-visual",
133
+ targetSelector: "#clip",
134
+ method: "to",
135
+ properties: {},
136
+ keyframes: {
137
+ keyframes: [
138
+ { percentage: 0, properties: { opacity: 0 } },
139
+ { percentage: 100, properties: { opacity: 1 } },
140
+ ],
141
+ },
142
+ } as unknown as GsapAnimation;
143
+
144
+ // commitKeyframeProps: these values plus the edit at the playhead, and backfilled into the rest.
145
+ const read = readAllAnimatedProperties(iframe, "#clip", anim);
146
+ live.kill();
147
+ gsap.set(el, { clearProps: "all" });
148
+ const edited = build({
149
+ "0%": { ...read, opacity: 0 },
150
+ "50%": { ...read, opacity: 0.3 },
151
+ "100%": { ...read, opacity: 1 },
152
+ });
153
+ edited.seek(3.2);
154
+ edited.seek(3.5);
155
+
156
+ expect(gsap.getProperty(el, "rotation")).toBe(90);
157
+ edited.kill();
158
+ });
159
+ });
@@ -8,6 +8,7 @@ import {
8
8
  HF_COLOR_GRADING_CANVAS_ID_PREFIX,
9
9
  } from "@hyperframes/core/color-grading";
10
10
  import { getIframeGsap, queryIframeElement, type IframeGsap } from "./gsapShared";
11
+ import { gsapWritesChannels } from "./gsapRuntimeKeyframes";
11
12
  import { roundTo3 } from "../utils/rounding";
12
13
 
13
14
  /**
@@ -50,27 +51,6 @@ export function readGsapProperty(
50
51
  }
51
52
 
52
53
  export const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]);
53
- const GSAP_CONFIG_KEYS = new Set([
54
- "duration",
55
- "ease",
56
- "delay",
57
- "stagger",
58
- "id",
59
- "onComplete",
60
- "onUpdate",
61
- "onStart",
62
- "onRepeat",
63
- "repeat",
64
- "yoyo",
65
- "repeatDelay",
66
- "paused",
67
- "immediateRender",
68
- "lazy",
69
- "overwrite",
70
- "keyframes",
71
- "parent",
72
- ]);
73
-
74
54
  export function readAllAnimatedProperties(
75
55
  iframe: HTMLIFrameElement | null,
76
56
  selector: string,
@@ -115,74 +95,7 @@ export function readAllAnimatedProperties(
115
95
  }
116
96
  }
117
97
 
118
- const otherTweenProps = new Set<string>();
119
- try {
120
- const win = iframe.contentWindow as unknown as { __timelines?: Record<string, unknown> };
121
- const timelines = win.__timelines;
122
- if (timelines) {
123
- for (const tl of Object.values(timelines)) {
124
- const tlObj = tl as {
125
- getChildren?: (
126
- deep: boolean,
127
- ) => Array<{ targets?: () => Element[]; vars?: Record<string, unknown> }>;
128
- };
129
- if (!tlObj?.getChildren) continue;
130
- for (const child of tlObj.getChildren(true)) {
131
- if (typeof child.targets !== "function") continue;
132
- const targets = child.targets();
133
- if (!targets.includes(el)) continue;
134
- const vars = child.vars;
135
- if (!vars) continue;
136
- for (const k of Object.keys(vars)) {
137
- if (!GSAP_CONFIG_KEYS.has(k) && inGroup(k)) otherTweenProps.add(k);
138
- }
139
- }
140
- }
141
- }
142
- } catch {}
143
- for (const p of groupedPropKeys) otherTweenProps.delete(p);
144
-
145
- // Tier 1: Transform + visual properties with universal CSS defaults.
146
- // Safe to compare against hardcoded values — these are always 0 or 1
147
- // regardless of the element's stylesheet.
148
- const UNIVERSAL_BASELINE: Record<string, number> = {
149
- opacity: 1,
150
- scale: 1,
151
- scaleX: 1,
152
- scaleY: 1,
153
- scaleZ: 1,
154
- rotation: 0,
155
- rotationX: 0,
156
- rotationY: 0,
157
- skewX: 0,
158
- skewY: 0,
159
- z: 0,
160
- xPercent: 0,
161
- yPercent: 0,
162
- transformPerspective: 0,
163
- blur: 0,
164
- brightness: 1,
165
- contrast: 1,
166
- saturate: 1,
167
- hueRotate: 0,
168
- grayscale: 0,
169
- sepia: 0,
170
- invert: 0,
171
- };
172
- // Collect all properties that ANY tween on this element explicitly targets.
173
- // Only capture baseline values for these — GSAP reports non-default values
174
- // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes.
175
- const allTweenedProps = new Set([...groupedPropKeys, ...otherTweenProps]);
176
- for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) {
177
- if (prop in result) continue;
178
- if (!allTweenedProps.has(prop)) continue;
179
- const val = readLiveGsapValue(gsap, el, prop);
180
- if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) {
181
- result[prop] = roundTo3(val);
182
- }
183
- }
184
-
185
- // Tier 2: Element-dependent properties — their "default" depends on the
98
+ // Element-dependent properties — their "default" depends on the
186
99
  // stylesheet, so we compare GSAP's runtime value against the element's
187
100
  // computed CSS value. Only capture if GSAP has actively changed it.
188
101
  const COMPUTED_BASELINE = [
@@ -209,7 +122,7 @@ export function readAllAnimatedProperties(
209
122
  for (const prop of COMPUTED_BASELINE) {
210
123
  if (prop in result) continue;
211
124
  if (!inGroup(prop)) continue;
212
- if (otherTweenProps.has(prop)) continue;
125
+ if (gsapWritesChannels(el, [prop])) continue;
213
126
  const gsapVal = Number(gsap.getProperty(el, prop));
214
127
  if (!Number.isFinite(gsapVal)) continue;
215
128
  let cssVal = NaN;
@@ -3,10 +3,12 @@ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { PublishSdkSession } from "../utils/sdkCutover";
5
5
  import type { RuntimeTweenChange } from "./gsapRuntimePatch";
6
+ import type { KeyframeUsageAction } from "../utils/keyframeUsage";
6
7
 
7
8
  export interface MutationResult {
8
9
  ok: boolean;
9
10
  changed?: boolean;
11
+ mutationChanges?: boolean[];
10
12
  parsed?: ParsedGsap;
11
13
  before?: string;
12
14
  after?: string;
@@ -15,6 +17,9 @@ export interface MutationResult {
15
17
 
16
18
  export interface CommitMutationOptions {
17
19
  label: string;
20
+ keyframeTelemetry?: boolean;
21
+ keyframeAction?: KeyframeUsageAction;
22
+ keyframeProperty?: string;
18
23
  /** Observe the durable writer result without duplicating the request path. */
19
24
  onResult?: (result: MutationResult) => void;
20
25
  coalesceKey?: string;