@hyperframes/studio 0.8.115 → 0.8.117

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 (163) hide show
  1. package/dist/assets/{hyperframes-player-C-9N8nk0.js → hyperframes-player-5fcgSstJ.js} +1 -1
  2. package/dist/assets/{index-CAvZ0AA-.js → index-C5qKdpgl.js} +205 -203
  3. package/dist/assets/{index-BoUM6Xqb.js → index-XJSawwYe.js} +1 -1
  4. package/dist/assets/{index-CVuorIMv.js → index-tg2LXUeu.js} +1 -1
  5. package/dist/{chunk-TXZMMOMA.js → chunk-3F47EFCT.js} +100 -2
  6. package/dist/chunk-3F47EFCT.js.map +1 -0
  7. package/dist/{domEditingLayers-D7VYFJJD.js → domEditingLayers-OL7GLSS4.js} +2 -2
  8. package/dist/index.d.ts +132 -41
  9. package/dist/index.html +1 -1
  10. package/dist/index.js +1184 -1084
  11. package/dist/index.js.map +1 -1
  12. package/package.json +8 -8
  13. package/src/captions/hooks/useCaptionSync.ts +10 -6
  14. package/src/components/ProjectUnreachableBanner.tsx +3 -3
  15. package/src/components/editor/DomEditOverlay.tsx +1 -0
  16. package/src/components/editor/domEditOverlayGestures.ts +1 -0
  17. package/src/components/editor/domEditOverlayStartGesture.ts +2 -2
  18. package/src/components/editor/domGroupMoveBy.test.tsx +252 -0
  19. package/src/components/editor/domGroupMoveBy.ts +95 -0
  20. package/src/components/editor/dragUndoPaint.test.ts +5 -5
  21. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +33 -0
  22. package/src/components/editor/manualEditingAvailability.ts +8 -0
  23. package/src/components/editor/manualOffsetDrag.ts +8 -9
  24. package/src/components/editor/motionPathSelection.test.ts +6 -31
  25. package/src/components/editor/plainTranslate.ts +5 -3
  26. package/src/components/editor/probeSourceElement.ts +2 -1
  27. package/src/components/editor/propertyPanelFont.tsx +3 -2
  28. package/src/components/editor/propertyPanelHelpers.ts +2 -2
  29. package/src/components/editor/propertyPanelTransformCommit.ts +2 -2
  30. package/src/components/editor/rotationDraft.ts +2 -1
  31. package/src/components/editor/useColorGradingController.ts +2 -1
  32. package/src/components/editor/useDomEditOverlayGestures.ts +2 -2
  33. package/src/components/editor/useFxCarveNodes.ts +2 -1
  34. package/src/components/editor/useFxLevelling.ts +2 -1
  35. package/src/components/feedback/projectProvenance.ts +2 -1
  36. package/src/components/nle/NLEContext.test.ts +1 -0
  37. package/src/components/nle/NLEContext.tsx +2 -1
  38. package/src/components/nle/NLEPreview.tsx +12 -10
  39. package/src/components/nle/PreviewPoster.tsx +2 -1
  40. package/src/components/renders/useFfmpegStatus.ts +2 -1
  41. package/src/components/renders/useRenderQueue.ts +5 -4
  42. package/src/components/sidebar/AssetsTab.tsx +2 -1
  43. package/src/components/sidebar/GlobalAssetsView.tsx +2 -1
  44. package/src/contexts/DomEditContext.tsx +4 -0
  45. package/src/hooks/domSelectionTestHarness.ts +23 -0
  46. package/src/hooks/domStyleCommit.propertyNames.test.ts +73 -0
  47. package/src/hooks/domStyleCommit.ts +8 -1
  48. package/src/hooks/draggedGsapPosition.ts +10 -0
  49. package/src/hooks/gsapDragCommit.test.ts +75 -164
  50. package/src/hooks/gsapDragCommit.ts +2 -105
  51. package/src/hooks/gsapDragPositionCommit.ts +30 -393
  52. package/src/hooks/gsapEditOutcome.ts +22 -2
  53. package/src/hooks/gsapGroupPreflight.ts +34 -0
  54. package/src/hooks/gsapMutationClient.ts +4 -3
  55. package/src/hooks/gsapParsedTween.test-helpers.ts +75 -0
  56. package/src/hooks/gsapParsedTween.test.ts +62 -0
  57. package/src/hooks/gsapParsedTween.ts +243 -0
  58. package/src/hooks/gsapPositionDetection.test.ts +78 -0
  59. package/src/hooks/gsapPositionDetection.ts +52 -33
  60. package/src/hooks/gsapResizeDropPoint.test.ts +4 -3
  61. package/src/hooks/gsapResizeElementSize.test.tsx +1 -0
  62. package/src/hooks/gsapResizeGeometrySweep.test.ts +13 -25
  63. package/src/hooks/gsapResizeIntercept.test.ts +63 -11
  64. package/src/hooks/gsapResizeIntercept.ts +79 -169
  65. package/src/hooks/gsapResizeSweep.test.ts +16 -33
  66. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +6 -8
  67. package/src/hooks/gsapRuntimeBridge.test.ts +41 -6
  68. package/src/hooks/gsapRuntimeBridge.ts +105 -131
  69. package/src/hooks/gsapRuntimeKeyframes.ts +16 -3
  70. package/src/hooks/gsapScriptCommitHelpers.ts +2 -1
  71. package/src/hooks/gsapScriptCommitTypes.ts +9 -1
  72. package/src/hooks/gsapShared.ts +7 -0
  73. package/src/hooks/gsapShared.writeTarget.test.ts +26 -58
  74. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +83 -0
  75. package/src/hooks/gsapValueAtPlayhead.realGsap.test.ts +179 -0
  76. package/src/hooks/gsapValueAtPlayhead.test.ts +552 -0
  77. package/src/hooks/gsapValueAtPlayhead.ts +292 -0
  78. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +43 -0
  79. package/src/hooks/gsapWholePropertyOffsetCommit.ts +2 -3
  80. package/src/hooks/keyframeCacheAstLoad.ts +2 -1
  81. package/src/hooks/newTweenTarget.test.ts +41 -76
  82. package/src/hooks/plainRotation.ts +5 -1
  83. package/src/hooks/timelineTimingSync.test.ts +1 -0
  84. package/src/hooks/timelineTimingSync.ts +2 -1
  85. package/src/hooks/useAskAgentModal.ts +2 -1
  86. package/src/hooks/useBlockCatalog.ts +2 -1
  87. package/src/hooks/useCaptionDetection.ts +2 -1
  88. package/src/hooks/useCompositionContentLoader.ts +2 -1
  89. package/src/hooks/useDomEditCommitsHelpers.ts +3 -2
  90. package/src/hooks/useDomEditPersist.test.tsx +335 -0
  91. package/src/hooks/useDomEditSession.test.tsx +30 -1
  92. package/src/hooks/useDomEditSession.ts +10 -0
  93. package/src/hooks/useDomEditTextCommits.test.tsx +23 -0
  94. package/src/hooks/useDomEditTextCommits.ts +8 -2
  95. package/src/hooks/useDomGeometryCommit.test.tsx +5 -1
  96. package/src/hooks/useDomGeometryCommit.ts +7 -9
  97. package/src/hooks/useEditHistoryActions.paint.test.tsx +82 -20
  98. package/src/hooks/useEditHistoryActions.test.tsx +15 -0
  99. package/src/hooks/useEditHistoryActions.ts +16 -5
  100. package/src/hooks/useElementLifecycleOps.ts +2 -1
  101. package/src/hooks/useEnableKeyframes.test.ts +32 -1
  102. package/src/hooks/useEnableKeyframes.ts +2 -1
  103. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +19 -0
  104. package/src/hooks/useExternalFileChangeCoordinator.ts +4 -2
  105. package/src/hooks/useFileManager.projectOwnership.test.tsx +6 -1
  106. package/src/hooks/useFileManager.ts +14 -10
  107. package/src/hooks/useFileTree.ts +3 -2
  108. package/src/hooks/useFrameCapture.ts +5 -1
  109. package/src/hooks/useFreezeFrame.test.ts +39 -3
  110. package/src/hooks/useFreezeFrame.ts +7 -3
  111. package/src/hooks/useGroupCommits.ts +2 -1
  112. package/src/hooks/useGsapAwareEditing.groupPlan.test.tsx +160 -0
  113. package/src/hooks/useGsapAwareEditing.test.tsx +42 -29
  114. package/src/hooks/useGsapAwareEditing.testHelpers.tsx +42 -0
  115. package/src/hooks/useGsapAwareEditing.ts +17 -8
  116. package/src/hooks/useGsapAwareGroupMove.ts +18 -18
  117. package/src/hooks/useGsapInteractionFailureTelemetry.ts +10 -4
  118. package/src/hooks/useGsapScriptCommits.test.tsx +72 -2
  119. package/src/hooks/useGsapScriptCommits.ts +21 -17
  120. package/src/hooks/useLintModal.ts +2 -1
  121. package/src/hooks/usePersistentEditHistory.test.ts +302 -3
  122. package/src/hooks/usePersistentEditHistory.ts +50 -17
  123. package/src/hooks/usePreviewPersistence.history.test.tsx +14 -3
  124. package/src/hooks/usePreviewPersistence.ts +13 -11
  125. package/src/hooks/useProjectFileWriter.ts +20 -19
  126. package/src/hooks/useRemoveBackground.ts +2 -1
  127. package/src/hooks/useSdkSession.ts +2 -1
  128. package/src/hooks/useServerConnection.ts +4 -3
  129. package/src/hooks/useStudioSelectionPublisher.ts +2 -1
  130. package/src/hooks/useThumbnailStill.test.ts +100 -0
  131. package/src/hooks/useThumbnailStill.ts +37 -0
  132. package/src/hooks/useTimelineDeleteOps.ts +2 -1
  133. package/src/index.ts +4 -0
  134. package/src/player/components/AudioWaveform.tsx +3 -2
  135. package/src/player/components/CompositionThumbnail.tsx +2 -1
  136. package/src/player/components/clipMenuNormalize.ts +2 -1
  137. package/src/player/components/clipPeakMap.ts +2 -1
  138. package/src/player/hooks/useTimelinePlayer.ts +5 -1
  139. package/src/player/lib/thumbnailScheduler.test.ts +16 -0
  140. package/src/player/lib/thumbnailScheduler.ts +10 -2
  141. package/src/player/sceneSwap.ts +2 -1
  142. package/src/telemetry/client.ts +2 -1
  143. package/src/utils/blockInstaller.ts +2 -1
  144. package/src/utils/elementGsap.test.ts +9 -0
  145. package/src/utils/elementGsap.ts +7 -0
  146. package/src/utils/gsapSoftReload.test.ts +39 -0
  147. package/src/utils/gsapSoftReload.ts +9 -14
  148. package/src/utils/gsapUndoRestore.browser.test.ts +94 -41
  149. package/src/utils/gsapUndoRestore.ts +3 -6
  150. package/src/utils/previewPrefetch.ts +2 -1
  151. package/src/utils/razorSplitTransaction.ts +12 -8
  152. package/src/utils/softReloadTargets.ts +5 -6
  153. package/src/utils/studioApiFetch.test.ts +75 -0
  154. package/src/utils/studioApiFetch.ts +13 -0
  155. package/src/utils/studioFileHistory.ts +5 -1
  156. package/src/utils/studioHelpers.ts +7 -1
  157. package/src/utils/studioPendingEdits.test.ts +23 -6
  158. package/src/utils/studioPendingEdits.ts +32 -8
  159. package/src/utils/studioTelemetry.ts +2 -1
  160. package/src/utils/timelineCompositionInsert.ts +3 -2
  161. package/src/webmcp/StudioAgentTools.tsx +2 -1
  162. package/dist/chunk-TXZMMOMA.js.map +0 -1
  163. /package/dist/{domEditingLayers-D7VYFJJD.js.map → domEditingLayers-OL7GLSS4.js.map} +0 -0
