@hyperframes/studio 0.8.114 → 0.8.116

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 (266) hide show
  1. package/dist/assets/{hyperframes-player-aXNJKiIg.js → hyperframes-player-mGYvPVpw.js} +1 -1
  2. package/dist/assets/{index-Bn2RJNTJ.js → index-BWHt0-ir.js} +1 -1
  3. package/dist/assets/{index-Dz7doz9l.js → index-DYNaXPVf.js} +203 -203
  4. package/dist/assets/{index-C9N-DGjC.js → index-Dr3hNpvS.js} +1 -1
  5. package/dist/assets/{index-BYnsJnJV.css → index-t1LH6H5H.css} +1 -1
  6. package/dist/{chunk-BIUXP3PG.js → chunk-3F47EFCT.js} +142 -277
  7. package/dist/chunk-3F47EFCT.js.map +1 -0
  8. package/dist/{chunk-K47E2ZJA.js → chunk-5EKWJZLM.js} +504 -17
  9. package/dist/chunk-5EKWJZLM.js.map +1 -0
  10. package/dist/{chunk-VWT7STY3.js → chunk-J6Q572FA.js} +2 -2
  11. package/dist/{domEditingLayers-V67MS4WP.js → domEditingLayers-OL7GLSS4.js} +3 -5
  12. package/dist/index.d.ts +146 -40
  13. package/dist/index.html +2 -2
  14. package/dist/index.js +4021 -3591
  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/captions/hooks/useCaptionSync.ts +10 -6
  21. package/src/components/LinkedSelectionToggle.tsx +3 -2
  22. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  23. package/src/components/dock/Dock.tsx +5 -7
  24. package/src/components/editor/CanvasContextMenu.tsx +3 -2
  25. package/src/components/editor/DomEditCropHandles.test.tsx +27 -0
  26. package/src/components/editor/DomEditCropHandles.tsx +7 -4
  27. package/src/components/editor/DomEditOverlay.tsx +5 -1
  28. package/src/components/editor/FileTreeNodes.tsx +13 -10
  29. package/src/components/editor/MotionPathOverlay.tsx +7 -2
  30. package/src/components/editor/PropertyPanelEmptyState.tsx +12 -2
  31. package/src/components/editor/SnapToolbar.test.tsx +100 -1
  32. package/src/components/editor/SnapToolbar.tsx +43 -5
  33. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +48 -1
  34. package/src/components/editor/audioFxTelemetry.ts +2 -3
  35. package/src/components/editor/commitPreflightHandles.test.tsx +2 -2
  36. package/src/components/editor/cropResize.ts +4 -3
  37. package/src/components/editor/domEditingDom.ts +4 -0
  38. package/src/components/editor/domEditingLayers.ts +1 -5
  39. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  40. package/src/components/editor/domGroupMoveBy.ts +95 -0
  41. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  42. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  43. package/src/components/editor/manualEditingAvailability.ts +8 -0
  44. package/src/components/editor/manualEdits.ts +1 -0
  45. package/src/components/editor/manualEditsParsing.ts +7 -0
  46. package/src/components/editor/manualOffsetDrag.ts +5 -6
  47. package/src/components/editor/marqueeCommit.test.tsx +106 -37
  48. package/src/components/editor/marqueeCommit.ts +24 -8
  49. package/src/components/editor/motionPathCommit.test.ts +29 -1
  50. package/src/components/editor/motionPathCommit.ts +21 -9
  51. package/src/components/editor/motionPathSelection.test.ts +6 -31
  52. package/src/components/editor/probeSourceElement.test.ts +45 -0
  53. package/src/components/editor/probeSourceElement.ts +41 -16
  54. package/src/components/editor/propertyPanelFlatTextSection.tsx +10 -7
  55. package/src/components/editor/propertyPanelFont.tsx +3 -2
  56. package/src/components/editor/propertyPanelFxAddMenu.tsx +11 -2
  57. package/src/components/editor/propertyPanelFxCarveModule.tsx +15 -5
  58. package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +3 -0
  59. package/src/components/editor/propertyPanelFxNodeParams.tsx +15 -2
  60. package/src/components/editor/propertyPanelFxNodeRow.tsx +3 -0
  61. package/src/components/editor/propertyPanelFxPresetRun.tsx +10 -2
  62. package/src/components/editor/propertyPanelFxSection.test.tsx +56 -0
  63. package/src/components/editor/propertyPanelFxSection.tsx +30 -28
  64. package/src/components/editor/propertyPanelTypes.ts +6 -3
  65. package/src/components/editor/useColorGradingController.ts +2 -1
  66. package/src/components/editor/useDomEditNudge.test.tsx +37 -0
  67. package/src/components/editor/useDomEditNudge.ts +2 -0
  68. package/src/components/editor/useDomEditOverlayGestures.ts +5 -0
  69. package/src/components/editor/useDomEditZOrder.ts +12 -2
  70. package/src/components/editor/useFxCarveNodes.ts +2 -1
  71. package/src/components/editor/useFxLevelling.ts +2 -1
  72. package/src/components/feedback/projectProvenance.ts +2 -1
  73. package/src/components/nle/NLEContext.test.ts +1 -0
  74. package/src/components/nle/NLEContext.tsx +2 -1
  75. package/src/components/nle/NLEPreview.tsx +12 -10
  76. package/src/components/nle/PreviewPoster.tsx +2 -1
  77. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  78. package/src/components/nle/useTimelineEditCallbacks.ts +7 -1
  79. package/src/components/renders/useFfmpegStatus.ts +2 -1
  80. package/src/components/renders/useRenderQueue.ts +5 -4
  81. package/src/components/sidebar/AssetContextMenu.tsx +4 -4
  82. package/src/components/sidebar/AssetsTab.tsx +2 -1
  83. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  84. package/src/components/ui/menuStyle.test.ts +61 -0
  85. package/src/components/ui/menuStyle.ts +10 -0
  86. package/src/contexts/DomEditContext.tsx +5 -1
  87. package/src/contexts/TimelineEditContext.tsx +1 -0
  88. package/src/domEditExports.test.tsx +9 -1
  89. package/src/hooks/domEditCommitRunner.ts +1 -1
  90. package/src/hooks/domSelectionTestHarness.ts +23 -0
  91. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  92. package/src/hooks/domStyleCommit.ts +8 -1
  93. package/src/hooks/draggedGsapPosition.ts +10 -0
  94. package/src/hooks/elementOffsetStager.ts +5 -4
  95. package/src/hooks/gsapDragCommit.test.ts +75 -164
  96. package/src/hooks/gsapDragCommit.ts +4 -125
  97. package/src/hooks/gsapDragPositionCommit.ts +36 -384
  98. package/src/hooks/gsapEditOutcome.ts +22 -2
  99. package/src/hooks/gsapGestureOutcome.test.ts +161 -0
  100. package/src/hooks/gsapGestureOutcome.ts +64 -0
  101. package/src/hooks/gsapGroupPreflight.ts +34 -0
  102. package/src/hooks/gsapKeyframeCommit.ts +3 -12
  103. package/src/hooks/gsapMutationClient.ts +4 -3
  104. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  105. package/src/hooks/gsapParsedTween.test.ts +62 -0
  106. package/src/hooks/gsapParsedTween.ts +243 -0
  107. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  108. package/src/hooks/gsapPositionDetection.ts +52 -33
  109. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  110. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  111. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  112. package/src/hooks/gsapResizeIntercept.test.ts +96 -11
  113. package/src/hooks/gsapResizeIntercept.ts +79 -168
  114. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  115. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  116. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  117. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  118. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  119. package/src/hooks/gsapScriptCommitTypes.ts +13 -0
  120. package/src/hooks/gsapShared.ts +7 -0
  121. package/src/hooks/gsapShared.writeTarget.test.ts +45 -57
  122. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  123. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  124. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  125. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  126. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  127. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  128. package/src/hooks/keyframeCacheAstLoad.ts +8 -6
  129. package/src/hooks/newTweenTarget.test.ts +41 -76
  130. package/src/hooks/plainRotation.ts +3 -2
  131. package/src/hooks/previewPromotionSelection.test.tsx +1 -1
  132. package/src/hooks/refreshTimelineRowText.test.ts +65 -0
  133. package/src/hooks/refreshTimelineRowText.ts +29 -0
  134. package/src/hooks/timelineTimingSync.test.ts +1 -0
  135. package/src/hooks/timelineTimingSync.ts +2 -1
  136. package/src/hooks/useAnimatedPropertyCommit.test.tsx +46 -5
  137. package/src/hooks/useAnimatedPropertyCommit.ts +2 -2
  138. package/src/hooks/useAskAgentModal.ts +2 -1
  139. package/src/hooks/useBlockCatalog.ts +2 -1
  140. package/src/hooks/useBlockedTimelineEditToast.ts +12 -3
  141. package/src/hooks/useCaptionDetection.ts +2 -1
  142. package/src/hooks/useCompositionContentLoader.ts +2 -1
  143. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  144. package/src/hooks/useDomEditPositionPatchCommit.ts +18 -23
  145. package/src/hooks/useDomEditSession.test.tsx +30 -1
  146. package/src/hooks/useDomEditSession.ts +10 -0
  147. package/src/hooks/useDomEditTextCommits.test.tsx +41 -0
  148. package/src/hooks/useDomEditTextCommits.ts +13 -3
  149. package/src/hooks/useDomEditWiring.ts +4 -2
  150. package/src/hooks/useDomGeometryCommit.test.tsx +25 -6
  151. package/src/hooks/useDomGeometryCommit.ts +8 -4
  152. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  153. package/src/hooks/useDomGeometryCommits.ts +3 -2
  154. package/src/hooks/useDomSelection.test.ts +26 -0
  155. package/src/hooks/useDomSelection.ts +10 -3
  156. package/src/hooks/useDomSelectionTypes.ts +6 -1
  157. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  158. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  159. package/src/hooks/useEditHistoryActions.ts +16 -5
  160. package/src/hooks/useElementLifecycleOps.ts +2 -1
  161. package/src/hooks/useEnableKeyframes.test.ts +34 -3
  162. package/src/hooks/useEnableKeyframes.ts +7 -4
  163. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +121 -0
  164. package/src/hooks/useExternalFileChangeCoordinator.ts +35 -8
  165. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  166. package/src/hooks/useFileManager.ts +14 -10
  167. package/src/hooks/useFileTree.ts +3 -2
  168. package/src/hooks/useFrameCapture.ts +5 -1
  169. package/src/hooks/useFreezeFrame.ts +3 -2
  170. package/src/hooks/useGestureCommit.test.tsx +225 -55
  171. package/src/hooks/useGestureCommit.ts +86 -55
  172. package/src/hooks/useGroupCommits.ts +2 -1
  173. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  174. package/src/hooks/useGsapAwareEditing.test.tsx +28 -35
  175. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  176. package/src/hooks/useGsapAwareEditing.ts +101 -195
  177. package/src/hooks/useGsapAwareGroupMove.ts +179 -0
  178. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  179. package/src/hooks/useGsapKeyframeOps.sdkUsage.test.tsx +114 -0
  180. package/src/hooks/useGsapKeyframeOps.ts +36 -10
  181. package/src/hooks/useGsapScriptCommits.test.tsx +60 -1
  182. package/src/hooks/useGsapScriptCommits.ts +15 -10
  183. package/src/hooks/useGsapSelectionHandlers.test.tsx +1 -1
  184. package/src/hooks/useGsapSelectionHandlers.ts +24 -18
  185. package/src/hooks/useGsapTweenCache.ts +18 -12
  186. package/src/hooks/useLintModal.ts +2 -1
  187. package/src/hooks/usePersistentEditHistory.test.ts +277 -1
  188. package/src/hooks/usePersistentEditHistory.ts +47 -8
  189. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +76 -2
  190. package/src/hooks/usePreviewInteraction.test.ts +21 -0
  191. package/src/hooks/usePreviewInteraction.ts +2 -0
  192. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  193. package/src/hooks/usePreviewPersistence.ts +13 -11
  194. package/src/hooks/useProjectFileWriter.ts +20 -19
  195. package/src/hooks/useRemoveBackground.ts +2 -1
  196. package/src/hooks/useRenderClipContent.test.ts +18 -0
  197. package/src/hooks/useRenderClipContent.ts +4 -5
  198. package/src/hooks/useSdkSession.ts +2 -1
  199. package/src/hooks/useServerConnection.ts +4 -3
  200. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  201. package/src/hooks/useThumbnailStill.test.ts +100 -0
  202. package/src/hooks/useThumbnailStill.ts +37 -0
  203. package/src/hooks/useTimelineDeleteOps.test.tsx +30 -22
  204. package/src/hooks/useTimelineDeleteOps.ts +22 -27
  205. package/src/hooks/useTimelineEditing.test.tsx +1 -1
  206. package/src/index.ts +4 -0
  207. package/src/player/components/AudioWaveform.tsx +3 -2
  208. package/src/player/components/AutomationSelectionMenu.tsx +3 -2
  209. package/src/player/components/ClipContextMenu.test.tsx +73 -0
  210. package/src/player/components/ClipContextMenu.tsx +33 -31
  211. package/src/player/components/CompositionThumbnail.tsx +2 -1
  212. package/src/player/components/KeyframeDiamondContextMenu.tsx +8 -9
  213. package/src/player/components/OutOfSyncBadge.tsx +5 -5
  214. package/src/player/components/TextClipContent.test.ts +19 -0
  215. package/src/player/components/TextClipContent.tsx +27 -0
  216. package/src/player/components/TrackGapContextMenu.tsx +3 -6
  217. package/src/player/components/clipMenuNormalize.ts +2 -1
  218. package/src/player/components/clipMenuToolItems.test.tsx +23 -0
  219. package/src/player/components/clipMenuToolItems.tsx +13 -9
  220. package/src/player/components/clipPeakMap.ts +2 -1
  221. package/src/player/components/timelineCallbacks.ts +1 -0
  222. package/src/player/components/timelineClipDragGestureLifecycle.ts +1 -1
  223. package/src/player/components/timelineClipGestureHandlers.test.ts +94 -0
  224. package/src/player/components/timelineClipGestureHandlers.ts +20 -2
  225. package/src/player/components/timelineEditing.ts +3 -1
  226. package/src/player/components/timelineLinkSelection.ts +35 -1
  227. package/src/player/components/useTimelineClipDrag.keyboard.test.tsx +14 -0
  228. package/src/player/components/useTimelineClipDrag.ts +6 -7
  229. package/src/player/hooks/timelinePlayerSync.test.ts +6 -0
  230. package/src/player/hooks/timelinePlayerSync.ts +6 -1
  231. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  232. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  233. package/src/player/lib/thumbnailScheduler.ts +10 -2
  234. package/src/player/lib/timelineElementHelpers.test.ts +87 -1
  235. package/src/player/lib/timelineElementHelpers.ts +6 -15
  236. package/src/player/lib/timelineText.ts +40 -0
  237. package/src/player/sceneSwap.ts +2 -1
  238. package/src/player/store/timelineElement.ts +12 -0
  239. package/src/styles/theme.css +3 -0
  240. package/src/telemetry/client.ts +2 -1
  241. package/src/utils/blockInstaller.ts +2 -1
  242. package/src/utils/elementGsap.test.ts +9 -0
  243. package/src/utils/elementGsap.ts +7 -0
  244. package/src/utils/gsapSoftReload.ts +2 -3
  245. package/src/utils/gsapUndoRestore.browser.test.ts +51 -45
  246. package/src/utils/gsapUndoRestore.ts +3 -6
  247. package/src/utils/keyframeUsage.ts +79 -0
  248. package/src/utils/previewFeatureUsage.test.ts +32 -0
  249. package/src/utils/previewFeatureUsage.ts +46 -0
  250. package/src/utils/previewPrefetch.ts +2 -1
  251. package/src/utils/razorSplitTransaction.ts +12 -8
  252. package/src/utils/softReloadTargets.ts +1 -3
  253. package/src/utils/studioApiFetch.test.ts +75 -0
  254. package/src/utils/studioApiFetch.ts +13 -0
  255. package/src/utils/studioFileHistory.ts +4 -1
  256. package/src/utils/studioHelpers.ts +7 -1
  257. package/src/utils/studioPendingEdits.test.ts +23 -6
  258. package/src/utils/studioPendingEdits.ts +32 -8
  259. package/src/utils/studioTelemetry.test.ts +107 -0
  260. package/src/utils/studioTelemetry.ts +51 -5
  261. package/src/utils/timelineCompositionInsert.ts +3 -2
  262. package/src/webmcp/StudioAgentTools.tsx +4 -3
  263. package/dist/chunk-BIUXP3PG.js.map +0 -1
  264. package/dist/chunk-K47E2ZJA.js.map +0 -1
  265. /package/dist/{chunk-VWT7STY3.js.map → chunk-J6Q572FA.js.map} +0 -0
  266. /package/dist/{domEditingLayers-V67MS4WP.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
9
9
  import { usePlayerStore } from "../player/store/playerStore";
10
- import { getIframeGsap, queryIframeElement } from "./gsapShared";
10
+ import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared";
11
11
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
12
12
 
13
13
  // fallow-ignore-next-line complexity
@@ -47,52 +47,71 @@ export function findGsapPositionAnimation(
47
47
  if (animations.length === 0) return null;
48
48
  const currentTime = usePlayerStore.getState().currentTime;
49
49
 
50
- const scored = animations
51
- .filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1)
52
- .map((a) => {
53
- let score = 0;
54
- if (animHasPosition(a)) score += 10;
55
- if (a.keyframes) score += 5;
56
- if (selector && a.targetSelector === selector) score += 8;
57
- else if (a.targetSelector.includes(",")) score -= 5;
58
- const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
59
- const dur = a.duration ?? 0;
60
- if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
61
- else
62
- score -= Math.round(
63
- Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
64
- );
65
- return { anim: a, score };
66
- });
50
+ const scored = animations.filter(animHasPosition).map((a) => {
51
+ let score = 0;
52
+ if (a.keyframes) score += 5;
53
+ if (selector && a.targetSelector === selector) score += 8;
54
+ else if (a.targetSelector.includes(",")) score -= 5;
55
+ return { anim: a, score: score + playheadProximity(a, currentTime) };
56
+ });
67
57
  scored.sort((a, b) => b.score - a.score);
68
- return scored[0]?.anim ?? animations[0];
58
+ return scored[0]?.anim ?? null;
59
+ }
60
+
61
+ function playheadProximity(a: GsapAnimation, currentTime: number): number {
62
+ const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
63
+ const dur = a.duration ?? 0;
64
+ if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) return 50;
65
+ return -Math.round(Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5);
66
+ }
67
+
68
+ /** Whether the file states `anim`'s value at `time`; a tween's implicit start is GSAP's current value. */
69
+ function statesValueAt(anim: GsapAnimation, time: number): boolean {
70
+ const duration = resolveTweenDuration(anim);
71
+ if (!(duration > 0)) return false;
72
+ const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100;
73
+ const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH;
74
+ if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage));
75
+ if (anim.method === "from") return near(0);
76
+ return near(100) || (anim.method === "fromTo" && near(0));
69
77
  }
