@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
@@ -0,0 +1,114 @@
1
+ // @vitest-environment happy-dom
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useGsapKeyframeOps } from "./useGsapKeyframeOps";
6
+ import type { CutoverResult } from "../utils/sdkCutover";
7
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
+ import { trackStudioEvent } from "../utils/studioTelemetry";
9
+
10
+ const sdkWrite = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<CutoverResult>>());
11
+ vi.mock("../utils/sdkCutover", async (original) => ({
12
+ ...(await original<typeof import("../utils/sdkCutover")>()),
13
+ sdkGsapKeyframePersist: sdkWrite,
14
+ sdkGsapConvertToKeyframesPersist: sdkWrite,
15
+ sdkGsapRemoveAllKeyframesPersist: sdkWrite,
16
+ }));
17
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
18
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
19
+ type Params = Parameters<typeof useGsapKeyframeOps>[0];
20
+ let root: Root;
21
+ function mount() {
22
+ let api: ReturnType<typeof useGsapKeyframeOps> | undefined;
23
+ const legacy = vi.fn(async () => undefined);
24
+ function Probe() {
25
+ api = useGsapKeyframeOps({
26
+ activeCompPath: "index.html",
27
+ commitMutation: legacy,
28
+ commitMutationSafely: legacy,
29
+ trackGsapSaveFailure: vi.fn(),
30
+ sdkSession: {} as NonNullable<Params["sdkSession"]>,
31
+ sdkDeps: {
32
+ editHistory: { recordEdit: vi.fn(async () => undefined) },
33
+ writeProjectFile: vi.fn(async () => undefined),
34
+ reloadPreview: vi.fn(),
35
+ },
36
+ });
37
+ return null;
38
+ }
39
+ root = createRoot(document.createElement("div"));
40
+ act(() => root.render(<Probe />));
41
+ if (!api) throw new Error("hook did not mount");
42
+ return { api, legacy };
43
+ }
44
+ const selection = { id: "private-target", sourceFile: "private.html" } as DomEditSelection;
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ sdkWrite.mockResolvedValue({
48
+ status: "committed",
49
+ version: "v2",
50
+ before: "before",
51
+ after: "after",
52
+ });
53
+ });
54
+ afterEach(() => act(() => root.unmount()));
55
+ describe("SDK keyframe usage", () => {
56
+ it.each(["add", "convert", "remove_all", "reset"] as const)(
57
+ "counts a changed committed %s operation once",
58
+ async (action) => {
59
+ const { api, legacy } = mount();
60
+ await act(async () => {
61
+ if (action === "add")
62
+ await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12, y: 4 });
63
+ else if (action === "convert") await api.convertToKeyframes(selection, "private-animation");
64
+ else await api.removeAllKeyframes(selection, "private-animation", action);
65
+ });
66
+ expect(legacy).not.toHaveBeenCalled();
67
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", { action });
68
+ },
69
+ );
70
+ it("preserves property metadata on a single-property SDK add", async () => {
71
+ const { api } = mount();
72
+ await act(async () => api.addKeyframe(selection, "private-animation", 50, "x", 12));
73
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("keyframe", {
74
+ action: "add",
75
+ property: "x",
76
+ });
77
+ });
78
+ it("does not count unchanged SDK source", async () => {
79
+ sdkWrite.mockResolvedValue({
80
+ status: "committed",
81
+ version: "v2",
82
+ before: "same",
83
+ after: "same",
84
+ });
85
+ const { api } = mount();
86
+ await api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 });
87
+ expect(await api.removeAllKeyframes(selection, "private-animation")).toBe(false);
88
+ expect(trackStudioEvent).not.toHaveBeenCalled();
89
+ });
90
+ it("honors intermediate-write suppression in SDK paths", async () => {
91
+ const { api } = mount();
92
+ await api.convertToKeyframes(selection, "private-animation", undefined, undefined, {
93
+ keyframeTelemetry: false,
94
+ });
95
+ await api.addKeyframeBatch(
96
+ selection,
97
+ "private-animation",
98
+ 50,
99
+ { x: 12 },
100
+ { keyframeTelemetry: false },
101
+ );
102
+ await api.removeAllKeyframes(selection, "private-animation", "remove_all", false);
103
+ expect(trackStudioEvent).not.toHaveBeenCalled();
104
+ });
105
+ it("does not fall back or count after a failed SDK write", async () => {
106
+ sdkWrite.mockResolvedValue({ status: "failed", error: new Error("refused") });
107
+ const { api, legacy } = mount();
108
+ await expect(
109
+ api.addKeyframeBatch(selection, "private-animation", 50, { x: 12 }),
110
+ ).rejects.toThrow("refused");
111
+ expect(legacy).not.toHaveBeenCalled();
112
+ expect(trackStudioEvent).not.toHaveBeenCalled();
113
+ });
114
+ });
@@ -1,3 +1,4 @@
1
+ import { trackKeyframeUsage } from "../utils/keyframeUsage";
1
2
  // fallow-ignore-file code-duplication
