@hyperframes/studio 0.6.86 → 0.6.87

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 (87) hide show
  1. package/dist/assets/index-BA19FAPN.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -4,7 +4,11 @@ type IframeWindow = Window & {
4
4
  __hfForceTimelineRebind?: () => void;
5
5
  __hfSuppressSceneMutations?: <T>(fn: () => T) => T;
6
6
  __hfStudioManualEditsApply?: () => void;
7
- gsap?: { timeline?: (...args: unknown[]) => unknown };
7
+ gsap?: {
8
+ timeline?: (...args: unknown[]) => unknown;
9
+ registerPlugin?: (...plugins: unknown[]) => unknown;
10
+ };
11
+ MotionPathPlugin?: unknown;
8
12
  };
9
13
 
10
14
  function isGsapScript(text: string): boolean {
@@ -64,16 +68,32 @@ export function applySoftReload(iframe: HTMLIFrameElement | null, scriptText: st
64
68
  }
65
69
 
66
70
  oldScriptEl.remove();
67
- const newScript = doc.createElement("script");
68
- // IIFE prevents const/let redeclaration errors across consecutive edits.
69
- // Top-level declarations are scoped to the IIFE; window.* assignments
70
- // (e.g. window.__timelines["root"] = tl) still reach the global scope.
71
- newScript.textContent = `(function(){${scriptText}\n})();`;
72
- doc.body.appendChild(newScript);
73
71
 
74
- win.__hfForceTimelineRebind?.();
75
- win.__player?.seek?.(currentTime);
76
- win.__hfStudioManualEditsApply?.();
72
+ const executeScript = () => {
73
+ if (win.MotionPathPlugin && win.gsap?.registerPlugin) {
74
+ win.gsap.registerPlugin(win.MotionPathPlugin);
75
+ }
76
+ const s = doc.createElement("script");
77
+ s.textContent = `(function(){${scriptText}\n})();`;
78
+ doc.body.appendChild(s);
79
+ win.__hfForceTimelineRebind?.();
80
+ win.__player?.seek?.(currentTime);
81
+ win.__hfStudioManualEditsApply?.();
82
+ };
83
+
84
+ // Load MotionPathPlugin on demand if the script uses motionPath.
85
+ // Uses the same CDN as composition templates (GSAP_CDN in constants.ts).
86
+ const needsMotionPath = /motionPath\s*[:{]/.test(scriptText);
87
+ if (needsMotionPath && !win.MotionPathPlugin && win.gsap) {
88
+ const pluginScript = doc.createElement("script");
89
+ pluginScript.src = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js";
90
+ pluginScript.onload = () => executeScript();
91
+ pluginScript.onerror = () => executeScript();
92
+ doc.head.appendChild(pluginScript);
93
+ return;
94
+ }
95
+
96
+ executeScript();
77
97
  };
78
98
 
79
99
  try {
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { computeSnapThreshold, snapKeyframe } from "./keyframeSnapping";
3
+
4
+ describe("snapKeyframe", () => {
5
+ test("snaps to frame boundary", () => {
6
+ const result = snapKeyframe(0.34, { fps: 30, keyframeTimes: [], threshold: 0.05 });
7
+ expect(result.snapType).toBe("frame");
8
+ expect(Math.abs(result.snappedTime - 1 / 3)).toBeLessThan(0.01);
9
+ });
10
+
11
+ test("snaps to cross-element keyframe when closest", () => {
12
+ const result = snapKeyframe(1.005, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
13
+ expect(result.snapType).toBe("keyframe");
14
+ expect(result.snappedTime).toBe(1.0);
15
+ });
16
+
17
+ test("keyframe snap wins tie with frame at same position", () => {
18
+ const result = snapKeyframe(1.0, { fps: 30, keyframeTimes: [1.0], threshold: 0.05 });
19
+ expect(result.snapType).toBe("keyframe");
20
+ expect(result.snappedTime).toBe(1.0);
21
+ });
22
+
23
+ test("snaps to beat marker when closer than frame", () => {
24
+ const result = snapKeyframe(2.49, {
25
+ fps: 30,
26
+ keyframeTimes: [],
27
+ beatTimes: [2.5],
28
+ threshold: 0.05,
29
+ });
30
+ expect(result.snapType).toBe("beat");
31
+ expect(result.snappedTime).toBe(2.5);
32
+ });
33
+
34
+ test("disabled returns raw time", () => {
35
+ const result = snapKeyframe(1.5, {
36
+ fps: 30,
37
+ keyframeTimes: [1.5],
38
+ threshold: 0.05,
39
+ disabled: true,
40
+ });
41
+ expect(result.snapType).toBeNull();
42
+ expect(result.snappedTime).toBe(1.5);
43
+ });
44
+
45
+ test("no snap when outside threshold", () => {
46
+ const result = snapKeyframe(1.5, {
47
+ fps: 30,
48
+ keyframeTimes: [0.5],
49
+ threshold: 0.05,
50
+ });
51
+ expect(result.snapType).toBe("frame");
52
+ });
53
+
54
+ test("empty beat times is graceful", () => {
55
+ const result = snapKeyframe(0.5, {
56
+ fps: 30,
57
+ keyframeTimes: [],
58
+ beatTimes: [],
59
+ threshold: 0.05,
60
+ });
61
+ expect(result.snapType).toBe("frame");
62
+ });
63
+ });
64
+
65
+ describe("computeSnapThreshold", () => {
66
+ test("returns threshold based on pixels per second", () => {
67
+ const threshold = computeSnapThreshold(100, 5);
68
+ expect(threshold).toBe(0.05);
69
+ });
70
+
71
+ test("fallback for zero pixels per second", () => {
72
+ expect(computeSnapThreshold(0)).toBe(0.1);
73
+ });
74
+ });
@@ -0,0 +1,63 @@
1
+ export type SnapType = "frame" | "keyframe" | "beat" | null;
2
+
3
+ export interface SnapResult {
4
+ snappedTime: number;
5
+ snapType: SnapType;
6
+ }
7
+
8
+ export function snapKeyframe(
9
+ time: number,
10
+ options: {
11
+ fps: number;
12
+ keyframeTimes: number[];
13
+ beatTimes?: number[];
14
+ threshold: number;
15
+ disabled?: boolean;
16
+ },
17
+ ): SnapResult {
18
+ if (options.disabled) return { snappedTime: time, snapType: null };
19
+
20
+ const { fps, keyframeTimes, beatTimes = [], threshold } = options;
21
+
22
+ let bestDist = threshold;
23
+ let bestTime = time;
24
+ let bestType: SnapType = null;
25
+
26
+ // Priority: cross-element keyframes > beat markers > frame boundaries
27
+ // Higher priority snaps use strict < so they win on equal distance
28
+ if (fps > 0) {
29
+ const frameDuration = 1 / fps;
30
+ const nearestFrame = Math.round(time / frameDuration) * frameDuration;
31
+ const dist = Math.abs(time - nearestFrame);
32
+ if (dist < bestDist) {
33
+ bestDist = dist;
34
+ bestTime = nearestFrame;
35
+ bestType = "frame";
36
+ }
37
+ }
38
+
39
+ for (const bt of beatTimes) {
40
+ const dist = Math.abs(time - bt);
41
+ if (dist <= bestDist) {
42
+ bestDist = dist;
43
+ bestTime = bt;
44
+ bestType = "beat";
45
+ }
46
+ }
47
+
48
+ for (const kt of keyframeTimes) {
49
+ const dist = Math.abs(time - kt);
50
+ if (dist <= bestDist) {
51
+ bestDist = dist;
52
+ bestTime = kt;
53
+ bestType = "keyframe";
54
+ }
55
+ }
56
+
57
+ return { snappedTime: bestTime, snapType: bestType };
58
+ }
59
+
60
+ export function computeSnapThreshold(pixelsPerSecond: number, baseThresholdPx: number = 5): number {
61
+ if (pixelsPerSecond <= 0) return 0.1;
62
+ return baseThresholdPx / pixelsPerSecond;
63
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Ramer-Douglas-Peucker simplification for time-series data.
3
+ *
4
+ * Used to reduce gesture recording samples into a minimal set of keyframes
5
+ * that approximate the original curve within a configurable tolerance.
6
+ */
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // 1D time-series simplification
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Perpendicular distance from point (t, v) to the line segment between
14
+ * (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
15
+ * distance from the point to the interpolated value on the line.
16
+ */
17
+ function perpendicularDistance(
18
+ t: number,
19
+ v: number,
20
+ t1: number,
21
+ v1: number,
22
+ t2: number,
23
+ v2: number,
24
+ ): number {
25
+ // Degenerate case: start and end share the same time
26
+ if (t2 === t1) return Math.abs(v - v1);
27
+ const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
28
+ return Math.abs(v - interpolated);
29
+ }
30
+
31
+ /**
32
+ * Standard Ramer-Douglas-Peucker on 1D time-series data.
33
+ *
34
+ * Each point is treated as (time, value) in 2D space. Returns the minimal
35
+ * subset of input points that approximates the curve within `epsilon`.
36
+ *
37
+ * - `epsilon = 0` returns all points (no simplification).
38
+ * - A large `epsilon` returns just the first and last points.
39
+ * - Empty or single-point input is returned unchanged.
40
+ */
41
+ function simplifyTimeSeries(
42
+ points: Array<{ time: number; value: number }>,
43
+ epsilon: number,
44
+ ): Array<{ time: number; value: number }> {
45
+ if (points.length <= 2) return points;
46
+ if (epsilon <= 0) return points;
47
+
48
+ const first = points[0];
49
+ const last = points[points.length - 1];
50
+
51
+ let maxDist = 0;
52
+ let maxIndex = 0;
53
+
54
+ for (let i = 1; i < points.length - 1; i++) {
55
+ const d = perpendicularDistance(
56
+ points[i].time,
57
+ points[i].value,
58
+ first.time,
59
+ first.value,
60
+ last.time,
61
+ last.value,
62
+ );
63
+ if (d > maxDist) {
64
+ maxDist = d;
65
+ maxIndex = i;
66
+ }
67
+ }
68
+
69
+ if (maxDist > epsilon) {
70
+ const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
71
+ const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
72
+ // left includes maxIndex, right starts with maxIndex — drop the duplicate
73
+ return left.slice(0, -1).concat(right);
74
+ }
75
+
76
+ return [first, last];
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Multi-property gesture simplification
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Simplify gesture recording samples into percentage-keyed keyframes.
85
+ *
86
+ * Runs `simplifyTimeSeries` independently per property across all samples,
87
+ * then merges the retained time points into a single Map keyed by percentage
88
+ * of `totalDuration` (0–100, rounded to 1 decimal).
89
+ *
90
+ * Independent per-property simplification means that complex motion on one
91
+ * property (e.g. `x`) does not force extra keyframes on a simpler property
92
+ * (e.g. `opacity`).
93
+ *
94
+ * At each retained percentage the output contains all properties interpolated
95
+ * at that time — not just the property that caused the time point to survive.
96
+ */
97
+ export function simplifyGestureSamples(
98
+ samples: Array<{ time: number; properties: Record<string, number> }>,
99
+ totalDuration: number,
100
+ epsilon: number,
101
+ ): Map<number, Record<string, number>> {
102
+ if (samples.length === 0) return new Map();
103
+ if (totalDuration <= 0) return new Map();
104
+
105
+ // Collect all property keys present across samples
106
+ const propertyKeys = new Set<string>();
107
+ for (const s of samples) {
108
+ for (const key of Object.keys(s.properties)) {
109
+ propertyKeys.add(key);
110
+ }
111
+ }
112
+
113
+ // Run RDP independently per property and collect surviving times
114
+ const survivingTimes = new Set<number>();
115
+
116
+ for (const key of propertyKeys) {
117
+ const series: Array<{ time: number; value: number }> = [];
118
+ for (const s of samples) {
119
+ if (key in s.properties) {
120
+ series.push({ time: s.time, value: s.properties[key] });
121
+ }
122
+ }
123
+ const simplified = simplifyTimeSeries(series, epsilon);
124
+ for (const pt of simplified) {
125
+ survivingTimes.add(pt.time);
126
+ }
127
+ }
128
+
129
+ // Sort surviving times so we can iterate in order
130
+ const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
131
+
132
+ // For each surviving time, interpolate all properties and store by percentage
133
+ const result = new Map<number, Record<string, number>>();
134
+
135
+ for (const t of sortedTimes) {
136
+ const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
137
+ const props: Record<string, number> = {};
138
+
139
+ for (const key of propertyKeys) {
140
+ props[key] = interpolatePropertyAtTime(samples, key, t);
141
+ }
142
+
143
+ result.set(pct, props);
144
+ }
145
+
146
+ return result;
147
+ }
148
+
149
+ /**
150
+ * Linearly interpolate a single property value at the given time from the
151
+ * samples array. Assumes samples are sorted by time.
152
+ */
153
+ function interpolatePropertyAtTime(
154
+ samples: Array<{ time: number; properties: Record<string, number> }>,
155
+ key: string,
156
+ t: number,
157
+ ): number {
158
+ // Find bracketing samples that contain this property
159
+ let before: { time: number; value: number } | undefined;
160
+ let after: { time: number; value: number } | undefined;
161
+
162
+ for (const s of samples) {
163
+ if (!(key in s.properties)) continue;
164
+ const v = s.properties[key];
165
+
166
+ if (s.time <= t) {
167
+ before = { time: s.time, value: v };
168
+ }
169
+ if (s.time >= t && after === undefined) {
170
+ after = { time: s.time, value: v };
171
+ }
172
+ }
173
+
174
+ // Exact match or only one side available
175
+ if (before && before.time === t) return before.value;
176
+ if (after && after.time === t) return after.value;
177
+ if (!before) return after!.value;
178
+ if (!after) return before.value;
179
+
180
+ // Linear interpolation
181
+ const ratio = (t - before.time) / (after.time - before.time);
182
+ return before.value + (after.value - before.value) * ratio;
183
+ }
@@ -92,6 +92,8 @@ export interface PatchOperation {
92
92
  value: string | null;
93
93
  }
94
94
 
95
+ // Runtime validation for hfId lives in findTagByTarget → execDataAttrPattern (CSS attr-value
96
+ // escape). This type is documentation only; the server's MutationTarget mirrors this shape.
95
97
  export interface PatchTarget {
96
98
  id?: string | null;
97
99
  hfId?: string;