@hyperframes/studio 0.8.138 → 0.8.140

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 (135) hide show
  1. package/dist/assets/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
  2. package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
  3. package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
  4. package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
  5. package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
  6. package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
  7. package/dist/chunk-TFLU2MGI.js.map +1 -0
  8. package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
  9. package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
  10. package/dist/index.d.ts +20 -3
  11. package/dist/index.html +1 -1
  12. package/dist/index.js +7327 -6265
  13. package/dist/index.js.map +1 -1
  14. package/dist/player.js +2 -2
  15. package/package.json +8 -8
  16. package/src/App.tsx +9 -4
  17. package/src/components/TimelineToolbar.test.tsx +79 -1
  18. package/src/components/TimelineToolbar.tsx +14 -29
  19. package/src/components/editor/DomEditOverlay.tsx +11 -7
  20. package/src/components/editor/PropertyPanel.test.tsx +600 -748
  21. package/src/components/editor/PropertyPanel.tsx +1 -0
  22. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  23. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
  24. package/src/components/editor/domEditOverlayGeometry.ts +7 -7
  25. package/src/components/editor/domEditOverlayGestures.ts +18 -0
  26. package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
  27. package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
  28. package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
  29. package/src/components/editor/dragUndoPaint.test.ts +2 -0
  30. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
  31. package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
  32. package/src/components/editor/manualEdits.ts +1 -1
  33. package/src/components/editor/manualEditsDom.ts +7 -7
  34. package/src/components/editor/manualEditsParsing.ts +14 -2
  35. package/src/components/editor/manualEditsTypes.ts +5 -0
  36. package/src/components/editor/manualOffsetDrag.test.ts +23 -0
  37. package/src/components/editor/manualOffsetDrag.ts +5 -0
  38. package/src/components/editor/previewGestureStarts.ts +284 -0
  39. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  40. package/src/components/editor/propertyPanelFill.tsx +36 -13
  41. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  42. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  43. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  44. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  45. package/src/components/editor/rotateInlineSave.test.ts +1 -0
  46. package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
  47. package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
  48. package/src/components/nle/NLEContext.test.ts +26 -0
  49. package/src/components/nle/NLEContext.tsx +8 -3
  50. package/src/components/nle/NLEPreview.test.ts +44 -1
  51. package/src/components/nle/NLEPreview.tsx +7 -2
  52. package/src/hooks/draggedGsapPosition.ts +8 -0
  53. package/src/hooks/editMoment.ts +13 -0
  54. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  55. package/src/hooks/gsapDragCommit.ts +2 -1
  56. package/src/hooks/gsapDragPositionCommit.ts +19 -7
  57. package/src/hooks/gsapPositionDetection.ts +5 -3
  58. package/src/hooks/gsapResizeIntercept.ts +11 -4
  59. package/src/hooks/gsapRuntimeBridge.ts +31 -11
  60. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  61. package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
  62. package/src/hooks/gsapValueAtPlayhead.ts +12 -5
  63. package/src/hooks/timelineEditingHelpers.test.ts +22 -0
  64. package/src/hooks/timelineEditingHelpers.ts +18 -2
  65. package/src/hooks/useDomSelection.test.ts +21 -2
  66. package/src/hooks/useEditHistoryActions.test.tsx +20 -0
  67. package/src/hooks/useEditHistoryActions.ts +2 -0
  68. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  69. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  70. package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
  71. package/src/hooks/useGsapAwareEditing.ts +148 -136
  72. package/src/hooks/useGsapAwareGroupMove.ts +126 -113
  73. package/src/hooks/useGsapScriptCommits.ts +5 -4
  74. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  75. package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
  76. package/src/hooks/usePreviewPersistence.ts +1 -4
  77. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  78. package/src/hooks/useSdkSession.ts +4 -1
  79. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  80. package/src/hooks/useThumbnailStripSize.ts +23 -1
  81. package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
  82. package/src/hooks/useTimelineDeleteOps.ts +9 -10
  83. package/src/index.ts +2 -0
  84. package/src/player/components/AudioWaveform.test.tsx +153 -1
  85. package/src/player/components/AudioWaveform.tsx +93 -37
  86. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  87. package/src/player/components/Timeline.test.ts +3 -1
  88. package/src/player/components/TimelineCanvas.tsx +4 -0
  89. package/src/player/components/TimelineClip.tsx +20 -6
  90. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  91. package/src/player/components/TimelineClipFades.tsx +4 -4
  92. package/src/player/components/TimelineClipJoins.tsx +23 -18
  93. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  94. package/src/player/components/TimelineGroupRow.tsx +1 -0
  95. package/src/player/components/TimelineLaneClip.tsx +127 -0
  96. package/src/player/components/TimelineLanes.test.tsx +51 -6
  97. package/src/player/components/TimelineLanes.tsx +166 -178
  98. package/src/player/components/TimelineRuler.tsx +2 -0
  99. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  100. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  101. package/src/player/components/VideoThumbnail.tsx +37 -9
  102. package/src/player/components/thumbnailUtils.test.ts +17 -3
  103. package/src/player/components/thumbnailUtils.ts +17 -1
  104. package/src/player/components/timelineLayout.test.ts +3 -3
  105. package/src/player/components/timelineMotion.test.ts +27 -0
  106. package/src/player/components/timelineMotion.ts +62 -0
  107. package/src/player/components/timelineZoomInput.test.ts +438 -0
  108. package/src/player/components/timelineZoomInput.ts +456 -0
  109. package/src/player/components/useTimelineGeometry.ts +0 -3
  110. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  111. package/src/player/components/useTimelinePlayhead.ts +53 -59
  112. package/src/player/components/useTimelineProviderState.tsx +16 -21
  113. package/src/player/components/useTimelineZoom.ts +7 -12
  114. package/src/player/hooks/useShadowPreviewReload.ts +6 -6
  115. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
  116. package/src/player/hooks/useTimelinePlayer.ts +8 -5
  117. package/src/player/lib/thumbnailScheduler.test.ts +20 -1
  118. package/src/player/lib/thumbnailScheduler.ts +1 -1
  119. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  120. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  121. package/src/player/lib/timelineElementHelpers.ts +0 -11
  122. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  123. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  124. package/src/player/previewReloading.test.ts +48 -0
  125. package/src/player/previewReloading.ts +48 -0
  126. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  127. package/src/utils/rootDuration.ts +5 -4
  128. package/src/utils/studioPendingEdits.test.ts +27 -0
  129. package/src/utils/studioPendingEdits.ts +9 -0
  130. package/src/utils/timelineAssetDrop.test.ts +13 -4
  131. package/src/utils/timelineAssetDrop.ts +6 -2
  132. package/dist/chunk-TDWGXBUT.js.map +0 -1
  133. /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
  134. /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
  135. /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
