@hyperframes/studio 0.6.91 → 0.6.93

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 (48) hide show
  1. package/dist/assets/{index-DSLrl2tB.js → index-BkwsVKGA.js} +24 -24
  2. package/dist/assets/index-DYRWmfMX.js +251 -0
  3. package/dist/assets/index-rm9tn9nH.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/components/StudioPreviewArea.tsx +48 -13
  7. package/src/components/TimelineToolbar.tsx +0 -21
  8. package/src/components/editor/DomEditOverlay.tsx +79 -0
  9. package/src/components/editor/PropertyPanel.tsx +19 -13
  10. package/src/components/editor/gsapAnimatesProperty.ts +30 -0
  11. package/src/components/editor/manualEditingAvailability.test.ts +5 -5
  12. package/src/components/editor/manualEditingAvailability.ts +11 -7
  13. package/src/components/editor/manualEditsDom.ts +25 -5
  14. package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
  15. package/src/components/editor/manualEditsDomPatches.ts +17 -1
  16. package/src/components/editor/manualEditsSnapshot.ts +16 -0
  17. package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
  18. package/src/components/editor/useOffScreenIndicators.ts +197 -0
  19. package/src/components/nle/NLELayout.tsx +16 -14
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/hooks/gsapDragCommit.ts +119 -43
  22. package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
  23. package/src/hooks/gsapRuntimeBridge.ts +266 -41
  24. package/src/hooks/gsapRuntimeReaders.ts +16 -2
  25. package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
  26. package/src/hooks/useDomEditCommits.ts +7 -1
  27. package/src/hooks/useDomEditSession.ts +13 -0
  28. package/src/hooks/useEnableKeyframes.ts +3 -1
  29. package/src/hooks/useGestureCommit.ts +99 -13
  30. package/src/hooks/useGestureRecording.ts +18 -2
  31. package/src/hooks/useGsapScriptCommits.ts +24 -3
  32. package/src/hooks/useGsapSelectionHandlers.ts +19 -3
  33. package/src/hooks/useGsapTweenCache.ts +30 -10
  34. package/src/hooks/useRazorSplit.ts +2 -7
  35. package/src/player/components/ClipContextMenu.tsx +9 -4
  36. package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
  37. package/src/player/components/Timeline.tsx +7 -3
  38. package/src/player/components/TimelineClipDiamonds.tsx +3 -1
  39. package/src/player/store/playerStore.ts +12 -0
  40. package/src/utils/globalTimeCompiler.test.ts +2 -2
  41. package/src/utils/globalTimeCompiler.ts +2 -1
  42. package/src/utils/gsapSoftReload.test.ts +16 -0
  43. package/src/utils/gsapSoftReload.ts +43 -8
  44. package/src/utils/rdpSimplify.ts +3 -2
  45. package/src/utils/timelineElementSplit.test.ts +50 -0
  46. package/src/utils/timelineElementSplit.ts +16 -0
  47. package/dist/assets/index-CgYcO2PV.js +0 -146
  48. package/dist/assets/index-D2NkPomd.css +0 -1
@@ -123,7 +123,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
123
123
  const kfKey = `${elementId}:${kf.percentage}`;
124
124
  const isKfSelected = selectedKeyframes.has(kfKey);
