@hyperframes/studio 0.8.48 → 0.8.50

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 (124) hide show
  1. package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
  2. package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
  3. package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
  4. package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
  5. package/dist/assets/index-pO6p0H27.css +1 -0
  6. package/dist/index.d.ts +16 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6209 -5924
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +10 -8
  12. package/src/components/EditorShell.selectionSync.test.tsx +4 -0
  13. package/src/components/EditorShell.tsx +24 -0
  14. package/src/components/StudioOverlays.tsx +1 -5
  15. package/src/components/editor/PreviewGuides.test.tsx +88 -0
  16. package/src/components/editor/PreviewGuides.tsx +125 -0
  17. package/src/components/editor/SnapToolbar.test.tsx +21 -0
  18. package/src/components/editor/SnapToolbar.tsx +25 -1
  19. package/src/components/editor/domEditingAgentPrompt.ts +5 -0
  20. package/src/components/editor/previewGuidesStore.ts +26 -0
  21. package/src/components/nle/NLEPreview.test.ts +13 -0
  22. package/src/components/nle/NLEPreview.tsx +9 -4
  23. package/src/components/nle/PreviewPane.test.tsx +52 -0
  24. package/src/components/nle/PreviewPane.tsx +2 -0
  25. package/src/components/nle/TimelinePane.tsx +14 -0
  26. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  27. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
  28. package/src/hooks/appHotkeysDispatch.ts +11 -0
  29. package/src/hooks/timelineAudioGroupCreate.ts +2 -2
  30. package/src/hooks/timelineTrackVisibility.ts +8 -11
  31. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  32. package/src/hooks/useAppHotkeys.ts +3 -0
  33. package/src/hooks/useAskAgentModal.ts +2 -0
  34. package/src/hooks/useBlockHandlers.ts +7 -2
  35. package/src/hooks/useClipboard.test.ts +234 -0
  36. package/src/hooks/useClipboard.ts +264 -70
  37. package/src/hooks/useGroupCommits.test.ts +0 -1
  38. package/src/hooks/useRazorSplit.history.test.tsx +0 -1
  39. package/src/hooks/useRazorSplit.test.ts +0 -2
  40. package/src/hooks/useRazorSplit.test.tsx +0 -1
  41. package/src/hooks/useStudioContextValue.ts +5 -19
  42. package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
  43. package/src/hooks/useTimelineAssetDropOps.ts +4 -2
  44. package/src/hooks/useTimelineEditing.test.tsx +0 -1
  45. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
  46. package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
  47. package/src/player/components/ClipContextMenu.tsx +87 -7
  48. package/src/player/components/Timeline.tsx +14 -14
  49. package/src/player/components/TimelineCanvas.tsx +30 -0
  50. package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
  51. package/src/player/components/TimelineGestureOverlay.tsx +1 -1
  52. package/src/player/components/TimelineLanes.test.tsx +22 -1
  53. package/src/player/components/TimelineOverlays.tsx +12 -0
  54. package/src/player/components/TimelineTypes.ts +4 -0
  55. package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
  56. package/src/player/components/timelineClipChildren.test.ts +2 -2
  57. package/src/player/components/timelineClipChildren.tsx +2 -1
  58. package/src/player/components/timelineClipDragCommit.test.ts +114 -0
  59. package/src/player/components/timelineClipDragCommit.ts +20 -51
  60. package/src/player/components/timelineClipDragPreview.test.ts +173 -1
  61. package/src/player/components/timelineClipDragPreview.ts +8 -1
  62. package/src/player/components/timelineCollision.test.ts +23 -0
  63. package/src/player/components/timelineCollision.ts +27 -0
  64. package/src/player/components/timelineDragDrop.test.tsx +31 -0
  65. package/src/player/components/timelineDragDrop.ts +30 -23
  66. package/src/player/components/timelineDragLanding.ts +108 -0
  67. package/src/player/components/timelineDropReveal.test.ts +34 -0
  68. package/src/player/components/timelineDropReveal.ts +12 -0
  69. package/src/player/components/timelineEditCapabilities.ts +1 -2
  70. package/src/player/components/timelineEditing.test.ts +0 -16
  71. package/src/player/components/timelineGroupEditing.test.ts +1 -3
  72. package/src/player/components/timelineGroupEditing.ts +0 -1
  73. package/src/player/components/timelineTrackDisplay.ts +1 -1
  74. package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
  75. package/src/player/components/useTimelineClipContextMenu.ts +32 -0
  76. package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
  77. package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
  78. package/src/player/components/useTimelineGeometry.test.tsx +117 -0
  79. package/src/player/components/useTimelineGeometry.ts +7 -2
  80. package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
  81. package/src/player/components/useTimelineLogicalFocus.ts +1 -0
  82. package/src/player/components/useTimelineTrackDerivations.ts +1 -1
  83. package/src/player/hooks/timelineDomBinding.test.ts +0 -3
  84. package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
  85. package/src/player/hooks/timelineSyncHydration.ts +37 -18
  86. package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
  87. package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
  88. package/src/player/hooks/useTimelineRowElements.ts +17 -0
  89. package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
  90. package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
  91. package/src/player/lib/describeClips.test.ts +95 -0
  92. package/src/player/lib/describeClips.ts +103 -0
  93. package/src/player/lib/timelineDOM.test.ts +0 -80
  94. package/src/player/lib/timelineDOM.ts +1 -61
  95. package/src/player/lib/timelineElementHelpers.ts +10 -53
  96. package/src/player/store/playerStore.ts +5 -0
  97. package/src/player/store/readinessSlice.ts +4 -3
  98. package/src/player/store/timelineElement.ts +1 -3
  99. package/src/shims/canvasBrowserStub.js +50 -0
  100. package/src/utils/blockInstaller.test.ts +4 -0
  101. package/src/utils/blockInstaller.ts +9 -3
  102. package/src/utils/clipboardPayload.test.ts +42 -2
  103. package/src/utils/clipboardPayload.ts +57 -32
  104. package/src/utils/previewSafeMargins.test.ts +34 -0
  105. package/src/utils/previewSafeMargins.ts +10 -0
  106. package/src/utils/razorSplitTransaction.test.ts +0 -1
  107. package/src/utils/retryBudget.ts +20 -0
  108. package/src/utils/sdkCanvasComposition.test.ts +77 -0
  109. package/src/utils/sdkCutover.familyGate.test.ts +5 -1
  110. package/src/utils/sdkCutover.ts +7 -4
  111. package/src/utils/sdkCutoverEligibility.test.ts +96 -1
  112. package/src/utils/sdkCutoverEligibility.ts +34 -11
  113. package/src/utils/studioUiPreferences.test.ts +43 -0
  114. package/src/utils/studioUiPreferences.ts +27 -6
  115. package/src/utils/timelineCompositionInsert.ts +2 -1
  116. package/src/utils/timelineElementSplit.test.ts +1 -2
  117. package/src/utils/timelineElementSplit.ts +0 -1
  118. package/src/webmcp/tools/lookTools.test.ts +40 -0
  119. package/src/webmcp/tools/lookTools.ts +32 -1
  120. package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
  121. package/dist/assets/index-B9LfWNyM.css +0 -1
  122. package/src/components/StudioGlobalDragOverlay.tsx +0 -24
  123. package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
  124. package/src/player/hooks/useExpandedTimelineElements.ts +0 -460