70
78
 
71
79
  /**
72
80
  * From a set of candidate tweens, pick the one whose time range is closest to
73
81
  * the current playhead. A tween that *contains* the playhead wins outright;
74
82
  * otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
75
- * extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
76
- * gesture-recorded tween beats a stub when both are equidistant).
83
+ * extends) the 4s tween, not the 1.5s one. Ties: the one whose file states a value at the
84
+ * playhead (the later one, which renders, if both do), then most keyframes.
77
85
  */
78
86
  // fallow-ignore-next-line complexity
79
87
  export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
80
88
  if (anims.length <= 1) return anims[0] ?? null;
81
89
  const ct = usePlayerStore.getState().currentTime;
82
90
  return anims.reduce((best, a) => {
83
- const s = resolveTweenStart(a) ?? 0;
84
- const e = s + resolveTweenDuration(a);
85
- const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
86
- const bestS = resolveTweenStart(best) ?? 0;
87
- const bestE = bestS + resolveTweenDuration(best);
88
- const bestDist =
89
- ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
91
+ const dist = playheadDistance(a, ct);
92
+ const bestDist = playheadDistance(best, ct);
90
93
  if (dist < bestDist) return a;
91
- if (
92
- dist === bestDist &&
93
- (a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
94
- )
95
- return a;
94
+ const stated = dist === 0 && bestDist === 0 ? statedValueWinner(a, best, ct) : null;
95
+ if (stated) return stated;
96
+ if (dist === bestDist && keyframeCount(a) > keyframeCount(best)) return a;
96
97
  return best;
97
98
  });
98
99
  }
