@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.
- package/dist/assets/hyperframes-player-CxxDxHTW.js +464 -0
- package/dist/assets/{index-D_j-0PD3.js → index-DUAIWxkI.js} +1 -1
- package/dist/assets/{index-B3-orIjW.js → index-Dt3qBI-9.js} +1 -1
- package/dist/assets/{index-C9gJKpqQ.js → index-n2ml4tUn.js} +214 -213
- package/dist/assets/index-pO6p0H27.css +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.html +2 -2
- package/dist/index.js +6209 -5924
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +10 -8
- package/src/components/EditorShell.selectionSync.test.tsx +4 -0
- package/src/components/EditorShell.tsx +24 -0
- package/src/components/StudioOverlays.tsx +1 -5
- package/src/components/editor/PreviewGuides.test.tsx +88 -0
- package/src/components/editor/PreviewGuides.tsx +125 -0
- package/src/components/editor/SnapToolbar.test.tsx +21 -0
- package/src/components/editor/SnapToolbar.tsx +25 -1
- package/src/components/editor/domEditingAgentPrompt.ts +5 -0
- package/src/components/editor/previewGuidesStore.ts +26 -0
- package/src/components/nle/NLEPreview.test.ts +13 -0
- package/src/components/nle/NLEPreview.tsx +9 -4
- package/src/components/nle/PreviewPane.test.tsx +52 -0
- package/src/components/nle/PreviewPane.tsx +2 -0
- package/src/components/nle/TimelinePane.tsx +14 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -3
- package/src/hooks/appHotkeysDispatch.ts +11 -0
- package/src/hooks/timelineAudioGroupCreate.ts +2 -2
- package/src/hooks/timelineTrackVisibility.ts +8 -11
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +3 -0
- package/src/hooks/useAskAgentModal.ts +2 -0
- package/src/hooks/useBlockHandlers.ts +7 -2
- package/src/hooks/useClipboard.test.ts +234 -0
- package/src/hooks/useClipboard.ts +264 -70
- package/src/hooks/useGroupCommits.test.ts +0 -1
- package/src/hooks/useRazorSplit.history.test.tsx +0 -1
- package/src/hooks/useRazorSplit.test.ts +0 -2
- package/src/hooks/useRazorSplit.test.tsx +0 -1
- package/src/hooks/useStudioContextValue.ts +5 -19
- package/src/hooks/useTimelineAssetDropOps.test.tsx +15 -0
- package/src/hooks/useTimelineAssetDropOps.ts +4 -2
- package/src/hooks/useTimelineEditing.test.tsx +0 -1
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +215 -3
- package/src/hooks/useTimelineSelectionPreviewSync.ts +63 -32
- package/src/player/components/ClipContextMenu.tsx +87 -7
- package/src/player/components/Timeline.tsx +14 -14
- package/src/player/components/TimelineCanvas.tsx +30 -0
- package/src/player/components/TimelineGestureOverlay.test.tsx +35 -1
- package/src/player/components/TimelineGestureOverlay.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +22 -1
- package/src/player/components/TimelineOverlays.tsx +12 -0
- package/src/player/components/TimelineTypes.ts +4 -0
- package/src/player/components/timelineAuthoredMoveTarget.ts +1 -2
- package/src/player/components/timelineClipChildren.test.ts +2 -2
- package/src/player/components/timelineClipChildren.tsx +2 -1
- package/src/player/components/timelineClipDragCommit.test.ts +114 -0
- package/src/player/components/timelineClipDragCommit.ts +20 -51
- package/src/player/components/timelineClipDragPreview.test.ts +173 -1
- package/src/player/components/timelineClipDragPreview.ts +8 -1
- package/src/player/components/timelineCollision.test.ts +23 -0
- package/src/player/components/timelineCollision.ts +27 -0
- package/src/player/components/timelineDragDrop.test.tsx +31 -0
- package/src/player/components/timelineDragDrop.ts +30 -23
- package/src/player/components/timelineDragLanding.ts +108 -0
- package/src/player/components/timelineDropReveal.test.ts +34 -0
- package/src/player/components/timelineDropReveal.ts +12 -0
- package/src/player/components/timelineEditCapabilities.ts +1 -2
- package/src/player/components/timelineEditing.test.ts +0 -16
- package/src/player/components/timelineGroupEditing.test.ts +1 -3
- package/src/player/components/timelineGroupEditing.ts +0 -1
- package/src/player/components/timelineTrackDisplay.ts +1 -1
- package/src/player/components/timelineTrackPersistPipeline.test.ts +0 -53
- package/src/player/components/useTimelineClipContextMenu.ts +32 -0
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +66 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +43 -3
- package/src/player/components/useTimelineGeometry.test.tsx +117 -0
- package/src/player/components/useTimelineGeometry.ts +7 -2
- package/src/player/components/useTimelineGeometryFocusOrder.test.tsx +184 -0
- package/src/player/components/useTimelineLogicalFocus.ts +1 -0
- package/src/player/components/useTimelineTrackDerivations.ts +1 -1
- package/src/player/hooks/timelineDomBinding.test.ts +0 -3
- package/src/player/hooks/timelineSyncHydration.test.ts +18 -0
- package/src/player/hooks/timelineSyncHydration.ts +37 -18
- package/src/player/hooks/useTimelinePlayer.test.ts +3 -20
- package/src/player/hooks/useTimelineRowElements.test.ts +28 -0
- package/src/player/hooks/useTimelineRowElements.ts +17 -0
- package/src/player/hooks/useTimelineSyncCallbacks.emptyManifest.test.tsx +41 -0
- package/src/player/hooks/useTimelineSyncCallbacks.ts +9 -10
- package/src/player/lib/describeClips.test.ts +95 -0
- package/src/player/lib/describeClips.ts +103 -0
- package/src/player/lib/timelineDOM.test.ts +0 -80
- package/src/player/lib/timelineDOM.ts +1 -61
- package/src/player/lib/timelineElementHelpers.ts +10 -53
- package/src/player/store/playerStore.ts +5 -0
- package/src/player/store/readinessSlice.ts +4 -3
- package/src/player/store/timelineElement.ts +1 -3
- package/src/shims/canvasBrowserStub.js +50 -0
- package/src/utils/blockInstaller.test.ts +4 -0
- package/src/utils/blockInstaller.ts +9 -3
- package/src/utils/clipboardPayload.test.ts +42 -2
- package/src/utils/clipboardPayload.ts +57 -32
- package/src/utils/previewSafeMargins.test.ts +34 -0
- package/src/utils/previewSafeMargins.ts +10 -0
- package/src/utils/razorSplitTransaction.test.ts +0 -1
- package/src/utils/retryBudget.ts +20 -0
- package/src/utils/sdkCanvasComposition.test.ts +77 -0
- package/src/utils/sdkCutover.familyGate.test.ts +5 -1
- package/src/utils/sdkCutover.ts +7 -4
- package/src/utils/sdkCutoverEligibility.test.ts +96 -1
- package/src/utils/sdkCutoverEligibility.ts +34 -11
- package/src/utils/studioUiPreferences.test.ts +43 -0
- package/src/utils/studioUiPreferences.ts +27 -6
- package/src/utils/timelineCompositionInsert.ts +2 -1
- package/src/utils/timelineElementSplit.test.ts +1 -2
- package/src/utils/timelineElementSplit.ts +0 -1
- package/src/webmcp/tools/lookTools.test.ts +40 -0
- package/src/webmcp/tools/lookTools.ts +32 -1
- package/dist/assets/hyperframes-player-CtVX6jL_.js +0 -464
- package/dist/assets/index-B9LfWNyM.css +0 -1
- package/src/components/StudioGlobalDragOverlay.tsx +0 -24
- package/src/player/hooks/useExpandedTimelineElements.test.ts +0 -774
- 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)
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
// (`
|
|
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("
|
|
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
|
-
<
|
|
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
|
+
}
|