@@ -11,6 +11,7 @@ import {
11
11
  type TimelineLogicalTarget,
12
12
  } from "./timelineKeyboardNavigation";
13
13
  import { computeRevealScroll } from "./timelineRevealScroll";
14
+ import { recordRetryAttempt, type RetryBudgetState } from "../../utils/retryBudget";
14
15
 
15
16
  interface TimelineFocusCoordinatorInput {
16
17
  scrollRef: RefObject<HTMLDivElement | null>;
@@ -24,6 +25,7 @@ interface TimelineFocusCoordinatorInput {
24
25
  projectId: string | null;
25
26
  sessionEpoch: number;
26
27
  syncScrollViewport: (element: HTMLDivElement) => void;
28
+ lastScrollLeftRef: RefObject<number>;
27
29
  }
28
30
 
29
31
  export interface TimelineFocusCoordinatorState {
@@ -77,6 +79,7 @@ function scrollToTarget(
77
79
  pixelsPerSecond: number,
78
80
  contentOrigin: number,
79
81
  allowHorizontal: boolean,
82
+ lastScrollLeftRef: RefObject<number>,
80
83
  ): boolean {
81
84
  const rowIndex = rowGeometry.getRowIndex(resolution.row.physicalTrackKey);
82
85
  if (rowIndex < 0) return false;
@@ -103,11 +106,26 @@ function scrollToTarget(
103
106
  stickyTop: RULER_H,
104
107
  allowHorizontal: allowHorizontal && left !== null,
105
108
  });
106
- if (target.left !== null) container.scrollLeft = target.left;
109
+ if (target.left !== null) {
110
+ container.scrollLeft = target.left;
111
+ // Keep the geometry effect's "restore scroll after an edit re-derives the
112
+ // elements" in sync, or it reads the pre-reveal position on the next tick
113
+ // and scrolls the freshly-revealed clip back out of view.
114
+ lastScrollLeftRef.current = target.left;
115
+ }
107
116
  if (target.top !== null) container.scrollTop = target.top;
108
117
  return target.left !== null || target.top !== null;
109
118
  }
110
119
 
120
+ // A request racing a fresh element's own creation (drop/paste) resolves within
121
+ // a render or two; past this many misses on the same nonce it truly never will.
122
+ const MAX_UNRESOLVED_FOCUS_RETRIES = 5;
123
+
124
+ // Backstop for a request whose target never appears and nothing else ever
125
+ // re-renders: the retry budget above can't advance without a rerun, so this
126
+ // bounds it by wall time instead, independent of re-renders.
127
+ const UNRESOLVED_FOCUS_TIMEOUT_MS = 4000;
128
+
111
129
  /** Model-first focus actor; mounting is a consequence of its returned pins. */
112
130
  // Resolution, fallback, reveal, and focus form one ordered state machine.
113
131
  // fallow-ignore-next-line complexity
@@ -123,11 +141,13 @@ export function useTimelineFocusCoordinator({
123
141
  projectId,
124
142
  sessionEpoch,
125
143
  syncScrollViewport,
144
+ lastScrollLeftRef,
126
145
  }: TimelineFocusCoordinatorInput): TimelineFocusCoordinatorState {
127
146
  const request = usePlayerStore((state) => state.timelineFocus);
128
147
  const previousRowsRef = useRef(logicalRows);
129
148
  const resolvedRef = useRef<{ nonce: number; id: string } | null>(null);
130
149
  const appliedRef = useRef<{ nonce: number; id: string } | null>(null);
150
+ const unresolvedFocusAttemptsRef = useRef<RetryBudgetState<number>>({ id: -1, count: 0 });
131
151
  const resolution = useMemo<ResolvedFocus | null>(() => {
132
152
  if (isCurrentRequest(request, projectId, sessionEpoch)) {
133
153
  if (resolvedRef.current?.nonce !== request.nonce) {
@@ -163,8 +183,26 @@ export function useTimelineFocusCoordinator({
163
183
  useEffect(() => {
164
184
  if (!isCurrentRequest(request, projectId, sessionEpoch)) return;
165
185
  if (!resolution) {
166
- usePlayerStore.getState().clearTimelineFocus(request.nonce);
167
- return;
186
+ // A request issued the same tick as its element (a fresh drop/paste) can
187
+ // outrun logicalRows by a render or two; only give up once it clearly
188
+ // never resolves, so the reveal isn't lost to that ordinary race.
189
+ const withinBudget = recordRetryAttempt(
190
+ unresolvedFocusAttemptsRef,
191
+ request.nonce,
192
+ MAX_UNRESOLVED_FOCUS_RETRIES,
193
+ );
194
+ if (!withinBudget) {
195
+ usePlayerStore.getState().clearTimelineFocus(request.nonce);
196
+ return;
197
+ }
198
+ const nonce = request.nonce;
199
+ const timeout = setTimeout(() => {
200
+ usePlayerStore.getState().clearTimelineFocus(nonce);
201
+ }, UNRESOLVED_FOCUS_TIMEOUT_MS);
202
+ return () => clearTimeout(timeout);
203
+ }
204
+ if (unresolvedFocusAttemptsRef.current.count !== 0) {
205
+ unresolvedFocusAttemptsRef.current = { id: -1, count: 0 };
168
206
  }
169
207
  if (resolution.target.id !== request.id) {
170
208
  usePlayerStore.getState().requestTimelineFocus(resolution.target.id);
@@ -187,6 +225,7 @@ export function useTimelineFocusCoordinator({
187
225
  pixelsPerSecond,
188
226
  contentOrigin,
189
227
  allowHorizontal,
228
+ lastScrollLeftRef,
190
229
  )
191
230
  ) {
192
231
  syncScrollViewport(container);
@@ -197,6 +236,7 @@ export function useTimelineFocusCoordinator({
197
236
  allowHorizontal,
198
237
  contentOrigin,
199
238
  elements,
239
+ lastScrollLeftRef,
200
240
  pixelsPerSecond,
201
241
  projectId,
202
242
  request,
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { useTimelineGeometry } from "./useTimelineGeometry";
8
+
9
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
+
11
+ const element: TimelineElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
12
+
13
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
14
+
15
+ function Harness({
16
+ expandedElements,
17
+ lastScrollLeftRef,
18
+ scrollWidth = 20_000,
19
+ }: {
20
+ expandedElements: TimelineElement[];
21
+ lastScrollLeftRef: React.RefObject<number>;
22
+ scrollWidth?: number;
23
+ }) {
24
+ const scrollRef = useRef<HTMLDivElement>(null);
25
+ const isDragging = useRef(false);
26
+ const ppsRef = useRef(0);
27
+ const fitPpsRef = useRef(0);
28
+ useTimelineGeometry({
29
+ viewportWidth: 1600,
30
+ effectiveDuration: 60,
31
+ zoomMode: "manual",
32
+ manualZoomPercent: 100,
33
+ ppsRef,
34
+ fitPpsRef,
35
+ draggedClip: null,
36
+ resizingClip: null,
37
+ expandedElements,
38
+ isDragging,
39
+ scrollRef,
40
+ lastScrollLeftRef,
41
+ contentOrigin: 32,
42
+ });
43
+ return (
44
+ <div
45
+ ref={(node) => {
46
+ scrollRef.current = node;
47
+ if (node) {
48
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 1600 });
49
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: scrollWidth });
50
+ }
51
+ }}
52
+ />
53
+ );
54
+ }
55
+
56
+ let host: HTMLDivElement;
57
+ let root: Root;
58
+ beforeEach(() => {
59
+ usePlayerStore.setState({ timelineProjectId: "project-a" });
60
+ host = document.createElement("div");
61
+ document.body.append(host);
62
+ root = createRoot(host);
63
+ });
64
+ afterEach(() => {
65
+ act(() => root.unmount());
66
+ usePlayerStore.getState().reset();
67
+ document.body.replaceChildren();
68
+ });
69
+
70
+ describe("useTimelineGeometry restore-scroll effect", () => {
71
+ it("reads lastScrollLeftRef at frame time, not effect time, so a same-commit reveal isn't undone", async () => {
72
+ // A sibling effect in the same commit (a fresh timeline-focus reveal) can
73
+ // update lastScrollLeftRef and the DOM scrollLeft together after this
74
+ // effect has already scheduled its frame. The frame must see that update,
75
+ // not the value that was current when it was scheduled.
76
+ const lastScrollLeftRef = { current: 132 };
77
+ act(() => {
78
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
79
+ });
80
+ const scroll = host.firstElementChild as HTMLDivElement;
81
+ scroll.scrollLeft = 132;
82
+
83
+ // Flush this effect (it schedules a frame, capturing whatever it reads
84
+ // right now) BEFORE the sibling mutates the ref, exactly as in a real
85
+ // commit where this effect registers and runs ahead of the reveal's.
86
+ act(() => {
87
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
88
+ });
89
+ // Simulate the sibling reveal effect: it runs after this one in the same
90
+ // commit and updates both the DOM and the ref before the frame fires.
91
+ lastScrollLeftRef.current = 450;
92
+ scroll.scrollLeft = 450;
93
+ await act(async () => {
94
+ await nextFrame();
95
+ });
96
+
97
+ expect(scroll.scrollLeft).toBe(450);
98
+ });
99
+
100
+ it("still restores the pre-edit position when nothing else changed the ref mid-frame", async () => {
101
+ const lastScrollLeftRef = { current: 300 };
102
+ act(() => {
103
+ root.render(<Harness expandedElements={[]} lastScrollLeftRef={lastScrollLeftRef} />);
104
+ });
105
+ const scroll = host.firstElementChild as HTMLDivElement;
106
+ scroll.scrollLeft = 0;
107
+
108
+ act(() => {
109
+ root.render(<Harness expandedElements={[element]} lastScrollLeftRef={lastScrollLeftRef} />);
110
+ });
111
+ await act(async () => {
112
+ await nextFrame();
113
+ });
114
+
115
+ expect(scroll.scrollLeft).toBe(300);
116
+ });
117
+ });
@@ -93,9 +93,14 @@ export function useTimelineGeometry({
93
93
  useEffect(() => {
94
94
  if (zoomMode !== "manual" || isDragging.current) return;
95
95
  const el = scrollRef.current;
96
- const target = lastScrollLeftRef.current;
97
- if (!el || target <= 0) return;
96
+ if (!el) return;
97
+ // Read the ref inside the frame, not here: a sibling effect in the same
98
+ // commit (a fresh reveal) can still update lastScrollLeftRef before this
99
+ // frame runs, and a value captured now would fight that reveal with a
100
+ // stale target.
98
101
  const raf = requestAnimationFrame(() => {
102
+ const target = lastScrollLeftRef.current;
103
+ if (target <= 0) return;
99
104
  const max = Math.max(0, el.scrollWidth - el.clientWidth);
100
105
  const next = Math.min(target, max);
101
106
  if (Math.abs(el.scrollLeft - next) > 0.5) el.scrollLeft = next;
@@ -0,0 +1,184 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useMemo, useRef } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { createTimelineRowGeometry } from "./timelineLayout";
8
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
9
+ import { timelineClipFocusId, timelineTrackRowId } from "./timelineNavigationIdentity";
10
+ import { useTimelineFocusCoordinator } from "./useTimelineFocusCoordinator";
11
+ import { useTimelineGeometry } from "./useTimelineGeometry";
12
+
13
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
14
+
15
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve));
16
+
17
+ const oldElement = { id: "old", tag: "div", start: 0, duration: 1, track: 1 };
18
+ const heroElement = { id: "hero", tag: "div", start: 20, duration: 2, track: 1 };
19
+ const clipId = timelineClipFocusId("hero");
20
+ const rowId = timelineTrackRowId(1);
21
+ const beforeRows: readonly TimelineLogicalRow[] = [
22
+ {
23
+ id: rowId,
24
+ kind: "row",
25
+ physicalTrackKey: 1,
26
+ logicalIndex: 0,
27
+ level: 1,
28
+ parentId: null,
29
+ elementId: null,
30
+ expandable: false,
31
+ expanded: false,
32
+ items: [],
33
+ },
34
+ ];
35
+ const afterRows: readonly TimelineLogicalRow[] = [
36
+ {
37
+ id: rowId,
38
+ kind: "row",
39
+ physicalTrackKey: 1,
40
+ logicalIndex: 0,
41
+ level: 1,
42
+ parentId: null,
43
+ elementId: null,
44
+ expandable: false,
45
+ expanded: false,
46
+ items: [{ id: clipId, kind: "clip", rowId, elementId: "hero", time: 21 }],
47
+ },
48
+ ];
49
+
50
+ // Renders the two real production hooks side by side, in a caller-chosen call
51
+ // order, to test whether their interaction actually depends on that order —
52
+ // as opposed to simulating one hook's effect by hand.
53
+ function OrderHarness({
54
+ order,
55
+ expandedElements,
56
+ logicalRows,
57
+ lastScrollLeftRef,
58
+ }: {
59
+ order: "geometry-first" | "coordinator-first";
60
+ expandedElements: (typeof oldElement)[];
61
+ logicalRows: readonly TimelineLogicalRow[];
62
+ lastScrollLeftRef: React.RefObject<number>;
63
+ }) {
64
+ const scrollRef = useRef<HTMLDivElement>(null);
65
+ const isDragging = useRef(false);
66
+ const ppsRef = useRef(0);
67
+ const fitPpsRef = useRef(0);
68
+ const rowGeometry = useMemo(() => createTimelineRowGeometry([1], [48]), []);
69
+
70
+ const runGeometry = () =>
71
+ useTimelineGeometry({
72
+ viewportWidth: 300,
73
+ effectiveDuration: 60,
74
+ zoomMode: "manual",
75
+ manualZoomPercent: 100,
76
+ ppsRef,
77
+ fitPpsRef,
78
+ draggedClip: null,
79
+ resizingClip: null,
80
+ expandedElements,
81
+ isDragging,
82
+ scrollRef,
83
+ lastScrollLeftRef,
84
+ contentOrigin: 32,
85
+ });
86
+ const runFocus = () =>
87
+ useTimelineFocusCoordinator({
88
+ scrollRef,
89
+ logicalRows,
90
+ elements: expandedElements,
91
+ rowGeometry,
92
+ pixelsPerSecond: 100,
93
+ contentOrigin: 32,
94
+ allowHorizontal: true,
95
+ viewportVersion: logicalRows.length,
96
+ projectId: "project-a",
97
+ sessionEpoch: 1,
98
+ syncScrollViewport: () => {},
99
+ lastScrollLeftRef,
100
+ });
101
+
102
+ // `order` is fixed for the lifetime of one mounted harness, so this call
103
+ // order is stable across that instance's renders.
104
+ if (order === "geometry-first") {
105
+ runGeometry();
106
+ runFocus();
107
+ } else {
108
+ runFocus();
109
+ runGeometry();
110
+ }
111
+
112
+ return (
113
+ <div
114
+ ref={(node) => {
115
+ scrollRef.current = node;
116
+ if (node) {
117
+ Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
118
+ Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
119
+ Object.defineProperty(node, "scrollWidth", { configurable: true, value: 20_000 });
120
+ }
121
+ }}
122
+ />
123
+ );
124
+ }
125
+
126
+ let host: HTMLDivElement;
127
+ let root: Root;
128
+ beforeEach(() => {
129
+ usePlayerStore.setState({ timelineProjectId: "project-a", timelineSessionEpoch: 1 });
130
+ host = document.createElement("div");
131
+ document.body.append(host);
132
+ root = createRoot(host);
133
+ });
134
+ afterEach(() => {
135
+ act(() => root.unmount());
136
+ usePlayerStore.getState().reset();
137
+ document.body.replaceChildren();
138
+ });
139
+
140
+ describe.each(["geometry-first", "coordinator-first"] as const)(
141
+ "useTimelineGeometry + useTimelineFocusCoordinator, %s",
142
+ (order) => {
143
+ it("a same-commit reveal survives the geometry restore effect regardless of hook registration order", async () => {
144
+ const lastScrollLeftRef = { current: 132 };
145
+ await act(async () => {
146
+ root.render(
147
+ <OrderHarness
148
+ order={order}
149
+ expandedElements={[oldElement]}
150
+ logicalRows={beforeRows}
151
+ lastScrollLeftRef={lastScrollLeftRef}
152
+ />,
153
+ );
154
+ });
155
+ const scroll = host.firstElementChild as HTMLDivElement;
156
+ scroll.scrollLeft = 132;
157
+ await act(async () => {
158
+ await nextFrame();
159
+ });
160
+ expect(scroll.scrollLeft).toBe(132);
161
+
162
+ // One commit: the drop both adds the element (expandedElements changes,
163
+ // arming the geometry restore effect) and resolves the focus request
164
+ // (arming the coordinator's reveal), exactly as Timeline.tsx renders them.
165
+ usePlayerStore.getState().requestTimelineFocus(clipId);
166
+ await act(async () => {
167
+ root.render(
168
+ <OrderHarness
169
+ order={order}
170
+ expandedElements={[oldElement, heroElement]}
171
+ logicalRows={afterRows}
172
+ lastScrollLeftRef={lastScrollLeftRef}
173
+ />,
174
+ );
175
+ });
176
+ await act(async () => {
177
+ await nextFrame();
178
+ });
179
+
180
+ expect(scroll.scrollLeft).toBe(1_944);
181
+ expect(lastScrollLeftRef.current).toBe(1_944);
182
+ });
183
+ },
184
+ );
@@ -63,6 +63,7 @@ export function useTimelineLogicalFocus(input: TimelineLogicalFocusInput) {
63
63
  projectId,
64
64
  sessionEpoch: input.sessionEpoch,
65
65
  syncScrollViewport: input.syncScrollViewport,
66
+ lastScrollLeftRef: input.lastScrollLeftRef,
66
67
  });
67
68
  const rows = useTimelineRowVirtualization({
68
69
  scrollRef: input.scrollRef,
@@ -98,7 +98,7 @@ function buildGroupInfo(
98
98
  id: groupId,
99
99
  label: membership.labelByGroup.get(groupId) ?? groupId,
100
100
  // Exactly x.5, which sub-composition child rows are now kept strictly below
101
- // (`useExpandedTimelineElements`) — they used to be able to land here and
101
+ // (`useTimelineRowElements`) — they used to be able to land here and
102
102
  // collide, duplicating the group header.
103
103
  anchorKey: (memberTracks[0] ?? fallbackTrackNum) - 0.5,
104
104
  memberTracks,
@@ -73,9 +73,6 @@ describe("live DOM manifest hydration", () => {
73
73
  canTrimEnd: true,
74
74
  });
75
75
  expect(getTimelineEditCapabilities({ ...input, timelineLocked: true }).canMove).toBe(false);
76
- expect(getTimelineEditCapabilities({ ...input, timingSource: "implicit" }).canTrimEnd).toBe(
77
- false,
78
- );
79
76
  });
80
77
  it("uses at most one fallback candidate snapshot per hydration pass", () => {
81
78
  const doc = documentWith(
@@ -3,6 +3,7 @@
3
3
  import { describe, expect, it } from "vitest";
4
4
  import {
5
5
  collectSubCompositionDomChildren,
6
+ collectTopLevelElementIds,
6
7
  collectSubCompositionHostState,
7
8
  } from "./timelineSyncHydration";
8
9
  import type { ClipManifestClip } from "../lib/playbackTypes";
@@ -94,3 +95,20 @@ describe("collectSubCompositionHostState", () => {
94
95
  expect(state.size).toBe(0);
95
96
  });
96
97
  });
98
+
99
+ describe("collectTopLevelElementIds", () => {
100
+ const docOf = (html: string) => new DOMParser().parseFromString(html, "text/html");
101
+
102
+ it("names the sub-composition host and a timed element, not what is nested inside them", () => {
103
+ const doc = docOf(`<div data-composition-id="root">
104
+ <div id="wrap"><div id="a" data-start="0" data-duration="2"><div id="inner" data-start="0"></div></div></div>
105
+ <div id="scene" data-composition-id="scene" data-composition-src="scene.html"><div id="box" data-start="0"></div></div>
106
+ </div>`);
107
+ expect([...collectTopLevelElementIds(doc)!].sort()).toEqual(["a", "scene"]);
108
+ });
109
+
110
+ it("is null when the document has no composition root", () => {
111
+ expect(collectTopLevelElementIds(docOf("<p>hi</p>"))).toBeNull();
112
+ expect(collectTopLevelElementIds(null)).toBeNull();
113
+ });
114
+ });
@@ -14,12 +14,12 @@ import { usePlayerStore } from "../store/playerStore";
14
14
  import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
15
15
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
16
16
  import type { ClipTree } from "@hyperframes/core/runtime/clipTree";
17
+ import { topLevelElements, type StructureNode } from "@hyperframes/parsers/top-level-elements";
17
18
  import { HF_AUDIO_GROUP_ATTR } from "@hyperframes/core/audio-groups";
18
19
  import { groupInfoFor } from "../lib/timelineGroupInfo";
19
20
  import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
20
21
  import {
21
22
  buildStandaloneRootTimelineElement,
22
- createImplicitTimelineLayersFromDOM,
23
23
  createTimelineElementFromManifestClip,
24
24
  getTimelineElementSelector,
25
25
  parseTimelineFromDOM,
@@ -137,6 +137,28 @@ export function collectSubCompositionDomChildren(
137
137
  return out;
138
138
  }
139
139
 
140
+ interface DomStructureNode extends StructureNode<DomStructureNode> {
141
+ id: string;
142
+ }
143
+
144
+ function toStructureNode(el: Element): DomStructureNode {
145
+ const attrs: Record<string, string> = {};
146
+ for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
147
+ return {
148
+ tag: el.tagName,
149
+ attrs,
150
+ id: el.id,
151
+ children: Array.from(el.children).map(toStructureNode),
152
+ };
153
+ }
154
+
155
+ /** DOM ids of the timeline's rows, by the definition the structure lint shares; null when there is no readable root. */
156
+ export function collectTopLevelElementIds(doc: Document | null): Set<string> | null {
157
+ const root = doc?.querySelector("[data-composition-id]");
158
+ if (!root) return null;
159
+ return new Set(topLevelElements(toStructureNode(root)).flatMap((n) => (n.id ? [n.id] : [])));
160
+ }
161
+
140
162
  /** The host-element `data-*` state one element carries, or null when it has none. */
141
163
  function readSubCompositionHostState(el: Element): SubCompositionHostState | null {
142
164
  const state: SubCompositionHostState = {};
@@ -214,28 +236,25 @@ export function buildTimelineElementsFromClips(
214
236
  });
215
237
  }
216
238
 
217
- /**
218
- * The clamped manifest elements plus the layers that exist only in the DOM.
219
- * Both halves need the same resolved duration, which is why they land together.
220
- */
221
- export function withImplicitDomLayers(
222
- els: readonly TimelineElement[],
223
- iframeDoc: Document | null,
224
- effectiveDuration: number,
225
- ): TimelineElement[] {
226
- const clamped = clampElementsToDuration(els, effectiveDuration);
227
- if (!iframeDoc || effectiveDuration <= 0) return clamped;
228
- return [
229
- ...clamped,
230
- ...createImplicitTimelineLayersFromDOM(iframeDoc, effectiveDuration, clamped),
231
- ];
232
- }
233
-
234
239
  /**
235
240
  * Drop elements that start past the composition's end and trim the ones that
236
241
  * straddle it. A non-positive duration means "not known yet" — pass through
237
242
  * untouched rather than clamping everything to nothing.
238
243
  */
244
+ /** Commits the manifest elements, including none. An empty manifest carries a 1s floor, not a duration. */
245
+ export function syncManifestTimeline(
246
+ els: readonly TimelineElement[],
247
+ manifestDuration: number,
248
+ storeDuration: number,
249
+ sync: (els: TimelineElement[], duration?: number) => void,
250
+ ): void {
251
+ const hasDuration = manifestDuration > 0 && els.length > 0;
252
+ sync(
253
+ clampElementsToDuration(els, hasDuration ? manifestDuration : storeDuration),
254
+ hasDuration ? manifestDuration : undefined,
255
+ );
256
+ }
257
+
239
258
  function clampElementsToDuration(
240
259
  els: readonly TimelineElement[],
241
260
  effectiveDuration: number,
@@ -330,34 +330,17 @@ describe("findTimelineDomNodeForClip", () => {
330
330
  });
331
331
 
332
332
  describe("anonymous timeline identity", () => {
333
- it("adds root-level untimed DOM layers as implicit full-duration layers", () => {
333
+ it("does not invent rows for root-level untimed wrappers", () => {
334
334
  const doc = createDocument(`
335
335
  <div data-composition-id="compare" data-start="0" data-duration="18">
336
- <link rel="stylesheet" href="styles.css" />
337
- <div class="scene-shell">
338
- <div class="topline">Title</div>
339
- </div>
336
+ <div class="scene-shell"><div class="topline">Title</div></div>
340
337
  <video id="main-video" class="clip main-video" data-start="0" data-duration="18" data-track-index="1"></video>
341
- <script></script>
342
338
  </div>
343
339
  `);
344
340
 
345
341
  const elements = parseTimelineFromDOM(doc, 18);
346
342
 
347
- expect(elements).toEqual(
348
- expect.arrayContaining([
349
- expect.objectContaining({
350
- duration: 18,
351
- label: "Scene Shell",
352
- selector: ".scene-shell",
353
- start: 0,
354
- tag: "div",
355
- timingSource: "implicit",
356
- }),
357
- ]),
358
- );
359
- expect(elements.find((element) => element.tag === "link")).toBeUndefined();
360
- expect(elements.find((element) => element.tag === "script")).toBeUndefined();
343
+ expect(elements.map((element) => element.tag)).toEqual(["video"]);
361
344
  });
362
345
 
363
346
  it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import { selectTimelineRowElements } from "./useTimelineRowElements";
4
+
5
+ const el = (id: string, domId?: string): TimelineElement => ({
6
+ id,
7
+ domId,
8
+ start: 0,
9
+ duration: 1,
10
+ track: 0,
11
+ tag: "div",
12
+ });
13
+
14
+ describe("selectTimelineRowElements", () => {
15
+ it("drops an element the shared definition does not call top-level", () => {
16
+ const rows = selectTimelineRowElements([el("a", "a"), el("b", "b")], new Set(["a"]));
17
+ expect(rows.map((r) => r.id)).toEqual(["a"]);
18
+ });
19
+
20
+ it("keeps everything until the document has been read", () => {
21
+ const all = [el("a", "a"), el("b", "b")];
22
+ expect(selectTimelineRowElements(all, null)).toBe(all);
23
+ });
24
+
25
+ it("keeps an element with no DOM id", () => {
26
+ expect(selectTimelineRowElements([el("k")], new Set())).toHaveLength(1);
27
+ });
28
+ });
@@ -0,0 +1,17 @@
1
+ import { useMemo } from "react";
2
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
+
4
+ /** Rows are the ids `topLevelElements` names, as the lint does; id-less ones are kept. */
5
+ export function selectTimelineRowElements(
6
+ elements: TimelineElement[],
7
+ topLevelIds: ReadonlySet<string> | null,
8
+ ): TimelineElement[] {
9
+ if (!topLevelIds) return elements;
10
+ return elements.filter((el) => el.domId === undefined || topLevelIds.has(el.domId));
11
+ }
12
+
13
+ export function useTimelineRowElements(): TimelineElement[] {
14
+ const elements = usePlayerStore((s) => s.elements);
15
+ const topLevelIds = usePlayerStore((s) => s.topLevelIds);
16
+ return useMemo(() => selectTimelineRowElements(elements, topLevelIds), [elements, topLevelIds]);
17
+ }