100
+
101
+ function playheadDistance(anim: GsapAnimation, time: number): number {
102
+ const s = resolveTweenStart(anim) ?? 0;
103
+ const e = s + resolveTweenDuration(anim);
104
+ return time >= s && time <= e ? 0 : Math.min(Math.abs(time - s), Math.abs(time - e));
105
+ }
106
+
107
+ function statedValueWinner(a: GsapAnimation, b: GsapAnimation, time: number) {
108
+ const [mine, theirs] = [statesValueAt(a, time), statesValueAt(b, time)];
109
+ if (mine !== theirs) return mine ? a : b;
110
+ const [s, bS] = [resolveTweenStart(a) ?? 0, resolveTweenStart(b) ?? 0];
111
+ if (mine && s !== bS) return s > bS ? a : b;
112
+ return null;
113
+ }
114
+
115
+ function keyframeCount(anim: GsapAnimation): number {
116
+ return anim.keyframes?.keyframes.length ?? 0;
117
+ }
@@ -148,17 +148,18 @@ function positionAnimation(
148
148
  } as unknown as GsapAnimation;
149
149
  }
150
150
 
151
- /** Every x/y a run wrote, in commit order. */
151
+ /** Every x/y a run wrote AT THE PLAYHEAD (percentage 0 here), in commit order. */
152
152
  function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> {
153
153
  const written: Array<{ x?: number; y?: number }> = [];
154
154
  for (const call of calls) {
155
155
  const mutation = call[1] as {
156
156
  properties?: Record<string, number>;
157
- keyframes?: Array<{ properties: Record<string, number> }>;
157
+ keyframes?: Array<{ percentage: number; properties: Record<string, number> }>;
158
158
  x?: number;
159
159
  y?: number;
160
160
  };
161
- const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)];
161
+ const atPlayhead = (mutation.keyframes ?? []).filter((k) => k.percentage === 0);
162
+ const sources = [mutation.properties, ...atPlayhead.map((k) => k.properties)];
162
163
  if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y });