@@ -7,7 +7,8 @@ import { observeGsapGesture } from "./gsapGestureOutcome";
7
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
8
8
  import type { CommitMutation } from "./gsapScriptCommitTypes";
9
9
  import { computeCurrentPercentage } from "./gsapDragCommit";
10
- import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
10
+ import { commitSizeAtPlayhead, tryGsapResizeIntercept } from "./gsapResizeIntercept";
11
+ import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers";
11
12
 
12
13
  vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
13
14
 
@@ -15,6 +16,8 @@ afterEach(() => {
15
16
  vi.clearAllMocks();
16
17
  vi.restoreAllMocks();
17
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 = "";
18
21
  });
19
22
 
20
23
  /**
@@ -42,10 +45,18 @@ function fakeIframe(el: HTMLElement, gsapValues: Record<string, number>) {
42
45
  // The element's OPACITY intro tween lives on the timeline: unfiltered
43
46
  // capture would pick `opacity` up via the other-tween sweep.
44
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
+ );
45
53
  return {
46
54
  contentWindow: {
47
- __timelines: { main: { getChildren: () => [opacityIntro] } },
48
- 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
+ },
49
60
  },
50
61
  contentDocument: document,
51
62
  } as unknown as HTMLIFrameElement;
@@ -295,24 +306,26 @@ async function runResize(
295
306
  return committed;
296
307
  }
297
308
 
298
- 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 () => {
299
310
  const el = makeGradedElement();
300
311
  const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 });
301
312
  // uniform: 800/640 === 450/360
302
313
  const committed = await runResize(el, iframe, { width: 800, height: 450 });
303
314
 
304
- const convert = committed.find((m) => m.type === "convert-to-keyframes");
305
- expect(convert).toBeDefined();
306
- const fromValues = convert!.resolvedFromValues as Record<string, number>;
307
- // Group filter: the opacity intro tween must NOT leak into the conversion.
308
- expect(fromValues).not.toHaveProperty("opacity");
309
- 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);
310
321
 
311
322
  // Every committed property is scale-group — the resize never writes
312
323
  // width/height for a scale-driven element (the double-apply bug class).
313
324
  const allProps = committed.flatMap((m) => [
314
325
  ...Object.keys((m.properties as Record<string, unknown>) ?? {}),
315
- ...Object.keys((m.resolvedFromValues as Record<string, unknown>) ?? {}),
326
+ ...((m.keyframes as Array<{ properties: object }>) ?? []).flatMap((k) =>
327
+ Object.keys(k.properties),
328
+ ),
316
329
  ]);
317
330
  expect(allProps).not.toContain("width");
318
331
  expect(allProps).not.toContain("height");
@@ -560,6 +573,45 @@ it("hands the size to the element's CSS when its only tween is a fade", async ()
560
573
  expect(commitMutation).not.toHaveBeenCalled();
561
574
  });
562
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
+
563
615
  it.each([
564
616
  [5, 1],
565
617
  [0.5, 1],
@@ -14,43 +14,34 @@ import {
14
14
  } from "../components/editor/manualEditsTypes";
15
15
  import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap";
16
16
  import { usePlayerStore } from "../player/store/playerStore";
17
- import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders";
17
+ import { readGsapProperty } from "./gsapRuntimeReaders";
18
18
  import {
19
19
  commitStaticGsapPosition,
20
20
  commitStaticGsapSize,
21
- commitKeyframedSizeFromResize,
22
21
  computeCurrentPercentage,
23
22
  findExistingPositionWrite,
24
23
  findSizeSetAnimation,
25
- materializeIfDynamic,
26
24
  } from "./gsapDragCommit";
27
25
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
28
- import { computeDraggedGsapPosition } from "./draggedGsapPosition";
26
+ import { computeDraggedGsapPosition, restoreDragOffset } from "./draggedGsapPosition";
29
27
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
30
28
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
31
29
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
32
- import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
30
+ import { resolveTweenDuration } from "../utils/globalTimeCompiler";
33
31
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
34
32
  import { roundTo3, roundToLayoutPx } from "../utils/rounding";
35
33
  import { resolveGroupTween } from "./gsapRuntimeBridge";
36
34
  import { logResize } from "../utils/resizeDebug";
37
- import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome";
35
+ import {
36
+ animationWritesAnyProperty,
37
+ assertGsapEditPersisted,
38
+ type GsapEditOutcome,
39
+ } from "./gsapEditOutcome";
40
+ import { commitValueAtPlayhead } from "./gsapValueAtPlayhead";
38
41
  import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight";
39
42
 
40
- const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]);
41
43
  const SIZE_PROPS = new Set(["width", "height"]);
42
-
43
- /** Build identity (zero / one) values for each property in `source`. */
44
- function synthesizeIdentityProps(
45
- source: Record<string, number | string>,
46
- ): Record<string, number | string> {
47
- const id: Record<string, number | string> = {};
48
- for (const [k, v] of Object.entries(source)) {
49
- if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
50
- else id[k] = v;
51
- }
52
- return id;
53
- }
44
+ const POSITION_XY = new Set(["x", "y"]);
54
45
 
