@hyperframes/studio 0.7.14 → 0.7.16

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 (72) hide show
  1. package/dist/assets/index-C4cUbqql.js +375 -0
  2. package/dist/assets/{index-CkrVt5DX.js → index-D2SMcOdY.js} +1 -1
  3. package/dist/assets/index-DmkOvZns.css +1 -0
  4. package/dist/assets/{index-DYgkaIlV.js → index-sCOjSz40.js} +1 -1
  5. package/dist/{chunk-KZXYQYIU.js → chunk-SBGXX7WY.js} +52 -22
  6. package/dist/chunk-SBGXX7WY.js.map +1 -0
  7. package/dist/{domEditingLayers-SSXQZHHQ.js → domEditingLayers-VZMLL4AP.js} +2 -4
  8. package/dist/index.d.ts +23 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +3158 -2809
  11. package/dist/index.js.map +1 -1
  12. package/package.json +7 -5
  13. package/src/App.tsx +2 -0
  14. package/src/components/StudioPreviewArea.tsx +0 -41
  15. package/src/components/StudioRightPanel.tsx +2 -0
  16. package/src/components/TimelineToolbar.tsx +4 -4
  17. package/src/components/editor/DomEditOverlay.tsx +5 -2
  18. package/src/components/editor/GestureRecordControl.tsx +2 -2
  19. package/src/components/editor/GsapAnimationSection.tsx +3 -3
  20. package/src/components/editor/InspectorHeaderActions.tsx +62 -0
  21. package/src/components/editor/LayersPanel.tsx +35 -2
  22. package/src/components/editor/MotionPathOverlay.tsx +24 -8
  23. package/src/components/editor/PropertyPanel.tsx +48 -60
  24. package/src/components/editor/Transform3DCube.tsx +84 -85
  25. package/src/components/editor/domEditOverlayGeometry.ts +28 -0
  26. package/src/components/editor/domEditingDom.ts +36 -2
  27. package/src/components/editor/domEditingGroups.ts +41 -0
  28. package/src/components/editor/domEditingLayers.test.ts +97 -1
  29. package/src/components/editor/domEditingLayers.ts +13 -29
  30. package/src/components/editor/domEditingTypes.ts +4 -1
  31. package/src/components/editor/gsapAnimationCallbacks.ts +1 -1
  32. package/src/components/editor/gsapAnimationHelpers.test.ts +2 -2
  33. package/src/components/editor/gsapAnimationHelpers.ts +1 -1
  34. package/src/components/editor/manualEditsTypes.ts +1 -1
  35. package/src/components/editor/manualOffsetDrag.ts +23 -2
  36. package/src/components/editor/motionPathCommit.test.ts +1 -1
  37. package/src/components/editor/motionPathSelection.ts +1 -1
  38. package/src/components/editor/propertyPanel3dTransform.tsx +70 -30
  39. package/src/components/editor/propertyPanelHelpers.ts +6 -4
  40. package/src/components/editor/useDomEditOverlayRects.ts +8 -3
  41. package/src/components/editor/useMotionPathData.ts +47 -1
  42. package/src/contexts/DomEditContext.tsx +16 -0
  43. package/src/contexts/TimelineEditContext.tsx +0 -1
  44. package/src/hooks/gsapRuntimeBridge.ts +9 -2
  45. package/src/hooks/gsapTweenSynth.ts +64 -0
  46. package/src/hooks/useAnimatedPropertyCommit.ts +126 -26
  47. package/src/hooks/useAppHotkeys.ts +34 -3
  48. package/src/hooks/useDomEditSession.ts +48 -0
  49. package/src/hooks/useDomSelection.ts +73 -9
  50. package/src/hooks/useEnableKeyframes.test.ts +35 -0
  51. package/src/hooks/useEnableKeyframes.ts +29 -1
  52. package/src/hooks/useGroupCommits.ts +188 -0
  53. package/src/hooks/useGsapSelectionHandlers.ts +4 -0
  54. package/src/hooks/useGsapTweenCache.ts +56 -72
  55. package/src/hooks/usePreviewInteraction.ts +60 -2
  56. package/src/player/components/ShortcutsPanel.tsx +2 -0
  57. package/src/player/components/Timeline.tsx +0 -15
  58. package/src/player/components/TimelineCanvas.tsx +0 -13
  59. package/src/player/components/TimelineClipDiamonds.tsx +4 -125
  60. package/src/player/components/timelineCallbacks.ts +0 -1
  61. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  62. package/src/player/hooks/useExpandedTimelineElements.ts +47 -4
  63. package/src/player/hooks/useTimelineSyncCallbacks.ts +43 -4
  64. package/src/player/store/playerStore.ts +20 -0
  65. package/src/utils/sdkCutoverParity.test.ts +1 -1
  66. package/src/utils/studioPreviewHelpers.ts +37 -0
  67. package/dist/assets/index-BXG-pbr_.js +0 -375
  68. package/dist/assets/index-svYFaNuq.css +0 -1
  69. package/dist/chunk-KZXYQYIU.js.map +0 -1
  70. package/src/components/editor/keyframeMove.test.ts +0 -101
  71. package/src/components/editor/keyframeMove.ts +0 -151
  72. /package/dist/{domEditingLayers-SSXQZHHQ.js.map → domEditingLayers-VZMLL4AP.js.map} +0 -0