2
3
  // Add/remove operation-family transaction shapes stay parallel until SDK graduation.
3
4
  import { useCallback } from "react";
@@ -139,10 +140,14 @@ export function useGsapKeyframeOps({
139
140
  coalesceKey: `gsap:${animationId}:kf:${percentage}`,
140
141
  },
141
142
  );
142
- if (cutoverCommittedOrThrow(handled)) return;
143
+ if (cutoverCommittedOrThrow(handled)) {
144
+ if (handled.before !== handled.after) trackKeyframeUsage("add", property);
145
+ return;
146
+ }
143
147
  }
144
148
  await commitMutation(selection, mutation, {
145
149
  label: `Add keyframe at ${percentage}%`,
150
+ keyframeProperty: property,
146
151
  softReload: true,
147
152
  });
148
153
  },
@@ -172,7 +177,11 @@ export function useGsapKeyframeOps({
172
177
  sdkDeps,
173
178
  toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
174
179
  );
175
- if (cutoverCommittedOrThrow(handled)) return;
180
+ if (cutoverCommittedOrThrow(handled)) {
181
+ if (handled.before !== handled.after && commitOverrides?.keyframeTelemetry !== false)
182
+ trackKeyframeUsage("add");
183
+ return;
184
+ }
176
185
  }
177
186
  return commitMutation(
178
187
  selection,
@@ -319,7 +328,11 @@ export function useGsapKeyframeOps({
319
328
  sdkDeps,
320
329
  toSdkPersistOptions("Convert to keyframes", commitOverrides),
321
330
  );
322
- if (cutoverCommittedOrThrow(handled)) return;
331
+ if (cutoverCommittedOrThrow(handled)) {
332
+ if (handled.before !== handled.after && commitOverrides.keyframeTelemetry !== false)
333
+ trackKeyframeUsage("convert");
334
+ return;
335
+ }
323
336
  }
324
337
  return commitMutation(
325
338
  selection,
@@ -333,11 +346,19 @@ export function useGsapKeyframeOps({
333
346
  );
334
347
 
335
348
  const removeAllKeyframes = useCallback(
336
- async (selection: DomEditSelection, animationId: string) => {
349
+ async (
350
+ selection: DomEditSelection,
351
+ animationId: string,
352
+ action: "remove_all" | "reset" = "remove_all",
353
+ telemetry = true,
354
+ ): Promise<boolean> => {
337
355
  const targetPath = selection.sourceFile || activeCompPath || "index.html";
338
356
  // A class/descendant selector can resolve a live element whose selection
339
357
  // deliberately has no id. The cache is still keyed by that DOM id.
340
- const cacheElementId = selection.id || selection.element?.id;
358
+ const clearCache = () => {
359
+ const cacheElementId = selection.id || selection.element?.id;
360
+ if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
361
+ };
341
362
  if (sdkSession && sdkDeps) {
342
363
  const handled = await sdkGsapRemoveAllKeyframesPersist(
343
364
  targetPath,
@@ -347,26 +368,31 @@ export function useGsapKeyframeOps({
347
368
  { label: "Remove all keyframes" },
348
369
  );
349
370
  if (cutoverCommittedOrThrow(handled)) {
350
- if (cacheElementId) clearKeyframeCacheForElement(targetPath, cacheElementId);
351
- return;
371
+ clearCache();
372
+ const changed = handled.before !== handled.after;
373
+ if (changed && telemetry) trackKeyframeUsage(action);
374
+ return changed;
352
375
  }
353
376
  }
377
+ let changed = false;
354
378
  await commitMutationSafely(
355
379
  selection,
356
380
  { type: "remove-all-keyframes", animationId },
357
381
  {
358
382
  label: "Remove all keyframes",
383
+ keyframeAction: action,
384
+ keyframeTelemetry: telemetry,
359
385
  softReload: true,
360
386
  // The committed result is the single success boundary: clearing
361
387
  // before it makes failed saves lie, while waiting for the reload leaves
362
388
  // stale diamonds visible during the source round-trip.
363
389
  onResult: (result) => {
364
- if (result.changed !== false && cacheElementId) {
365
- clearKeyframeCacheForElement(targetPath, cacheElementId);
366
- }
390
+ changed = result.ok && result.changed === true;
391
+ if (changed) clearCache();
367
392
  },
368
393
  },
369
394
  );
395
+ return changed;
370
396
  },
371
397
  [commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
372
398
  );
@@ -590,6 +590,63 @@ describe("runCommit — instantPatch wiring", () => {
590
590
  vi.unstubAllGlobals();
591
591
  });
592
592
 
593
+ it.each([
594
+ ["add-keyframe", "add"],
595
+ ["add-with-keyframes", "add"],
596
+ ["convert-to-keyframes", "convert"],
597
+ ["remove-all-keyframes", "remove_all"],
598
+ ])("counts %s only after a changed durable write", async (type, action) => {
599
+ mockFetchResult();
600
+ const deps = renderCommitHook();
601
+ await deps.api.commitMutation(
602
+ selection,
603
+ { type, properties: { text: "private-content" } },
604
+ { label: "Edit" },
605
+ );
606
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
607
+ ["keyframe", { action }],
608
+ ]);
609
+ });
610
+
611
+ it("counts reset with its explicit action and suppresses intermediate writes", async () => {
612
+ mockFetchResult();
613
+ const deps = renderCommitHook();
614
+ await deps.api.commitMutation(
615
+ selection,
616
+ { type: "convert-to-keyframes" },
617
+ { label: "Convert", keyframeTelemetry: false },
618
+ );
619
+ await deps.api.commitMutation(
620
+ selection,
621
+ { type: "remove-all-keyframes" },
622
+ { label: "Reset", keyframeAction: "reset" },
623
+ );
624
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([
625
+ ["keyframe", { action: "reset" }],
626
+ ]);
627
+ });
628
+
629
+ it("does not count a successful unchanged keyframe write", async () => {
630
+ mockFetchResult({ changed: false });
631
+ const deps = renderCommitHook();
632
+ await deps.api.commitMutation(selection, { type: "add-keyframe" }, { label: "Add" });
633
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
634
+ });
635
+
636
+ it("does not count a failed write that skipReload suppresses", async () => {
637
+ vi.stubGlobal(
638
+ "fetch",
639
+ vi.fn(async () => ({ ok: false, status: 500 })),
640
+ );
641
+ const deps = renderCommitHook();
642
+ await deps.api.commitMutation(
643
+ selection,
644
+ { type: "add-keyframe" },
645
+ { label: "Add", skipReload: true },
646
+ );
647
+ expect(trackStudioEvent.mock.calls.filter(([event]) => event === "keyframe")).toEqual([]);
648
+ });
649
+
593
650
  const NESTED_SCRIPT = 'window.__timelines["root"] = tl;';
594
651
  const SUB = `<template><div data-composition-id="sub"><div id="nwid" style="left: 40px"></div></div></template>`;
595
652
 
@@ -11,6 +11,7 @@ import {
11
11
  } from "../utils/gsapSoftReload";
12
12
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
13
13
  import { trackStudioEvent } from "../utils/studioTelemetry";
14
+ import { trackKeyframeCommit } from "../utils/keyframeUsage";
14
15
  import { serializeStudioFileMutation } from "../utils/studioFileMutationCoordinator";
15
16
  import { trackedStudioEdit } from "../utils/studioPendingEdits";
16
17
  import {
@@ -391,6 +392,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
391
392
  mutateGsapScript(pid, targetPath, mutation),
392
393
  );
393
394
  if (!result) return;
395
+ trackKeyframeCommit([mutation], result, options);
394
396
  options.onResult?.(result);
395
397
  await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
396
398
  }, [showToast, finalizeSuccessfulMutation]);
@@ -404,6 +406,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
404
406
  mutateGsapScriptBatch(pid, targetPath, mutations),
405
407
  );
406
408
  if (!result) return;
409
+ trackKeyframeCommit(mutations, result, options, calls.map((call) => call.options));
407
410
  options.onResult?.(result);
408
411
  // Each call brings its own fast-path patch; the batch wrote them all, so the
409
412
  // preview sync applies them all rather than just the last call's.
@@ -42,7 +42,7 @@ function makeParams(overrides: Partial<Params> = {}): Params {
42
42
  moveKeyframe: vi.fn().mockResolvedValue(true),
43
43
  resizeKeyframedTween: vi.fn().mockResolvedValue(true),
44
44
  convertToKeyframes: resolved(),
45
- removeAllKeyframes: resolved(),
45
+ removeAllKeyframes: vi.fn().mockResolvedValue(true),
46
46
  handleDomManualEditsReset: vi.fn(),
47
47
  selectedGsapAnimations: [],
48
48
  showToast: vi.fn(),
@@ -109,9 +109,16 @@ export function useGsapSelectionHandlers({
109
109
  duration?: number,
110
110
  commitOverrides?: Partial<CommitMutationOptions>,
111
111
  ) => Promise<void>;
112
- removeAllKeyframes: (sel: DomEditSelection, animId: string) => Promise<void>;
112
+ removeAllKeyframes: (
113
+ sel: DomEditSelection,
114
+ animId: string,
115
+ action?: "remove_all" | "reset",
116
+ telemetry?: boolean,
117
+ ) => Promise<boolean>;
113
118
 
114
- handleDomManualEditsReset: (sel: DomEditSelection) => Promise<void>;
119
+ handleDomManualEditsReset: (
120
+ sel: DomEditSelection,
121
+ ) => Promise<void | import("./domEditCommitTypes").DomEditPersistOutcome>;
115
122
  selectedGsapAnimations: GsapAnimation[];
116
123
  showToast: (message: string, tone?: "error" | "info") => void;
117
124
  }) {
@@ -323,7 +330,6 @@ export function useGsapSelectionHandlers({
323
330
  ) => {
324
331
  const sel = resolveWriteSelection(selectionOverride);
325
332
  if (!sel) return;
326
- trackStudioEvent("keyframe", { action: "add", property });
327
333
  addKeyframe(sel, animId, percentage, property, value);
328
334
  },
329
335
  [resolveWriteSelection, addKeyframe],
@@ -441,31 +447,31 @@ export function useGsapSelectionHandlers({
441
447
  );
442
448
 
443
449
  const handleGsapRemoveAllKeyframes = useCallback(
444
- (animId: string, selectionOverride?: DomEditSelection | null) => {
450
+ (animId: string, selectionOverride?: DomEditSelection | null, telemetry = true) => {
445
451
  const selection = resolveWriteSelection(selectionOverride);
446
452
  if (!selection) return Promise.resolve(false);
447
- return observeGsapMutation(
448
- removeAllKeyframes(selection, animId),
449
- selection,
450
- "remove-all-keyframes",
451
- "Remove all keyframes",
452
- );
453
+ return removeAllKeyframes(selection, animId, "remove_all", telemetry).catch((error) => {
454
+ trackGsapHandlerFailure(error, selection, "remove-all-keyframes", "Remove all keyframes");
455
+ return false;
456
+ });
453
457
  },
454
- [resolveWriteSelection, observeGsapMutation, removeAllKeyframes],
458
+ [resolveWriteSelection, trackGsapHandlerFailure, removeAllKeyframes],
455
459
  );
456
460
 
457
461
  const handleResetSelectedElementKeyframes = useCallback((): boolean => {
458
462
  if (!domEditSelection) return false;
459
463
  const withKeyframes = selectedGsapAnimations.find((a) => a.keyframes);
460
464
  if (!withKeyframes) return false;
461
- observeGsapMutation(
462
- removeAllKeyframes(domEditSelection, withKeyframes.id),
463
- domEditSelection,
464
- "remove-all-keyframes",
465
- "Remove all keyframes",
466
- );
465
+ void removeAllKeyframes(domEditSelection, withKeyframes.id, "reset").catch((error) => {
466
+ trackGsapHandlerFailure(
467
+ error,
468
+ domEditSelection,
469
+ "remove-all-keyframes",
470
+ "Remove all keyframes",
471
+ );
472
+ });
467
473
  return true;
468
- }, [domEditSelection, observeGsapMutation, removeAllKeyframes, selectedGsapAnimations]);
474
+ }, [domEditSelection, trackGsapHandlerFailure, removeAllKeyframes, selectedGsapAnimations]);
469
475
 
470
476
  return {
471
477
  handleGsapUpdateProperty,
@@ -290,17 +290,15 @@ export function usePopulateKeyframeCacheForFile(
290
290
  const domClipChildrenKey = usePlayerStore((s) =>
291
291
  s.domClipChildren.map((c) => `${c.id}<${c.hostId}`).join("|"),
292
292
  );
293
- const lastFetchKeyRef = useRef("");
293
+ const loadedRef = useRef<{ dataKey: string; files: Set<string> }>({
294
+ dataKey: "",
295
+ files: new Set(),
296
+ });
294
297
 
295
298
  const runtimeScanDoneRef = useRef("");
296
- const astFetchDoneRef = useRef("");
297
299
 
298
300
  useEffect(() => {
299
- const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
300
- if (fetchKey === lastFetchKeyRef.current) return;
301
- lastFetchKeyRef.current = fetchKey;
302
- runtimeScanDoneRef.current = "";
303
- astFetchDoneRef.current = "";
301
+ const dataKey = `kf-cache:${projectId}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`;
304
302
  if (!projectId) return;
305
303
 
306
304
  // The active file first: it owns the selection, and each file clears only
@@ -309,13 +307,21 @@ export function usePopulateKeyframeCacheForFile(
309
307
  const files = Array.from(
310
308
  new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]),
311
309
  );
312
- const doc = iframeRef?.current?.contentDocument;
310
+ const sameData = loadedRef.current.dataKey === dataKey;
311
+ const stale = sameData ? files.filter((sf) => !loadedRef.current.files.has(sf)) : files;
312
+ const covered = sameData ? new Set([...loadedRef.current.files, ...files]) : new Set(files);
313
+ loadedRef.current = { dataKey, files: covered };
313
314
  // Everything the previous scan cached for a file this one no longer covers
314
315
  // (the composition just switched away from) has no owner left to clear it.
315
- pruneKeyframeCacheToFiles(files);
316
- Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => {
317
- astFetchDoneRef.current = fetchKey;
318
- });
316
+ if (!sameData) pruneKeyframeCacheToFiles(files);
317
+ if (stale.length === 0) return;
318
+ runtimeScanDoneRef.current = "";
319
+ const doc = iframeRef?.current?.contentDocument;
320
+ for (const sf of stale) {
321
+ void populateKeyframeCacheFromAst(projectId, sf, doc).then((loaded) => {
322
+ if (!loaded && loadedRef.current.dataKey === dataKey) loadedRef.current.files.delete(sf);
323
+ });
324
+ }
319
325
  // elementCount is in the deps because new timeline elements (e.g. after a
320
326
  // sub-composition expand) need their keyframe cache populated immediately;
321
327
  // without it the effect won't re-run when elements appear/disappear.
@@ -8,8 +8,8 @@ import { usePlayerStore } from "../player/store/playerStore";
8
8
 
9
9
  Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
10
10
 
11
- function HookHost() {
12
- usePopulateKeyframeCacheForFile("demo", "index.html", 1);
11
+ function HookHost({ sourceFile = "index.html", version = 1 }) {
12
+ usePopulateKeyframeCacheForFile("demo", sourceFile, version);
13
13
  return null;
14
14
  }
15
15
 
@@ -67,4 +67,78 @@ describe("usePopulateKeyframeCacheForFile", () => {
67
67
  expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true);
68
68
  expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true);
69
69
  });
70
+
71
+ it("keeps every covered file when the selection switches between them, and refetches all on new data", async () => {
72
+ // Every click on a clip from another file flips `sourceFile`; a flip must not
73
+ // re-read every composition file, or a multi-select pays files x clicks fetches.
74
+ const urls: string[] = [];
75
+ vi.stubGlobal(
76
+ "fetch",
77
+ vi.fn((input: RequestInfo | URL) => {
78
+ urls.push(String(input));
79
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) });
80
+ }),
81
+ );
82
+ const render = async (props: { sourceFile: string; version: number }) => {
83
+ await act(async () => {
84
+ if (!root) {
85
+ container = document.createElement("div");
86
+ document.body.appendChild(container);
87
+ root = createRoot(container);
88
+ }
89
+ root.render(<HookHost {...props} />);
90
+ });
91
+ await act(async () => {
92
+ await Promise.resolve();
93
+ });
94
+ };
95
+
96
+ await render({ sourceFile: "index.html", version: 1 });
97
+ const loaded = urls.length;
98
+ await render({ sourceFile: "compositions/keyframe-lab.html", version: 1 });
99
+ expect(urls.length).toBe(loaded);
100
+ await render({ sourceFile: "index.html", version: 1 });
101
+ expect(urls.length).toBe(loaded);
102
+
103
+ await render({ sourceFile: "index.html", version: 2 });
104
+ expect(urls.length).toBe(loaded * 2);
105
+ });
106
+
107
+ it("asks again for a covered file whose first read failed", async () => {
108
+ const urls: string[] = [];
109
+ vi.stubGlobal(
110
+ "fetch",
111
+ vi.fn((input: RequestInfo | URL) => {
112
+ const url = String(input);
113
+ urls.push(url);
114
+ const failed =
115
+ url.includes("keyframe-lab.html") && urls.filter((u) => u === url).length === 1;
116
+ return Promise.resolve({
117
+ ok: !failed,
118
+ json: () => Promise.resolve({ animations: [] }),
119
+ });
120
+ }),
121
+ );
122
+ const render = async (sourceFile: string) => {
123
+ await act(async () => {
124
+ if (!root) {
125
+ container = document.createElement("div");
126
+ document.body.appendChild(container);
127
+ root = createRoot(container);
128
+ }
129
+ root.render(<HookHost sourceFile={sourceFile} />);
130
+ });
131
+ await act(async () => {
132
+ await Promise.resolve();
133
+ });
134
+ };
135
+
136
+ await render("index.html");
137
+ const labReads = () => urls.filter((u) => u.includes("keyframe-lab.html")).length;
138
+ expect(labReads()).toBe(1);
139
+ await render("compositions/keyframe-lab.html");
140
+ expect(labReads()).toBe(2);
141
+ await render("index.html");
142
+ expect(labReads()).toBe(2);
143
+ });
70
144
  });