125
125
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
126
- const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3";
126
+ const isHighlighted = isKfSelected || atPlayhead;
127
+ const color = isHighlighted ? accentColor : "#a3a3a3";
127
128
  return (
128
129
  <button
129
130
  key={`${i}-${kf.percentage}`}
@@ -135,6 +136,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
135
136
  transform: "translateY(-50%)",
136
137
  width: diamondSize,
137
138
  height: diamondSize,
139
+ zIndex: isHighlighted ? 2 : 1,
138
140
  pointerEvents: "auto",
139
141
  background: "none",
140
142
  border: "none",
@@ -6,6 +6,10 @@ export interface KeyframeCacheEntry {
6
6
  format: string;
7
7
  keyframes: Array<{
8
8
  percentage: number;
9
+ /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
10
+ tweenPercentage?: number;
11
+ /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
12
+ propertyGroup?: string;
9
13
  properties: Record<string, number | string>;
10
14
  ease?: string;
11
15
  }>;
@@ -74,6 +78,11 @@ interface PlayerState {
74
78
  toggleSelectedKeyframe: (key: string) => void;
75
79
  clearSelectedKeyframes: () => void;
76
80
 
81
+ /** Tween-relative percentage of the last-clicked keyframe diamond. Operations
82
+ * (drag, resize, rotate) target this instead of recomputing from playhead. */
83
+ activeKeyframePct: number | null;
84
+ setActiveKeyframePct: (pct: number | null) => void;
85
+
77
86
  /** Multi-select: additional selected elements beyond selectedElementId. */
78
87
  selectedElementIds: Set<string>;
79
88
  toggleSelectedElementId: (id: string) => void;
@@ -170,6 +179,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
170
179
  }),
171
180
  clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
172
181
 
182
+ activeKeyframePct: null,
183
+ setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
184
+
173
185
  keyframeClipboard: null,
174
186
  setKeyframeClipboard: (data) => set({ keyframeClipboard: data }),
175
187
 
@@ -103,8 +103,8 @@ describe("resolveTweenDuration", () => {
103
103
  expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2);
104
104
  });
105
105
 
106
- test("missing duration defaults to 1", () => {
107
- expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1);
106
+ test("missing duration defaults to GSAP default (0.5)", () => {
107
+ expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5);
108
108
  });
109
109
  });
110
110
 
@@ -27,6 +27,7 @@ export function isTimeWithinTween(
27
27
  }
28
28
 
29
29
  export function resolveTweenStart(animation: GsapAnimation): number | null {
30
+ if (animation.resolvedStart != null) return animation.resolvedStart;
30
31
  if (typeof animation.position === "number") return animation.position;
31
32
  const parsed = Number.parseFloat(animation.position as string);
32
33
  if (!Number.isNaN(parsed)) return parsed;
@@ -34,7 +35,7 @@ export function resolveTweenStart(animation: GsapAnimation): number | null {
34
35
  }
35
36
 
36
37
  export function resolveTweenDuration(animation: GsapAnimation): number {
37
- return animation.duration ?? 1;
38
+ return animation.duration ?? 0.5;
38
39
  }
39
40
 
40
41
  export function findTweenAtTime(
@@ -28,10 +28,26 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
28
28
  ...overrides,
29
29
  };
30
30
 
31
+ // Intercept appendChild: when a <script> is appended, simulate execution by
32
+ // repopulating __timelines (mimicking what the real GSAP script would do).
33
+ const realAppendChild = container.appendChild.bind(container);
34
+ container.appendChild = <T extends Node>(node: T): T => {
35
+ const result = realAppendChild(node);
36
+ if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
37
+ // Simulate the script populating __timelines
38
+ const cw = contentWindow as { __timelines?: Record<string, unknown> };
39
+ if (cw.__timelines) {
40
+ cw.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
41
+ }
42
+ }
43
+ return result;
44
+ };
45
+
31
46
  const contentDocument = {
32
47
  querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
33
48
  createElement: (tag: string) => document.createElement(tag),
34
49
  body: container,
50
+ head: document.createElement("div"),
35
51
  };
36
52
 
