@hyperframes/studio 0.7.25 → 0.7.26

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 (34) hide show
  1. package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
  2. package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
  3. package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
  4. package/dist/index.d.ts +6 -0
  5. package/dist/index.html +1 -1
  6. package/dist/index.js +377 -210
  7. package/dist/index.js.map +1 -1
  8. package/package.json +7 -7
  9. package/src/components/TimelineToolbar.test.tsx +50 -0
  10. package/src/components/TimelineToolbar.tsx +70 -35
  11. package/src/components/editor/MotionPathOverlay.tsx +27 -4
  12. package/src/components/editor/SnapToolbar.test.tsx +124 -0
  13. package/src/components/editor/SnapToolbar.tsx +1 -0
  14. package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
  15. package/src/hooks/gsapRuntimeBridge.ts +41 -1
  16. package/src/hooks/gsapTweenSynth.test.ts +55 -0
  17. package/src/hooks/gsapTweenSynth.ts +12 -6
  18. package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
  19. package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
  20. package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
  21. package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
  22. package/src/hooks/useAppHotkeys.ts +3 -2
  23. package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
  24. package/src/hooks/useGsapScriptCommits.ts +6 -1
  25. package/src/hooks/useRenderClipContent.test.ts +54 -1
  26. package/src/hooks/useRenderClipContent.ts +7 -5
  27. package/src/player/components/Timeline.test.ts +51 -1
  28. package/src/player/components/Timeline.tsx +0 -2
  29. package/src/player/components/TimelineCanvas.tsx +1 -0
  30. package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
  31. package/src/player/components/TimelineClipDiamonds.tsx +46 -2
  32. package/src/player/store/playerStore.ts +8 -0
  33. package/src/utils/gsapSoftReload.test.ts +12 -0
  34. package/src/utils/gsapSoftReload.ts +15 -2
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
5
+ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
6
+
7
+ // Regression (#1808): with auto-keyframe recording off, a manual edit on an
8
+ // element that already has a keyframed tween must shift every keyframe by
9
+ // the edit's delta (preserving the animation's shape) instead of inserting
10
+ // or updating a keyframe at the playhead.
11
+
12
+ const selection = (): DomEditSelection => ({ id: "box", selector: "#box" }) as DomEditSelection;
13
+
14
+ function recordingCallbacks(): {
15
+ mutations: Array<Record<string, unknown>>;
16
+ callbacks: GsapDragCommitCallbacks;
17
+ } {
18
+ const mutations: Array<Record<string, unknown>> = [];
19
+ return {
20
+ mutations,
21
+ callbacks: {
22
+ commitMutation: async (_sel, mutation) => {
23
+ mutations.push(mutation);
24
+ },
25
+ },
26
+ };
27
+ }
28
+
29
+ describe("commitWholePropertyOffset", () => {
30
+ it("shifts every keyframe of a keyframed tween by the delta at the nearest keyframe", async () => {
31
+ const anim = {
32
+ id: "#box-rotate",
33
+ targetSelector: "#box",
34
+ method: "to",
35
+ resolvedStart: 0,
36
+ duration: 2,
37
+ keyframes: {
38
+ keyframes: [
39
+ { percentage: 0, properties: { rotation: 10 } },
40
+ { percentage: 50, properties: { rotation: 20 } },
41
+ { percentage: 100, properties: { rotation: 40 } },
42
+ ],
43
+ },
44
+ } as unknown as GsapAnimation;
45
+
46
+ const { mutations, callbacks } = recordingCallbacks();
47
+ // currentPct=48 lands nearest the 50% keyframe (rotation 20) — dragging to
48
+ // 30 is a +10 delta, applied to every keyframe.
49
+ await commitWholePropertyOffset(
50
+ selection(),
51
+ anim,
52
+ { rotation: 30 },
53
+ 48,
54
+ null,
55
+ callbacks,
56
+ "Rotate",
57
+ );
58
+
59
+ expect(mutations).toHaveLength(1);
60
+ const mutation = mutations[0]!;
61
+ expect(mutation.type).toBe("replace-with-keyframes");
62
+ const keyframes = mutation.keyframes as Array<{
63
+ percentage: number;
64
+ properties: Record<string, number>;
65
+ }>;
66
+ expect(keyframes.map((k) => k.percentage)).toEqual([0, 50, 100]);
67
+ expect(keyframes.map((k) => k.properties.rotation)).toEqual([20, 30, 50]);
68
+ });
69
+
70
+ it("materializes a flat tween into a 2-point range before shifting", async () => {
71
+ const anim = {
72
+ id: "#box-fade",
73
+ targetSelector: "#box",
74
+ method: "fromTo",
75
+ resolvedStart: 0,
76
+ duration: 1,
77
+ properties: { opacity: 1 },
78
+ fromProperties: { opacity: 0 },
79
+ } as unknown as GsapAnimation;
80
+
81
+ const { mutations, callbacks } = recordingCallbacks();
82
+ // Nearest keyframe to pct=100 is the synthesized 100% stop (opacity 1) —
83
+ // dragging opacity to 0.5 is a -0.5 delta, applied to both stops.
84
+ await commitWholePropertyOffset(
85
+ selection(),
86
+ anim,
87
+ { opacity: 0.5 },
88
+ 100,
89
+ null,
90
+ callbacks,
91
+ "Fade",
92
+ );
93
+
94
+ expect(mutations).toHaveLength(1);
95
+ const keyframes = mutations[0]!.keyframes as Array<{
96
+ percentage: number;
97
+ properties: Record<string, number>;
98
+ }>;
99
+ expect(keyframes).toEqual([
100
+ { percentage: 0, properties: { opacity: -0.5 } },
101
+ { percentage: 100, properties: { opacity: 0.5 } },
102
+ ]);
103
+ });
104
+
105
+ it("preserves keyframes' other properties and per-keyframe ease untouched", async () => {
106
+ const anim = {
107
+ id: "#box-move",
108
+ targetSelector: "#box",
109
+ method: "to",
110
+ resolvedStart: 0,
111
+ duration: 1,
112
+ keyframes: {
113
+ keyframes: [
114
+ { percentage: 0, properties: { x: 0, opacity: 1 }, ease: "power1.in" },
115
+ { percentage: 100, properties: { x: 100, opacity: 0.5 } },
116
+ ],
117
+ },
118
+ } as unknown as GsapAnimation;
119
+
120
+ const { mutations, callbacks } = recordingCallbacks();
121
+ await commitWholePropertyOffset(selection(), anim, { x: 120 }, 100, null, callbacks, "Move");
122
+
123
+ const keyframes = mutations[0]!.keyframes as Array<{
124
+ percentage: number;
125
+ properties: Record<string, number>;
126
+ ease?: string;
127
+ }>;
128
+ // Delta is +20 (120 - 100 at the nearest, 100%, keyframe).
129
+ expect(keyframes[0]).toEqual({
130
+ percentage: 0,
131
+ properties: { x: 20, opacity: 1 },
132
+ ease: "power1.in",
133
+ });
134
+ expect(keyframes[1]).toEqual({ percentage: 100, properties: { x: 120, opacity: 0.5 } });
135
+ });
136
+
137
+ it("persists a flat update instead of crashing when the tween has no synthesizable shape", async () => {
138
+ // Regression: removeAllKeyframesFromScript collapses a keyframed tween into a
139
+ // zero-duration immediateRender hold — synthesizeFlatTweenKeyframes treats that
140
+ // as a static hold (returns null), so `kfs` is empty. Resizing this element with
141
+ // auto-keyframe off used to call `kfs.reduce(...)` with no initial value, which
142
+ // throws "Reduce of empty array with no initial value".
143
+ const anim = {
144
+ id: "#box-hold",
145
+ targetSelector: "#box",
146
+ method: "to",
147
+ resolvedStart: 0,
148
+ duration: 0,
149
+ properties: { width: 200 },
150
+ extras: { immediateRender: "__raw:true" },
151
+ } as unknown as GsapAnimation;
152
+
153
+ const { mutations, callbacks } = recordingCallbacks();
154
+ await expect(
155
+ commitWholePropertyOffset(selection(), anim, { width: 300 }, 0, null, callbacks, "Resize"),
156
+ ).resolves.toBeUndefined();
157
+
158
+ expect(mutations).toEqual([
159
+ { type: "update-properties", animationId: "#box-hold", properties: { width: 300 } },
160
+ ]);
161
+ });
162
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * commitWholePropertyOffset — extracted from gsapDragCommit.ts to keep file
3
+ * sizes under the 600-line limit (mirrors gsapDragPositionCommit.ts).
4
+ */
5
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
7
+ import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
8
+ import { roundTo3 } from "../utils/rounding";
9
+ import { PROPERTY_DEFAULTS } from "./gsapShared";
10
+ import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
11
+ import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit";
12
+
13
+ /**
14
+ * Generic sibling of commitWholePathOffset for property groups other than
15
+ * position (rotation, size, scale) — shift every keyframe of `anim` by the
16
+ * same delta for each key in `newValues`, preserving the tween's shape. The
17
+ * delta is computed against the keyframe NEAREST `currentPct` (the one an
18
+ * ordinary edit would otherwise overwrite), not the live DOM: by the time a
19
+ * drag gesture reaches its commit step, the preview has often already
20
+ * gsap.set the dragged property to its new value, so the DOM can no longer
21
+ * tell "old" from "new".
22
+ */
23
+ export async function commitWholePropertyOffset(
24
+ selection: DomEditSelection,
25
+ anim: GsapAnimation,
26
+ newValues: Record<string, number>,
27
+ currentPct: number,
28
+ iframe: HTMLIFrameElement | null,
29
+ callbacks: GsapDragCommitCallbacks,
30
+ label: string,
31
+ ): Promise<void> {
32
+ let effectiveAnim = anim;
33
+ if (anim.keyframes) {
34
+ const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
35
+ if (newId) effectiveAnim = { ...anim, id: newId };
36
+ }
37
+
38
+ const ts = resolveTweenStart(effectiveAnim);
39
+ const td = resolveTweenDuration(effectiveAnim);
40
+ const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
41
+ const keys = Object.keys(newValues);
42
+ const at = (props: Record<string, number | string>, key: string) =>
43
+ typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0);
44
+
45
+ const kfs =
46
+ effectiveAnim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(effectiveAnim)?.keyframes;
47
+ if (!kfs || kfs.length === 0) {
48
+ // A `to()`/`from()` collapsed to a zero-duration immediateRender hold (what
49
+ // removeAllKeyframesFromScript leaves behind) has no shape to preserve —
50
+ // just persist the flat value instead of replacing with an empty keyframe list.
51
+ await callbacks.commitMutation(
52
+ selection,
53
+ { type: "update-properties", animationId: effectiveAnim.id, properties: newValues },
54
+ { label, softReload: true },
55
+ );
56
+ return;
57
+ }
58
+ const nearest = kfs.reduce((best, kf) =>
59
+ Math.abs(kf.percentage - currentPct) < Math.abs(best.percentage - currentPct) ? kf : best,
60
+ );
61
+
62
+ const shifted = kfs.map((kf) => {
63
+ const properties = { ...kf.properties };
64
+ for (const key of keys) {
65
+ properties[key] = roundTo3(
66
+ at(properties, key) + (newValues[key]! - at(nearest.properties, key)),
67
+ );
68
+ }
69
+ return { percentage: kf.percentage, properties, ...(kf.ease ? { ease: kf.ease } : {}) };
70
+ });
71
+
72
+ await callbacks.commitMutation(
73
+ selection,
74
+ {
75
+ type: "replace-with-keyframes",
76
+ animationId: effectiveAnim.id,
77
+ targetSelector: effectiveAnim.targetSelector,
78
+ position: roundTo3(ts ?? 0),
79
+ duration: roundTo3(td || 1),
80
+ keyframes: shifted,
81
+ ease,
82
+ },
83
+ { label, softReload: true },
84
+ );
85
+ }
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
8
+ import { usePlayerStore } from "../player/store/playerStore";
9
+ import { useAnimatedPropertyCommit } from "./useAnimatedPropertyCommit";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ usePlayerStore.setState({ autoKeyframeEnabled: true, currentTime: 0 });
16
+ });
17
+
18
+ const selection = { id: "box", selector: "#box" } as DomEditSelection;
19
+
20
+ const keyframedAnim = {
21
+ id: "#box-to-position",
22
+ targetSelector: "#box",
23
+ propertyGroup: "position",
24
+ method: "to",
25
+ properties: {},
26
+ resolvedStart: 0,
27
+ duration: 2,
28
+ keyframes: {
29
+ keyframes: [
30
+ { percentage: 0, properties: { x: 0, y: 0 } },
31
+ { percentage: 100, properties: { x: 100, y: 0 } },
32
+ ],
33
+ },
34
+ } as unknown as GsapAnimation;
35
+
36
+ type Commit = (
37
+ selection: DomEditSelection,
38
+ props: Record<string, number | string>,
39
+ ) => Promise<void>;
40
+
41
+ /** Renders the hook and hands its commit function to the caller via a ref callback. */
42
+ function renderCommitHook(
43
+ mutations: Array<Record<string, unknown>>,
44
+ onReady: (commit: Commit) => void,
45
+ ) {
46
+ function Harness() {
47
+ const { commitAnimatedProperties } = useAnimatedPropertyCommit({
48
+ selectedGsapAnimations: [keyframedAnim],
49
+ gsapCommitMutation: async (_sel, mutation) => {
50
+ mutations.push(mutation);
51
+ },
52
+ addGsapAnimation: vi.fn(),
53
+ convertToKeyframes: vi.fn(),
54
+ previewIframeRef: { current: null },
55
+ bumpGsapCache: vi.fn(),
56
+ });
57
+ onReady(commitAnimatedProperties);
58
+ return null;
59
+ }
60
+ const host = document.createElement("div");
61
+ document.body.append(host);
62
+ const root = createRoot(host);
63
+ act(() => {
64
+ root.render(<Harness />);
65
+ });
66
+ return root;
67
+ }
68
+
69
+ // Regression (#1808): a "3D transform" / design-panel property edit on an
70
+ // element that already has a keyframed tween is the ACTUAL path a manual
71
+ // canvas nudge exercises (not the raw drag intercept) — with auto-keyframe
72
+ // off, it must shift the whole tween instead of adding/updating a keyframe
73
+ // at the playhead.
74
+ describe("useAnimatedPropertyCommit — autoKeyframeEnabled toggle (#1808)", () => {
75
+ it("shifts the whole tween instead of updating a keyframe when the toggle is off", async () => {
76
+ usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 0 });
77
+ const mutations: Array<Record<string, unknown>> = [];
78
+ let commit: Commit | undefined;
79
+ const root = renderCommitHook(mutations, (fn) => (commit = fn));
80
+
81
+ await act(async () => {
82
+ await commit!(selection, { x: 50 });
83
+ });
84
+
85
+ expect(mutations).toHaveLength(1);
86
+ expect(mutations[0]!.type).toBe("replace-with-keyframes");
87
+ act(() => root.unmount());
88
+ });
89
+
90
+ it("still updates a keyframe at the playhead when the toggle is on (default)", async () => {
91
+ const mutations: Array<Record<string, unknown>> = [];
92
+ let commit: Commit | undefined;
93
+ const root = renderCommitHook(mutations, (fn) => (commit = fn));
94
+
95
+ await act(async () => {
96
+ await commit!(selection, { x: 50 });
97
+ });
98
+
99
+ expect(mutations.some((m) => m.type === "update-keyframe" || m.type === "add-keyframe")).toBe(
100
+ true,
101
+ );
102
+ expect(mutations.some((m) => m.type === "replace-with-keyframes")).toBe(false);
103
+ act(() => root.unmount());
104
+ });
105
+ });
@@ -17,6 +17,7 @@ import type { SetPatchProps } from "./gsapRuntimePatch";
17
17
  import { selectorFromSelection, computeElementPercentage } from "./gsapShared";