55
46
  /**
56
47
  * The element's box before the resize draft ran, in CSS pixels.
@@ -72,6 +63,51 @@ function originalBoxSize(
72
63
  return Number.isFinite(inline) && inline > 0 ? inline : null;
73
64
  }
74
65
 
66
+ /** The box's width and height before the draft, for a size tween that animates only one. */
67
+ function preGestureBoxSize(el: HTMLElement): Record<string, number> {
68
+ const width = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width");
69
+ const height = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height");
70
+ return { ...(width != null && { width }), ...(height != null && { height }) };
71
+ }
72
+
73
+ /** A size write at the playhead. When the same tween animates position, the resize's anchor move
74
+ * goes into this one write: a second write to the tween in the same gesture would plan on stale ids. */
75
+ export async function commitSizeAtPlayhead(
76
+ selection: DomEditSelection,
77
+ anim: GsapAnimation,
78
+ size: Record<string, number>,
79
+ iframe: HTMLIFrameElement | null,
80
+ dragOffset: { x: number; y: number } | undefined,
81
+ callbacks: GsapDragCommitCallbacks,
82
+ ): Promise<GsapEditOutcome> {
83
+ const selector = selectorFromSelection(selection);
84
+ const moves = !!dragOffset && (dragOffset.x !== 0 || dragOffset.y !== 0) && !!selector;
85
+ const anchor =
86
+ moves && animationWritesAnyProperty(anim, POSITION_XY)
87
+ ? computeDraggedGsapPosition(
88
+ selection.element,
89
+ dragOffset,
90
+ readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 },
91
+ )
92
+ : null;
93
+ const written = await commitValueAtPlayhead(
94
+ selection,
95
+ anim,
96
+ anchor ? { ...size, x: anchor.newX, y: anchor.newY } : size,
97
+ iframe,
98
+ callbacks,
99
+ {
100
+ label: "Resize",
101
+ backfill: {
102
+ ...preGestureBoxSize(selection.element),
103
+ ...(anchor && { x: anchor.baseGsapX, y: anchor.baseGsapY }),
104
+ },
105
+ ...(anchor && { beforeReload: () => restoreDragOffset(selection.element) }),
106
+ },
107
+ );
108
+ return written.status === "persisted" && anchor ? { ...written, ownsDragOffset: true } : written;
109
+ }
110
+
75
111
  /**
76
112
  * Whether this tween already states scale as `scaleX`/`scaleY`.
77
113
  *
@@ -98,6 +134,7 @@ export async function tryGsapResizeIntercept(
98
134
  iframe: HTMLIFrameElement | null,
99
135
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
100
136
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
137
+ dragOffset?: { x: number; y: number },
101
138
  ): Promise<GsapEditOutcome> {
102
139
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
103
140
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
@@ -120,7 +157,7 @@ export async function tryGsapResizeIntercept(
120
157
  postSplitFetch,
121
158
  );
122
159
 
123
- let anim =
160
+ const anim =
124
161
  resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties)
125
162
  ? resolved.anim
126
163
  : null;
@@ -166,15 +203,11 @@ export async function tryGsapResizeIntercept(
166
203
  );
167
204
  if (animatedTween) {
168
205
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
169
- const handled = await commitKeyframedSizeFromResize(
170
- selection,
171
- size,
172
- sel,
173
- sizeSet,
174
- animatedTween,
175
- { commitMutation, fetchAnimations: fetchFallbackAnimations },
176
- );
177
- if (handled) return { status: "persisted" };
206
+ const sized = { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) };
207
+ return commitSizeAtPlayhead(selection, animatedTween, sized, iframe, dragOffset, {
208
+ commitMutation,
209
+ fetchAnimations: fetchFallbackAnimations,
210
+ });
178
211
  }
179
212
  }
180
213
 
@@ -196,16 +229,10 @@ export async function tryGsapResizeIntercept(
196
229
 
197
230
  const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
198
231
  const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
199
- if (activeKeyframePct != null) setActiveKeyframePct(null);
200
232
  const selector = selectorFromSelection(selection);
201
- // Scope every capture to the resize group — same contract as the rotation
202
- // intercept. Unfiltered, an opacity-touching intro tween on the element
203
- // would ride into resize conversions/backfills (the Fix-2 bake class).
204
- const runtimeProps = selector
205
- ? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
206
- : {};
207
233
 
208
234
  let resizeProps: Record<string, number>;
235
+ let resizeBackfill: Record<string, number>;
209
236
  let scaleDraftEl: HTMLElement | null = null;
210
237
  let scaleDraftDropPoint: { x: number; y: number } | null = null;
211
238
  /** The scale this commit is putting on the element, for the finalize step. */
