@hyperframes/studio 0.7.78 → 0.7.80
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/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
- package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
- package/dist/assets/index-D379YOKT.css +1 -0
- package/dist/index.d.ts +45 -3
- package/dist/index.html +2 -2
- package/dist/index.js +1891 -1522
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +7 -5
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/AnimationCard.test.tsx +225 -18
- package/src/components/editor/AnimationCard.tsx +24 -3
- package/src/components/editor/EaseCurveSection.test.tsx +147 -2
- package/src/components/editor/EaseCurveSection.tsx +77 -15
- package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
- package/src/components/editor/GsapAnimationSection.tsx +6 -1
- package/src/components/editor/KeyframeEaseList.tsx +4 -0
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +58 -20
- package/src/components/editor/PropertyPanel.tsx +7 -7
- package/src/components/editor/PropertyPanelFlat.tsx +6 -91
- package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
- package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
- package/src/components/editor/holdEaseSeek.test.ts +37 -0
- package/src/components/editor/keyframeRetime.test.ts +42 -0
- package/src/components/editor/keyframeRetime.ts +4 -1
- package/src/components/editor/motionPathSelection.test.ts +65 -0
- package/src/components/editor/motionPathSelection.ts +13 -3
- package/src/components/editor/propertyPanelColor.test.tsx +181 -12
- package/src/components/editor/propertyPanelColor.tsx +32 -26
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
- package/src/components/editor/propertyPanelFlatProps.ts +91 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
- package/src/components/editor/propertyPanelTypes.ts +2 -0
- package/src/components/nle/TimelineResizeDivider.tsx +9 -4
- package/src/components/pointerTargetSize.test.tsx +65 -0
- package/src/components/sidebar/CompositionsTab.tsx +4 -1
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
- package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
- package/src/hooks/gsapKeyframeCommit.ts +11 -2
- package/src/hooks/gsapResizeIntercept.ts +2 -2
- package/src/hooks/gsapRuntimeBridge.ts +3 -2
- package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
- package/src/hooks/gsapShared.test.ts +144 -0
- package/src/hooks/gsapShared.ts +264 -0
- package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
- package/src/hooks/gsapTweenSynth.test.ts +70 -15
- package/src/hooks/gsapTweenSynth.ts +50 -23
- package/src/hooks/keyframeCacheAstLoad.ts +9 -83
- package/src/hooks/newTweenTarget.test.ts +364 -0
- package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
- package/src/hooks/timelineTrackVisibility.test.ts +61 -1
- package/src/hooks/timelineTrackVisibility.ts +11 -1
- package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
- package/src/hooks/useDomEditSession.test.tsx +152 -3
- package/src/hooks/useDomEditSession.ts +4 -53
- package/src/hooks/useEnableKeyframes.test.ts +44 -0
- package/src/hooks/useEnableKeyframes.ts +18 -5
- package/src/hooks/useGestureCommit.ts +21 -4
- package/src/hooks/useGsapScriptCommits.ts +3 -0
- package/src/hooks/useGsapTweenCache.test.ts +11 -3
- package/src/hooks/useGsapTweenCache.ts +44 -45
- package/src/hooks/useKeyframeEaseCommits.ts +78 -0
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
- package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
- package/src/player/components/LayerDisclosureRow.tsx +4 -1
- package/src/player/components/ShortcutsPanel.test.tsx +158 -0
- package/src/player/components/ShortcutsPanel.tsx +11 -15
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +2 -1
- package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
- package/src/player/components/TimelineLanes.test.tsx +281 -0
- package/src/player/components/TimelineLanes.tsx +43 -97
- package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
- package/src/player/components/TimelinePropertyLanes.tsx +80 -11
- package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
- package/src/player/components/TimelineTrackHeader.tsx +52 -22
- package/src/player/components/timelineDiamondTypes.ts +4 -3
- package/src/player/components/timelineKeyframeIdentity.ts +3 -0
- package/src/player/components/timelineLaneProps.ts +86 -0
- package/src/player/components/timelineTrackDisplay.test.ts +35 -0
- package/src/player/components/timelineTrackDisplay.ts +38 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
- package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
- package/src/player/components/useTimelineRangeSelection.ts +5 -0
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
- package/src/player/components/useTimelineTrackLayout.ts +4 -4
- package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
- package/src/player/store/keyframeSlice.ts +15 -5
- package/src/telemetry/client.test.ts +12 -3
- package/src/telemetry/client.ts +3 -2
- package/dist/assets/index-DpkO2Hvw.css +0 -1
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
// Regression guard for the ruler-click seek. `handlePointerUp` replays
|
|
4
|
+
// `pendingClientXRef`, which only `updateScrubDrag` (pointermove) used to write,
|
|
5
|
+
// so a press with no move settled on the ref's initial 0 and clamped the
|
|
6
|
+
// playhead to t=0 — silently discarding the correct pointerdown seek.
|
|
7
|
+
import React, { act } from "react";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
|
|
10
|
+
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
|
|
11
|
+
|
|
12
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
13
|
+
|
|
14
|
+
/** Scroll container top; a press within RULER_H of this counts as a ruler press. */
|
|
15
|
+
const SCROLL_TOP = 100;
|
|
16
|
+
/** Comfortably inside the ruler band (RULER_H is 28 at time of writing). */
|
|
17
|
+
const RULER_Y = SCROLL_TOP + 4;
|
|
18
|
+
|
|
19
|
+
type Handlers = ReturnType<typeof useTimelineRangeSelection>;
|
|
20
|
+
|
|
21
|
+
function makeScrollEl(): HTMLDivElement {
|
|
22
|
+
const el = document.createElement("div");
|
|
23
|
+
el.getBoundingClientRect = () =>
|
|
24
|
+
({ top: SCROLL_TOP, left: 0, width: 1000, height: 400 }) as DOMRect;
|
|
25
|
+
return el;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makePointerEvent(clientX: number, clientY: number): React.PointerEvent {
|
|
29
|
+
const currentTarget = document.createElement("div");
|
|
30
|
+
currentTarget.setPointerCapture = vi.fn();
|
|
31
|
+
return {
|
|
32
|
+
button: 0,
|
|
33
|
+
shiftKey: false,
|
|
34
|
+
metaKey: false,
|
|
35
|
+
ctrlKey: false,
|
|
36
|
+
pointerId: 1,
|
|
37
|
+
clientX,
|
|
38
|
+
clientY,
|
|
39
|
+
target: document.createElement("div"),
|
|
40
|
+
currentTarget,
|
|
41
|
+
} as unknown as React.PointerEvent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const roots: Array<{ unmount: () => void }> = [];
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
for (const root of roots.splice(0)) act(() => root.unmount());
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn> } {
|
|
51
|
+
const seekFromX = vi.fn();
|
|
52
|
+
const scrollEl = makeScrollEl();
|
|
53
|
+
let latest: Handlers | null = null;
|
|
54
|
+
|
|
55
|
+
// Hoisted so they survive re-renders. The hook mutates `isDragging.current`
|
|
56
|
+
// across the press, and a fresh object per render would reset it to false.
|
|
57
|
+
const scrollRef = { current: scrollEl };
|
|
58
|
+
const ppsRef = { current: 25 };
|
|
59
|
+
const dragScrollRaf = { current: 0 };
|
|
60
|
+
const isDragging = { current: false };
|
|
61
|
+
const elementsRef = { current: [] };
|
|
62
|
+
const trackOrderRef = { current: [] };
|
|
63
|
+
const rowHeightsRef = { current: [] };
|
|
64
|
+
|
|
65
|
+
function Probe(): null {
|
|
66
|
+
latest = useTimelineRangeSelection({
|
|
67
|
+
scrollRef,
|
|
68
|
+
ppsRef,
|
|
69
|
+
effectiveDuration: 30,
|
|
70
|
+
pps: 25,
|
|
71
|
+
seekFromX,
|
|
72
|
+
autoScrollDuringDrag: vi.fn(),
|
|
73
|
+
dragScrollRaf,
|
|
74
|
+
isDragging,
|
|
75
|
+
setShowPopover: vi.fn(),
|
|
76
|
+
elementsRef,
|
|
77
|
+
trackOrderRef,
|
|
78
|
+
rowHeightsRef,
|
|
79
|
+
contentOrigin: 0,
|
|
80
|
+
});
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
roots.push(mountReactHarness(<Probe />));
|
|
85
|
+
return {
|
|
86
|
+
handlers: () => {
|
|
87
|
+
if (!latest) throw new Error("hook did not render");
|
|
88
|
+
return latest;
|
|
89
|
+
},
|
|
90
|
+
seekFromX,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
describe("useTimelineRangeSelection — ruler press seek", () => {
|
|
95
|
+
it("replays the pressed x on pointerup when the pointer never moved", () => {
|
|
96
|
+
const { handlers, seekFromX } = setup();
|
|
97
|
+
|
|
98
|
+
act(() => handlers().handlePointerDown(makePointerEvent(1000, RULER_Y)));
|
|
99
|
+
act(() => handlers().handlePointerUp());
|
|
100
|
+
|
|
101
|
+
// Both the press and the settle must land on the SAME x. The bug settled on
|
|
102
|
+
// 0, so the LAST call is the one that decides where the playhead ends up.
|
|
103
|
+
expect(seekFromX).toHaveBeenCalledWith(1000);
|
|
104
|
+
expect(seekFromX).not.toHaveBeenCalledWith(0);
|
|
105
|
+
expect(seekFromX.mock.calls.at(-1)).toEqual([1000]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("does not fall back to x=0 for a press nearer the left edge either", () => {
|
|
109
|
+
const { handlers, seekFromX } = setup();
|
|
110
|
+
|
|
111
|
+
act(() => handlers().handlePointerDown(makePointerEvent(400, RULER_Y)));
|
|
112
|
+
act(() => handlers().handlePointerUp());
|
|
113
|
+
|
|
114
|
+
expect(seekFromX.mock.calls.at(-1)).toEqual([400]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("settles on the final pointermove x when the pointer did move", () => {
|
|
118
|
+
const { handlers, seekFromX } = setup();
|
|
119
|
+
|
|
120
|
+
act(() => handlers().handlePointerDown(makePointerEvent(700, RULER_Y)));
|
|
121
|
+
act(() => handlers().handlePointerMove(makePointerEvent(760, RULER_Y)));
|
|
122
|
+
act(() => handlers().handlePointerUp());
|
|
123
|
+
|
|
124
|
+
// The move must win over the seeded press coordinate.
|
|
125
|
+
expect(seekFromX.mock.calls.at(-1)).toEqual([760]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -6,6 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
|
6
6
|
import { afterEach, describe, expect, it } from "vitest";
|
|
7
7
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
8
8
|
import { LANE_H, TRACK_H } from "./timelineLayout";
|
|
9
|
+
import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
|
|
9
10
|
import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
|
|
10
11
|
|
|
11
12
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
@@ -50,4 +51,38 @@ describe("useTimelineTrackLayout", () => {
|
|
|
50
51
|
expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
|
|
51
52
|
act(() => root.unmount());
|
|
52
53
|
});
|
|
54
|
+
|
|
55
|
+
// The row height reserved here and the lanes actually rendered are two
|
|
56
|
+
// readings of the same question. They used to be two inline copies of the
|
|
57
|
+
// group-set rule, and a mixed-group tween made them disagree: zero reserved
|
|
58
|
+
// rows under two rendered lanes.
|
|
59
|
+
it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => {
|
|
60
|
+
const elements: TimelineElement[] = [
|
|
61
|
+
{ id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
|
|
62
|
+
];
|
|
63
|
+
const mixed: GsapAnimation = {
|
|
64
|
+
id: "entrance",
|
|
65
|
+
targetSelector: "#clip-1",
|
|
66
|
+
method: "to",
|
|
67
|
+
position: 0,
|
|
68
|
+
duration: 1,
|
|
69
|
+
properties: { x: 420, opacity: 1 },
|
|
70
|
+
};
|
|
71
|
+
const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
|
|
72
|
+
usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
|
|
73
|
+
|
|
74
|
+
let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
|
|
75
|
+
function Probe() {
|
|
76
|
+
layout = useTimelineTrackLayout(elements, animations, null, new Set());
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const root = createRoot(document.createElement("div"));
|
|
81
|
+
act(() => root.render(React.createElement(Probe)));
|
|
82
|
+
|
|
83
|
+
expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
|
|
84
|
+
expect(layout?.laneCounts.get("clip-1")).toBe(2);
|
|
85
|
+
expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
|
|
86
|
+
act(() => root.unmount());
|
|
87
|
+
});
|
|
53
88
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMemo, useRef } from "react";
|
|
2
2
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
-
import {
|
|
3
|
+
import { animationLaneGroups } from "./TimelinePropertyLanes";
|
|
4
4
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
5
5
|
import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
|
|
6
6
|
import type { DraggedClipState } from "./timelineClipDragTypes";
|
|
@@ -56,9 +56,9 @@ function computeLaneCounts(
|
|
|
56
56
|
const clipId = element.key ?? element.id;
|
|
57
57
|
const propertyGroups = new Set<string>();
|
|
58
58
|
for (const animation of gsapAnimations.get(clipId) ?? []) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// Same helper the rendered lanes count through, so a reserved row and a
|
|
60
|
+
// drawn lane can never disagree.
|
|
61
|
+
for (const group of animationLaneGroups(animation)) propertyGroups.add(group);
|
|
62
62
|
}
|
|
63
63
|
laneCounts.set(clipId, propertyGroups.size);
|
|
64
64
|
}
|
|
@@ -143,6 +143,51 @@ describe("buildExpandedElements", () => {
|
|
|
143
143
|
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
+
it("keeps the middle host's row when drilling two levels deep", () => {
|
|
147
|
+
// A embeds B; C lives in B. Drilling into B must leave BOTH host rows
|
|
148
|
+
// standing: sparing only the top-level one drops B's row, and its keyframe
|
|
149
|
+
// lane goes with it because diamonds render per row.
|
|
150
|
+
const elements = [
|
|
151
|
+
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
152
|
+
el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }),
|
|
153
|
+
];
|
|
154
|
+
const manifest = [
|
|
155
|
+
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
156
|
+
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
157
|
+
clip({ id: "C", start: 13, duration: 2 }),
|
|
158
|
+
];
|
|
159
|
+
const parentMap = new Map([
|
|
160
|
+
["B", "A"],
|
|
161
|
+
["C", "B"],
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
165
|
+
const rows = out.map((e) => e.domId ?? e.id);
|
|
166
|
+
expect(rows).toContain("B");
|
|
167
|
+
// The child sits under its own host, not under the top-level row.
|
|
168
|
+
expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B"));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("still drills a host that exists only in the manifest, without a row for it", () => {
|
|
172
|
+
// Same shape, but B has no store element, so there is no row to spare. The
|
|
173
|
+
// children stay anchored to the top-level row rather than vanishing.
|
|
174
|
+
const elements = [
|
|
175
|
+
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
176
|
+
];
|
|
177
|
+
const manifest = [
|
|
178
|
+
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
|
179
|
+
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
|
180
|
+
clip({ id: "C", start: 13, duration: 2 }),
|
|
181
|
+
];
|
|
182
|
+
const parentMap = new Map([
|
|
183
|
+
["B", "A"],
|
|
184
|
+
["C", "B"],
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
|
188
|
+
expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]);
|
|
189
|
+
});
|
|
190
|
+
|
|
146
191
|
// Regression: an expanded child must share one identity (`key`) with the flat
|
|
147
192
|
// store element for the same DOM id. Before the fix the child key fell back to
|
|
148
193
|
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
|
|
@@ -176,6 +221,35 @@ describe("buildExpandedElements", () => {
|
|
|
176
221
|
expect(child.key).toBe(expectedStoreKey);
|
|
177
222
|
});
|
|
178
223
|
|
|
224
|
+
// Regression: a child row is built from a manifest clip, which carries none of
|
|
225
|
+
// the host element's attributes. Reading hidden off the manifest left every
|
|
226
|
+
// expanded row reporting itself visible, so the eye wrote data-hidden a second
|
|
227
|
+
// time instead of removing it and the element could never be shown again.
|
|
228
|
+
it("inherits hidden and locked state from the flat store element", () => {
|
|
229
|
+
const elements = [
|
|
230
|
+
el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
|
|
231
|
+
el({
|
|
232
|
+
id: "eyebrow",
|
|
233
|
+
key: "index.html#eyebrow",
|
|
234
|
+
domId: "eyebrow",
|
|
235
|
+
start: 0,
|
|
236
|
+
duration: 14,
|
|
237
|
+
hidden: true,
|
|
238
|
+
timelineLocked: true,
|
|
239
|
+
}),
|
|
240
|
+
];
|
|
241
|
+
const manifest = [
|
|
242
|
+
clip({ id: "s1", start: 0, duration: 14 }),
|
|
243
|
+
clip({ id: "eyebrow", start: 0, duration: 14 }),
|
|
244
|
+
];
|
|
245
|
+
const parentMap = new Map([["eyebrow", "s1"]]);
|
|
246
|
+
|
|
247
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
|
|
248
|
+
const child = out.find((e) => e.domId === "eyebrow")!;
|
|
249
|
+
expect(child.hidden).toBe(true);
|
|
250
|
+
expect(child.timelineLocked).toBe(true);
|
|
251
|
+
});
|
|
252
|
+
|
|
179
253
|
// Sub-comp internals (group + pills) have no data-start, so they're not in the
|
|
180
254
|
// manifest. They arrive as DOM children and must still expand under their host.
|
|
181
255
|
it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
|
|
@@ -240,8 +314,112 @@ describe("buildExpandedElements", () => {
|
|
|
240
314
|
expect(pills[0]!.duration).toBe(6);
|
|
241
315
|
expect(pills[0]!.sourceFile).toBe("scene.html");
|
|
242
316
|
expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
|
|
243
|
-
// The host row
|
|
244
|
-
expect(out.some((e) => e.
|
|
317
|
+
// The host row survives the expansion; its children are added under it.
|
|
318
|
+
expect(out.some((e) => e.id === "scene-host")).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// fallow-ignore-next-line code-duplication
|
|
322
|
+
it("keeps the host row and appends its children directly below it", () => {
|
|
323
|
+
const elements = [
|
|
324
|
+
el({
|
|
325
|
+
id: "s3",
|
|
326
|
+
domId: "s3",
|
|
327
|
+
key: "index.html#s3",
|
|
328
|
+
start: 16,
|
|
329
|
+
duration: 7,
|
|
330
|
+
compositionSrc: "stats.html",
|
|
331
|
+
}),
|
|
332
|
+
el({ id: "outro", start: 23, duration: 3, track: 1 }),
|
|
333
|
+
];
|
|
334
|
+
const manifest = [
|
|
335
|
+
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
|
336
|
+
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
|
337
|
+
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
|
338
|
+
];
|
|
339
|
+
const parentMap = new Map([
|
|
340
|
+
["stat-1", "s3"],
|
|
341
|
+
["stat-2", "s3"],
|
|
342
|
+
]);
|
|
343
|
+
|
|
344
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
345
|
+
|
|
346
|
+
const hostIndex = out.findIndex((e) => e.id === "s3");
|
|
347
|
+
expect(hostIndex).toBeGreaterThanOrEqual(0);
|
|
348
|
+
// Host row untouched (same key → same keyframe lane), children nested under it.
|
|
349
|
+
expect(out[hostIndex]!.key).toBe("index.html#s3");
|
|
350
|
+
expect(out[hostIndex]!.track).toBe(0);
|
|
351
|
+
expect(out[hostIndex + 1]!.domId).toBe("stat-1");
|
|
352
|
+
expect(out[hostIndex + 2]!.domId).toBe("stat-2");
|
|
353
|
+
// Exactly one more row than the old substitution behaviour (host + 2 children + outro).
|
|
354
|
+
expect(out).toHaveLength(4);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
|
|
358
|
+
// Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
|
|
359
|
+
// seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
|
|
360
|
+
// so the whole lane went with the host row whenever the paused drill-in
|
|
361
|
+
// substituted it for its children.
|
|
362
|
+
const elements = [
|
|
363
|
+
el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
|
|
364
|
+
];
|
|
365
|
+
const manifest = [
|
|
366
|
+
clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
|
|
367
|
+
clip({ id: "headline", start: 0, duration: 12 }),
|
|
368
|
+
];
|
|
369
|
+
const parentMap = new Map([["headline", "scene"]]);
|
|
370
|
+
|
|
371
|
+
for (const currentTime of [0, 7.68, 0.2]) {
|
|
372
|
+
const rawId = resolveTimelineExpansionRawId({
|
|
373
|
+
selectedElementId: null,
|
|
374
|
+
isPlaying: false,
|
|
375
|
+
currentTime,
|
|
376
|
+
manifest,
|
|
377
|
+
parentMap,
|
|
378
|
+
});
|
|
379
|
+
const rows = rawId
|
|
380
|
+
? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
|
|
381
|
+
: elements;
|
|
382
|
+
expect(rows.map((row) => row.key)).toContain("index.html#scene");
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// Regression: DOM-only children were synthesized against the TOP-LEVEL element
|
|
387
|
+
// instead of the sub-comp host they actually live in, so every child row read
|
|
388
|
+
// the whole top-level window rather than its host's.
|
|
389
|
+
it("spans DOM-only children over their nested host's window, not the top-level one", () => {
|
|
390
|
+
const elements = [
|
|
391
|
+
el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
392
|
+
];
|
|
393
|
+
const manifest = [
|
|
394
|
+
clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
395
|
+
clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
|
|
396
|
+
];
|
|
397
|
+
const parentMap = new Map([
|
|
398
|
+
["sub-host", "scene-host"],
|
|
399
|
+
["pill-1", "sub-host"],
|
|
400
|
+
]);
|
|
401
|
+
const domClipChildren = [
|
|
402
|
+
{
|
|
403
|
+
id: "pill-1",
|
|
404
|
+
parentId: "sub-host",
|
|
405
|
+
hostId: "sub-host",
|
|
406
|
+
label: "pill-1",
|
|
407
|
+
stackingContextId: "css:0.0",
|
|
408
|
+
},
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
const out = buildExpandedElements(
|
|
412
|
+
elements,
|
|
413
|
+
manifest,
|
|
414
|
+
parentMap,
|
|
415
|
+
"scene-host",
|
|
416
|
+
"sub-host",
|
|
417
|
+
domClipChildren,
|
|
418
|
+
);
|
|
419
|
+
const pill = out.find((e) => e.domId === "pill-1")!;
|
|
420
|
+
expect(pill.start).toBe(5);
|
|
421
|
+
expect(pill.duration).toBe(6);
|
|
422
|
+
expect(pill.sourceFile).toBe("sub.html");
|
|
245
423
|
});
|
|
246
424
|
});
|
|
247
425
|
|
|
@@ -134,6 +134,27 @@ interface DisplayBounds {
|
|
|
134
134
|
track: number;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* State that lives on the live host element, not in the clip manifest:
|
|
139
|
+
* `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
|
|
140
|
+
* built from a manifest clip with no hostEl to read, so
|
|
141
|
+
* createTimelineElementFromManifestClip cannot see any of it. The flat store
|
|
142
|
+
* element for the same child WAS built with one, so it is inherited from there.
|
|
143
|
+
*
|
|
144
|
+
* Without this the eye on an expanded child always reported the row visible, so
|
|
145
|
+
* clicking it wrote data-hidden again instead of removing it, and a hidden child
|
|
146
|
+
* could never be shown again (not even after a reload, since the attribute is in
|
|
147
|
+
* the source).
|
|
148
|
+
*/
|
|
149
|
+
function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
|
|
150
|
+
if (!flat) return {};
|
|
151
|
+
return {
|
|
152
|
+
hidden: flat.hidden,
|
|
153
|
+
timelineLocked: flat.timelineLocked,
|
|
154
|
+
timelineRole: flat.timelineRole,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
// `display` bounds come from the top-level scene clip (where the expanded row is
|
|
138
159
|
// drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
|
|
139
160
|
// start anchors local-time edits and its compositionSrc is the file edits write to.
|
|
@@ -143,6 +164,7 @@ function buildChildElements(
|
|
|
143
164
|
display: DisplayBounds,
|
|
144
165
|
editBasis: { start: number; sourceFile: string | undefined },
|
|
145
166
|
expandedHostKey: string,
|
|
167
|
+
elements: readonly TimelineElement[],
|
|
146
168
|
): TimelineElement[] {
|
|
147
169
|
const result: TimelineElement[] = [];
|
|
148
170
|
for (const child of siblings) {
|
|
@@ -170,6 +192,7 @@ function buildChildElements(
|
|
|
170
192
|
});
|
|
171
193
|
result.push({
|
|
172
194
|
...base,
|
|
195
|
+
...hostElementState(elements.find((element) => element.key === key)),
|
|
173
196
|
key,
|
|
174
197
|
start: clamped.start,
|
|
175
198
|
duration: clamped.duration,
|
|
@@ -209,7 +232,13 @@ function buildChildElements(
|
|
|
209
232
|
function domSiblingClips(
|
|
210
233
|
domClipChildren: DomClipChild[],
|
|
211
234
|
siblingParentId: string,
|
|
212
|
-
host:
|
|
235
|
+
host: {
|
|
236
|
+
id: string | null;
|
|
237
|
+
start: number;
|
|
238
|
+
duration: number;
|
|
239
|
+
track: number;
|
|
240
|
+
compositionSrc?: string | null;
|
|
241
|
+
},
|
|
213
242
|
): ClipManifestClip[] {
|
|
214
243
|
return domClipChildren
|
|
215
244
|
.filter((c) => c.parentId === siblingParentId)
|
|
@@ -243,20 +272,23 @@ export function buildExpandedElements(
|
|
|
243
272
|
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
|
244
273
|
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
|
245
274
|
|
|
275
|
+
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
276
|
+
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
|
277
|
+
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
|
278
|
+
|
|
246
279
|
// Prefer real manifest children; fall back to DOM-only sub-comp children
|
|
247
280
|
// (groups/pills) that have no data-start and thus never enter the manifest.
|
|
281
|
+
// Those are synthesized against the host they actually live in, not the
|
|
282
|
+
// top-level element, or every child row reads the whole top-level window.
|
|
248
283
|
const siblings = (() => {
|
|
249
284
|
const fromManifest = manifest.filter(
|
|
250
285
|
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
|
|
251
286
|
);
|
|
252
287
|
if (fromManifest.length > 0) return fromManifest;
|
|
253
|
-
return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
|
|
288
|
+
return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement);
|
|
254
289
|
})();
|
|
255
290
|
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
|
256
291
|
|
|
257
|
-
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
258
|
-
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
|
259
|
-
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
|
260
292
|
const editBasis = {
|
|
261
293
|
start: parentHost?.start ?? topLevelElement.start,
|
|
262
294
|
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
|
@@ -272,12 +304,43 @@ export function buildExpandedElements(
|
|
|
272
304
|
},
|
|
273
305
|
editBasis,
|
|
274
306
|
parentKey,
|
|
307
|
+
elements,
|
|
275
308
|
);
|
|
276
309
|
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
277
310
|
|
|
311
|
+
// Every host between the drilled one and the top level owns a row, so the
|
|
312
|
+
// drill has to spare all of them, not just the top. A middle host is still a
|
|
313
|
+
// host: dropping its row drops its keyframe lane with it.
|
|
314
|
+
const drillPath = new Set<string>();
|
|
315
|
+
for (let cursor: string | undefined = siblingParentId; cursor; ) {
|
|
316
|
+
if (drillPath.has(cursor)) break;
|
|
317
|
+
drillPath.add(cursor);
|
|
318
|
+
if (cursor === topLevelId) break;
|
|
319
|
+
cursor = parentMap.get(cursor);
|
|
320
|
+
}
|
|
321
|
+
// Children hang under the DEEPEST host on that path, so anchor them there
|
|
322
|
+
// when it has a row of its own and fall back to the top-level row when it
|
|
323
|
+
// does not (a host that lives only in the manifest never had one).
|
|
324
|
+
const anchorsChildren = (el: TimelineElement): boolean =>
|
|
325
|
+
drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId)
|
|
326
|
+
? (el.domId ?? el.id) === siblingParentId
|
|
327
|
+
: (el.key ?? el.id) === parentKey;
|
|
328
|
+
|
|
329
|
+
// ADDITIVE drill-in: the host row stays and its children are appended under
|
|
330
|
+
// it. Expansion is also triggered by the playhead alone (paused auto-expand),
|
|
331
|
+
// so substituting the host row made it vanish on an ordinary seek, and with
|
|
332
|
+
// it the host's keyframe lane, since diamonds render per row from
|
|
333
|
+
// `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
|
|
334
|
+
// strictly between the host's lane and the next integer, so the children have
|
|
335
|
+
// their own rows without the host having to give up its own.
|
|
278
336
|
return elements
|
|
279
|
-
.filter(
|
|
280
|
-
|
|
337
|
+
.filter(
|
|
338
|
+
(el) =>
|
|
339
|
+
(el.key ?? el.id) === parentKey ||
|
|
340
|
+
drillPath.has(el.domId ?? el.id) ||
|
|
341
|
+
!parentMap.has(el.domId ?? el.id),
|
|
342
|
+
)
|
|
343
|
+
.flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el]));
|
|
281
344
|
}
|
|
282
345
|
|
|
283
346
|
export function useExpandedTimelineElements(): TimelineElement[] {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { StoreApi } from "zustand";
|
|
3
|
+
import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
|
|
3
4
|
|
|
4
5
|
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
|
5
6
|
export interface KeyframeCacheEntry {
|
|
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
|
|
|
14
15
|
animationId?: string;
|
|
15
16
|
properties: Record<string, number | string>;
|
|
16
17
|
ease?: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
easeAmbiguous?: boolean;
|
|
18
|
+
/** Source animation/keyframe targets that collide at this clip percentage. */
|
|
19
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
20
20
|
}>;
|
|
21
21
|
ease?: string;
|
|
22
22
|
easeEach?: string;
|
|
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
|
|
|
37
37
|
|
|
38
38
|
/** elementId scopes the request to one element so a shared (class-selector)
|
|
39
39
|
* animation id can't open the ease editor on the wrong element. */
|
|
40
|
-
focusedEaseSegment: {
|
|
40
|
+
focusedEaseSegment: {
|
|
41
|
+
animationId: string;
|
|
42
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
43
|
+
tweenPercentage: number;
|
|
44
|
+
elementId: string;
|
|
45
|
+
} | null;
|
|
41
46
|
setFocusedEaseSegment: (
|
|
42
|
-
target: {
|
|
47
|
+
target: {
|
|
48
|
+
animationId: string;
|
|
49
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
50
|
+
tweenPercentage: number;
|
|
51
|
+
elementId: string;
|
|
52
|
+
} | null,
|
|
43
53
|
) => void;
|
|
44
54
|
|
|
45
55
|
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
|
@@ -56,10 +56,19 @@ describe("studio client shouldTrack", () => {
|
|
|
56
56
|
expect(shouldTrack()).toBe(false);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it("
|
|
60
|
-
|
|
59
|
+
it.each(["true", "TRUE", " yes ", "on"])(
|
|
60
|
+
"returns false when VITE_HYPERFRAMES_NO_TELEMETRY=%j",
|
|
61
|
+
async (value) => {
|
|
62
|
+
setNoTelemetry(value);
|
|
63
|
+
const shouldTrack = await loadShouldTrack();
|
|
64
|
+
expect(shouldTrack()).toBe(false);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
it("does not opt out for an explicit false value", async () => {
|
|
69
|
+
setNoTelemetry("false");
|
|
61
70
|
const shouldTrack = await loadShouldTrack();
|
|
62
|
-
expect(shouldTrack()).toBe(
|
|
71
|
+
expect(shouldTrack()).toBe(true);
|
|
63
72
|
});
|
|
64
73
|
|
|
65
74
|
it("returns false in vite dev mode", async () => {
|
package/src/telemetry/client.ts
CHANGED
|
@@ -34,12 +34,13 @@ function isApiKeyConfigured(): boolean {
|
|
|
34
34
|
|
|
35
35
|
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
|
|
36
36
|
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
|
|
37
|
-
// bundle the same way. Vite injects it at build time.
|
|
37
|
+
// bundle the same way. Vite injects it at build time. Match the CLI's
|
|
38
|
+
// affirmative privacy-control spellings.
|
|
38
39
|
// `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
|
|
39
40
|
function isBuildTimeOptOut(): boolean {
|
|
40
41
|
try {
|
|
41
42
|
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
|
|
42
|
-
return v
|
|
43
|
+
return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
|
|
43
44
|
} catch {
|
|
44
45
|
return false;
|
|
45
46
|
}
|