163
164
  for (const source of sources) {
164
165
  if (source && (source.x != null || source.y != null))
@@ -37,6 +37,7 @@ const widthTween = {
37
37
  position: 0,
38
38
  resolvedStart: 0,
39
39
  duration: 4,
40
+ ease: "none",
40
41
  } as unknown as GsapAnimation;
41
42
 
42
43
  function mount(animations: GsapAnimation[]) {
@@ -23,17 +23,13 @@
23
23
  * the way through. A sweep across rotations is what tells us it is.
24
24
  */
25
25
  import { afterEach, expect, it, vi } from "vitest";
26
- import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
27
26
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
28
27
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
29
28
  import { usePlayerStore } from "../player/store/playerStore";
29
+ import { elTween, resetGsapEditState } from "./gsapParsedTween.test-helpers";
30
30
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
31
31
 
32
- afterEach(() => {
33
- vi.restoreAllMocks();
34
- usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
35
- document.body.innerHTML = "";
36
- });
32
+ afterEach(resetGsapEditState);
37
33
 
38
34
  const LAYOUT = { left: 120, top: 520 };
39
35
 
@@ -57,20 +53,6 @@ function renderRect(pose: Pose, rotationDeg: number) {
57
53
 
58
54
  type Props = Record<string, number>;
59
55
 
60
- function tween(id: string, properties: Props, duration: number): GsapAnimation {
61
- return {
62
- id,
63
- targetSelector: "#el",
64
- propertyGroup: classifyTweenPropertyGroup(properties),
65
- method: "to",
66
- properties,
67
- position: 0,
68
- resolvedStart: 0,
69
- duration,
70
- ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
71
- } as unknown as GsapAnimation;
72
- }
73
-
74
56
  interface Case {
75
57
  name: string;
76
58
  /** The element's untransformed CSS box. */
@@ -99,18 +81,24 @@ const ROTATIONS = [0, -8, 45, -47, 90, 180];
99
81
  * the size routes here however it looks from the animation list.
100
82
  */
101
83
  const ROUTES = {
102
- "scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
84
+ "scale tween": {
85
+ animations: () => [elTween("#el-scale", { scale: 1 }, 2, "none")],
86
+ settles: true,
87
+ },
103
88
  "scale longhands": {
104
- animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
89
+ animations: () => [elTween("#el-scale", { scaleX: 1, scaleY: 1 }, 2, "none")],
105
90
  settles: true,
106
91
  },
107
- "scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
92
+ "scale instant hold": {
93
+ animations: () => [elTween("#el-scale", { scale: 1 }, 0, "none")],
94
+ settles: false,
95
+ },
108
96
  "size tween": {
109
- animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
97
+ animations: () => [elTween("#el-size", { width: 630, height: 408 }, 2, "none")],
110
98
  settles: false,
111
99
  },
112
100
  "size instant hold": {
113
- animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
101
+ animations: () => [elTween("#el-size", { width: 630, height: 408 }, 0, "none")],
114
102
  settles: false,
115
103
  },
116
104
  } as const;
@@ -3,12 +3,21 @@ 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
- import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
10
+ import { commitSizeAtPlayhead, tryGsapResizeIntercept } from "./gsapResizeIntercept";
11
+ import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
12
+
13
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
8
14
 
9
15
  afterEach(() => {
16
+ vi.clearAllMocks();
10
17
  vi.restoreAllMocks();
11
18
  usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
19
+ // One #clip per test: leftovers would make every tween on it look shared.
20
+ document.body.innerHTML = "";
12
21
  });
13
22
 
14
23
  /**
@@ -36,10 +45,18 @@ function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
36
45
  // The element's OPACITY intro tween lives on the timeline: unfiltered
37
46
  // capture would pick `opacity` up via the other-tween sweep.
38
47
  const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } };
48
+ const scaleIntro = liveTween(
49
+ el,
50
+ { start: 0.2, duration: 0.8, vars: { scale: 0.9 }, ends: { scaleX: [0.9, 1] } },
51
+ { from: true },
52
+ );
39
53
  return {
40
54
  contentWindow: {
41
- __timelines: { main: { getChildren: () => [opacityIntro] } },
42
- gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 },
55
+ __timelines: { main: { getChildren: () => [opacityIntro, scaleIntro] } },
56
+ gsap: {
57
+ getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0,
58
+ defaults: () => ({ ease: "power1.out" }),
59
+ },
43
60
  },
44
61
  contentDocument: document,
45
62
  } as unknown as HTMLIFrameElement;
@@ -289,24 +306,26 @@ async function runResize(
289
306
  return committed;
290
307
  }
291
308
 
292
- it("scale-route resize converts via the group filter and commits scale, not width/height", async () => {
309
+ it("scale-route resize keyframes scale through the group filter, not width/height", async () => {
293
310
  const el = makeGradedElement();
294
311
  const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
295
312
  // uniform: 800/640 === 450/360
296
313
  const committed = await runResize(el, iframe, { width: 800, height: 450 });
297
314
 
298
- const convert = committed.find((m) => m.type === "convert-to-keyframes");
299
- expect(convert).toBeDefined();
300
- const fromValues = convert!.resolvedFromValues as Record<string, number>;
301
- // Group filter: the opacity intro tween must NOT leak into the conversion.
302
- expect(fromValues).not.toHaveProperty("opacity");
303
- expect(fromValues).toHaveProperty("scale");
315
+ const keyframes = committed.find((m) => m.type === "replace-with-keyframes")?.keyframes as
316
+ | Array<{ properties: Record<string, number> }>
317
+ | undefined;
318
+ // Group filter: the opacity intro tween must NOT leak into the scale keyframes.
319
+ expect(keyframes?.some((k) => "opacity" in k.properties)).toBe(false);
320
+ expect(keyframes?.some((k) => "scale" in k.properties)).toBe(true);
304
321
 
305
322
  // Every committed property is scale-group — the resize never writes
306
323
  // width/height for a scale-driven element (the double-apply bug class).
307
324
  const allProps = committed.flatMap((m) => [
308
325
  ...Object.keys((m.properties as Record<string, unknown>) ?? {}),
309
- ...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
326
+ ...((m.keyframes as Array<{ properties: object }>) ?? []).flatMap((k) =>
327
+ Object.keys(k.properties),
328
+ ),
310
329
  ]);
311
330
  expect(allProps).not.toContain("width");
312
331
  expect(allProps).not.toContain("height");
@@ -553,3 +572,69 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
553
572
  expect(handled).toEqual({ status: "element-size" });
554
573
  expect(commitMutation).not.toHaveBeenCalled();
555
574
  });
575
+
576
+ it.each([
577
+ ["stays put", { x: 0, y: 0 }, { width: 300 }, undefined],
578
+ ["moves", { x: -40, y: 0 }, { width: 300, x: 10, y: 0 }, true],
579
+ ])(
580
+ "a size written at the playhead carries the anchor only when the box %s",
581
+ async (_, offset, written, owns) => {
582
+ const selection = titleSelection();
583
+ const el = selection.element;
584
+ el.setAttribute("data-hf-drag-gsap-base-x", "50");
585
+ el.setAttribute("data-hf-drag-gsap-base-y", "0");
586
+ const slide = tween({
587
+ id: "#title-to-0",
588
+ targetSelector: "#title",
589
+ propertyGroup: undefined,
590
+ method: "to",
591
+ properties: { x: 100, width: 320 },
592
+ resolvedStart: 0,
593
+ duration: 2,
594
+ ease: "none",
595
+ });
596
+ usePlayerStore.setState({ currentTime: 1 });
597
+ const commitMutation = vi.fn();
598
+ const live = liveTween(el, { start: 0, duration: 2, vars: slide.properties });
599
+
600
+ const outcome = await commitSizeAtPlayhead(
601
+ selection,
602
+ slide,
603
+ { width: 300 },
604
+ previewWith(el, [live]),
605
+ offset,
606
+ { commitMutation },
607
+ );
608
+
609
+ expect(outcome.ownsDragOffset).toBe(owns);
610
+ const mutation = commitMutation.mock.calls[0]![1];
611
+ expect(mutation.keyframes[0].properties).toEqual(written);
612
+ },
613
+ );
614
+
615
+ it.each([
616
+ [5, 1],
617
+ [0.5, 1],
618
+ [0.2, 0],
619
+ ])("counts scale resize insertion at %ss", async (time, count) => {
620
+ const el = makeGradedElement();
621
+ const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1 });
622
+ usePlayerStore.setState({ currentTime: time, activeKeyframePct: null });
623
+ const writer: CommitMutation = async (_selection, _mutation, options) => {
624
+ options.onResult?.({ ok: true, changed: true });
625
+ };
626
+ const outcome = observeGsapGesture(writer);
627
+ const result = await tryGsapResizeIntercept(
628
+ { id: "clip", selector: "#clip", element: el } as DomEditSelection,
629
+ { width: 700, height: 400 },
630
+ [keyframedScaleFixture()],
631
+ iframe,
632
+ outcome.commit!,
633
+ );
634
+ expect(result.status).toBe("persisted");
635
+ outcome.finish();
636
+ expect(trackStudioEvent).toHaveBeenCalledTimes(count);
637
+ if (count) expect(trackStudioEvent).toHaveBeenCalledWith("keyframe", { action: "add" });
638
+ el.remove();
639
+ document.querySelector("#__hf_color_grading_clip")?.remove();
640
+ });