@@ -262,6 +289,7 @@ export async function tryGsapResizeIntercept(
262
289
  resizeProps = useScaleLonghands
263
290
  ? { scaleX: newScaleX, scaleY: newScaleY }
264
291
  : { scale: newScaleX };
292
+ resizeBackfill = { scaleX: liveScaleX, scaleY: liveScaleY };
265
293
  logResize("intercept-route", {
266
294
  route: "scale-tween",
267
295
  cssW,
@@ -295,6 +323,7 @@ export async function tryGsapResizeIntercept(
295
323
  width: roundToLayoutPx(size.width),
296
324
  height: roundToLayoutPx(size.height),
297
325
  };
326
+ resizeBackfill = preGestureBoxSize(selection.element);
298
327
  }
299
328
  // Finalize a scale-route commit: tear down the gesture's inline width/height
300
329
  // draft (leaving it applied compounds with the committed scale — the element
@@ -404,10 +433,12 @@ export async function tryGsapResizeIntercept(
404
433
  );
405
434
  if (positionTween) {
406
435
  logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
407
- await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, {
408
- commitMutation,
409
- fetchAnimations: fetchFallbackAnimations,
410
- });
436
+ assertGsapEditPersisted(
437
+ await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, {
438
+ commitMutation,
439
+ fetchAnimations: fetchFallbackAnimations,
440
+ }),
441
+ );
411
442
  return true;
412
443
  }