@@ -1,4 +1,4 @@
1
- import { memo, useEffect, useRef, useState } from "react";
1
+ import { memo } from "react";
2
2
  import { BEAT_BAND_H } from "./BeatStrip";
3
3
 
4
4
  interface KeyframeEntry {
@@ -28,15 +28,7 @@ interface TimelineClipDiamondsProps {
28
28
  selectedKeyframes: Set<string>;
29
29
  onClickKeyframe?: (percentage: number) => void;
30
30
  onShiftClickKeyframe?: (elementId: string, percentage: number) => void;
31
- onDragKeyframe?: (percentage: number, newPercentage: number) => void;
32
31
  onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void;
33
- /** Snap a clip-relative percentage to the nearest beat (returns it unchanged
34
- * when no beat is within range). Drives live beat-snapping while dragging. */
35
- snapPct?: (percentage: number) => number;
36
- /** Select this element when a keyframe drag begins, so its GSAP session is
37
- * loaded by the time the move commits (diamonds render on unselected clips
38
- * too, and a drag suppresses the selecting click). */
39
- onPickForDrag?: () => void;
40
32
  }
41
33
 
42
34
  const DIAMOND_RATIO = 0.8;
@@ -59,54 +51,8 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
59
51
  selectedKeyframes,
60
52
  onClickKeyframe,
61
53
  onShiftClickKeyframe,
62
- onDragKeyframe,
63
54
  onContextMenuKeyframe,
64
- snapPct,
65
- onPickForDrag,
66
55
  }: TimelineClipDiamondsProps) {
67
- // Live drag: which keyframe (by original %) is being dragged and its current
68
- // (beat-snapped) %, so the diamond + its connecting lines follow the cursor.
69
- const dragRef = useRef<{ origPct: number; pct: number; moved: boolean } | null>(null);
70
- const [drag, setDrag] = useState<{ origPct: number; pct: number } | null>(null);
71
- // Commit through the latest callback, not the one captured at pointer-down:
72
- // selecting the element on drag-start loads its GSAP session asynchronously,
73
- // and the commit must use the closure that sees the loaded session.
74
- const onDragKeyframeRef = useRef(onDragKeyframe);
75
- onDragKeyframeRef.current = onDragKeyframe;
76
- // Optimistic hold: after a commit, keep the diamond at the dropped position
77
- // until the cache reflects the change (the file round-trip rewrites
78
- // keyframesData), so it doesn't flash back to the old spot in between.
79
- const pendingRef = useRef(false);
80
- const pendingHeldPctRef = useRef<number | null>(null);
81
- const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
82
- // Cleanup for an in-flight drag's document listeners, so an unmount mid-drag
83
- // (clip deleted, comp switch, zoom-out → early return) doesn't leak them.
84
- const dragCleanupRef = useRef<(() => void) | null>(null);
85
-
86
- useEffect(() => {
87
- if (!pendingRef.current) return;
88
- // Only release the optimistic hold once the cache actually reflects the
89
- // committed position (a keyframe near the held %). An unrelated cache
90
- // rebuild (e.g. elementCount change) rebuilds keyframesData with the SAME
91
- // percentages — releasing then would flash the diamond back to the old spot.
92
- const held = pendingHeldPctRef.current;
93
- if (held != null && !keyframesData.keyframes.some((k) => Math.abs(k.percentage - held) < 0.3)) {
94
- return;
95
- }
96
- pendingRef.current = false;
97
- pendingHeldPctRef.current = null;
98
- if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
99
- setDrag(null);
100
- }, [keyframesData]);
101
-
102
- useEffect(
103
- () => () => {
104
- clearTimeout(pendingTimerRef.current ?? undefined);
105
- dragCleanupRef.current?.();
106
- },
107
- [],
108
- );
109
-
110
56
  if (clipWidthPx < 20) return null;
111
57
 
112
58
  // When the beat strip occupies the top band, shrink the diamonds and center
@@ -129,79 +75,13 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
129
75
  }
