@hyperframes/studio 0.8.114 → 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 (155) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-C-9N8nk0.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BoUM6Xqb.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-CAvZ0AA-.js} +201 -201
  4. package/dist/assets/{index-C9N-DGjC.js → index-CVuorIMv.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  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 +43 -19
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +2939 -2596
  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 +9 -8
  19. package/src/App.tsx +2 -2
  20. package/src/components/LinkedSelectionToggle.tsx +3 -2
  21. package/src/components/dock/Dock.tsx +5 -7
  22. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  23. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  24. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  25. package/src/components/editor/DomEditOverlay.tsx +5 -1
  26. package/src/components/editor/FileTreeNodes.tsx +13 -10
  27. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  28. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  29. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  30. package/src/components/editor/SnapToolbar.tsx +43 -5
  31. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  32. package/src/components/editor/audioFxTelemetry.ts +2 -3
  33. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  34. package/src/components/editor/cropResize.ts +4 -3
  35. package/src/components/editor/domEditingDom.ts +4 -0
  36. package/src/components/editor/domEditingLayers.ts +1 -5
  37. package/src/components/editor/manualEdits.ts +1 -0
  38. package/src/components/editor/manualEditsParsing.ts +7 -0
  39. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  40. package/src/components/editor/marqueeCommit.ts +24 -8
  41. package/src/components/editor/motionPathCommit.test.ts +29 -1
  42. package/src/components/editor/motionPathCommit.ts +21 -9
  43. package/src/components/editor/probeSourceElement.test.ts +45 -0
  44. package/src/components/editor/probeSourceElement.ts +39 -15
  45. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  46. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  47. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  48. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  49. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  50. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  51. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  52. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  53. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  54. package/src/components/editor/propertyPanelTypes.ts +6 -3
  55. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  56. package/src/components/editor/useDomEditNudge.ts +2 -0
  57. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  58. package/src/components/editor/useDomEditZOrder.ts +12 -2
  59. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  60. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  61. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  62. package/src/components/ui/menuStyle.test.ts +61 -0
  63. package/src/components/ui/menuStyle.ts +10 -0
  64. package/src/contexts/DomEditContext.tsx +1 -1
  65. package/src/contexts/TimelineEditContext.tsx +1 -0
  66. package/src/domEditExports.test.tsx +9 -1
  67. package/src/hooks/domEditCommitRunner.ts +1 -1
  68. package/src/hooks/elementOffsetStager.ts +5 -4
  69. package/src/hooks/gsapDragCommit.ts +2 -20
  70. package/src/hooks/gsapDragPositionCommit.ts +18 -3
  71. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  72. package/src/hooks/gsapGestureOutcome.ts +64 -0
  73. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  74. package/src/hooks/gsapResizeIntercept.test.ts +33 -0
  75. package/src/hooks/gsapResizeIntercept.ts +1 -0
  76. package/src/hooks/gsapScriptCommitTypes.ts +5 -0
  77. package/src/hooks/gsapShared.writeTarget.test.ts +20 -0
  78. package/src/hooks/keyframeCacheAstLoad.ts +6 -5
  79. package/src/hooks/plainRotation.ts +3 -2
  80. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  81. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  82. package/src/hooks/refreshTimelineRowText.ts +29 -0
  83. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  84. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  85. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  86. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  87. package/src/hooks/useDomEditTextCommits.test.tsx +18 -0
  88. package/src/hooks/useDomEditTextCommits.ts +5 -1
  89. package/src/hooks/useDomEditWiring.ts +4 -2
  90. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  91. package/src/hooks/useDomGeometryCommit.ts +7 -4
  92. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  93. package/src/hooks/useDomGeometryCommits.ts +3 -2
  94. package/src/hooks/useDomSelection.test.ts +26 -0
  95. package/src/hooks/useDomSelection.ts +10 -3
  96. package/src/hooks/useDomSelectionTypes.ts +6 -1
  97. package/src/hooks/useEnableKeyframes.test.ts +2 -2
  98. package/src/hooks/useEnableKeyframes.ts +5 -3
  99. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +102 -0
  100. package/src/hooks/useExternalFileChangeCoordinator.ts +31 -6
  101. package/src/hooks/useGestureCommit.test.tsx +225 -55
  102. package/src/hooks/useGestureCommit.ts +86 -55
  103. package/src/hooks/useGsapAwareEditing.test.tsx +11 -6
  104. package/src/hooks/useGsapAwareEditing.ts +96 -195
  105. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  106. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  107. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  108. package/src/hooks/useGsapScriptCommits.test.tsx +57 -0
  109. package/src/hooks/useGsapScriptCommits.ts +3 -0
  110. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  111. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  112. package/src/hooks/useGsapTweenCache.ts +18 -12
  113. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  114. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  115. package/src/hooks/usePreviewInteraction.ts +2 -0
  116. package/src/hooks/useRenderClipContent.test.ts +18 -0
  117. package/src/hooks/useRenderClipContent.ts +4 -5
  118. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  119. package/src/hooks/useTimelineDeleteOps.ts +21 -27
  120. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  121. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  122. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  123. package/src/player/components/ClipContextMenu.tsx +33 -31
  124. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  125. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  126. package/src/player/components/TextClipContent.test.ts +19 -0
  127. package/src/player/components/TextClipContent.tsx +27 -0
  128. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  129. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  130. package/src/player/components/clipMenuToolItems.tsx +13 -9
  131. package/src/player/components/timelineCallbacks.ts +1 -0
  132. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  133. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  134. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  135. package/src/player/components/timelineEditing.ts +3 -1
  136. package/src/player/components/timelineLinkSelection.ts +35 -1
  137. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  138. package/src/player/components/useTimelineClipDrag.ts +6 -7
  139. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  140. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  141. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  142. package/src/player/lib/timelineElementHelpers.ts +6 -15
  143. package/src/player/lib/timelineText.ts +40 -0
  144. package/src/player/store/timelineElement.ts +12 -0
  145. package/src/styles/theme.css +3 -0
  146. package/src/utils/keyframeUsage.ts +79 -0
  147. package/src/utils/previewFeatureUsage.test.ts +32 -0
  148. package/src/utils/previewFeatureUsage.ts +46 -0
  149. package/src/utils/studioTelemetry.test.ts +107 -0
  150. package/src/utils/studioTelemetry.ts +49 -4
  151. package/src/webmcp/StudioAgentTools.tsx +2 -2
  152. package/dist/chunk-BIUXP3PG.js.map +0 -1
  153. package/dist/chunk-K47E2ZJA.js.map +0 -1
  154. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  155. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-D7VYFJJD.js.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2
2
  import { filename } from "./assetHelpers";
3
+ import { menuClasses } from "../ui/menuStyle";
3
4
 
4
5
  /** Reject names that would escape the asset directory or break paths. */
5
6
  function isValidAssetName(name: string): boolean {
@@ -94,8 +95,7 @@ export function ContextMenu({
94
95
  onClose();
95
96
  }, [renameDraft, asset, onRename, onClose]);
96
97
 
97
- const itemCls =
98
- "w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 outline-hidden active:bg-neutral-700/70 transition-colors";
98
+ const itemCls = `${menuClasses.row} ${menuClasses.rowEnabled} active:bg-neutral-700/70 transition-colors`;
99
99
 
100
100
  return (
101
101
  <div
@@ -110,7 +110,7 @@ export function ContextMenu({
110
110
  ref={menuRef}
111
111
  role="menu"
112
112
  aria-label={`Actions for ${filename(asset)}`}
113
- className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[160px] text-xs"
113
+ className={`${menuClasses.panel} absolute min-w-[160px] text-xs`}
114
114
  style={{ left: pos.x, top: pos.y }}
115
115
  onClick={(e) => e.stopPropagation()}
116
116
  >
@@ -147,7 +147,7 @@ export function ContextMenu({
147
147
  <button
148
148
  role="menuitem"
149
149
  onClick={() => setMode("confirm-delete")}
150
- className={`${itemCls} text-danger-ink`}
150
+ className={`${menuClasses.row} ${menuClasses.rowDanger} active:bg-neutral-700/70 transition-colors`}
151
151
  >
152
152
  Delete
153
153
  </button>
@@ -0,0 +1,61 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { menuClasses } from "./menuStyle";
5
+
6
+ const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
7
+ const root = join(__dirname, "../..");
8
+
9
+ /** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
10
+ const NOT_ROW_MENUS = new Set([
11
+ "player/components/AudioGainDialog.tsx",
12
+ "components/editor/CropPresetBar.tsx",
13
+ "components/editor/EaseCurveSection.tsx",
14
+ "player/components/SpeedMenu.tsx",
15
+ ]);
16
+
17
+ function sources(dir: string): string[] {
18
+ return readdirSync(dir).flatMap((name) => {
19
+ const path = join(dir, name);
20
+ if (statSync(path).isDirectory()) return sources(path);
21
+ return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
22
+ });
23
+ }
24
+
25
+ const files = sources(root).map((path) => ({
26
+ name: relative(root, path).replaceAll("\\", "/"),
27
+ text: readFileSync(path, "utf8"),
28
+ }));
29
+
30
+ describe("menu style", () => {
31
+ it("adds no padding or margin around a menu's rows", () => {
32
+ for (const key of ["panel", "group", "divider"] as const)
33
+ expect(menuClasses[key], key).not.toMatch(spacing);
34
+ });
35
+
36
+ it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
37
+ expect(menuClasses.panel).toContain("overflow-hidden");
38
+ expect(menuClasses.panel).toMatch(/(^| )rounded-/);
39
+ });
40
+
41
+ it("is the only place a menu panel's fill, border and shadow are written", () => {
42
+ const handBuilt = files
43
+ .filter(({ name }) => !NOT_ROW_MENUS.has(name))
44
+ .filter(({ text }) =>
45
+ [...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
46
+ ([, literal]) =>
47
+ /shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
48
+ ),
49
+ )
50
+ .map(({ name }) => name);
51
+ expect(handBuilt).toEqual([]);
52
+ });
53
+
54
+ it("is the panel of every role=menu, wherever its element is written", () => {
55
+ const unowned = files
56
+ .filter(({ text }) => text.includes('role="menu"'))
57
+ .filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
58
+ .map(({ name }) => name);
59
+ expect(unowned).toEqual([]);
60
+ });
61
+ });
@@ -0,0 +1,10 @@
1
+ /** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
2
+ export const menuClasses = {
3
+ panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
4
+ group: "empty:hidden border-b border-neutral-700/60",
5
+ divider: "border-t border-neutral-700/60",
6
+ row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
7
+ rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
8
+ rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
9
+ rowDisabled: "text-neutral-600 cursor-not-allowed",
10
+ } as const;
@@ -251,7 +251,7 @@ export function DomEditProvider({
251
251
  commitMutationRef.current = commitMutation;
252
252
 
253
253
  const stableCommitMutation = useCallback<DomEditActionsValue["commitMutation"]>(
254
- (mutation, options) => commitMutationRef.current(mutation, options),
254
+ (mutation, options, selection) => commitMutationRef.current(mutation, options, selection),
255
255
  [],
256
256
  );
257
257
 
@@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record<keyof TimelineEditCallbacks, true> = {
46
46
  onRazorSplit: true,
47
47
  onRazorSplitAll: true,
48
48
  onFreezeFrame: true,
49
+ clipMenuTools: true,
49
50
  onNotice: true,
50
51
  onDeleteKeyframe: true,
51
52
  onDeleteAllKeyframes: true,
@@ -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() };
@@ -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;
@@ -6,6 +6,10 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { buildStableSelector, getSelectorIndex } from "../components/editor/domEditingDom";
7
7
  import { resolveSelectorElementIds, tweenTargetsElement, writeTargetSelector } from "./gsapShared";
8
8
  import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
9
+ import { trackStudioEvent } from "../utils/studioTelemetry";
10
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
11
+ import type { CommitMutation } from "./gsapScriptCommitTypes";
12
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
9
13
  import { promoteSetToKeyframes } from "./useEnableKeyframes";
10
14
 
11
15
  afterEach(() => {
@@ -287,3 +291,19 @@ describe("commitKeyframeAtTimeImpl — no one-element target", () => {
287
291
  expect(commitMutation).not.toHaveBeenCalled();
288
292
  });
289
293
  });
294
+
295
+ it("counts a keyframe-at-time conversion and insertion as one add gesture", async () => {
296
+ vi.clearAllMocks();
297
+ const el = document.createElement("div");
298
+ el.id = "target";
299
+ document.body.append(el);
300
+ const selection = selectionFor(el);
301
+ const animations = parseGsapScript(
302
+ 'const tl = gsap.timeline(); tl.to("#target", {x:100,duration:4},0);',
303
+ ).animations;
304
+ const commit: CommitMutation = async (_selection, mutation, options) => {
305
+ trackKeyframeCommit([mutation], { ok: true, changed: true }, options);
306
+ };
307
+ await commitKeyframeAtTimeImpl(selection, 1, animations, { x: 25 }, commit);
308
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action: "add" });
309
+ });