18
18
  import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
19
19
  import { roundTo3 } from "../utils/rounding";
20
+ import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
20
21
 
21
22
  interface CommitAnimatedPropertyDeps {
22
23
  selectedGsapAnimations: GsapAnimation[];
@@ -341,6 +342,27 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
341
342
  // keyframe would never land (the bug: scrolling depth on a keyframed element
342
343
  // just changed the constant instead of dropping a keyframe).
343
344
  if (elementHasKeyframes && anim) {
345
+ // With auto-keyframe off (#1808), nudge the whole tween instead of
346
+ // adding/updating a keyframe at the playhead.
347
+ if (!usePlayerStore.getState().autoKeyframeEnabled) {
348
+ const pct = computeElementPercentage(
349
+ usePlayerStore.getState().currentTime,
350
+ selection,
351
+ anim,
352
+ );
353
+ await commitWholePropertyOffset(
354
+ selection,
355
+ anim,
356
+ Object.fromEntries(
357
+ propEntries.filter((e): e is [string, number] => typeof e[1] === "number"),
358
+ ),
359
+ pct,
360
+ iframe,
361
+ { commitMutation: gsapCommitMutation },
362
+ `Edit ${primaryProp} (whole animation)`,
363
+ );
364
+ return;
365
+ }
344
366
  await commitKeyframeProps(
345
367
  selection,
346
368
  anim,
@@ -228,6 +228,9 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
228
228
  }
229
229
 
230
230
  if (event.key === "s" && !event.altKey) {
231
+ // Reserve bare `s` for Split even when the current selection cannot split,
232
+ // so secondary listeners do not reinterpret the same key as Snap toggle.
233
+ event.preventDefault();
231
234
  const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
232
235
  if (selectedElementId) {
233
236
  const el = elements.find((e) => (e.key ?? e.id) === selectedElementId);
@@ -237,7 +240,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
237
240
  currentTime > el.start &&
238
241
  currentTime < el.start + el.duration
239
242
  ) {
240
- event.preventDefault();
241
243
  void cb.handleTimelineElementSplit(el, currentTime);
242
244
  return;
243
245
  }
@@ -245,7 +247,6 @@ function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCallbacks
245
247
  // that isn't in the raw `elements` list, so the s-key can't resolve them.
246
248
  // Nudge toward the razor tool instead of failing silently.
247
249
  if (!el && selectedElementId.includes("#")) {
248
- event.preventDefault();
249
250
  cb.showToast("Use the razor tool (B) to split clips inside a sub-composition", "info");
250
251
  return;
251
252
  }
@@ -86,7 +86,7 @@ describe("applyPreviewSync", () => {
86
86
 
87
87
  // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
88
88
  // CDN load failure escalates to a full reload — but it is NOT called eagerly.
89
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
89
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
90
90
  expect(reloadPreview).not.toHaveBeenCalled();
91
91
  // A successful instant patch is the fast path; here it missed → fallback event.
92
92
  expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -113,7 +113,7 @@ describe("applyPreviewSync", () => {
113
113
 
114
114
  // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
115
115
  // is correct, so we must NOT escalate to a full reload.
116
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
116
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
117
117
  expect(reloadPreview).not.toHaveBeenCalled();
118
118
  // Telemetry records the suppressed transient (escalated: false).
119
119
  expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -143,7 +143,7 @@ describe("applyPreviewSync", () => {
143
143
  );
144
144
 
145
145
  // Structural failure: the preview is genuinely stale/broken → full reload.
146
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
146
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
147
147
  expect(reloadPreview).toHaveBeenCalledTimes(1);
148
148
  expect(trackStudioEvent).toHaveBeenCalledWith(
149
149
  "gsap_soft_reload_outcome",
@@ -167,7 +167,7 @@ describe("applyPreviewSync", () => {
167
167
  );
168
168
 
169
169
  expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
170
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
170
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
171
171
  expect(reloadPreview).not.toHaveBeenCalled();
172
172
  // "applied" emits no telemetry (only the failure paths do).
173
173
  expect(trackStudioEvent).not.toHaveBeenCalled();
@@ -185,7 +185,7 @@ describe("applyPreviewSync", () => {
185
185
  );
186
186
 
187
187
  // onAsyncFailure is wired, but the transient result does not trigger it.
188
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
188
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
189
189
  expect(reloadPreview).not.toHaveBeenCalled();
190
190
  expect(trackStudioEvent).toHaveBeenCalledWith(
191
191
  "gsap_soft_reload_outcome",
@@ -204,7 +204,7 @@ describe("applyPreviewSync", () => {
204
204
  reloadPreview,
205
205
  );
206
206
 
207
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview);
207
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
208
208
  expect(reloadPreview).toHaveBeenCalledTimes(1);
209
209
  expect(trackStudioEvent).toHaveBeenCalledWith(
210
210
  "gsap_soft_reload_outcome",
@@ -345,7 +345,7 @@ describe("runCommit — instantPatch wiring", () => {
345
345
  });
346
346
 
347
347
  expect(fetch).toHaveBeenCalledTimes(1);
348
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
348
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
349
349
  expect(deps.reloadPreview).not.toHaveBeenCalled();
350
350
  expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
351
351
  });
@@ -360,7 +360,7 @@ describe("runCommit — instantPatch wiring", () => {
360
360
  });
361
361
 
362
362
  expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
363
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview);
363
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
364
364
  expect(deps.reloadPreview).not.toHaveBeenCalled();
365
365
  });
366
366
  });
@@ -1,6 +1,7 @@
1
1
  import { useCallback, useMemo, useRef } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
+ import { usePlayerStore } from "../player/store/playerStore";
4
5
  import { applySoftReload, extractGsapScriptText } from "../utils/gsapSoftReload";
5
6
  import type { SoftReloadResult } from "../utils/gsapSoftReload";
6
7
  import { trackStudioEvent } from "../utils/studioTelemetry";
@@ -66,7 +67,11 @@ function softReloadOrEscalate(
66
67
  reloadPreview: () => void,
67
68
  origin: "preview_sync" | "sdk_refresh",
68
69
  ): void {
69
- const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview);
70
+ // Seek the rebuilt timeline to the studio's own authoritative scrub position,
71
+ // not the iframe's raw `__player.getTime()` — see the comment in
72
+ // applySoftReload for why the two can desync after a keyframe-node drag.
73
+ const currentTime = usePlayerStore.getState().currentTime;
74
+ const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime);
70
75
  if (result === "applied") return;
71
76
  trackStudioEvent("gsap_soft_reload_outcome", {
72
77
  origin,
@@ -1,5 +1,18 @@
1
- import { describe, expect, it } from "vitest";
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, isValidElement, type ReactNode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it } from "vitest";
6
+ import { AudioWaveform } from "../player/components/AudioWaveform";
7
+ import type { TimelineElement } from "../player/store/playerStore";
2
8
  import { normalizeCompositionSrc } from "./useRenderClipContent";
9
+ import { useRenderClipContent } from "./useRenderClipContent";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ });
3
16
 
4
17
  describe("normalizeCompositionSrc", () => {
5
18
  const origin = "http://localhost:5190";
@@ -48,3 +61,43 @@ describe("normalizeCompositionSrc", () => {
48
61
  expect(result).toBe("compositions/scenes/hero.html");
49
62
  });
50
63
  });
64
+
65
+ describe("useRenderClipContent", () => {
66
+ function renderClipContent(el: TimelineElement): ReactNode {
67
+ const host = document.createElement("div");
68
+ document.body.append(host);
69
+ const root = createRoot(host);
70
+ let content: ReactNode = null;
71
+
72
+ function Harness() {
73
+ const render = useRenderClipContent({
74
+ projectIdRef: { current: "my-project" },
75
+ compIdToSrc: new Map(),
76
+ activePreviewUrl: "/api/projects/my-project/preview",
77
+ effectiveTimelineDuration: 12,
78
+ });
79
+ content = render(el, { clip: "#222", label: "#fff" });
80
+ return null;
81
+ }
82
+
83
+ act(() => {
84
+ root.render(React.createElement(Harness));
85
+ });
86
+ act(() => root.unmount());
87
+ return content;
88
+ }
89
+
90
+ it("renders audio clips as waveforms even when a composition preview URL is active", () => {
91
+ const content = renderClipContent({
92
+ id: "voiceover",
93
+ tag: "audio",
94
+ start: 1,
95
+ duration: 4,
96
+ track: 1,
97
+ src: "assets/voiceover.mp3",
98
+ });
99
+
100
+ expect(isValidElement(content)).toBe(true);
101
+ if (isValidElement(content)) expect(content.type).toBe(AudioWaveform);
102
+ });
103
+ });
@@ -110,6 +110,13 @@ export function useRenderClipContent({
110
110
  });
111
111
  }
112
112
 
113
+ // Audio clips — waveform visualization. Resolve these before the generic
114
+ // activePreviewUrl thumbnail branch; audio rows need waveform data, not a
115
+ // captured frame from the currently drilled composition preview.
116
+ if (el.tag === "audio") {
117
+ return renderAudioClip(el, pid, style.label);
118
+ }
119
+
113
120
  // When drilled into a composition, render all inner elements via
114
121
  // CompositionThumbnail at their start time — most accurate visual.
115
122
  if (activePreviewUrl && el.duration > 0) {
@@ -131,11 +138,6 @@ export function useRenderClipContent({
131
138
  el.duration < effectiveTimelineDuration * 0.92 &&
132
139
  !/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
133
140
 
134
- // Audio clips — waveform visualization
135
- if (el.tag === "audio") {
136
- return renderAudioClip(el, pid, style.label);
137
- }
138
-
139
141
  if ((el.tag === "video" || el.tag === "img") && el.src) {
140
142
  const mediaSrc = el.src.startsWith("http")
141
143
  ? el.src
@@ -3,7 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach } from "vitest";
6
- import { describe, it, expect } from "vitest";
6
+ import { describe, it, expect, vi } from "vitest";
7
7
  import {
8
8
  Timeline,
9
9
  formatTimelineTickLabel,
@@ -54,6 +54,56 @@ describe("Timeline provider boundary", () => {
54
54
 
55
55
  act(() => root.unmount());
56
56
  });
57
+
58
+ it("opens the keyframe context menu without seeking to that keyframe", () => {
59
+ const host = document.createElement("div");
60
+ document.body.append(host);
61
+ Object.defineProperty(host, "clientWidth", {
62
+ configurable: true,
63
+ value: 720,
64
+ });
65
+
66
+ usePlayerStore.setState({
67
+ duration: 4,
68
+ timelineReady: true,
69
+ currentTime: 0.25,
70
+ selectedElementId: "clip-1",
71
+ elements: [{ id: "clip-1", tag: "div", start: 0, duration: 4, track: 0 }],
72
+ keyframeCache: new Map([
73
+ [
74
+ "clip-1",
75
+ {
76
+ format: "percentage",
77
+ keyframes: [{ percentage: 50, properties: { x: 100 }, tweenPercentage: 50 }],
78
+ },
79
+ ],
80
+ ]),
81
+ });
82
+
83
+ const onSeek = vi.fn();
84
+ const root = createRoot(host);
85
+ act(() => {
86
+ root.render(React.createElement(Timeline, { onSeek }));
87
+ });
88
+
89
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]');
90
+ expect(diamond).not.toBeNull();
91
+
92
+ act(() => {
93
+ diamond!.dispatchEvent(
94
+ new MouseEvent("contextmenu", {
95
+ bubbles: true,
96
+ cancelable: true,
97
+ button: 2,
98
+ clientX: 120,
99
+ clientY: 40,
100
+ }),
101
+ );
102
+ });
103
+
104
+ expect(onSeek).not.toHaveBeenCalled();
105
+ act(() => root.unmount());
106
+ });
57
107
  });
58
108
 
59
109
  describe("generateTicks", () => {
@@ -487,8 +487,6 @@ export const Timeline = memo(function Timeline({
487
487
  if (el) {
488
488
  setSelectedElementId(elId);
489
489
  onSelectElement?.(el);
490
- const absTime = el.start + (pct / 100) * el.duration;
491
- onSeek?.(absTime);
492
490
  }
493
491
  const kfData = keyframeCache.get(elId);
494
492
  const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2);
@@ -446,6 +446,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
446
446
  onShiftClickKeyframe={onShiftClickKeyframe}
447
447
  onContextMenuKeyframe={onContextMenuKeyframe}
448
448
  onMoveKeyframe={onMoveKeyframe}
449
+ suppressClickRef={suppressClickRef}
449
450
  />
450
451
  )}
451
452
  </TimelineClip>