@hyperframes/studio 0.7.86 → 0.7.87
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-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
- package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
- package/dist/index.d.ts +14 -6
- package/dist/index.html +2 -2
- package/dist/index.js +6265 -4922
- package/dist/index.js.map +1 -1
- package/package.json +11 -8
- package/src/components/TimelineToolbar.tsx +14 -5
- package/src/components/nle/NLEContext.tsx +4 -1
- package/src/components/nle/TimelinePane.tsx +2 -0
- package/src/hooks/studioTestMode.test.ts +35 -0
- package/src/hooks/studioTestMode.ts +23 -0
- package/src/hooks/useGestureRecording.test.tsx +120 -0
- package/src/hooks/useGestureRecording.ts +49 -40
- package/src/hooks/useStudioTestHooks.test.tsx +161 -0
- package/src/hooks/useStudioTestHooks.ts +54 -7
- package/src/player/components/BeatStrip.tsx +21 -5
- package/src/player/components/CompositionThumbnail.test.ts +96 -2
- package/src/player/components/CompositionThumbnail.tsx +29 -16
- package/src/player/components/Timeline.test.ts +104 -0
- package/src/player/components/Timeline.tsx +141 -131
- package/src/player/components/Timeline.virtualization.test.tsx +400 -0
- package/src/player/components/TimelineCanvas.tsx +18 -10
- package/src/player/components/TimelineClip.tsx +4 -0
- package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
- package/src/player/components/TimelineClipDiamonds.tsx +53 -13
- package/src/player/components/TimelineLanes.test.tsx +15 -4
- package/src/player/components/TimelineLanes.tsx +81 -26
- package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
- package/src/player/components/TimelinePropertyLanes.tsx +1 -0
- package/src/player/components/TimelineRuler.tsx +21 -5
- package/src/player/components/TimelineTrackRow.tsx +46 -0
- package/src/player/components/TimelineTypes.ts +2 -0
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
- package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
- package/src/player/components/timelineDiamondTypes.ts +5 -0
- package/src/player/components/timelineDragDrop.ts +5 -5
- package/src/player/components/timelineEditing.test.ts +32 -0
- package/src/player/components/timelineEditing.ts +47 -5
- package/src/player/components/timelineFocusIdentity.test.ts +21 -0
- package/src/player/components/timelineFocusIdentity.ts +21 -0
- package/src/player/components/timelineLaneProps.ts +9 -0
- package/src/player/components/timelineLayout.test.ts +59 -0
- package/src/player/components/timelineLayout.ts +202 -176
- package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
- package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
- package/src/player/components/timelineRulerGeometry.ts +142 -0
- package/src/player/components/timelineViewModel.ts +44 -0
- package/src/player/components/timelineViewportGeometry.ts +51 -0
- package/src/player/components/timelineZoom.test.ts +42 -28
- package/src/player/components/timelineZoom.ts +42 -25
- package/src/player/components/useTimelineActiveClips.test.ts +82 -79
- package/src/player/components/useTimelineActiveClips.ts +49 -55
- package/src/player/components/useTimelineClipDrag.ts +40 -230
- package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
- package/src/player/components/useTimelineGeometry.ts +4 -11
- package/src/player/components/useTimelinePlayhead.ts +30 -14
- package/src/player/components/useTimelineRangeSelection.ts +5 -4
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
- package/src/player/components/useTimelineRevealClip.ts +171 -47
- package/src/player/components/useTimelineRowVirtualization.ts +140 -0
- package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
- package/src/player/components/useTimelineScrollViewport.ts +87 -9
- package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
- package/src/player/components/useTimelineShiftModifier.ts +20 -0
- package/src/player/components/useTimelineTicks.ts +22 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
- package/src/player/components/useTimelineTrackLayout.ts +35 -18
- package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
- package/src/player/components/useTimelineVirtualRows.ts +126 -0
- package/src/player/hooks/timelinePlayerSync.ts +20 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
- package/src/player/hooks/useTimelinePlayer.ts +8 -20
- package/src/player/lib/playbackScrub.ts +2 -2
- package/src/player/lib/timelineClipIndex.test.ts +169 -0
- package/src/player/lib/timelineClipIndex.ts +208 -0
- package/src/player/lib/timelineElementIndexes.test.ts +31 -0
- package/src/player/lib/timelineElementIndexes.ts +44 -0
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
- package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
- package/src/player/lib/timelinePerformanceFixture.ts +167 -0
- package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
- package/src/player/lib/timelineViewportBudgets.ts +151 -0
- package/src/player/store/playerStore.test.ts +20 -3
- package/src/player/store/playerStore.ts +73 -45
- package/src/utils/studioHelpers.test.ts +48 -1
- package/src/utils/studioHelpers.ts +31 -34
- package/src/utils/studioUiPreferences.ts +3 -1
- package/dist/assets/index-D379YOKT.css +0 -1
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
import { createTimelineRowGeometry, RULER_H, TRACKS_TOP_PAD } from "./timelineLayout";
|
|
7
|
+
import { extractTimelineVirtualRowRange, useTimelineVirtualRows } from "./useTimelineVirtualRows";
|
|
8
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
class MockResizeObserver {
|
|
13
|
+
constructor(private readonly callback: ResizeObserverCallback) {}
|
|
14
|
+
observe(target: Element) {
|
|
15
|
+
const { width, height } = target.getBoundingClientRect();
|
|
16
|
+
this.callback(
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
target,
|
|
20
|
+
borderBoxSize: [{ inlineSize: width, blockSize: height }],
|
|
21
|
+
} as unknown as ResizeObserverEntry,
|
|
22
|
+
],
|
|
23
|
+
this as unknown as ResizeObserver,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
unobserve() {}
|
|
27
|
+
disconnect() {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
31
|
+
|
|
32
|
+
beforeAll(() => {
|
|
33
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterAll(() => {
|
|
37
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
document.body.innerHTML = "";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
function createScrollElement(scrollTop: number, width = 800, height = 192): HTMLDivElement {
|
|
45
|
+
const element = document.createElement("div");
|
|
46
|
+
document.body.append(element);
|
|
47
|
+
Object.defineProperties(element, {
|
|
48
|
+
scrollTop: { configurable: true, writable: true, value: scrollTop },
|
|
49
|
+
scrollLeft: { configurable: true, writable: true, value: 0 },
|
|
50
|
+
clientWidth: { configurable: true, value: width },
|
|
51
|
+
clientHeight: { configurable: true, value: height },
|
|
52
|
+
scrollWidth: { configurable: true, value: width },
|
|
53
|
+
scrollHeight: { configurable: true, value: 60_000 },
|
|
54
|
+
});
|
|
55
|
+
element.getBoundingClientRect = () =>
|
|
56
|
+
({ width, height, top: 0, left: 0, right: width, bottom: height, x: 0, y: 0 }) as DOMRect;
|
|
57
|
+
return element;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function viewport(element: HTMLDivElement): TimelineScrollViewportSnapshot {
|
|
61
|
+
return {
|
|
62
|
+
scrollLeft: element.scrollLeft,
|
|
63
|
+
scrollTop: element.scrollTop,
|
|
64
|
+
clientWidth: element.clientWidth,
|
|
65
|
+
clientHeight: element.clientHeight,
|
|
66
|
+
scrollWidth: element.scrollWidth,
|
|
67
|
+
scrollHeight: element.scrollHeight,
|
|
68
|
+
isScrolling: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createLargeGeometry(keyOffset = 0) {
|
|
73
|
+
const keys = Array.from({ length: 1_000 }, (_, index) => index + keyOffset);
|
|
74
|
+
return createTimelineRowGeometry(
|
|
75
|
+
keys,
|
|
76
|
+
keys.map(() => 48),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("extractTimelineVirtualRowRange", () => {
|
|
81
|
+
it("unions visible overscan with unique actor pins", () => {
|
|
82
|
+
expect(
|
|
83
|
+
extractTimelineVirtualRowRange(
|
|
84
|
+
{ startIndex: 10, endIndex: 12, overscan: 2, count: 100 },
|
|
85
|
+
[0, 11, 99, 99, -1, 100],
|
|
86
|
+
),
|
|
87
|
+
).toEqual([0, 8, 9, 10, 11, 12, 13, 14, 99]);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("useTimelineVirtualRows", () => {
|
|
92
|
+
it("mounts a bounded vertical range plus the focused row", () => {
|
|
93
|
+
const geometry = createLargeGeometry(0.5);
|
|
94
|
+
const scroll = createScrollElement(RULER_H + TRACKS_TOP_PAD + 500 * 48);
|
|
95
|
+
const scrollRef = { current: scroll };
|
|
96
|
+
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
|
97
|
+
|
|
98
|
+
function Probe() {
|
|
99
|
+
rows = useTimelineVirtualRows({
|
|
100
|
+
enabled: true,
|
|
101
|
+
scrollRef,
|
|
102
|
+
viewport: viewport(scroll),
|
|
103
|
+
rowGeometry: geometry,
|
|
104
|
+
sessionEpoch: 1,
|
|
105
|
+
pinnedRowKeys: [],
|
|
106
|
+
focusedRowKey: 900.5,
|
|
107
|
+
});
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const root = createRoot(document.createElement("div"));
|
|
112
|
+
act(() => root.render(React.createElement(Probe)));
|
|
113
|
+
act(() => {});
|
|
114
|
+
|
|
115
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
116
|
+
expect(rows.length).toBeLessThanOrEqual(16);
|
|
117
|
+
expect(rows.some((row) => row.index === 500)).toBe(true);
|
|
118
|
+
expect(rows.some((row) => row.rowKey === 900.5)).toBe(true);
|
|
119
|
+
act(() => root.unmount());
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps compatibility rendering complete while the gate is disabled", () => {
|
|
123
|
+
const geometry = createTimelineRowGeometry([10, 5.5, 20], [48, 76, 48]);
|
|
124
|
+
const scroll = createScrollElement(0);
|
|
125
|
+
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
|
126
|
+
|
|
127
|
+
function Probe() {
|
|
128
|
+
rows = useTimelineVirtualRows({
|
|
129
|
+
enabled: false,
|
|
130
|
+
scrollRef: { current: scroll },
|
|
131
|
+
viewport: viewport(scroll),
|
|
132
|
+
rowGeometry: geometry,
|
|
133
|
+
sessionEpoch: 1,
|
|
134
|
+
pinnedRowKeys: [],
|
|
135
|
+
});
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const root = createRoot(document.createElement("div"));
|
|
140
|
+
act(() => root.render(React.createElement(Probe)));
|
|
141
|
+
expect(rows.map((row) => row.rowKey)).toEqual([10, 5.5, 20]);
|
|
142
|
+
act(() => root.unmount());
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("observes Timeline's authoritative scroll reset when the session epoch changes", () => {
|
|
146
|
+
const geometry = createLargeGeometry();
|
|
147
|
+
const scroll = createScrollElement(RULER_H + TRACKS_TOP_PAD + 500 * 48);
|
|
148
|
+
const scrollRef = { current: scroll };
|
|
149
|
+
let rows: ReturnType<typeof useTimelineVirtualRows> = [];
|
|
150
|
+
|
|
151
|
+
function Probe({ sessionEpoch }: { sessionEpoch: number }) {
|
|
152
|
+
rows = useTimelineVirtualRows({
|
|
153
|
+
enabled: true,
|
|
154
|
+
scrollRef,
|
|
155
|
+
viewport: viewport(scroll),
|
|
156
|
+
rowGeometry: geometry,
|
|
157
|
+
sessionEpoch,
|
|
158
|
+
pinnedRowKeys: [],
|
|
159
|
+
});
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const root = createRoot(document.createElement("div"));
|
|
164
|
+
act(() => root.render(React.createElement(Probe, { sessionEpoch: 1 })));
|
|
165
|
+
expect(rows.some((row) => row.index === 500)).toBe(true);
|
|
166
|
+
|
|
167
|
+
scroll.scrollTop = 0;
|
|
168
|
+
act(() => root.render(React.createElement(Probe, { sessionEpoch: 2 })));
|
|
169
|
+
expect(rows.some((row) => row.index === 0)).toBe(true);
|
|
170
|
+
expect(rows.some((row) => row.index === 500)).toBe(false);
|
|
171
|
+
|
|
172
|
+
act(() => root.unmount());
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, type RefObject } from "react";
|
|
2
|
+
import { defaultRangeExtractor, useVirtualizer, type Range } from "@tanstack/react-virtual";
|
|
3
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
|
4
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
5
|
+
import { RULER_H, TRACKS_TOP_PAD, type TimelineRowGeometry } from "./timelineLayout";
|
|
6
|
+
|
|
7
|
+
export interface TimelineVirtualRow {
|
|
8
|
+
readonly index: number;
|
|
9
|
+
readonly rowKey: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function extractTimelineVirtualRowRange(
|
|
13
|
+
range: Range,
|
|
14
|
+
pinnedRowIndexes: readonly number[],
|
|
15
|
+
): number[] {
|
|
16
|
+
const indexes = new Set(defaultRangeExtractor(range));
|
|
17
|
+
for (const index of pinnedRowIndexes) {
|
|
18
|
+
if (index >= 0 && index < range.count) indexes.add(index);
|
|
19
|
+
}
|
|
20
|
+
return [...indexes].sort((left, right) => left - right);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function extractTimelineVirtualRowSeed(
|
|
24
|
+
count: number,
|
|
25
|
+
pinnedRowIndexes: readonly number[],
|
|
26
|
+
): number[] {
|
|
27
|
+
const seedCount = Math.min(count, TIMELINE_VIEWPORT_BUDGETS.rowOverscanPerSide * 2 + 1);
|
|
28
|
+
const indexes = new Set(Array.from({ length: seedCount }, (_, index) => index));
|
|
29
|
+
for (const index of pinnedRowIndexes) {
|
|
30
|
+
if (index >= 0 && index < count) indexes.add(index);
|
|
31
|
+
}
|
|
32
|
+
return [...indexes].sort((left, right) => left - right);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface UseTimelineVirtualRowsInput {
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
scrollRef: RefObject<HTMLDivElement | null>;
|
|
38
|
+
viewport: TimelineScrollViewportSnapshot;
|
|
39
|
+
rowGeometry: TimelineRowGeometry;
|
|
40
|
+
sessionEpoch: number;
|
|
41
|
+
pinnedRowKeys: readonly number[];
|
|
42
|
+
focusedRowKey?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useTimelineVirtualRows({
|
|
46
|
+
enabled,
|
|
47
|
+
scrollRef,
|
|
48
|
+
viewport,
|
|
49
|
+
rowGeometry,
|
|
50
|
+
sessionEpoch,
|
|
51
|
+
pinnedRowKeys,
|
|
52
|
+
focusedRowKey,
|
|
53
|
+
}: UseTimelineVirtualRowsInput): readonly TimelineVirtualRow[] {
|
|
54
|
+
const viewportReady = viewport.clientWidth > 0 && viewport.clientHeight > 0;
|
|
55
|
+
const pinnedRowIndexes = useMemo(
|
|
56
|
+
() => [
|
|
57
|
+
...new Set(
|
|
58
|
+
[...pinnedRowKeys, ...(focusedRowKey === undefined ? [] : [focusedRowKey])].map((key) =>
|
|
59
|
+
rowGeometry.getRowIndex(key),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
],
|
|
63
|
+
[focusedRowKey, pinnedRowKeys, rowGeometry],
|
|
64
|
+
);
|
|
65
|
+
const estimateSize = useCallback(
|
|
66
|
+
(index: number) => rowGeometry.getRowHeight(index),
|
|
67
|
+
[rowGeometry],
|
|
68
|
+
);
|
|
69
|
+
const getItemKey = useCallback(
|
|
70
|
+
(index: number) => rowGeometry.rowKeys[index] ?? index,
|
|
71
|
+
[rowGeometry],
|
|
72
|
+
);
|
|
73
|
+
const rangeExtractor = useCallback(
|
|
74
|
+
(range: Range) => extractTimelineVirtualRowRange(range, pinnedRowIndexes),
|
|
75
|
+
[pinnedRowIndexes],
|
|
76
|
+
);
|
|
77
|
+
const initialOffset = useCallback(() => viewport.scrollTop, [viewport.scrollTop]);
|
|
78
|
+
const virtualizer = useVirtualizer({
|
|
79
|
+
enabled: enabled && viewportReady,
|
|
80
|
+
useFlushSync: false,
|
|
81
|
+
count: rowGeometry.rowKeys.length,
|
|
82
|
+
getScrollElement: () => scrollRef.current,
|
|
83
|
+
estimateSize,
|
|
84
|
+
getItemKey,
|
|
85
|
+
overscan: TIMELINE_VIEWPORT_BUDGETS.rowOverscanPerSide,
|
|
86
|
+
rangeExtractor,
|
|
87
|
+
scrollMargin: RULER_H + TRACKS_TOP_PAD,
|
|
88
|
+
initialRect: { width: viewport.clientWidth, height: viewport.clientHeight },
|
|
89
|
+
initialOffset,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!enabled || !viewportReady) return;
|
|
94
|
+
virtualizer.measure();
|
|
95
|
+
// Timeline owns the epoch reset. This event only makes the virtualizer
|
|
96
|
+
// observe the authoritative DOM offset after that reset; it never writes
|
|
97
|
+
// scrollTop itself.
|
|
98
|
+
scrollRef.current?.dispatchEvent(new Event("scroll"));
|
|
99
|
+
}, [enabled, scrollRef, sessionEpoch, viewportReady, virtualizer]);
|
|
100
|
+
|
|
101
|
+
const focusedRowIndex = focusedRowKey === undefined ? -1 : rowGeometry.getRowIndex(focusedRowKey);
|
|
102
|
+
const focusedRowHeight = focusedRowIndex < 0 ? 0 : rowGeometry.getRowHeight(focusedRowIndex);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (enabled && viewportReady && focusedRowIndex >= 0) {
|
|
105
|
+
virtualizer.resizeItem(focusedRowIndex, focusedRowHeight);
|
|
106
|
+
}
|
|
107
|
+
}, [enabled, focusedRowHeight, focusedRowIndex, sessionEpoch, viewportReady, virtualizer]);
|
|
108
|
+
|
|
109
|
+
const compatibilityRows = useMemo(
|
|
110
|
+
() => (enabled ? null : rowGeometry.rowKeys.map((rowKey, index) => ({ index, rowKey }))),
|
|
111
|
+
[enabled, rowGeometry],
|
|
112
|
+
);
|
|
113
|
+
if (compatibilityRows) return compatibilityRows;
|
|
114
|
+
if (!viewportReady) {
|
|
115
|
+
return extractTimelineVirtualRowSeed(rowGeometry.rowKeys.length, pinnedRowIndexes).map(
|
|
116
|
+
(index) => ({
|
|
117
|
+
index,
|
|
118
|
+
rowKey: rowGeometry.rowKeys[index] ?? index,
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return virtualizer.getVirtualItems().map(({ index }) => ({
|
|
123
|
+
index,
|
|
124
|
+
rowKey: rowGeometry.rowKeys[index] ?? index,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
|
|
3
|
+
/** Whether a derived timeline changes any field that affects rendering. */
|
|
4
|
+
export function timelineElementsChanged(
|
|
5
|
+
previous: TimelineElement[],
|
|
6
|
+
next: TimelineElement[],
|
|
7
|
+
): boolean {
|
|
8
|
+
if (next.length !== previous.length) return true;
|
|
9
|
+
return next.some((element, index) => {
|
|
10
|
+
const prior = previous[index];
|
|
11
|
+
return (
|
|
12
|
+
!prior ||
|
|
13
|
+
element.id !== prior.id ||
|
|
14
|
+
element.start !== prior.start ||
|
|
15
|
+
element.duration !== prior.duration ||
|
|
16
|
+
element.track !== prior.track ||
|
|
17
|
+
element.sourceDuration !== prior.sourceDuration
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client";
|
|
|
5
5
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { useTimelinePlayer } from "./useTimelinePlayer";
|
|
7
7
|
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
8
|
+
import { setTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
8
9
|
|
|
9
10
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
11
|
|
|
@@ -40,10 +41,30 @@ function renderTimelinePlayerHarness() {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
afterEach(() => {
|
|
44
|
+
setTimelinePerformanceFixtureLease(false);
|
|
43
45
|
document.body.innerHTML = "";
|
|
44
46
|
resetPlayerStore();
|
|
45
47
|
});
|
|
46
48
|
|
|
49
|
+
function attachIframeWindow(
|
|
50
|
+
api: ReturnType<typeof useTimelinePlayer>,
|
|
51
|
+
iframeWindow: Record<string, unknown>,
|
|
52
|
+
): void {
|
|
53
|
+
const iframe = document.createElement("iframe");
|
|
54
|
+
Object.defineProperty(iframe, "contentWindow", {
|
|
55
|
+
value: iframeWindow,
|
|
56
|
+
configurable: true,
|
|
57
|
+
});
|
|
58
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
59
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
60
|
+
configurable: true,
|
|
61
|
+
});
|
|
62
|
+
act(() => {
|
|
63
|
+
api.iframeRef.current = iframe;
|
|
64
|
+
api.onIframeLoad();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
function attachIframeAdapter(
|
|
48
69
|
api: ReturnType<typeof useTimelinePlayer>,
|
|
49
70
|
options: {
|
|
@@ -52,7 +73,6 @@ function attachIframeAdapter(
|
|
|
52
73
|
duration?: number;
|
|
53
74
|
} = {},
|
|
54
75
|
) {
|
|
55
|
-
const iframe = document.createElement("iframe");
|
|
56
76
|
let currentTime = 0;
|
|
57
77
|
let playing = false;
|
|
58
78
|
const adapter = {
|
|
@@ -69,24 +89,13 @@ function attachIframeAdapter(
|
|
|
69
89
|
getDuration: () => options.duration ?? 30,
|
|
70
90
|
isPlaying: () => playing,
|
|
71
91
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
removeEventListener: () => {},
|
|
80
|
-
},
|
|
81
|
-
configurable: true,
|
|
82
|
-
});
|
|
83
|
-
Object.defineProperty(iframe, "contentDocument", {
|
|
84
|
-
value: document.implementation.createHTMLDocument("preview"),
|
|
85
|
-
configurable: true,
|
|
86
|
-
});
|
|
87
|
-
act(() => {
|
|
88
|
-
api.iframeRef.current = iframe;
|
|
89
|
-
api.onIframeLoad();
|
|
92
|
+
attachIframeWindow(api, {
|
|
93
|
+
__player: adapter,
|
|
94
|
+
__timelines: options.timelines,
|
|
95
|
+
postMessage: options.postMessage ?? (() => {}),
|
|
96
|
+
scrollTo: () => {},
|
|
97
|
+
addEventListener: () => {},
|
|
98
|
+
removeEventListener: () => {},
|
|
90
99
|
});
|
|
91
100
|
return adapter;
|
|
92
101
|
}
|
|
@@ -129,6 +138,61 @@ function expectStorePlaybackState(
|
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
describe("useTimelinePlayer seek hydration", () => {
|
|
141
|
+
it("ignores runtime timeline work while the performance fixture lease is active", () => {
|
|
142
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
143
|
+
attachIframeAdapter(api);
|
|
144
|
+
const iframeWindow = api.iframeRef.current?.contentWindow;
|
|
145
|
+
const iframeDocument = api.iframeRef.current?.contentDocument;
|
|
146
|
+
if (!iframeWindow || !iframeDocument) throw new Error("iframe did not attach");
|
|
147
|
+
const querySelector = vi.spyOn(iframeDocument, "querySelector");
|
|
148
|
+
const clips = [
|
|
149
|
+
{
|
|
150
|
+
id: "runtime-clip",
|
|
151
|
+
label: "Runtime clip",
|
|
152
|
+
start: 0,
|
|
153
|
+
duration: 1,
|
|
154
|
+
track: 0,
|
|
155
|
+
kind: "element",
|
|
156
|
+
tagName: "div",
|
|
157
|
+
compositionId: null,
|
|
158
|
+
parentCompositionId: null,
|
|
159
|
+
compositionSrc: null,
|
|
160
|
+
assetUrl: null,
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
const dispatchTimeline = () =>
|
|
164
|
+
window.dispatchEvent(
|
|
165
|
+
new MessageEvent("message", {
|
|
166
|
+
source: iframeWindow,
|
|
167
|
+
data: {
|
|
168
|
+
source: "hf-preview",
|
|
169
|
+
type: "timeline",
|
|
170
|
+
clips,
|
|
171
|
+
durationInFrames: 30,
|
|
172
|
+
fps: 30,
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
try {
|
|
177
|
+
act(() => {
|
|
178
|
+
usePlayerStore.setState({ clipManifest: null });
|
|
179
|
+
setTimelinePerformanceFixtureLease(true);
|
|
180
|
+
dispatchTimeline();
|
|
181
|
+
});
|
|
182
|
+
expect(usePlayerStore.getState().clipManifest).toBeNull();
|
|
183
|
+
expect(querySelector).not.toHaveBeenCalled();
|
|
184
|
+
|
|
185
|
+
act(() => {
|
|
186
|
+
setTimelinePerformanceFixtureLease(false);
|
|
187
|
+
dispatchTimeline();
|
|
188
|
+
});
|
|
189
|
+
expect(usePlayerStore.getState().clipManifest).toEqual(clips);
|
|
190
|
+
expect(querySelector).toHaveBeenCalled();
|
|
191
|
+
} finally {
|
|
192
|
+
unmountWithAct(root);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
132
196
|
it("keeps an external seek request until the iframe adapter is ready", () => {
|
|
133
197
|
const observedTimes: number[] = [];
|
|
134
198
|
const unsubscribe = liveTime.subscribe((time) => {
|
|
@@ -156,26 +220,13 @@ describe("useTimelinePlayer seek hydration", () => {
|
|
|
156
220
|
|
|
157
221
|
it("does not settle from an unsupported runtime protocol message", () => {
|
|
158
222
|
const { api, root } = renderTimelinePlayerHarness();
|
|
159
|
-
const iframe = document.createElement("iframe");
|
|
160
223
|
const iframeWindow = {
|
|
161
224
|
postMessage: vi.fn(),
|
|
162
225
|
scrollTo: vi.fn(),
|
|
163
226
|
addEventListener: vi.fn(),
|
|
164
227
|
removeEventListener: vi.fn(),
|
|
165
228
|
} as Record<string, unknown>;
|
|
166
|
-
|
|
167
|
-
value: iframeWindow,
|
|
168
|
-
configurable: true,
|
|
169
|
-
});
|
|
170
|
-
Object.defineProperty(iframe, "contentDocument", {
|
|
171
|
-
value: document.implementation.createHTMLDocument("preview"),
|
|
172
|
-
configurable: true,
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
act(() => {
|
|
176
|
-
api.iframeRef.current = iframe;
|
|
177
|
-
api.onIframeLoad();
|
|
178
|
-
});
|
|
229
|
+
attachIframeWindow(api, iframeWindow);
|
|
179
230
|
expect(usePlayerStore.getState().timelineReady).toBe(false);
|
|
180
231
|
|
|
181
232
|
iframeWindow.__player = {
|
|
@@ -339,7 +390,6 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
|
|
|
339
390
|
type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
|
|
340
391
|
|
|
341
392
|
function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
|
|
342
|
-
const iframe = document.createElement("iframe");
|
|
343
393
|
let currentTime = 0;
|
|
344
394
|
let playing = false;
|
|
345
395
|
const seekCalls: SeekCall[] = [];
|
|
@@ -361,23 +411,12 @@ describe("useTimelinePlayer RAF loop wrap-around", () => {
|
|
|
361
411
|
currentTime = t;
|
|
362
412
|
},
|
|
363
413
|
};
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
removeEventListener: () => {},
|
|
371
|
-
},
|
|
372
|
-
configurable: true,
|
|
373
|
-
});
|
|
374
|
-
Object.defineProperty(iframe, "contentDocument", {
|
|
375
|
-
value: document.implementation.createHTMLDocument("preview"),
|
|
376
|
-
configurable: true,
|
|
377
|
-
});
|
|
378
|
-
act(() => {
|
|
379
|
-
api.iframeRef.current = iframe;
|
|
380
|
-
api.onIframeLoad();
|
|
414
|
+
attachIframeWindow(api, {
|
|
415
|
+
__player: adapter,
|
|
416
|
+
postMessage: () => {},
|
|
417
|
+
scrollTo: () => {},
|
|
418
|
+
addEventListener: () => {},
|
|
419
|
+
removeEventListener: () => {},
|
|
381
420
|
});
|
|
382
421
|
return { adapter, seekCalls };
|
|
383
422
|
}
|
|
@@ -39,30 +39,12 @@ import {
|
|
|
39
39
|
import { normalizeToZones } from "../components/timelineZones";
|
|
40
40
|
import { setPreviewMediaMuted, setPreviewPlaybackRate } from "../lib/timelineIframeHelpers";
|
|
41
41
|
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
|
|
42
|
+
import { hasTimelinePerformanceFixtureLease } from "../lib/timelinePerformanceFixture";
|
|
42
43
|
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
|
|
43
44
|
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
|
|
44
45
|
import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
|
|
45
46
|
import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Whether the derived elements differ from the current ones in any field that
|
|
49
|
-
* affects rendering (identity, timing, track, or source length) — used to skip
|
|
50
|
-
* redundant store writes.
|
|
51
|
-
*/
|
|
52
|
-
function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean {
|
|
53
|
-
if (next.length !== prev.length) return true;
|
|
54
|
-
return next.some((el, i) => {
|
|
55
|
-
const p = prev[i];
|
|
56
|
-
return (
|
|
57
|
-
!p ||
|
|
58
|
-
el.id !== p.id ||
|
|
59
|
-
el.start !== p.start ||
|
|
60
|
-
el.duration !== p.duration ||
|
|
61
|
-
el.track !== p.track ||
|
|
62
|
-
el.sourceDuration !== p.sourceDuration
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
}
|
|
47
|
+
import { timelineElementsChanged } from "./timelinePlayerSync";
|
|
66
48
|
|
|
67
49
|
export function useTimelinePlayer() {
|
|
68
50
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
@@ -81,8 +63,13 @@ export function useTimelinePlayer() {
|
|
|
81
63
|
const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
|
|
82
64
|
usePlayerStore.getState();
|
|
83
65
|
|
|
66
|
+
// The fixture lease belongs at this shared synchronization boundary so every
|
|
67
|
+
// iframe discovery path has the same owner for deciding whether it may write.
|
|
84
68
|
const syncTimelineElements = useCallback(
|
|
69
|
+
// The lease guard adds one deliberate branch at the shared synchronization boundary.
|
|
70
|
+
// fallow-ignore-next-line complexity
|
|
85
71
|
(elements: TimelineElement[], nextDuration?: number) => {
|
|
72
|
+
if (hasTimelinePerformanceFixtureLease()) return;
|
|
86
73
|
const state = usePlayerStore.getState();
|
|
87
74
|
const resolvedDuration = nextDuration ?? state.duration;
|
|
88
75
|
// applyCachedSourceDurations re-applies the cached probe duration: re-derived
|
|
@@ -480,6 +467,7 @@ export function useTimelinePlayer() {
|
|
|
480
467
|
// Pre-existing message-router complexity — surfaced by line shifts, not new logic.
|
|
481
468
|
// fallow-ignore-next-line complexity
|
|
482
469
|
const handleMessage = (e: MessageEvent) => {
|
|
470
|
+
if (hasTimelinePerformanceFixtureLease()) return;
|
|
483
471
|
const data = e.data;
|
|
484
472
|
const ourIframe = iframeRef.current;
|
|
485
473
|
if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { usePlayerStore } from "../store/playerStore";
|
|
2
|
-
import { isMusicTrack } from "../../utils/timelineInspector";
|
|
3
2
|
import { scrubPreviewAudio, stopScrubPreviewAudio } from "./timelineIframeHelpers";
|
|
3
|
+
import { getTimelineElementIndexes } from "./timelineElementIndexes";
|
|
4
4
|
|
|
5
5
|
export { stopScrubPreviewAudio };
|
|
6
6
|
|
|
@@ -8,7 +8,7 @@ export { stopScrubPreviewAudio };
|
|
|
8
8
|
// Skipped when audio is muted or the time falls outside the music clip.
|
|
9
9
|
export function scrubMusicAtSeek(iframe: HTMLIFrameElement | null, nextTime: number): void {
|
|
10
10
|
const s = usePlayerStore.getState();
|
|
11
|
-
const music = s.elements.
|
|
11
|
+
const music = getTimelineElementIndexes(s.elements).musicElement;
|
|
12
12
|
if (!music || s.audioMuted) return;
|
|
13
13
|
const rel = nextTime - music.start;
|
|
14
14
|
const audioFileTime = rel >= 0 && rel <= music.duration ? (music.playbackStart ?? 0) + rel : null;
|