@hyperframes/studio 0.7.78 → 0.7.79
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-mFah2TZE.js} +1 -1
- package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
- package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
- package/dist/assets/index-gGVKuFg5.css +1 -0
- package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +1303 -1121
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/StudioLeftSidebar.tsx +10 -5
- package/src/components/StudioRightPanel.tsx +5 -4
- package/src/components/TimelineToolbar.tsx +3 -1
- package/src/components/editor/KeyframeNavigation.tsx +5 -0
- package/src/components/editor/MotionPathOverlay.tsx +4 -1
- package/src/components/editor/PropertyPanel.tsx +3 -7
- 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/propertyPanelPrimitives.tsx +3 -1
- 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/hooks/gsapDragCommit.ts +43 -10
- package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
- package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
- package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
- 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/keyframeCacheAstLoad.ts +5 -71
- 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/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 +4 -11
- 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 +17 -2
- package/src/player/components/TimelineClipDiamonds.tsx +11 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
- 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/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/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 +151 -2
- package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
- 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,86 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
|
+
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
4
|
+
import type { TimelineElement, KeyframeCacheEntry } from "../store/playerStore";
|
|
5
|
+
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
|
|
6
|
+
import type { TimelineTheme } from "./timelineTheme";
|
|
7
|
+
import type { TrackVisualStyle } from "./timelineIcons";
|
|
8
|
+
import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
|
12
|
+
* the lane renderer ({@link import("./TimelineLanes")}). TimelineCanvas passes
|
|
13
|
+
* these straight through via spread, so they are declared once here and both
|
|
14
|
+
* prop types compose from this base — no duplicated prop list. Kept in its own
|
|
15
|
+
* module so neither side has to import the other just to name the contract.
|
|
16
|
+
*/
|
|
17
|
+
export interface TimelineLaneBaseProps {
|
|
18
|
+
pps: number;
|
|
19
|
+
contentOrigin: number;
|
|
20
|
+
contentGutter: number;
|
|
21
|
+
trackContentWidth: number;
|
|
22
|
+
theme: TimelineTheme;
|
|
23
|
+
displayTrackOrder: number[];
|
|
24
|
+
rowHeights: readonly number[];
|
|
25
|
+
trackOrder: number[];
|
|
26
|
+
tracks: [number, TimelineElement[]][];
|
|
27
|
+
trackStyles: Map<number, TrackVisualStyle>;
|
|
28
|
+
laneCounts: ReadonlyMap<string, number>;
|
|
29
|
+
selectedElementId: string | null;
|
|
30
|
+
selectedElementIds: Set<string>;
|
|
31
|
+
hoveredClip: string | null;
|
|
32
|
+
draggedClip: DraggedClipState | null;
|
|
33
|
+
blockedClipRef: React.RefObject<BlockedClipState | null>;
|
|
34
|
+
suppressClickRef: React.RefObject<boolean>;
|
|
35
|
+
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
36
|
+
renderClipContent?: (
|
|
37
|
+
element: TimelineElement,
|
|
38
|
+
style: { clip: string; label: string },
|
|
39
|
+
) => ReactNode;
|
|
40
|
+
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
41
|
+
onDrillDown?: (element: TimelineElement) => void;
|
|
42
|
+
onSelectElement?: (element: TimelineElement | null) => void;
|
|
43
|
+
setHoveredClip: (key: string | null) => void;
|
|
44
|
+
setShowPopover: (v: boolean) => void;
|
|
45
|
+
setRangeSelection: (v: null) => void;
|
|
46
|
+
setResizingClip: (v: ResizingClipState | null) => void;
|
|
47
|
+
setDraggedClip: (v: DraggedClipState | null) => void;
|
|
48
|
+
setSelectedElementId: (id: string | null) => void;
|
|
49
|
+
syncClipDragAutoScroll: (x: number, y: number) => void;
|
|
50
|
+
shiftClickClipRef: React.RefObject<{
|
|
51
|
+
element: TimelineElement;
|
|
52
|
+
anchorX: number;
|
|
53
|
+
anchorY: number;
|
|
54
|
+
} | null>;
|
|
55
|
+
getPreviewElement: (element: TimelineElement) => TimelineElement;
|
|
56
|
+
getTrackStyle: (tag: string) => TrackVisualStyle;
|
|
57
|
+
keyframeCache?: Map<string, KeyframeCacheEntry>;
|
|
58
|
+
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
59
|
+
selectedKeyframes: Set<string>;
|
|
60
|
+
currentTime: number;
|
|
61
|
+
onSeek?: (time: number) => void;
|
|
62
|
+
onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
63
|
+
onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
|
|
64
|
+
onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
|
|
65
|
+
onContextMenuKeyframe?: (
|
|
66
|
+
e: React.MouseEvent,
|
|
67
|
+
elementId: string,
|
|
68
|
+
target: TimelineKeyframeTarget,
|
|
69
|
+
) => void;
|
|
70
|
+
onMoveKeyframe?: (
|
|
71
|
+
elementId: string,
|
|
72
|
+
keyframe: TimelineKeyframeTarget,
|
|
73
|
+
toClipPercentage: number,
|
|
74
|
+
propertyGroup?: string,
|
|
75
|
+
tweenPercentage?: number,
|
|
76
|
+
animationId?: string,
|
|
77
|
+
) => Promise<boolean>;
|
|
78
|
+
onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
|
|
79
|
+
/**
|
|
80
|
+
* Right-click on EMPTY lane space (not on a clip — those preventDefault
|
|
81
|
+
* before this fires — not the gutter/ruler, not below the lanes). `time` is
|
|
82
|
+
* the timeline time (seconds) under the pointer on that lane.
|
|
83
|
+
*/
|
|
84
|
+
onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
|
|
85
|
+
beatAnalysis?: MusicBeatAnalysis | null;
|
|
86
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { timelineTrackOrder, trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
|
|
3
|
+
|
|
4
|
+
describe("timelineTrackOrder", () => {
|
|
5
|
+
it("is the ascending distinct key order, fractional sub-comp keys included", () => {
|
|
6
|
+
const elements = [{ track: 1 }, { track: 0 }, { track: 1 / 6 }, { track: 1 }];
|
|
7
|
+
|
|
8
|
+
expect(timelineTrackOrder(elements)).toEqual([0, 1 / 6, 1]);
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("trackDisplayNumber", () => {
|
|
13
|
+
it("is the key's 1-based row, not the fractional key itself", () => {
|
|
14
|
+
const order = timelineTrackOrder([{ track: 0 }, { track: 1 / 6 }, { track: 1 }]);
|
|
15
|
+
|
|
16
|
+
expect(trackDisplayNumber(order, 1 / 6)).toBe(2);
|
|
17
|
+
expect(trackDisplayNumber(order, 1)).toBe(3);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("is null for a key with no row rather than a plausible-looking one", () => {
|
|
21
|
+
// The old end-row fallback announced "track 4" for a track the user cannot
|
|
22
|
+
// see at row 4, and nothing upstream could tell that apart from a real row.
|
|
23
|
+
expect(trackDisplayNumber([0, 1, 2], 7)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("trackDisplaySuffix", () => {
|
|
28
|
+
it("names the row when there is one", () => {
|
|
29
|
+
expect(`Hide track${trackDisplaySuffix(3)}`).toBe("Hide track 3");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("drops the number entirely when there is no row", () => {
|
|
33
|
+
expect(`Hide track${trackDisplaySuffix(null)}`).toBe("Hide track");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one owner of "what track number does the user see".
|
|
3
|
+
*
|
|
4
|
+
* `TimelineElement.track` is a z-order SORT key, not a row number: an expanded
|
|
5
|
+
* sub-composition child gets a synthesized fractional key (`host.track + n /
|
|
6
|
+
* (siblings + 2)`, see `useExpandedTimelineElements`), so putting it in a string
|
|
7
|
+
* announces "Hide track 0.16666666666666666". Every user-visible track number,
|
|
8
|
+
* whether it is rendered by a component or baked into an undo-history label,
|
|
9
|
+
* routes through here; the raw key stays in callbacks and lookups only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Ascending distinct track keys, the row order the timeline renders in. */
|
|
13
|
+
export function timelineTrackOrder(elements: readonly { track: number }[]): number[] {
|
|
14
|
+
return [...new Set(elements.map((element) => element.track))].sort((a, b) => a - b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A track key's 1-based display row, or null when the key is not in
|
|
19
|
+
* `trackOrder` at all.
|
|
20
|
+
*
|
|
21
|
+
* Both callers build `trackOrder` from the same elements the key came from, so
|
|
22
|
+
* null is unreachable by construction today. It is null rather than a number
|
|
23
|
+
* because the only numbers available to return (the end row, the last row) are
|
|
24
|
+
* indistinguishable from a real answer: a label would announce a row the user
|
|
25
|
+
* can see is wrong, and nothing upstream would ever learn it had guessed.
|
|
26
|
+
*/
|
|
27
|
+
export function trackDisplayNumber(trackOrder: readonly number[], track: number): number | null {
|
|
28
|
+
const row = trackOrder.indexOf(track);
|
|
29
|
+
return row < 0 ? null : row + 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `" 3"` in `Hide track 3`, empty when there is no display row to name.
|
|
34
|
+
* Announcing "Hide track" is thin; announcing an invented row is wrong.
|
|
35
|
+
*/
|
|
36
|
+
export function trackDisplaySuffix(displayNumber: number | null): string {
|
|
37
|
+
return displayNumber === null ? "" : ` ${displayNumber}`;
|
|
38
|
+
}
|
|
@@ -268,6 +268,11 @@ export function useTimelineRangeSelection({
|
|
|
268
268
|
if (!point || !scrollRect || isTimelineRulerPress(e.clientY, scrollRect.top)) {
|
|
269
269
|
isDragging.current = true;
|
|
270
270
|
setIsScrubbing(true);
|
|
271
|
+
// Seed the pending coordinate so a press with no pointermove still
|
|
272
|
+
// replays THIS x on pointerup. `updateScrubDrag` is the only other
|
|
273
|
+
// writer, so without this a plain click settles on the ref's initial
|
|
274
|
+
// 0 and clamps the playhead back to t=0.
|
|
275
|
+
pendingClientXRef.current = e.clientX;
|
|
271
276
|
seekFromX(e.clientX);
|
|
272
277
|
return;
|
|
273
278
|
}
|
|
@@ -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
|
|
@@ -240,8 +285,112 @@ describe("buildExpandedElements", () => {
|
|
|
240
285
|
expect(pills[0]!.duration).toBe(6);
|
|
241
286
|
expect(pills[0]!.sourceFile).toBe("scene.html");
|
|
242
287
|
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.
|
|
288
|
+
// The host row survives the expansion; its children are added under it.
|
|
289
|
+
expect(out.some((e) => e.id === "scene-host")).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// fallow-ignore-next-line code-duplication
|
|
293
|
+
it("keeps the host row and appends its children directly below it", () => {
|
|
294
|
+
const elements = [
|
|
295
|
+
el({
|
|
296
|
+
id: "s3",
|
|
297
|
+
domId: "s3",
|
|
298
|
+
key: "index.html#s3",
|
|
299
|
+
start: 16,
|
|
300
|
+
duration: 7,
|
|
301
|
+
compositionSrc: "stats.html",
|
|
302
|
+
}),
|
|
303
|
+
el({ id: "outro", start: 23, duration: 3, track: 1 }),
|
|
304
|
+
];
|
|
305
|
+
const manifest = [
|
|
306
|
+
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
|
307
|
+
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
|
308
|
+
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
|
309
|
+
];
|
|
310
|
+
const parentMap = new Map([
|
|
311
|
+
["stat-1", "s3"],
|
|
312
|
+
["stat-2", "s3"],
|
|
313
|
+
]);
|
|
314
|
+
|
|
315
|
+
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
|
316
|
+
|
|
317
|
+
const hostIndex = out.findIndex((e) => e.id === "s3");
|
|
318
|
+
expect(hostIndex).toBeGreaterThanOrEqual(0);
|
|
319
|
+
// Host row untouched (same key → same keyframe lane), children nested under it.
|
|
320
|
+
expect(out[hostIndex]!.key).toBe("index.html#s3");
|
|
321
|
+
expect(out[hostIndex]!.track).toBe(0);
|
|
322
|
+
expect(out[hostIndex + 1]!.domId).toBe("stat-1");
|
|
323
|
+
expect(out[hostIndex + 2]!.domId).toBe("stat-2");
|
|
324
|
+
// Exactly one more row than the old substitution behaviour (host + 2 children + outro).
|
|
325
|
+
expect(out).toHaveLength(4);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
|
|
329
|
+
// Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
|
|
330
|
+
// seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
|
|
331
|
+
// so the whole lane went with the host row whenever the paused drill-in
|
|
332
|
+
// substituted it for its children.
|
|
333
|
+
const elements = [
|
|
334
|
+
el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
|
|
335
|
+
];
|
|
336
|
+
const manifest = [
|
|
337
|
+
clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
|
|
338
|
+
clip({ id: "headline", start: 0, duration: 12 }),
|
|
339
|
+
];
|
|
340
|
+
const parentMap = new Map([["headline", "scene"]]);
|
|
341
|
+
|
|
342
|
+
for (const currentTime of [0, 7.68, 0.2]) {
|
|
343
|
+
const rawId = resolveTimelineExpansionRawId({
|
|
344
|
+
selectedElementId: null,
|
|
345
|
+
isPlaying: false,
|
|
346
|
+
currentTime,
|
|
347
|
+
manifest,
|
|
348
|
+
parentMap,
|
|
349
|
+
});
|
|
350
|
+
const rows = rawId
|
|
351
|
+
? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
|
|
352
|
+
: elements;
|
|
353
|
+
expect(rows.map((row) => row.key)).toContain("index.html#scene");
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Regression: DOM-only children were synthesized against the TOP-LEVEL element
|
|
358
|
+
// instead of the sub-comp host they actually live in, so every child row read
|
|
359
|
+
// the whole top-level window rather than its host's.
|
|
360
|
+
it("spans DOM-only children over their nested host's window, not the top-level one", () => {
|
|
361
|
+
const elements = [
|
|
362
|
+
el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
363
|
+
];
|
|
364
|
+
const manifest = [
|
|
365
|
+
clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
|
366
|
+
clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
|
|
367
|
+
];
|
|
368
|
+
const parentMap = new Map([
|
|
369
|
+
["sub-host", "scene-host"],
|
|
370
|
+
["pill-1", "sub-host"],
|
|
371
|
+
]);
|
|
372
|
+
const domClipChildren = [
|
|
373
|
+
{
|
|
374
|
+
id: "pill-1",
|
|
375
|
+
parentId: "sub-host",
|
|
376
|
+
hostId: "sub-host",
|
|
377
|
+
label: "pill-1",
|
|
378
|
+
stackingContextId: "css:0.0",
|
|
379
|
+
},
|
|
380
|
+
];
|
|
381
|
+
|
|
382
|
+
const out = buildExpandedElements(
|
|
383
|
+
elements,
|
|
384
|
+
manifest,
|
|
385
|
+
parentMap,
|
|
386
|
+
"scene-host",
|
|
387
|
+
"sub-host",
|
|
388
|
+
domClipChildren,
|
|
389
|
+
);
|
|
390
|
+
const pill = out.find((e) => e.domId === "pill-1")!;
|
|
391
|
+
expect(pill.start).toBe(5);
|
|
392
|
+
expect(pill.duration).toBe(6);
|
|
393
|
+
expect(pill.sourceFile).toBe("sub.html");
|
|
245
394
|
});
|
|
246
395
|
});
|
|
247
396
|
|
|
@@ -209,7 +209,13 @@ function buildChildElements(
|
|
|
209
209
|
function domSiblingClips(
|
|
210
210
|
domClipChildren: DomClipChild[],
|
|
211
211
|
siblingParentId: string,
|
|
212
|
-
host:
|
|
212
|
+
host: {
|
|
213
|
+
id: string | null;
|
|
214
|
+
start: number;
|
|
215
|
+
duration: number;
|
|
216
|
+
track: number;
|
|
217
|
+
compositionSrc?: string | null;
|
|
218
|
+
},
|
|
213
219
|
): ClipManifestClip[] {
|
|
214
220
|
return domClipChildren
|
|
215
221
|
.filter((c) => c.parentId === siblingParentId)
|
|
@@ -243,20 +249,23 @@ export function buildExpandedElements(
|
|
|
243
249
|
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
|
244
250
|
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
|
245
251
|
|
|
252
|
+
// The sub-comp host the children actually live in: top-level host for 1-level
|
|
253
|
+
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
|
254
|
+
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
|
255
|
+
|
|
246
256
|
// Prefer real manifest children; fall back to DOM-only sub-comp children
|
|
247
257
|
// (groups/pills) that have no data-start and thus never enter the manifest.
|
|
258
|
+
// Those are synthesized against the host they actually live in, not the
|
|
259
|
+
// top-level element, or every child row reads the whole top-level window.
|
|
248
260
|
const siblings = (() => {
|
|
249
261
|
const fromManifest = manifest.filter(
|
|
250
262
|
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
|
|
251
263
|
);
|
|
252
264
|
if (fromManifest.length > 0) return fromManifest;
|
|
253
|
-
return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
|
|
265
|
+
return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement);
|
|
254
266
|
})();
|
|
255
267
|
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
|
256
268
|
|
|
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
269
|
const editBasis = {
|
|
261
270
|
start: parentHost?.start ?? topLevelElement.start,
|
|
262
271
|
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
|
@@ -275,9 +284,39 @@ export function buildExpandedElements(
|
|
|
275
284
|
);
|
|
276
285
|
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
|
277
286
|
|
|
287
|
+
// Every host between the drilled one and the top level owns a row, so the
|
|
288
|
+
// drill has to spare all of them, not just the top. A middle host is still a
|
|
289
|
+
// host: dropping its row drops its keyframe lane with it.
|
|
290
|
+
const drillPath = new Set<string>();
|
|
291
|
+
for (let cursor: string | undefined = siblingParentId; cursor; ) {
|
|
292
|
+
if (drillPath.has(cursor)) break;
|
|
293
|
+
drillPath.add(cursor);
|
|
294
|
+
if (cursor === topLevelId) break;
|
|
295
|
+
cursor = parentMap.get(cursor);
|
|
296
|
+
}
|
|
297
|
+
// Children hang under the DEEPEST host on that path, so anchor them there
|
|
298
|
+
// when it has a row of its own and fall back to the top-level row when it
|
|
299
|
+
// does not (a host that lives only in the manifest never had one).
|
|
300
|
+
const anchorsChildren = (el: TimelineElement): boolean =>
|
|
301
|
+
drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId)
|
|
302
|
+
? (el.domId ?? el.id) === siblingParentId
|
|
303
|
+
: (el.key ?? el.id) === parentKey;
|
|
304
|
+
|
|
305
|
+
// ADDITIVE drill-in: the host row stays and its children are appended under
|
|
306
|
+
// it. Expansion is also triggered by the playhead alone (paused auto-expand),
|
|
307
|
+
// so substituting the host row made it vanish on an ordinary seek, and with
|
|
308
|
+
// it the host's keyframe lane, since diamonds render per row from
|
|
309
|
+
// `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
|
|
310
|
+
// strictly between the host's lane and the next integer, so the children have
|
|
311
|
+
// their own rows without the host having to give up its own.
|
|
278
312
|
return elements
|
|
279
|
-
.filter(
|
|
280
|
-
|
|
313
|
+
.filter(
|
|
314
|
+
(el) =>
|
|
315
|
+
(el.key ?? el.id) === parentKey ||
|
|
316
|
+
drillPath.has(el.domId ?? el.id) ||
|
|
317
|
+
!parentMap.has(el.domId ?? el.id),
|
|
318
|
+
)
|
|
319
|
+
.flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el]));
|
|
281
320
|
}
|
|
282
321
|
|
|
283
322
|
export function useExpandedTimelineElements(): TimelineElement[] {
|
|
@@ -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
|
}
|