@@ -50,6 +50,7 @@ interface NLEPreviewProps extends Pick<
50
50
  onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
51
51
  /** Draws the picture edge to edge in this box, without Studio's inset band. */
52
52
  fillBox?: boolean;
53
+ compositionSizeHint?: PreviewCompositionSize;
53
54
  }
54
55
 
55
56
  export function getPreviewPlayerKey({
@@ -127,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({
127
128
  onStageRef,
128
129
  onCompositionSizeChange,
129
130
  fillBox,
131
+ compositionSizeHint,
130
132
  }: NLEPreviewProps) {
131
133
  const activeKey = getPreviewPlayerKey({ projectId, directUrl });
132
134
  const viewportRef = useRef<HTMLDivElement>(null);
@@ -151,6 +153,8 @@ export const NLEPreview = memo(function NLEPreview({
151
153
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
152
154
  const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
153
155
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
156
+ const hintWidth = compositionSizeHint?.width;
157
+ const hintHeight = compositionSizeHint?.height;
154
158
 
155
159
  const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
156
160
  const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
@@ -183,7 +187,8 @@ export const NLEPreview = memo(function NLEPreview({
183
187
  resolvePreviewStageSize(
184
188
  rect.width,
185
189
  rect.height,
186
- compositionSize,
190
+ compositionSize ??
191
+ (hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
187
192
  portrait,
188
193
  gutterPx,
189
194
  insetPx,
@@ -195,7 +200,7 @@ export const NLEPreview = memo(function NLEPreview({
195
200
  const observer = new ResizeObserver(updateStageSize);
196
201
  observer.observe(viewport);
197
202
  return () => observer.disconnect();
198
- }, [compositionSize, portrait, gutterPx, insetPx]);
203
+ }, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
199
204
 
200
205
  const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
201
206
  onCompositionSizeChangeRef.current = onCompositionSizeChange;
@@ -1,4 +1,5 @@
1
1
  import { roundTo3 } from "../utils/rounding";
2
+ import { STUDIO_EDIT_MOMENT_ATTR, type EditMoment } from "../components/editor/manualEditsTypes";
2
3
 
3
4
  /**
4
5
  * Drag → GSAP position math, shared by the commit path
@@ -41,6 +42,7 @@ export interface DragStamp {
41
42
  origY: number;
42
43
  baseX: number;
43
44
  baseY: number;
45
+ at?: EditMoment;
44
46
  frozen?: boolean;
45
47
  }
46
48
 
@@ -52,9 +54,15 @@ export function readDragStamp(element: HTMLElement): DragStamp {
52
54
  origY: read("data-hf-drag-initial-offset-y") || 0,
53
55
  baseX: read("data-hf-drag-gsap-base-x"),
54
56
  baseY: read("data-hf-drag-gsap-base-y"),
57
+ at: readEditMoment(element),
55
58
  };
56
59
  }
57
60
 
61
+ function readEditMoment(element: HTMLElement): EditMoment | undefined {
62
+ const raw = element.getAttribute(STUDIO_EDIT_MOMENT_ATTR);
63
+ return raw ? (JSON.parse(raw) as EditMoment) : undefined;
64
+ }
65
+
58
66
  /** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
59
67
  export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
60
68
  ...readDragStamp(element),
@@ -0,0 +1,13 @@
1
+ import { usePlayerStore } from "../player/store/playerStore";
2
+ import type { EditMoment } from "../components/editor/manualEditsTypes";
3
+ import type { DragStamp } from "./draggedGsapPosition";
4
+
5
+ export function playheadMoment(): EditMoment {
6
+ const { currentTime, activeKeyframePct } = usePlayerStore.getState();
7
+ return { time: currentTime, keyframePct: activeKeyframePct };
8
+ }
9
+
10
+ /** The playhead an edit writes at: its gesture's press, else the playhead now. */
11
+ export function editMoment(stamp?: DragStamp): EditMoment {
12
+ return stamp?.at ?? playheadMoment();
13
+ }
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
21
21
  new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
22
22
  "utf8",
23
23
  );
24
- // vitest defines `import.meta.hot`, so the selection is the one claim left to
25
- // the source; the channel's own behaviour is exercised below.
26
- expect(coordinator.match(/sseFileChangeChannel\(handler\)/g)).toHaveLength(1);
24
+ expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
25
+ });
26
+
27
+ it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
28
+ const listeners = new Map<string, (event: unknown) => void>();
29
+ class FakeEventSource {
30
+ addEventListener(type: string, listener: (event: unknown) => void) {
31
+ listeners.set(type, listener);
32
+ }
33
+ close = vi.fn();
34
+ }
35
+ vi.stubGlobal("EventSource", FakeEventSource);
36
+ try {
37
+ const onReconnect = vi.fn();
38
+ sseFileChangeChannel(vi.fn(), onReconnect);
39
+ listeners.get("open")?.(new Event("open"));
40
+ expect(onReconnect).not.toHaveBeenCalled();
41
+ listeners.get("error")?.(new Event("error"));
42
+ listeners.get("open")?.(new Event("open"));
43
+ listeners.get("open")?.(new Event("open"));
44
+ expect(onReconnect).toHaveBeenCalledTimes(2);
45
+ } finally {
46
+ vi.unstubAllGlobals();
47
+ }
27
48
  });
28
49
 
29
50
  it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
42
63
  vi.stubGlobal("EventSource", FakeEventSource);
43
64
  try {
44
65
  const onDelivery = vi.fn();
45
- const stop = sseFileChangeChannel(onDelivery);
66
+ const stop = sseFileChangeChannel(onDelivery, vi.fn());
46
67
  const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
47
68
  listeners.get("file-change")?.(event);
48
69
  expect(opened).toEqual(["/api/events"]);
@@ -56,8 +56,9 @@ function newTweenTarget(selection: DomEditSelection): string | null {
56
56
  export function computeCurrentPercentage(
57
57
  selection: DomEditSelection,
58
58
  animation?: GsapAnimation,
59
+ time = usePlayerStore.getState().currentTime,
59
60
  ): number {
60
- return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
61
+ return computeElementPercentage(time, selection, animation);
61
62
  }
62
63
 
63
64
  // When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact
@@ -21,6 +21,8 @@ import {
21
21
  } from "./gsapDragCommit";
22
22
  import type { GsapEditOutcome } from "./gsapEditOutcome";
23
23
  import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
24
+ import { editMoment } from "./editMoment";
25
+ import type { EditMoment } from "../components/editor/manualEditsTypes";
24
26
 
25
27
  /**
26
28
  * The tween's keyframes with one inserted at `percentage`. Any existing keyframe
@@ -95,6 +97,7 @@ export function gsapPositionFromDragOutcome(
95
97
  studioOffset: { x: number; y: number },
96
98
  gsapPos: { x: number; y: number },
97
99
  iframe: HTMLIFrameElement | null,
100
+ moment?: EditMoment,
98
101
  ): GsapEditOutcome {
99
102
  if (anim.arcPath?.enabled) return { status: "persisted" };
100
103
  const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
@@ -104,6 +107,7 @@ export function gsapPositionFromDragOutcome(
104
107
  );
105
108
  const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
106
109
  backfill: { x: baseGsapX, y: baseGsapY },
110
+ moment,
107
111
  });
108
112
  return plan.ok
109
113
  ? { status: "persisted" }
@@ -132,7 +136,8 @@ export async function commitGsapPositionFromDrag(
132
136
  const restoreOffset = () => restoreDragOffset(el, stamp);
133
137
 
134
138
  if (anim.arcPath?.enabled) {
135
- const { activeKeyframePct, currentTime, setActiveKeyframePct } = usePlayerStore.getState();
139
+ const { keyframePct: activeKeyframePct, time: currentTime } = editMoment(stamp);
140
+ const { setActiveKeyframePct } = usePlayerStore.getState();
136
141
  const tweenStart = resolveTweenStart(anim);
137
142
  const tweenDuration = resolveTweenDuration(anim);
138
143
  if (
@@ -165,7 +170,7 @@ export async function commitGsapPositionFromDrag(
165
170
  );
166
171
  return { status: "persisted" };
167
172
  }
168
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
173
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, currentTime);
169
174
  const keyframes = anim.keyframes?.keyframes ?? [];
170
175
  // A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
171
176
  // so landing a fraction of a percent off an authored waypoint updates that point
@@ -212,9 +217,16 @@ export async function commitGsapPositionFromDrag(
212
217
  );
213
218
  return { status: "persisted" };
214
219
  }
215
- return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, {
216
- label: "Move layer",
217
- backfill: { x: baseGsapX, y: baseGsapY },
218
- beforeReload: restoreOffset,
219
- });
220
+ return commitValueAtPlayhead(
221
+ selection,
222
+ anim,
223
+ { x: newX, y: newY },
224
+ iframe,
225
+ { ...callbacks, stamp },
226
+ {
227
+ label: "Move layer",
228
+ backfill: { x: baseGsapX, y: baseGsapY },
229
+ beforeReload: restoreOffset,
230
+ },
231
+ );
220
232
  }
@@ -44,9 +44,9 @@ function animHasPosition(anim: GsapAnimation): boolean {
44
44
  export function findGsapPositionAnimation(
45
45
  animations: GsapAnimation[],
46
46
  selector?: string,
47
+ currentTime = usePlayerStore.getState().currentTime,
47
48
  ): GsapAnimation | null {
48
49
  if (animations.length === 0) return null;
49
- const currentTime = usePlayerStore.getState().currentTime;
50
50
 
51
51
  const scored = animations.filter(animHasPosition).map((a) => {
52
52
  let score = 0;
@@ -87,9 +87,11 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
87
87
  * playhead (the later one, which renders, if both do), then most keyframes.
88
88
  */
89
89
  // fallow-ignore-next-line complexity
90
- export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
90
+ export function pickClosestToPlayhead(
91
+ anims: GsapAnimation[],
92
+ ct = usePlayerStore.getState().currentTime,
93
+ ): GsapAnimation | null {
91
94
  if (anims.length <= 1) return anims[0] ?? null;
92
- const ct = usePlayerStore.getState().currentTime;
93
95
  return anims.reduce((best, a) => {
94
96
  const dist = playheadDistance(a, ct);
95
97
  const bestDist = playheadDistance(best, ct);
@@ -33,6 +33,7 @@ import {
33
33
  type DragStamp,
34
34
  } from "./draggedGsapPosition";
35
35
  import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
36
+ import { editMoment } from "./editMoment";
36
37
  import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
37
38
  import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
38
39
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
@@ -141,8 +142,9 @@ function firstSizeKey(
141
142
  size: { width: number; height: number },
142
143
  resizeGroup: PropertyGroupName,
143
144
  animations: GsapAnimation[],
145
+ currentTime: number,
144
146
  ): Record<string, unknown> | null {
145
- const { autoKeyframeEnabled, currentTime } = usePlayerStore.getState();
147
+ const { autoKeyframeEnabled } = usePlayerStore.getState();
146
148
  const keyframed = animations.some(
147
149
  (a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
148
150
  );
@@ -180,6 +182,7 @@ export async function tryGsapResizeIntercept(
180
182
  draw: <T>(run: () => T) => T = (run) => run(),
181
183
  stamp?: DragStamp,
182
184
  ): Promise<GsapEditOutcome> {
185
+ const moment = editMoment(stamp);
183
186
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
184
187
  const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
185
188
  if (outcome.status === "blocked") return outcome;
@@ -199,6 +202,7 @@ export async function tryGsapResizeIntercept(
199
202
  selection,
200
203
  commitMutation,
201
204
  postSplitFetch,
205
+ moment.time,
202
206
  );
203
207
 
204
208
  const anim =
@@ -218,7 +222,7 @@ export async function tryGsapResizeIntercept(
218
222
  animationWritesAnyProperty(a, SIZE_PROPS),
219
223
  );
220
224
  if (!scriptWritesSize) {
221
- const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
225
+ const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations, moment.time);
222
226
  if (!firstKey) return { status: "element-size" };
223
227
  await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
224
228
  return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
@@ -249,6 +253,7 @@ export async function tryGsapResizeIntercept(
249
253
  resolveTweenDuration(a) > 0 &&
250
254
  animationWritesAnyProperty(a, resizeProperties),
251
255
  ),
256
+ moment.time,
252
257
  );
253
258
  if (animatedTween) {
254
259
  logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
@@ -284,8 +289,9 @@ export async function tryGsapResizeIntercept(
284
289
  return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
285
290
  }
286
291
 
287
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
288
- const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
292
+ const { setActiveKeyframePct } = usePlayerStore.getState();
293
+ const activeKeyframePct = moment.keyframePct;
294
+ const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, moment.time);
289
295
  const selector = selectorFromSelection(selection);
290
296
 
291
297
  let resizeProps: Record<string, number>;
@@ -473,6 +479,7 @@ export async function tryGsapResizeIntercept(
473
479
  currentAnimations.filter(
474
480
  (a) => a.propertyGroup === "position" && !isInstantHold(a) && resolveTweenDuration(a) > 0,
475
481
  ),
482
+ moment.time,
476
483
  );
477
484
  if (positionTween) {
478
485
  logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
@@ -31,7 +31,9 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
31
31
  import { resolveTweenDuration } from "../utils/globalTimeCompiler";
32
32
  import { roundTo3 } from "../utils/rounding";
33
33
  import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
34
- import type { DragStamp } from "./draggedGsapPosition";
34
+ import { readDragStamp, type DragStamp } from "./draggedGsapPosition";
35
+ import { editMoment } from "./editMoment";
36
+ import type { EditMoment } from "../components/editor/manualEditsTypes";
35
37
  import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
36
38
  import {
37
39
  findGsapPositionAnimation,
@@ -66,12 +68,13 @@ export async function resolveGroupTween(
66
68
  _selection: DomEditSelection,
67
69
  _commitMutation: GsapDragCommitCallbacks["commitMutation"],
68
70
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
71
+ time?: number,
69
72
  ): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
70
73
  const inGroup = (list: GsapAnimation[]) => {
71
74
  const tagged = list.filter((a) => a.propertyGroup === group);
72
75
  const props = new Set(PROPERTY_GROUPS[group]);
73
76
  const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
74
- return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
77
+ return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed, time);
75
78
  };
76
79
  const anim = inGroup(animations);
77
80
  if (anim) return { anim, animations };
@@ -205,6 +208,7 @@ async function planDrag(
205
208
  allAnimations: GsapAnimation[],
206
209
  iframe: HTMLIFrameElement | null,
207
210
  options: { altKey?: boolean },
211
+ moment: EditMoment,
208
212
  ): Promise<GsapEditOutcome> {
209
213
  const selector = selectorFromSelection(selection);
210
214
  if (!selector) return { status: "blocked", reason: "no-selector" };
@@ -213,8 +217,15 @@ async function planDrag(
213
217
  const animations = keeper
214
218
  ? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
215
219
  : own;
216
- const resolved = await resolveGroupTween("position", animations, selection, async () => {});
217
- const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector);
220
+ const resolved = await resolveGroupTween(
221
+ "position",
222
+ animations,
223
+ selection,
224
+ async () => {},
225
+ undefined,
226
+ moment.time,
227
+ );
228
+ const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector, moment.time);
218
229
  const route = dragRoute(posAnim, iframe, selector, options.altKey);
219
230
  if (route === "static") return { status: "persisted" };
220
231
  if (!posAnim) {
@@ -227,7 +238,7 @@ async function planDrag(
227
238
  : { status: "persisted" };
228
239
  }
229
240
  const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
230
- return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
241
+ return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe, moment);
231
242
  }
232
243
 
233
244
  /** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
@@ -247,6 +258,9 @@ export async function tryGsapDragIntercept(
247
258
  stamp?: DragStamp;
248
259
  },
249
260
  ): Promise<GsapEditOutcome> {
261
+ const stamp = options?.stamp ?? readDragStamp(selection.element);
262
+ const moment = editMoment(stamp);
263
+ const time = moment.time;
250
264
  if (!options?.preflightPassed) {
251
265
  const preflight = await preflightGsapDragIntercept(
252
266
  selection,
@@ -258,7 +272,7 @@ export async function tryGsapDragIntercept(
258
272
  if (preflight.status !== "persisted") return preflight;
259
273
  if (options?.preflightOnly) {
260
274
  return options.group
261
- ? planDrag(selection, offset, allAnimations, iframe, options)
275
+ ? planDrag(selection, offset, allAnimations, iframe, options, moment)
262
276
  : preflight;
263
277
  }
264
278
  }
@@ -302,16 +316,17 @@ export async function tryGsapDragIntercept(
302
316
  selection,
303
317
  commitMutation,
304
318
  fetchFallbackAnimations,
319
+ time,
305
320
  );
306
321
 
307
322
  let posAnim = resolved?.anim ?? null;
308
323
  let resolvedAnimations = resolved?.animations ?? workingAnimations;
309
324
  if (!posAnim) {
310
- posAnim = findGsapPositionAnimation(workingAnimations, selector);
325
+ posAnim = findGsapPositionAnimation(workingAnimations, selector, time);
311
326
  if (!posAnim && fetchFallbackAnimations) {
312
327
  const fresh = await fetchFallbackAnimations();
313
328
  resolvedAnimations = fresh;
314
- posAnim = findGsapPositionAnimation(fresh, selector);
329
+ posAnim = findGsapPositionAnimation(fresh, selector, time);
315
330
  }
316
331
  }
317
332
 
@@ -349,13 +364,14 @@ export async function tryGsapDragIntercept(
349
364
  a.propertyGroup === posAnim!.propertyGroup &&
350
365
  isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
351
366
  ),
367
+ time,
352
368
  );
353
369
  if (freshMatch && freshMatch.id !== posAnim.id) {
354
370
  posAnim = freshMatch;
355
371
  }
356
372
  }
357
373
 
358
- const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp: options?.stamp };
374
+ const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
359
375
  if (route === "whole-path") {
360
376
  await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
361
377
  } else {
@@ -412,7 +428,9 @@ export async function tryGsapRotationIntercept(
412
428
  iframe: HTMLIFrameElement | null,
413
429
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
414
430
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
431
+ stamp?: DragStamp,
415
432
  ): Promise<GsapEditOutcome> {
433
+ const time = editMoment(stamp).time;
416
434
  const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
417
435
  const outcome = preflightGsapRotationIntercept(selection, animations, iframe, fetchedAnimations);
418
436
  if (outcome.status === "blocked") return outcome;
@@ -429,6 +447,7 @@ export async function tryGsapRotationIntercept(
429
447
  selection,
430
448
  commitMutation,
431
449
  postSplitFetch,
450
+ time,
432
451
  );
433
452
  const resolvedAnimations = resolved?.animations ?? workingAnimations;
434
453
 
@@ -440,6 +459,7 @@ export async function tryGsapRotationIntercept(
440
459
  if (!anim) {
441
460
  anim = pickClosestToPlayhead(
442
461
  workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
462
+ time,
443
463
  );
444
464
  }
445
465
 
@@ -462,7 +482,7 @@ export async function tryGsapRotationIntercept(
462
482
  return { status: "persisted" };
463
483
  }
464
484
 
465
- const pct = computeCurrentPercentage(selection, anim);
485
+ const pct = computeCurrentPercentage(selection, anim, time);
466
486
 
467
487
  // With auto-keyframe off (#1808), a rotation tween already exists for this
468
488
  // element (checked above) so nudge it as a whole rather than adding a
@@ -485,7 +505,7 @@ export async function tryGsapRotationIntercept(
485
505
  anim,
486
506
  { rotation: newRotation },
487
507
  iframe,
488
- { commitMutation, fetchAnimations: fetchFallbackAnimations },
508
+ { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp },
489
509
  { label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
490
510
  );
491
511
  }
@@ -1,18 +1,14 @@
1
1
  // @vitest-environment happy-dom
2
2
  // Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
3
- import { readFileSync } from "node:fs";
4
- import { createRequire } from "node:module";
5
3
  import { gsap } from "gsap";
6
4
  import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
7
5
  import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
8
- import puppeteer from "puppeteer-core";
9
6
  import { expect, it, vi } from "vitest";
10
- import { findSystemChrome } from "../../vite.browser";
7
+ import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
11
8
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
12
9
  import { usePlayerStore } from "../player/store/playerStore";
13
10
  import { planValueEdit } from "./gsapValueAtPlayhead";
14
11
 
15
- const require = createRequire(import.meta.url);
16
12
  vi.setConfig({ testTimeout: 60_000 });
17
13
 
18
14
  const SRC = `var tl = gsap.timeline({ paused: true });
@@ -49,25 +45,10 @@ const page = (script: string) => `<!doctype html><html><body>
49
45
 
50
46
  it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
51
47
  const written = writeDrag(90, 2);
52
- const executablePath = findSystemChrome();
53
- if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
54
- const browser = await puppeteer.launch({
55
- executablePath,
56
- headless: true,
57
- args: ["--no-sandbox"],
58
- });
48
+ const browser = await launchTestChrome();
59
49
  try {
60
50
  const tab = await browser.newPage();
61
- await tab.setRequestInterception(true);
62
- // Only the GSAP script is served; anything else is refused, never fetched.
63
- tab.on("request", (request) =>
64
- request.url().endsWith("/gsap.min.js")
65
- ? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
66
- : request.abort("blockedbyclient"),
67
- );
68
- await tab.setContent(page(written), { waitUntil: "load" });
69
- await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
70
- await tab.waitForFunction(() => "__player" in window);
51
+ await showWithRuntime(tab, page(written));
71
52
  const shown = await tab.evaluate(() => {
72
53
  const w = window as unknown as {
73
54
  __player: { seek(t: number): void };
@@ -8,6 +8,7 @@ import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-
8
8
  import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
9
9
  import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
10
10
  import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
11
+ import { STUDIO_EDIT_MOMENT_ATTR } from "../components/editor/manualEditsTypes";
11
12
 
12
13
  let el: HTMLElement;
13
14
  let selection: DomEditSelection;
@@ -181,6 +182,20 @@ describe("a move on a GSAP-animated layer, at the playhead", () => {
181
182
  ]);
182
183
  });
183
184
 
185
+ it("keys the time the drag was pressed at, wherever the playhead is when it commits", async () => {
186
+ const { keys, live } = boxSteps([
187
+ [2, { x: 60 }],
188
+ [1, { x: 120 }],
189
+ ]);
190
+ el.setAttribute(STUDIO_EDIT_MOMENT_ATTR, JSON.stringify({ time: 1, keyframePct: null }));
191
+ const { mutations } = await drag([keys], [live(el)], { time: 2, base: [30, 0], dx: 10 });
192
+ expect(mutations[0].keyframes[0]).toEqual({
193
+ percentage: 33.333,
194
+ properties: { x: 40, y: 0 },
195
+ ease: "none",
196
+ });
197
+ });
198
+
184
199
  it("never writes x and y into a tween that only animates size", async () => {
185
200
  const size = tween({
186
201
  id: "#box-to-0-size",
@@ -1,6 +1,8 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
+ import type { EditMoment } from "../components/editor/manualEditsTypes";
5
+ import { editMoment, playheadMoment } from "./editMoment";
4
6
  import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
5
7
  import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
6
8
  import { roundTo3 } from "../utils/rounding";
@@ -287,11 +289,15 @@ export function planValueEdit(
287
289
  anim: GsapAnimation,
288
290
  values: Record<string, number>,
289
291
  iframe: HTMLIFrameElement | null,
290
- { backfill, holdFromStart }: Pick<PlayheadEdit, "backfill" | "holdFromStart"> = {},
292
+ {
293
+ backfill,
294
+ holdFromStart,
295
+ moment = playheadMoment(),
296
+ }: Pick<PlayheadEdit, "backfill" | "holdFromStart"> & { moment?: EditMoment } = {},
291
297
  ): PlayheadEditPlan {
292
298
  // One keyframe of a tween its siblings share would move them all.
293
299
  if (tweenReach(anim, selection.element) === "shared") return refuse("shared-tween");
294
- const { activeKeyframePct, currentTime } = usePlayerStore.getState();
300
+ const { keyframePct: activeKeyframePct, time: currentTime } = moment;
295
301
  const tween = findParsedTween(iframe, selection.element, anim);
296
302
  const timed = withExactStepTimes(anim, tween);
297
303
  return planValueAtPlayhead({
@@ -321,11 +327,12 @@ export async function commitValueAtPlayhead(
321
327
  },
322
328
  ): Promise<GsapEditOutcome> {
323
329
  await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
324
- const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
330
+ const moment = editMoment(callbacks.stamp);
331
+ const { setActiveKeyframePct } = usePlayerStore.getState();
325
332
  const tween = findParsedTween(iframe, selection.element, anim);
326
333
  const parkAt =
327
- activeKeyframePct == null ? null : exactKeyframePct(anim, tween, activeKeyframePct);
328
- const plan = planValueEdit(selection, anim, values, iframe, options);
334
+ moment.keyframePct == null ? null : exactKeyframePct(anim, tween, moment.keyframePct);
335
+ const plan = planValueEdit(selection, anim, values, iframe, { ...options, moment });
329
336
  if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
330
337
  await callbacks.commitMutation(selection, plan.mutation, {
331
338
  label: options.label,
@@ -233,6 +233,28 @@ describe("extendRootDurationIfNeeded", () => {
233
233
  });
234
234
  });
235
235
 
236
+ describe("buildTimelineMoveTimingPatch", () => {
237
+ it.each([
238
+ { root: ' data-duration="20"', length: 'data-composition-id="c" data-duration="8"' },
239
+ { root: "", length: 'data-composition-id="c">' },
240
+ ])(
241
+ "parses the saved file once to move a clip and sync the length (root$root)",
242
+ ({ root, length }) => {
243
+ const source = `<div data-composition-id="c"${root}><div id="a" class="clip" data-start="1" data-duration="3"></div></div>`;
244
+ const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
245
+ try {
246
+ const patched = buildTimelineMoveTimingPatch(source, { id: "a" }, 5, 3);
247
+
248
+ expect(patched).toContain('data-start="5"');
249
+ expect(patched).toContain(length);
250
+ expect(parse).toHaveBeenCalledTimes(1);
251
+ } finally {
252
+ parse.mockRestore();
253
+ }
254
+ },
255
+ );
256
+ });
257
+
236
258
  describe("buildTimelineResizeTimingPatch", () => {
237
259
  it("moves a source-only in-point by the caller's own start change", () => {
238
260
  const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" data-media-start="0.337"></video></div>`;
@@ -15,7 +15,7 @@ import {
15
15
  } from "../player/components/timelineEditing";
16
16
  import { getElementZIndex } from "../player/lib/layerOrdering";
17
17
  import {
18
- furthestClipEndFromSource,
18
+ furthestClipEndFromDocument,
19
19
  getTimelineElementIdentity,
20
20
  playbackStartAttributeForElement,
21
21
  readPlaybackStartAttributes,
@@ -29,6 +29,7 @@ import {
29
29
  import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
30
30
  import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
31
31
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
32
+ import { rootCompositionDurationOf } from "../utils/rootDuration";
32
33
  import { readFileContent } from "./timelineTimingSync";
33
34
  import {
34
35
  findElementForSelection,
@@ -263,7 +264,22 @@ export function applyTimelineMoveAttributes(
263
264
  // Root data-duration follows the furthest clip end in the source; the store's durations are
264
265
  // truncated to the current length, so reading them would ratchet the duration down every edit.
265
266
  export function syncCompositionDurationToContent(source: string): string {
266
- return setCompositionDurationToContent(source, furthestClipEndFromSource(source));
267
+ return syncCompositionDurationWithEnd(source).source;
268
+ }
269
+
270
+ /** The synced source and the clip end it was synced to, from one parse of the source. */
271
+ export function syncCompositionDurationWithEnd(source: string): {
272
+ source: string;
273
+ contentEnd: number;
274
+ } {
275
+ const doc = new DOMParser().parseFromString(source, "text/html");
276
+ const contentEnd = furthestClipEndFromDocument(doc);
277
+ const synced = setCompositionDurationToContent(
278
+ source,
279
+ contentEnd,
280
+ rootCompositionDurationOf(doc),
281
+ );
282
+ return { source: synced, contentEnd };
267
283
  }
268
284
 
269
285
  export function buildTimelineMoveTimingPatch(