@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.
- package/dist/assets/{index-CF35g_E4.js → index-BPya4QRs.js} +1 -1
- package/dist/assets/{index-HBY-OdAh.js → index-BZqvc9Ns.js} +183 -183
- package/dist/assets/{index-Bmd8RnCq.js → index-D3eh3pRK.js} +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.html +1 -1
- package/dist/index.js +377 -210
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/TimelineToolbar.test.tsx +50 -0
- package/src/components/TimelineToolbar.tsx +70 -35
- package/src/components/editor/MotionPathOverlay.tsx +27 -4
- package/src/components/editor/SnapToolbar.test.tsx +124 -0
- package/src/components/editor/SnapToolbar.tsx +1 -0
- package/src/hooks/gsapRuntimeBridge.test.ts +66 -0
- package/src/hooks/gsapRuntimeBridge.ts +41 -1
- package/src/hooks/gsapTweenSynth.test.ts +55 -0
- package/src/hooks/gsapTweenSynth.ts +12 -6
- package/src/hooks/gsapWholePropertyOffsetCommit.test.ts +162 -0
- package/src/hooks/gsapWholePropertyOffsetCommit.ts +85 -0
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +105 -0
- package/src/hooks/useAnimatedPropertyCommit.ts +22 -0
- package/src/hooks/useAppHotkeys.ts +3 -2
- package/src/hooks/useGsapScriptCommits.test.tsx +8 -8
- package/src/hooks/useGsapScriptCommits.ts +6 -1
- package/src/hooks/useRenderClipContent.test.ts +54 -1
- package/src/hooks/useRenderClipContent.ts +7 -5
- package/src/player/components/Timeline.test.ts +51 -1
- package/src/player/components/Timeline.tsx +0 -2
- package/src/player/components/TimelineCanvas.tsx +1 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +215 -0
- package/src/player/components/TimelineClipDiamonds.tsx +46 -2
- package/src/player/store/playerStore.ts +8 -0
- package/src/utils/gsapSoftReload.test.ts +12 -0
- 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
|
-
|
|
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
|
-
|
|
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>
|