130
76
  };
131
77
 
132
- const handlePointerDown = (e: React.PointerEvent, pct: number) => {
133
- if (e.button !== 0) return;
134
- e.stopPropagation();
135
- // Ignore a new drag while a prior drop is still settling: `pct` comes from
136
- // props (the pre-drop position) but the diamond is held at its dropped spot
137
- // via effPct(), so a re-grab would track from a stale origin and commit
138
- // against the wrong tween. The hold clears on the cache round-trip (≤2s).
139
- if (pendingRef.current) return;
140
- // Select the element up front so its GSAP session loads during the drag and
141
- // the commit (which resolves the animation from the selection) isn't a no-op.
142
- onPickForDrag?.();
143
- const startX = e.clientX;
144
- dragRef.current = { origPct: pct, pct, moved: false };
145
-
146
- const handleMove = (me: PointerEvent) => {
147
- const d = dragRef.current;
148
- if (!d) return;
149
- const dx = me.clientX - startX;
150
- // 4px dead zone so a click doesn't register as a drag.
151
- if (!d.moved && Math.abs(dx) <= 4) return;
152
- d.moved = true;
153
- const rawPct = Math.max(0, Math.min(100, pct + (dx / clipWidthPx) * 100));
154
- const snapped = snapPct ? snapPct(rawPct) : rawPct;
155
- d.pct = snapped;
156
- setDrag({ origPct: pct, pct: snapped });
157
- };
158
-
159
- const handleUp = () => {
160
- document.removeEventListener("pointermove", handleMove);
161
- document.removeEventListener("pointerup", handleUp);
162
- dragCleanupRef.current = null;
163
- const d = dragRef.current;
164
- dragRef.current = null;
165
- const willCommit = !!(d && d.moved && Math.abs(d.pct - d.origPct) > 0.5);
166
- if (willCommit && d) {
167
- // Hold the dropped position optimistically; the effect clears it once the
168
- // cache round-trip lands (fallback timeout in case it never does).
169
- pendingRef.current = true;
170
- pendingHeldPctRef.current = d.pct;
171
- setDrag({ origPct: d.origPct, pct: d.pct });
172
- if (pendingTimerRef.current) clearTimeout(pendingTimerRef.current);
173
- pendingTimerRef.current = setTimeout(() => {
174
- pendingRef.current = false;
175
- pendingHeldPctRef.current = null;
176
- setDrag(null);
177
- }, 2000);
178
- onDragKeyframeRef.current?.(d.origPct, d.pct);
179
- } else {
180
- setDrag(null);
181
- }
182
- };
183
-
184
- dragCleanupRef.current = () => {
185
- document.removeEventListener("pointermove", handleMove);
186
- document.removeEventListener("pointerup", handleUp);
187
- };
188
-
189
- document.addEventListener("pointermove", handleMove);
190
- document.addEventListener("pointerup", handleUp);
191
- };
192
-
193
- const effPct = (p: number): number => (drag && drag.origPct === p ? drag.pct : p);
194
-
195
78
  return (
196
79
  <div className="absolute inset-0" style={{ zIndex: 3, pointerEvents: "none" }}>
197
80
  {sorted.map((kf, i) => {
198
81
  if (i === 0) return null;
199
82
  const prev = sorted[i - 1]!;
200
- const x1 = Math.max(
201
- 0,
202
- Math.min(clipWidthPx, (effPct(prev.percentage) / 100) * clipWidthPx),
203
- );
204
- const x2 = Math.max(0, Math.min(clipWidthPx, (effPct(kf.percentage) / 100) * clipWidthPx));
83
+ const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx));
84
+ const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx));
205
85
  if (x2 - x1 < 1) return null;