413
444
  const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element);
@@ -435,135 +466,14 @@ export async function tryGsapResizeIntercept(
435
466
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
436
467
  }
437
468
 
438
- const ct = usePlayerStore.getState().currentTime;
439
- const ts = resolveTweenStart(anim);
440
- const td = tweenDuration;
441
- const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); // Convert flat tweens to keyframes only for in-range resizes.
442
- // Outside-range uses the extend path which handles everything atomically.
443
- if (!outsideRange) {
444
- // fallow-ignore-next-line code-duplication
445
- if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) {
446
- const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection);
447
- if (newId) anim = { ...anim, id: newId };
448
- } else if (!anim.keyframes) {
449
- const resolvedFromValues = selector
450
- ? readAllAnimatedProperties(iframe, selector, anim, resizeGroup)
451
- : undefined;
452
- await commitMutation(
453
- selection,
454
- { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues },
455
- { label: "Convert to keyframes for resize" },
456
- );
457
- if (fetchFallbackAnimations) {
458
- const fresh = await fetchFallbackAnimations();
459
- const refreshed = fresh.find(
460
- (a) => a.targetSelector === anim!.targetSelector && a.keyframes,
461
- );
462
- if (refreshed) anim = refreshed;
463
- }
464
- }
465
- }
466
-
467
- // A NON-uniform scale must also take the full-rewrite path: it mixes
468
- // scaleX/scaleY into a tween whose existing keyframes may carry the uniform
469
- // `scale` shorthand, and GSAP's percentage keyframes animate each property
470
- // name independently — a shorthand/longhand mix would leave the old `scale`
471
- // sub-tween running against the new scaleX/scaleY. The rewrite below
472
- // normalizes every keyframe to the longhands. For an in-range resize the
473
- // min/max window math below degenerates to the tween's own start/duration,
474
- // so timing is unchanged.
475
- if ((outsideRange || useScaleLonghands) && ts !== null) {
476
- // For flat tweens, synthesize the keyframes from the tween's properties
477
- const kfs =
478
- anim.keyframes?.keyframes ??
479
- (() => {
480
- const fromProps =
481
- anim.method === "from" || anim.method === "fromTo"
482
- ? { ...anim.properties }
483
- : synthesizeIdentityProps(anim.properties);
484
- const toProps =
485
- anim.method === "from"
486
- ? synthesizeIdentityProps(anim.properties)
487
- : { ...anim.properties };
488
- return [
489
- { percentage: 0, properties: fromProps },
490
- { percentage: 100, properties: toProps },
491
- ];
492
- })();
493
- const newStart = Math.min(ct, ts);
494
- const newEnd = Math.max(ct, ts + td);
495
- const newDuration = Math.max(0.01, newEnd - newStart);
496
- const existingKfs = kfs;
497
- const remapped: Array<{ percentage: number; properties: Record<string, number | string> }> = [];
498
- for (const kf of existingKfs) {
499
- const absTime = ts + (kf.percentage / 100) * td;
500
- const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
501
- const props = { ...kf.properties };
502
- // Normalize the uniform `scale` shorthand to longhands when this commit
503
- // writes scaleX/scaleY, so the tween never mixes the two forms.
504
- if (nonUniformScale && "scale" in props) {
505
- const uniform = props.scale;
506
- if (typeof uniform === "number") {
507
- props.scaleX = uniform;
508
- props.scaleY = uniform;
509
- }
510
- delete props.scale;
511
- }
512
- // Only backfill properties that the animation already had (x, y, scale).
513
- // Don't backfill width/height — they should only appear on the resize keyframe.
514
- for (const k of Object.keys(resizeProps)) {
515
- if (k in props) continue;
516
- if (k === "width" || k === "height") continue;
517
- props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
518
- }
519
- remapped.push({ percentage: newPct, properties: props });
520
- }
521
- const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10;
522
- // An in-range rewrite can land on an existing keyframe's percentage —
523
- // merge into it instead of emitting a duplicate step.
524
- const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05);
525
- if (collidingKf) Object.assign(collidingKf.properties, resizeProps);
526
- else remapped.push({ percentage: targetPct, properties: resizeProps });
527
- remapped.sort((a, b) => a.percentage - b.percentage);
528
-
529
- await commitMutation(
530
- selection,
531
- {
532
- type: "replace-with-keyframes",
533
- animationId: anim.id,
534
- targetSelector: anim.targetSelector,
535
- position: roundTo3(newStart),
536
- duration: roundTo3(newDuration),
537
- keyframes: remapped,
538
- },
539
- {
540
- label: outsideRange
541
- ? `Resize (extended to ${ct.toFixed(2)}s)`
542
- : `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`,
543
- softReload: true,
544
- ...(!collidingKf ? { keyframeAction: "add" as const } : {}),
545
- },
546
- );
547
- return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
548
- }
549
-
550
- const backfillDefaults: Record<string, number> = {};
551
- for (const k of Object.keys(runtimeProps)) {
552
- if (SIZE_PROPS.has(k)) continue;
553
- backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0;
554
- }
555
-
556
- await commitMutation(
557
- selection,
558
- {
559
- type: "add-keyframe",
560
- animationId: anim.id,
561
- percentage: pct,
562
- properties: resizeProps,
563
- backfillDefaults,
564
- },
565
- { label: `Resize (keyframe ${pct}%)`, softReload: true },
566
- );
469
+ const callbacks = { commitMutation, fetchAnimations: fetchFallbackAnimations };
470
+ if (resizeGroup === "size")
471
+ return commitSizeAtPlayhead(selection, anim, resizeProps, iframe, dragOffset, callbacks);
472
+ const written = await commitValueAtPlayhead(selection, anim, resizeProps, iframe, callbacks, {
473
+ label: "Resize",
474
+ backfill: resizeBackfill,
475
+ });
476
+ if (written.status !== "persisted") return written;
567
477
  return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() };
