@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. 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
- Object.defineProperty(iframe, "contentWindow", {
73
- value: {
74
- __player: adapter,
75
- __timelines: options.timelines,
76
- postMessage: options.postMessage ?? (() => {}),
77
- scrollTo: () => {},
78
- addEventListener: () => {},
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
- Object.defineProperty(iframe, "contentWindow", {
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
- Object.defineProperty(iframe, "contentWindow", {
365
- value: {
366
- __player: adapter,
367
- postMessage: () => {},
368
- scrollTo: () => {},
369
- addEventListener: () => {},
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.find(isMusicTrack);
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;