206
86
  return (
207
87
  <div
@@ -227,7 +107,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
227
107
  // is the clip's left edge (the diamond's left half overflows, which the
228
108
  // overflow-visible clip shows) — NOT shifted fully inside. No clamp, or
229
109
  // boundary keyframes (0% / 100%) would render off-center.
230
- const leftPx = (effPct(kf.percentage) / 100) * clipWidthPx - half;
110
+ const leftPx = (kf.percentage / 100) * clipWidthPx - half;
231
111
  const kfKey = `${elementId}:${kf.percentage}`;
232
112
  const isKfSelected = selectedKeyframes.has(kfKey);
233
113
  const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5;
@@ -252,7 +132,6 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
252
132
  padding: 0,
253
133
  }}
254
134
  onClick={(e) => handleClick(e, kf.percentage)}
255
- onPointerDown={(e) => handlePointerDown(e, kf.percentage)}
256
135
  onContextMenu={(e) => {
257
136
  e.preventDefault();
258
137
  e.stopPropagation();
@@ -39,6 +39,5 @@ export interface TimelineEditCallbacks {
39
39
  onDeleteKeyframe?: (elementId: string, percentage: number) => void;
40
40
  onDeleteAllKeyframes?: (elementId: string) => void;
41
41
  onChangeKeyframeEase?: (elementId: string, percentage: number, ease: string) => void;
42
- onMoveKeyframe?: (element: TimelineElement, oldPct: number, newPct: number) => void;
43
42
  onToggleKeyframeAtPlayhead?: (element: TimelineElement) => void;
44
43
  }
@@ -122,4 +122,47 @@ describe("buildExpandedElements", () => {
122
122
  expect(child.key).toBe("index.html#eyebrow");
123
123
  expect(child.key).toBe(expectedStoreKey);
124
124
  });
125
+
126
+ // Sub-comp internals (group + pills) have no data-start, so they're not in the
127
+ // manifest. They arrive as DOM children and must still expand under their host.
128
+ it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
129
+ const elements = [
130
+ el({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
131
+ ];
132
+ const manifest = [
133
+ clip({ id: "scene-host", start: 5, duration: 6, compositionSrc: "scene.html" }),
134
+ ];
135
+ // pill-3 selected → parent group-1 → host scene-host. None of group-1/pills
136
+ // are in the manifest; they're DOM children with parent links.
137
+ const parentMap = new Map([
138
+ ["group-1", "scene-host"],
139
+ ["pill-1", "group-1"],
140
+ ["pill-2", "group-1"],
141
+ ["pill-3", "group-1"],
142
+ ]);
143
+ const domClipChildren = [
144
+ { id: "group-1", parentId: "scene-host", hostId: "scene-host", label: "Group 1" },
145
+ { id: "pill-1", parentId: "group-1", hostId: "scene-host", label: "pill-1" },
146
+ { id: "pill-2", parentId: "group-1", hostId: "scene-host", label: "pill-2" },
147
+ { id: "pill-3", parentId: "group-1", hostId: "scene-host", label: "pill-3" },
148
+ ];
149
+
150
+ // Expanding pill-3's siblings: topLevel scene-host, immediate parent group-1.
151
+ const out = buildExpandedElements(
152
+ elements,
153
+ manifest,
154
+ parentMap,
155
+ "scene-host",
156
+ "group-1",
157
+ domClipChildren,
158
+ );
159
+ const pills = out.filter((e) => e.domId?.startsWith("pill-"));
160
+ expect(pills).toHaveLength(3);
161
+ // Children span the host's bounds and rebase onto the host's file.
162
+ expect(pills[0]!.start).toBe(5);
163
+ expect(pills[0]!.duration).toBe(6);
164
+ expect(pills[0]!.sourceFile).toBe("scene.html");
165
+ // The host row is replaced by its children.
166
+ expect(out.some((e) => e.domId === "scene-host")).toBe(false);
167
+ });
125
168
  });
