@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.
- package/dist/assets/{index-DSLrl2tB.js → index-BkwsVKGA.js} +24 -24
- package/dist/assets/index-DYRWmfMX.js +251 -0
- package/dist/assets/index-rm9tn9nH.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioPreviewArea.tsx +48 -13
- package/src/components/TimelineToolbar.tsx +0 -21
- package/src/components/editor/DomEditOverlay.tsx +79 -0
- package/src/components/editor/PropertyPanel.tsx +19 -13
- package/src/components/editor/gsapAnimatesProperty.ts +30 -0
- package/src/components/editor/manualEditingAvailability.test.ts +5 -5
- package/src/components/editor/manualEditingAvailability.ts +11 -7
- package/src/components/editor/manualEditsDom.ts +25 -5
- package/src/components/editor/manualEditsDomPatches.test.ts +1 -0
- package/src/components/editor/manualEditsDomPatches.ts +17 -1
- package/src/components/editor/manualEditsSnapshot.ts +16 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +19 -4
- package/src/components/editor/useOffScreenIndicators.ts +197 -0
- package/src/components/nle/NLELayout.tsx +16 -14
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +119 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +9 -4
- package/src/hooks/gsapRuntimeBridge.ts +266 -41
- package/src/hooks/gsapRuntimeReaders.ts +16 -2
- package/src/hooks/useAnimatedPropertyCommit.ts +11 -5
- package/src/hooks/useDomEditCommits.ts +7 -1
- package/src/hooks/useDomEditSession.ts +13 -0
- package/src/hooks/useEnableKeyframes.ts +3 -1
- package/src/hooks/useGestureCommit.ts +99 -13
- package/src/hooks/useGestureRecording.ts +18 -2
- package/src/hooks/useGsapScriptCommits.ts +24 -3
- package/src/hooks/useGsapSelectionHandlers.ts +19 -3
- package/src/hooks/useGsapTweenCache.ts +30 -10
- package/src/hooks/useRazorSplit.ts +2 -7
- package/src/player/components/ClipContextMenu.tsx +9 -4
- package/src/player/components/KeyframeDiamondContextMenu.tsx +14 -93
- package/src/player/components/Timeline.tsx +7 -3
- package/src/player/components/TimelineClipDiamonds.tsx +3 -1
- package/src/player/store/playerStore.ts +12 -0
- package/src/utils/globalTimeCompiler.test.ts +2 -2
- package/src/utils/globalTimeCompiler.ts +2 -1
- package/src/utils/gsapSoftReload.test.ts +16 -0
- package/src/utils/gsapSoftReload.ts +43 -8
- package/src/utils/rdpSimplify.ts +3 -2
- package/src/utils/timelineElementSplit.test.ts +50 -0
- package/src/utils/timelineElementSplit.ts +16 -0
- package/dist/assets/index-CgYcO2PV.js +0 -146
- 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
|
|
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
|
|
107
|
-
expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(
|
|
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 ??
|
|
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
|
-
|
|
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
|
}
|
package/src/utils/rdpSimplify.ts
CHANGED
|
@@ -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
|
|
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 &&
|