@@ -299,3 +299,24 @@ describe("a refused move's toast", () => {
299
299
  ]);
300
300
  });
301
301
  });
302
+
303
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
304
+ import { trackStudioEvent } from "../utils/studioTelemetry";
305
+ it("counts additive preview selection only after resolving a hit", async () => {
306
+ vi.mocked(trackStudioEvent).mockClear();
307
+ const selection = makeSelection("Card", document.createElement("div"));
308
+ const applyDomSelection = vi.fn();
309
+ const { canvas, cleanup } = renderHarness({
310
+ previewIframe: createPreviewIframe(vi.fn()),
311
+ resolveDomSelectionFromPreviewPoint: vi.fn(async () => selection),
312
+ applyDomSelection,
313
+ });
314
+ await dispatchMouseDown(canvas, { shiftKey: true });
315
+ expect(applyDomSelection).toHaveBeenCalledWith(selection, { additive: true });
316
+ expect(trackStudioEvent).toHaveBeenCalledExactlyOnceWith("feature_used", {
317
+ feature: "multi_select",
318
+ surface: "preview",
319
+ method: "button",
320
+ });
321
+ cleanup();
322
+ });
@@ -1,3 +1,4 @@
1
+ import { trackPreviewFeatureUsed } from "../utils/previewFeatureUsage";
1
2
  import { useCallback, useRef } from "react";