568
478
  }
569
479
 
@@ -23,57 +23,40 @@ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
23
23
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
24
24
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
25
25
  import { usePlayerStore } from "../player/store/playerStore";
26
+ import { elTween, resetGsapEditState } from "./gsapParsedTween.test-helpers";
26
27
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
27
28
 
28
- afterEach(() => {
29
- vi.restoreAllMocks();
30
- usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
31
- document.body.innerHTML = "";
32
- });
29
+ afterEach(resetGsapEditState);
33
30
 
34
31
  type Props = Record<string, number>;
35
32
 
36
- function tween(id: string, properties: Props, duration: number): GsapAnimation {
37
- return {
38
- id,
39
- targetSelector: "#el",
40
- propertyGroup: classifyTweenPropertyGroup(properties),
41
- method: "to",
42
- properties,
43
- position: 0,
44
- resolvedStart: 0,
45
- duration,
46
- ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
47
- } as unknown as GsapAnimation;
48
- }
49
-
50
33
  /** The dimensions an element's animations actually vary across. */
51
34
  const SCALE = {
52
35
  none: null,
53
- "instant hold": () => tween("#el-scale", { scale: 1.2 }, 0),
54
- tween: () => tween("#el-scale", { scale: 1.2 }, 2),
55
- longhands: () => tween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
36
+ "instant hold": () => elTween("#el-scale", { scale: 1.2 }, 0),
37
+ tween: () => elTween("#el-scale", { scale: 1.2 }, 2),
38
+ longhands: () => elTween("#el-scale", { scaleX: 1.2, scaleY: 1.1 }, 2),
56
39
  } as const;
