@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.
Files changed (74) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-r1tKKlU7.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1303 -1121
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  26. package/src/hooks/gsapDragCommit.ts +43 -10
  27. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  28. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  29. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  30. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  31. package/src/hooks/gsapResizeIntercept.ts +2 -2
  32. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  33. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  34. package/src/hooks/gsapShared.test.ts +144 -0
  35. package/src/hooks/gsapShared.ts +264 -0
  36. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  37. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  38. package/src/hooks/newTweenTarget.test.ts +364 -0
  39. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  40. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  41. package/src/hooks/timelineTrackVisibility.ts +11 -1
  42. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  43. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  44. package/src/hooks/useEnableKeyframes.ts +18 -5
  45. package/src/hooks/useGestureCommit.ts +21 -4
  46. package/src/hooks/useGsapScriptCommits.ts +3 -0
  47. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  48. package/src/hooks/useGsapTweenCache.ts +4 -11
  49. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  50. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  51. package/src/player/components/ShortcutsPanel.tsx +11 -15
  52. package/src/player/components/Timeline.test.ts +3 -1
  53. package/src/player/components/TimelineCanvas.tsx +2 -1
  54. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  55. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  56. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  57. package/src/player/components/TimelineLanes.test.tsx +281 -0
  58. package/src/player/components/TimelineLanes.tsx +43 -97
  59. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  60. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  61. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  62. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  63. package/src/player/components/timelineLaneProps.ts +86 -0
  64. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  65. package/src/player/components/timelineTrackDisplay.ts +38 -0
  66. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  67. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  68. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  69. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  70. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  71. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  72. package/src/telemetry/client.test.ts +12 -3
  73. package/src/telemetry/client.ts +3 -2
  74. 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 { animationContributesLane } from "./TimelinePropertyLanes";
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
- if (animation.propertyGroup && animationContributesLane(animation)) {
60
- propertyGroups.add(animation.propertyGroup);
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 is replaced by its children.
244
- expect(out.some((e) => e.domId === "scene-host")).toBe(false);
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: TimelineElement,
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((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
280
- .flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
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("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
60
- setNoTelemetry("true");
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(false);
71
+ expect(shouldTrack()).toBe(true);
63
72
  });
64
73
 
65
74
  it("returns false in vite dev mode", async () => {
@@ -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. Accepts "1" or "true".
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 === "1" || v === "true";
43
+ return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
43
44
  } catch {
44
45
  return false;
45
46
  }