@@ -1,5 +1,5 @@
1
1
  import { useMemo } from "react";
2
- import { usePlayerStore, type TimelineElement } from "../store/playerStore";
2
+ import { usePlayerStore, type TimelineElement, type DomClipChild } from "../store/playerStore";
3
3
  import type { ClipManifestClip } from "../lib/playbackTypes";
4
4
  import { createTimelineElementFromManifestClip } from "../lib/timelineDOM";
5
5
  import { buildTimelineElementKey } from "../lib/timelineElementHelpers";
@@ -111,6 +111,32 @@ function buildChildElements(
111
111
  return result;
112
112
  }
113
113
 
114
+ // Sub-comp DOM children (groups/pills) aren't manifest clips and have no timing
115
+ // of their own — they're "always on" within their sub-comp host, so synthesize
116
+ // clips spanning the host's full bounds. The host element supplies start/duration
117
+ // and the composition file edits write to.
118
+ function domSiblingClips(
119
+ domClipChildren: DomClipChild[],
120
+ siblingParentId: string,
121
+ host: TimelineElement,
122
+ ): ClipManifestClip[] {
123
+ return domClipChildren
124
+ .filter((c) => c.parentId === siblingParentId)
125
+ .map((c) => ({
126
+ id: c.id,
127
+ label: c.label,
128
+ start: host.start,
129
+ duration: host.duration,
130
+ track: host.track,
131
+ kind: "element" as const,
132
+ tagName: null,
133
+ compositionId: null,
134
+ parentCompositionId: host.id ?? null,
135
+ compositionSrc: host.compositionSrc ?? null,
136
+ assetUrl: null,
137
+ }));
138
+ }
139
+
114
140
  // Exported for tests.