2
3
  import { liveTime, usePlayerStore } from "../player";
3
4
  import { pauseStudioPreviewPlayback } from "../utils/studioPreviewHelpers";
@@ -169,6 +170,7 @@ export function usePreviewInteraction({
169
170
  e.preventDefault();
170
171
  e.stopPropagation();
171
172
  applyDomSelection(nextSelection, { additive: true });
173
+ trackPreviewFeatureUsed("multi_select", "button");
172
174
  return;
173
175
  }
174
176
 
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
6
6
  import { CompositionThumbnail, VideoThumbnail } from "../player";
7
7
  import { AudioWaveform } from "../player/components/AudioWaveform";
8
8
  import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
9
+ import { TextClipContent } from "../player/components/TextClipContent";
9
10
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
10
11
 
11
12
  function unwrapPeakMarks(node: ReactNode): ReactNode {
@@ -280,6 +281,23 @@ describe("useRenderClipContent", () => {
280
281
  expect(isValidElement(content) && content.type).toBe(AudibleVideoClipContent);
281
282
  });
282
283
 
284
+ it("gives a layer spanning the whole film its own frame, whatever its id", () => {
285
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
286
+ for (const id of ["waves", "background-glyphs"]) {
287
+ const content = renderClipContent({ id, tag: "div", start: 0, duration: 12, track: 0 }, null);
288
+ expect(isValidElement(content) && content.type).toBe(CompositionThumbnail);
289
+ }
290
+ });
291
+
292
+ it("draws a text layer's own words instead of capturing them", () => {
293
+ usePlayerStore.setState({ thumbnailMode: "adaptive" });
294
+ const content = renderClipContent(
295
+ { id: "title", tag: "h1", start: 0, duration: 12, track: 0, text: { value: "Ship it" } },
296
+ null,
297
+ );
298
+ expect(isValidElement(content) && content.type).toBe(TextClipContent);
299
+ });
300
+
283
301
  it("passes empty labels to thumbnail content so TimelineClip owns clip names", () => {
284
302
  usePlayerStore.setState({ thumbnailMode: "adaptive" });
285
303
 
@@ -12,6 +12,7 @@ import type { TimelineClipRenderContext } from "../player/components/TimelineTyp
12
12
  import { audioPillFlags } from "../player/components/audioClipLink";
13
13
  import { AudioWaveform, rendersWaveform } from "../player/components/AudioWaveform";
14
14
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
15
+ import { TextClipContent } from "../player/components/TextClipContent";
15
16
  import { AudibleVideoClipContent } from "../player/components/AudibleVideoClipContent";
16
17
  import { ClipPeakMarks } from "../player/components/ClipPeakMarks";
17
18
  import { clipPeaksUrl, clipSourceWindow } from "../player/components/clipPeakMap";
@@ -184,6 +185,8 @@ export function useRenderClipContent({
184
185
  return renderAudioClip(el, pid, sessionEpoch, style.label, context, elements);
185
186
  }
186
187
 
188
+ if (el.text) return createElement(TextClipContent, { text: el.text });
189
+
187
190
  // When drilled into a composition, render all inner elements via
188
191
  // CompositionThumbnail at their start time — most accurate visual.
189
192
  if (activePreviewUrl && el.duration > 0) {
@@ -207,11 +210,7 @@ export function useRenderClipContent({
207
210
  });
208
211
  }
209
212
 
210
- const htmlPreviewEligible =
211
- el.duration > 0 &&
212
- effectiveTimelineDuration > 0 &&
213
- el.duration < effectiveTimelineDuration * 0.92 &&
214
- !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
213
+ const htmlPreviewEligible = el.duration > 0 && effectiveTimelineDuration > 0;
215
214
 
216
215
  if ((el.tag === "video" || el.tag === "img") && el.src) {
217
216
  const mediaSrc = resolveMediaPreviewUrl(el.src, pid, window.location.origin);