57
40
  const SIZE = {
58
41
  none: null,
59
- "instant hold": () => tween("#el-size", { width: 300, height: 200 }, 0),
60
- tween: () => tween("#el-size", { width: 300, height: 200 }, 2),
42
+ "instant hold": () => elTween("#el-size", { width: 300, height: 200 }, 0),
43
+ tween: () => elTween("#el-size", { width: 300, height: 200 }, 2),
61
44
  } as const;
62
45
  const POSITION = {
63
46
  none: null,
64
- "static hold": () => tween("#el-position", { x: 40, y: 60 }, 0),
65
- tween: () => tween("#el-position", { x: 40, y: 60 }, 2),
47
+ "static hold": () => elTween("#el-position", { x: 40, y: 60 }, 0),
48
+ tween: () => elTween("#el-position", { x: 40, y: 60 }, 2),
66
49
  } as const;
67
50
  const EXTRA = {
68
51
  none: null,
69
52
  // What a 3D card carries, and the shape that produced two same-group ids.
70
53
  "3d and rotation": () => [
71
- tween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
72
- tween("#el-rotation", { rotation: 720 }, 0),
73
- tween("#el-other-2", { z: 50 }, 0),
54
+ elTween("#el-other", { rotationY: -540, rotationX: 720, _auto: 0 }, 0),
55
+ elTween("#el-rotation", { rotation: 720 }, 0),
56
+ elTween("#el-other-2", { z: 50 }, 0),
74
57
  ],
75
58
  // A tween that already spans two groups, which the resize has to split.
76
- "a mixed tween": () => [tween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
59
+ "a mixed tween": () => [elTween("#el-mixed", { scale: 1.2, width: 300, height: 200 }, 0)],
77
60
  } as const;
78
61
 
79
62
  interface Recorded {
@@ -113,7 +96,7 @@ function fakeSource(initial: GsapAnimation[]) {
113
96
  current = [
114
97
  ...current.filter((animation) => animation.id !== id),
115
98
  ...[...byGroup].map(([group, properties]) =>
116
- tween(`#el-split-${group}`, properties, target.duration ?? 0),
99
+ elTween(`#el-split-${group}`, properties, target.duration ?? 0),
117
100
  ),
118
101
  ];
119
102
  };
@@ -123,7 +106,7 @@ function fakeSource(initial: GsapAnimation[]) {
123
106
  if (mutation.type === "split-into-property-groups") return id && split(id);
124
107
  if (!id) {
125
108
  if (mutation.type === "add")
126
- current = [...current, tween(`#el-added-${current.length}`, properties, 0)];
109
+ current = [...current, elTween(`#el-added-${current.length}`, properties, 0)];
127
110
  return;
128
111
  }
129
112
  mergeInto(id, properties);
@@ -224,7 +207,7 @@ it(`sweeps ${CASES.length} animated shapes without a stale id or a mixed tween`,
224
207
  */
225
208
  function scaleAt(id: string, position: number): GsapAnimation {
226
209
  return {
227
- ...tween(id, { scale: 1 }, 2),
210
+ ...elTween(id, { scale: 1 }, 2),
228
211
  position,
229
212
  resolvedStart: position,
230
213
  keyframes: {
@@ -121,7 +121,7 @@ describe("tryGsapDragIntercept: which tweens a word's drag may use", () => {
121
121
  });
122
122
 
123
123
  describe("tryGsapDragIntercept: one drag is one undo step", () => {
124
- it("records the split and the move under one gesture key", async () => {
124
+ it("records the move under one gesture key, without splitting the mixed tween", async () => {
125
125
  document.body.innerHTML = `<div id="hero"></div>`;
126
126
  const hero = document.getElementById("hero")!;
127
127
  const mixed = {
@@ -133,12 +133,8 @@ describe("tryGsapDragIntercept: one drag is one undo step", () => {
133
133
  position: 0,
134
134
  resolvedStart: 0,
135
135
  duration: 1,
136
+ ease: "none",
136
137
  } as unknown as GsapAnimation;
137
- const split = [
138
- { ...mixed, id: "#hero-fromTo-0-position", propertyGroup: "position" },
139
- { ...mixed, id: "#hero-fromTo-0-opacity", propertyGroup: "opacity" },
140
- ] as GsapAnimation[];
141
- let fetches = 0;
142
138
  const commitMutation = vi.fn();
143
139
  await tryGsapDragIntercept(
144
140
  { id: "hero", selector: "#hero", element: hero } as unknown as DomEditSelection,
@@ -146,10 +142,12 @@ describe("tryGsapDragIntercept: one drag is one undo step", () => {
146
142
  [mixed],
147
143
  null,
148
144
  commitMutation,
149
- async () => (fetches++ === 0 ? [mixed] : split),
145
+ async () => [mixed],
150
146
  );
147
+ const types = commitMutation.mock.calls.map((call) => call[1].type);
151
148
  const options = commitMutation.mock.calls.map((call) => call[2]);
152
- expect(options.length).toBeGreaterThan(1);
149
+ expect(types).not.toContain("split-into-property-groups");
150
+ expect(options.length).toBeGreaterThan(0);
153
151
  expect(new Set(options.map((o) => o.coalesceKey)).size).toBe(1);
154
152
  expect(options[0].coalesceKey).toMatch(/^gsap:drag:/);
155
153
  expect(options.every((o) => o.coalesceMs === Number.POSITIVE_INFINITY)).toBe(true);
@@ -23,7 +23,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement {
23
23
  return {
24
24
  contentWindow: {
25
25
  __timelines: { "index.html": timeline },
26
- gsap: { getProperty: () => 0 },
26
+ gsap: { getProperty: () => 0, defaults: () => ({ ease: "power1.out" }) },
27
27
  },
28
28
  contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) },
29
29
  } as unknown as HTMLIFrameElement;
@@ -451,7 +451,8 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
451
451
  fakeIframe("puck-b", []),
452
452
  commitMutation,
453
453
  );
454
- return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) };
454
+ const mutations = commitMutation.mock.calls.map(([, mutation]) => mutation);
455
+ return { handled, mutations, types: mutations.map((mutation) => mutation.type) };
455
456
  }
456
457
 
457
458
  it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => {
@@ -461,10 +462,40 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
461
462
  expect(types).not.toContain("add-keyframe");
462
463
  });
463
464
 
464
- it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => {
465
- const { handled, types } = await runAutoKeyframeDrag(true);
465
+ it("shifts a linear flat tween as keyframes that stay linear", async () => {
466
+ usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 1 });
467
+ const commitMutation = vi.fn();
468
+ const linear = { ...stalePositionAnim, ease: "none" } as GsapAnimation;
469
+ const live = {
470
+ targets: () => [{ id: "puck-b" }],
471
+ vars: { x: -180, y: -60, ease: "none" },
472
+ duration: () => 2,
473
+ startTime: () => 1,
474
+ };
475
+ await tryGsapDragIntercept(
476
+ selection,
477
+ { x: -50, y: 0 },
478
+ [linear],
479
+ fakeIframe("puck-b", [live]),
480
+ commitMutation,
481
+ );
482
+ const [mutation] = commitMutation.mock.calls.map(([, m]) => m);
483
+ expect(mutation).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" });
484
+ expect(mutation).not.toHaveProperty("ease", "none");
485
+ });
486
+
487
+ it("still changes only the keyframe at the playhead when the toggle is on (default)", async () => {
488
+ const { handled, mutations } = await runAutoKeyframeDrag(true);
466
489
  expect(handled).toEqual({ status: "persisted" });
467
- expect(types).not.toContain("replace-with-keyframes");
490
+ expect(mutations).toEqual([
491
+ expect.objectContaining({
492
+ type: "replace-with-keyframes",
493
+ keyframes: [
494
+ { percentage: 0, properties: { x: 0, y: 0 } },
495
+ { percentage: 100, properties: { x: -50, y: 0 } },
496
+ ],
497
+ }),
498
+ ]);
468
499
  });
469
500
  });
470
501
 
@@ -540,7 +571,11 @@ describe("tryGsapDragIntercept — motion paths", () => {
540
571
  ],
541
572
  ease: "none",
542
573
  },
543
- expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
574
+ expect.objectContaining({
575
+ label: "Move layer (new keyframe)",
576
+ keyframeAction: "add",
577
+ softReload: true,
578
+ }),
544
579
  );
545
580
  expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
546
581
  "add-motion-path-point",