115
141
  export function buildExpandedElements(
116
142
  elements: TimelineElement[],
@@ -118,11 +144,20 @@ export function buildExpandedElements(
118
144
  parentMap: Map<string, string>,
119
145
  topLevelId: string,
120
146
  siblingParentId: string,
147
+ domClipChildren: DomClipChild[] = [],
121
148
  ): TimelineElement[] {
122
149
  const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
123
150
  if (!topLevelElement) return filterToTopLevel(elements, parentMap);
124
151
 
125
- const siblings = manifest.filter((c) => c.id != null && parentMap.get(c.id) === siblingParentId);
152
+ // Prefer real manifest children; fall back to DOM-only sub-comp children
153
+ // (groups/pills) that have no data-start and thus never enter the manifest.
154
+ const siblings = (() => {
155
+ const fromManifest = manifest.filter(
156
+ (c) => c.id != null && parentMap.get(c.id) === siblingParentId,
157
+ );
158
+ if (fromManifest.length > 0) return fromManifest;
159
+ return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
160
+ })();
126
161
  if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
127
162
 
128
163
  // The sub-comp host the children actually live in: top-level host for 1-level
@@ -154,6 +189,7 @@ export function useExpandedTimelineElements(): TimelineElement[] {
154
189
  const elements = usePlayerStore((s) => s.elements);
155
190
  const clipManifest = usePlayerStore((s) => s.clipManifest);
156
191
  const clipParentMap = usePlayerStore((s) => s.clipParentMap);
192
+ const domClipChildren = usePlayerStore((s) => s.domClipChildren);
157
193
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
158
194
 
159
195
  return useMemo(() => {
@@ -166,6 +202,13 @@ export function useExpandedTimelineElements(): TimelineElement[] {
166
202
 
167
203
  const immediateParent = clipParentMap.get(rawId)!;
168
204
  const topLevel = findTopLevelAncestor(rawId, clipParentMap) ?? immediateParent;
169
- return buildExpandedElements(elements, clipManifest, clipParentMap, topLevel, immediateParent);
170
- }, [elements, clipManifest, clipParentMap, selectedElementId]);
205
+ return buildExpandedElements(
206
+ elements,
207
+ clipManifest,
208
+ clipParentMap,
209
+ topLevel,
210
+ immediateParent,
211
+ domClipChildren,
212
+ );
213
+ }, [elements, clipManifest, clipParentMap, domClipChildren, selectedElementId]);
171
214
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { useCallback } from "react";
12
12
  import { liveTime, usePlayerStore } from "../store/playerStore";
13
- import type { TimelineElement } from "../store/playerStore";
13
+ import type { TimelineElement, DomClipChild } from "../store/playerStore";
14
14
  import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
15
15
  import {
16
16
  parseTimelineFromDOM,
@@ -85,8 +85,8 @@ export function useTimelineSyncCallbacks({
85
85
  | (Window & { __clipTree?: import("@hyperframes/core/runtime/clipTree").ClipTree })
86
86
  | null;
87
87
  const clipTree = iframeWin?.__clipTree;
88
+ const parentMap = new Map<string, string>();
88
89
  if (clipTree) {
89
- const parentMap = new Map<string, string>();
90
90
  const walk = (nodes: typeof clipTree.roots) => {
91
91
  for (const node of nodes) {
92
92
  if (node.id && node.parentId) parentMap.set(node.id, node.parentId);
@@ -94,11 +94,50 @@ export function useTimelineSyncCallbacks({
94
94
  }
95
95
  };
96
96
  walk(clipTree.roots);
97
- usePlayerStore.getState().setClipParentMap(parentMap);
98
97
  }
98
+
99
+ // Descend into each sub-composition host: its internal elements (group
100
+ // wrappers + their children) carry no `data-start`, so the clip
101
+ // tree/manifest never enumerate them. Surface them studio-side as DOM
102
+ // children + parent links so the timeline can expand a sub-comp/group
103
+ // row to show them. Manifest stays lean (timed clips only).
104
+ const domClipChildren: DomClipChild[] = [];
105
+ if (iframeDoc) {
106
+ for (const clip of data.clips) {
107
+ if (clip.kind !== "composition" || !clip.id) continue;
108
+ const hostEl = iframeDoc.getElementById(clip.id);
109
+ if (!hostEl) continue;
110
+ const hostId = clip.id;
111
+ const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl;
112
+ // Collect the sub-comp's id'd descendants (grouped OR ungrouped) so they
113
+ // expand into timeline rows. Descends through id-less structural wrappers
114
+ // (the inlined sub-comp body), and one level into groups for drill-in.
115
+ const collect = (parentEl: Element, parentId: string) => {
116
+ for (const child of Array.from(parentEl.children)) {
117
+ if (!child.id) {
118
+ collect(child, parentId); // unwrap id-less structural containers
119
+ continue;
120
+ }
121
+ const isGroup = child.hasAttribute("data-hf-group");
122
+ domClipChildren.push({
123
+ id: child.id,
124
+ parentId,
125
+ hostId,
126
+ label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
127
+ });
128
+ parentMap.set(child.id, parentId);
129
+ if (isGroup) collect(child, child.id);
130
+ }
131
+ };
132
+ collect(innerRoot, hostId);
133
+ }
134
+ }
135
+ usePlayerStore.getState().setClipParentMap(parentMap);
136
+ usePlayerStore.getState().setDomClipChildren(domClipChildren);
99
137
  } catch {
100
- // cross-origin or __clipTree not available — parentMap stays empty
138
+ // cross-origin or __clipTree not available — maps stay empty
101
139
  }
140
+
102
141
  const usedHostEls = new Set<Element>();
103
142
  const els: TimelineElement[] = filtered.map((clip, index) => {
104
143
  const hostEl = iframeDoc
@@ -165,6 +165,23 @@ interface PlayerState {
165
165
  setClipManifest: (clips: ClipManifestClip[] | null) => void;
166
166
  clipParentMap: Map<string, string>;
167
167
  setClipParentMap: (map: Map<string, string>) => void;
168
+ /**
169
+ * Sub-composition DOM descendants (groups + their children) that have no
170
+ * `data-start`, so they're absent from the clip manifest/tree. Collected
171
+ * studio-side from the live preview so the timeline can expand a sub-comp row
172
+ * to show its DOM-only children. Keeps the manifest lean (timed clips only).
173
+ */
174
+ domClipChildren: DomClipChild[];
175
+ setDomClipChildren: (children: DomClipChild[]) => void;
176
+ }
177
+
178
+ /** A sub-comp DOM-only timeline child (no data-start) and its nesting context. */
179
+ export interface DomClipChild {
180
+ id: string;
181
+ parentId: string;
182
+ /** The manifest sub-comp host clip id this descendant ultimately lives under. */
183
+ hostId: string;
184
+ label: string;
168
185
  }
169
186
 
170
187
  interface BeatHistoryEntry {
@@ -296,6 +313,8 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
296
313
  setClipManifest: (clips) => set({ clipManifest: clips }),
297
314
  clipParentMap: new Map(),
298
315
  setClipParentMap: (map) => set({ clipParentMap: map }),
316
+ domClipChildren: [],
317
+ setDomClipChildren: (children) => set({ domClipChildren: children }),
299
318
 
300
319
  setIsPlaying: (playing) => {
301
320
  if (get().isPlaying === playing) return;
@@ -380,6 +399,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
380
399
  beatPersist: null,
381
400
  clipManifest: null,
382
401
  clipParentMap: new Map(),
402
+ domClipChildren: [],
383
403
  }),
384
404
  }));
385
405
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { openComposition } from "@hyperframes/sdk";
3
- import { patchElementInHtml } from "../../../core/src/studio-api/helpers/sourceMutation.js";
3
+ import { patchElementInHtml } from "../../../studio-server/src/helpers/sourceMutation.js";
4
4
  import type { PatchOperation } from "./sourcePatcher";
5
5
  import { patchOpsToSdkEditOps } from "./sdkOpMapping";
6
6
 
@@ -81,6 +81,37 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void {
81
81
  }
82
82
  }
83
83
 
84
+ // Animated group members can move outside their wrapper's static layout box, so
85
+ // the empty space inside a group's *visual* bounds (the member-union the overlay
86
+ // draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the
87
+ // point falls within a group's live member-union rect, return that wrapper.
88
+ // Innermost (smallest-area) group wins for nested groups.
89
+ function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null {
90
+ let best: HTMLElement | null = null;
91
+ let bestArea = Infinity;
92
+ for (const group of Array.from(doc.querySelectorAll<HTMLElement>("[data-hf-group]"))) {
93
+ let left = Infinity;
94
+ let top = Infinity;
95
+ let right = -Infinity;
96
+ let bottom = -Infinity;
97
+ for (const member of Array.from(group.children)) {
98
+ const r = member.getBoundingClientRect();
99
+ if (r.width === 0 && r.height === 0) continue;
100
+ left = Math.min(left, r.left);
101
+ top = Math.min(top, r.top);
102
+ right = Math.max(right, r.right);
103
+ bottom = Math.max(bottom, r.bottom);
104
+ }
105
+ if (right < left || x < left || x > right || y < top || y > bottom) continue;
106
+ const area = (right - left) * (bottom - top);
107
+ if (area < bestArea) {
108
+ bestArea = area;
109
+ best = group;
110
+ }
111
+ }
112
+ return best;
113
+ }
114
+
84
115
  // fallow-ignore-next-line complexity
85
116
  export function getPreviewTargetFromPointer(
86
117
  iframe: HTMLIFrameElement,
@@ -113,6 +144,12 @@ export function getPreviewTargetFromPointer(
113
144
  if (visualTarget) return visualTarget;
114
145
  }
115
146
 
147
+ // No element hit (e.g. empty space inside an animated group's overlay) — fall
148
+ // back to the group whose member-union contains the point, so the whole group
149
+ // area is hoverable/selectable, not just where a member currently sits.
150
+ const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y);
151
+ if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit;
152
+
116
153
  const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y));
117
154
  if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null;
118
155
  if (!isElementComputedVisible(fallback)) return null;