37
53
  return {
@@ -7,6 +7,8 @@ type IframeWindow = Window & {
7
7
  gsap?: {
8
8
  timeline?: (...args: unknown[]) => unknown;
9
9
  registerPlugin?: (...plugins: unknown[]) => unknown;
10
+ set?: (targets: Element | Element[], vars: Record<string, unknown>) => void;
11
+ globalTimeline?: { getChildren?: (deep: boolean) => Array<{ kill?: () => void }> };
10
12
  };
11
13
  MotionPathPlugin?: unknown;
12
14
  };
@@ -29,6 +31,14 @@ function findGsapScriptElements(doc: Document): HTMLScriptElement[] {
29
31
  return results;
30
32
  }
31
33
 
34
+ /** Check that the new script repopulated __timelines with at least one entry. */
35
+ function verifyTimelinesPopulated(win: IframeWindow): boolean {
36
+ const tlKeys = win.__timelines
37
+ ? Object.keys(win.__timelines).filter((k) => k !== "__proxied")
38
+ : [];
39
+ return tlKeys.length > 0;
40
+ }
41
+
32
42
  /**
33
43
  * Replace the GSAP script in the live iframe without reloading. This preserves
34
44
  * the WebGL context and shader transition cache.
@@ -56,24 +66,30 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
56
66
 
57
67
  const currentTime = win.__player?.getTime?.() ?? 0;
58
68
 
69
+ // Track whether the MotionPath async path was taken. When it is, the script
70
+ // executes inside pluginScript.onload — after applySoftReload has already
71
+ // returned. We optimistically return true because the script WILL execute
72
+ // once the plugin loads; the alternative (returning false) would trigger a
73
+ // full iframe reload that destroys the very WebGL context we're preserving.
74
+ let deferredToAsync = false;
75
+
59
76
  const doReload = () => {
60
77
  const timelines = win.__timelines;
78
+ const allTargets: Element[] = [];
79
+
61
80
  if (timelines) {
62
81
  for (const key of Object.keys(timelines)) {
82
+ if (key === "__proxied") continue;
63
83
  try {
64
84
  const tl = timelines[key] as {
65
85
  kill?: () => void;
66
86
  getChildren?: (deep: boolean) => Array<{ targets?: () => Element[] }>;
67
87
  };
68
- const allTargets: Element[] = [];
69
88
  if (tl?.getChildren) {
70
89
  try {
71
90
  for (const child of tl.getChildren(true)) {
72
91
  if (typeof child.targets === "function") {
73
- for (const t of child.targets()) {
74
- allTargets.push(t);
75
- delete (t as unknown as Record<string, unknown>)._gsap;
76
- }
92
+ for (const t of child.targets()) allTargets.push(t);
77
93
  }
78
94
  }
79
95
  } catch {}
@@ -84,6 +100,23 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
84
100
  }
85
101
  }
86
102
 
103
+ // Kill bare gsap.to/from tweens not registered on __timelines
104
+ if (win.gsap?.globalTimeline?.getChildren) {
105
+ try {
106
+ for (const child of win.gsap.globalTimeline.getChildren(false)) {
107
+ child.kill?.();
108
+ }
109
+ } catch {}
110
+ }
111
+
112
+ // Clear residual inline transforms left by killed tweens so from() tweens
113
+ // don't read stale end values from the DOM on re-execution
114
+ if (allTargets.length > 0 && win.gsap?.set) {
115
+ try {
116
+ win.gsap.set(allTargets, { clearProps: "all" });
117
+ } catch {}
118
+ }
119
+
87
120
  oldScriptEl.remove();
88
121
 
89
122
  const executeScript = () => {
@@ -98,10 +131,9 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
98
131
  win.__hfStudioManualEditsApply?.();
99
132
  };
100
133
 
101
- // Load MotionPathPlugin on demand if the script uses motionPath.
102
- // Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
103
134
  const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
104
135
  if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
136
+ deferredToAsync = true;
105
137
  const pluginScript = doc.createElement("script");
106
138
  pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
107
139
  pluginScript.onload = () => executeScript();
@@ -119,7 +151,10 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
119
151
  } else {
120
152
  doReload();
121
153
  }
122
- return true;
154
+ // When MotionPath needs async loading, the script hasn't executed yet —
155
+ // skip the __timelines check and return true optimistically.
156
+ if (deferredToAsync) return true;
157
+ return verifyTimelinesPopulated(win);
123
158
  } catch {
124
159
  return false;
125
160
  }
@@ -97,7 +97,7 @@ function simplifyTimeSeries(
97
97
  export function simplifyGestureSamples(
98
98
  samples: Array<{ time: number; properties: Record<string, number> }>,
99
99
  totalDuration: number,
100
- epsilon: number,
100
+ epsilon: number | ((key: string) => number),
101
101
  ): Map<number, Record<string, number>> {
102
102
  if (samples.length === 0) return new Map();
103
103
  if (totalDuration <= 0) return new Map();
@@ -120,7 +120,8 @@ export function simplifyGestureSamples(
120
120
  series.push({ time: s.time, value: s.properties[key] });
121
121
  }
122
122
  }
123
- const simplified = simplifyTimeSeries(series, epsilon);
123
+ const keyEpsilon = typeof epsilon === "function" ? epsilon(key) : epsilon;
124
+ const simplified = simplifyTimeSeries(series, keyEpsilon);
124
125
  for (const pt of simplified) {
125
126
  survivingTimes.add(pt.time);
126
127
  }
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { SPLIT_BOUNDARY_EPSILON_S, isSplitTimeWithinBounds } from "./timelineElementSplit";
3
+
4
+ describe("isSplitTimeWithinBounds", () => {
5
+ const start = 1;
6
+ const duration = 4;
7
+ const end = start + duration;
8
+
9
+ it("accepts the exact lower clamp boundary", () => {
10
+ // The timeline canvas clamps an edge click to exactly
11
+ // start + SPLIT_BOUNDARY_EPSILON_S, so that value must be splittable.
12
+ expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, duration)).toBe(true);
13
+ });
14
+
15
+ it("accepts the exact upper clamp boundary", () => {
16
+ expect(
17
+ isSplitTimeWithinBounds(start + duration - SPLIT_BOUNDARY_EPSILON_S, start, duration),
18
+ ).toBe(true);
19
+ });
20
+
21
+ it("accepts an interior split time", () => {
22
+ expect(isSplitTimeWithinBounds(3, start, duration)).toBe(true);
23
+ });
24
+
25
+ it("rejects times at or outside the clip edges", () => {
26
+ expect(isSplitTimeWithinBounds(start, start, duration)).toBe(false);
27
+ expect(isSplitTimeWithinBounds(end, start, duration)).toBe(false);
28
+ expect(isSplitTimeWithinBounds(start - 1, start, duration)).toBe(false);
29
+ expect(isSplitTimeWithinBounds(end + 1, start, duration)).toBe(false);
30
+ });
31
+
32
+ it("rejects times inside the epsilon margins", () => {
33
+ expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
34
+ false,
35
+ );
36
+ expect(isSplitTimeWithinBounds(end - SPLIT_BOUNDARY_EPSILON_S / 2, start, duration)).toBe(
37
+ false,
38
+ );
39
+ });
40
+
41
+ it("rejects every time on a clip shorter than two epsilons", () => {
42
+ // Math.max(min, Math.min(max, t)) collapses to min when the clip is too
43
+ // short for the clamp range; that collapsed value must still be rejected.
44
+ const shortDuration = SPLIT_BOUNDARY_EPSILON_S;
45
+ expect(isSplitTimeWithinBounds(start + SPLIT_BOUNDARY_EPSILON_S, start, shortDuration)).toBe(
46
+ false,
47
+ );
48
+ expect(isSplitTimeWithinBounds(start + shortDuration / 2, start, shortDuration)).toBe(false);
49
+ });
50
+ });
@@ -5,6 +5,22 @@ export { buildPatchTarget, readFileContent } from "../hooks/timelineEditingHelpe
5
5
  /** Minimum distance (seconds) from clip boundaries to allow a split. */
6
6
  export const SPLIT_BOUNDARY_EPSILON_S = 0.03;
7
7
 
8
+ /**
9
+ * True when splitTime leaves at least SPLIT_BOUNDARY_EPSILON_S on both sides
10
+ * of the cut. Inclusive at the epsilon offsets: the timeline canvas clamps
11
+ * edge clicks to exactly start/end ± epsilon, so the clamped value must pass.
12
+ */
13
+ export function isSplitTimeWithinBounds(
14
+ splitTime: number,
15
+ clipStart: number,
16
+ clipDuration: number,
17
+ ): boolean {
18
+ return (
19
+ splitTime >= clipStart + SPLIT_BOUNDARY_EPSILON_S &&
20
+ splitTime <= clipStart + clipDuration - SPLIT_BOUNDARY_EPSILON_S
21
+ );
22
+ }
23
+
8
24
  export function canSplitElement(el: TimelineElement): boolean {
9
25
  return (
10
26
  !el.timelineLocked &&