@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,169 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
|
4
|
+
import { createTimelineClipIndex, queryTimelineClipIndex } from "./timelineClipIndex";
|
|
5
|
+
|
|
6
|
+
function clip(
|
|
7
|
+
id: string,
|
|
8
|
+
start: number,
|
|
9
|
+
duration: number,
|
|
10
|
+
track = 1,
|
|
11
|
+
hidden = false,
|
|
12
|
+
): TimelineElement {
|
|
13
|
+
return { id, tag: "div", start, duration, track, hidden };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ids(elements: readonly TimelineElement[]): string[] {
|
|
17
|
+
return elements.map(getTimelineElementIdentity);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createDeterministicRandom(seed: number): () => number {
|
|
21
|
+
let state = seed >>> 0;
|
|
22
|
+
return () => {
|
|
23
|
+
state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;
|
|
24
|
+
return state / 0x1_0000_0000;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function linearOracle(
|
|
29
|
+
elements: readonly TimelineElement[],
|
|
30
|
+
range: { start: number; end: number },
|
|
31
|
+
pinned: ReadonlySet<string>,
|
|
32
|
+
): readonly TimelineElement[] {
|
|
33
|
+
return elements.filter((element) => {
|
|
34
|
+
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return false;
|
|
35
|
+
if (pinned.has(getTimelineElementIdentity(element))) return true;
|
|
36
|
+
if (range.end <= range.start) return false;
|
|
37
|
+
const end = element.start + Math.max(0, element.duration);
|
|
38
|
+
if (end <= element.start) return element.start >= range.start && element.start < range.end;
|
|
39
|
+
return element.start < range.end && end > range.start;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("timelineClipIndex", () => {
|
|
44
|
+
it("finds a long predecessor spanning the window", () => {
|
|
45
|
+
const index = createTimelineClipIndex([[1, [clip("long", 0, 100), clip("late", 80, 1)]]]);
|
|
46
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 50, end: 51 }))).toEqual(["long"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("prunes 50k ended clips around one long interval in a late window", () => {
|
|
50
|
+
const elements = [
|
|
51
|
+
clip("long", 0, 50_000),
|
|
52
|
+
...Array.from({ length: 49_999 }, (_, index) => clip(`short-${index}`, index, 0.25)),
|
|
53
|
+
];
|
|
54
|
+
const index = createTimelineClipIndex([[1, elements]]);
|
|
55
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 49_999, end: 49_999.5 }))).toEqual([
|
|
56
|
+
"long",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("queries a shifted multi-drag window on a 50k-clip row without a row scan", () => {
|
|
61
|
+
const elements = Array.from({ length: 50_000 }, (_, index) =>
|
|
62
|
+
clip(`clip-${index}`, index, 0.25),
|
|
63
|
+
);
|
|
64
|
+
const index = createTimelineClipIndex([[1, elements]]);
|
|
65
|
+
const selected = new Set(["clip-10", "clip-49"]);
|
|
66
|
+
|
|
67
|
+
expect(
|
|
68
|
+
ids(
|
|
69
|
+
queryTimelineClipIndex(index, 1, { start: 60_000, end: 60_001 }, new Set(), [
|
|
70
|
+
{ range: { start: 10, end: 11 }, identities: selected },
|
|
71
|
+
]),
|
|
72
|
+
),
|
|
73
|
+
).toEqual(["clip-10"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("uses half-open render boundaries and retains zero-duration points", () => {
|
|
77
|
+
const index = createTimelineClipIndex([
|
|
78
|
+
[
|
|
79
|
+
1,
|
|
80
|
+
[
|
|
81
|
+
clip("left", 0, 2),
|
|
82
|
+
clip("right", 2, 2),
|
|
83
|
+
clip("point", 3, 0),
|
|
84
|
+
clip("negative", 3.5, -2),
|
|
85
|
+
clip("micro", 4, 1e-9),
|
|
86
|
+
],
|
|
87
|
+
],
|
|
88
|
+
]);
|
|
89
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 2, end: 4 }))).toEqual([
|
|
90
|
+
"right",
|
|
91
|
+
"point",
|
|
92
|
+
"negative",
|
|
93
|
+
]);
|
|
94
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 4, end: 5 }))).toEqual(["micro"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("preserves projection order after overlap and pin union", () => {
|
|
98
|
+
const index = createTimelineClipIndex([
|
|
99
|
+
[1, [clip("pinned", 10, 1), clip("visible-b", 2, 1), clip("visible-a", 1, 3)]],
|
|
100
|
+
]);
|
|
101
|
+
expect(
|
|
102
|
+
ids(queryTimelineClipIndex(index, 1, { start: 1.5, end: 2.5 }, new Set(["pinned"]))),
|
|
103
|
+
).toEqual(["pinned", "visible-b", "visible-a"]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps duplicate identities deterministic and ignores stale pins", () => {
|
|
107
|
+
const index = createTimelineClipIndex([
|
|
108
|
+
[1, [clip("duplicate", 10, 1), clip("duplicate", 20, 1), clip("hidden", 30, 1, 1, true)]],
|
|
109
|
+
[2, [clip("duplicate", 40, 1, 2)]],
|
|
110
|
+
]);
|
|
111
|
+
expect(
|
|
112
|
+
ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }, new Set(["duplicate", "stale"]))),
|
|
113
|
+
).toEqual(["duplicate", "duplicate"]);
|
|
114
|
+
expect(
|
|
115
|
+
ids(queryTimelineClipIndex(index, 2, { start: 0, end: 1 }, new Set(["duplicate"]))),
|
|
116
|
+
).toEqual(["duplicate"]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps an immutable interval snapshot when the source projection array changes", () => {
|
|
120
|
+
const source = [clip("first", 0, 2), clip("second", 5, 2)];
|
|
121
|
+
const index = createTimelineClipIndex([[1, source]]);
|
|
122
|
+
source.splice(0, source.length, clip("replacement", 0, 20));
|
|
123
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual(["first"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("excludes clips with non-finite timing", () => {
|
|
127
|
+
const index = createTimelineClipIndex([
|
|
128
|
+
[1, [clip("bad-start", Number.NaN, 1), clip("bad-duration", 0, Number.POSITIVE_INFINITY)]],
|
|
129
|
+
]);
|
|
130
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 0, end: 1 }))).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("indexes synthetic fractional display rows independently", () => {
|
|
134
|
+
const index = createTimelineClipIndex([
|
|
135
|
+
[1, [clip("host", 0, 1)]],
|
|
136
|
+
[1.5, [clip("child", 10, 1, 1.5)]],
|
|
137
|
+
]);
|
|
138
|
+
expect(ids(queryTimelineClipIndex(index, 1.5, { start: 10, end: 11 }))).toEqual(["child"]);
|
|
139
|
+
expect(ids(queryTimelineClipIndex(index, 1, { start: 10, end: 11 }))).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("matches a linear oracle across deterministic randomized windows and pins", () => {
|
|
143
|
+
const random = createDeterministicRandom(0x2698);
|
|
144
|
+
for (let scenario = 0; scenario < 50; scenario += 1) {
|
|
145
|
+
const elements = Array.from({ length: 60 }, (_, index) => {
|
|
146
|
+
const element = clip(
|
|
147
|
+
`clip-${scenario}-${index}`,
|
|
148
|
+
Math.floor(random() * 120) - 10,
|
|
149
|
+
random() < 0.15 ? -Math.floor(random() * 5) : Math.floor(random() * 30),
|
|
150
|
+
);
|
|
151
|
+
if (index % 4 === 0) element.key = `index.html#${element.id}`;
|
|
152
|
+
return element;
|
|
153
|
+
});
|
|
154
|
+
const index = createTimelineClipIndex([[1, elements]]);
|
|
155
|
+
for (let query = 0; query < 12; query += 1) {
|
|
156
|
+
const start = Math.floor(random() * 120) - 10;
|
|
157
|
+
const range = { start, end: start + Math.floor(random() * 35) };
|
|
158
|
+
const pinned = new Set(
|
|
159
|
+
elements
|
|
160
|
+
.filter(() => random() < 0.05)
|
|
161
|
+
.map((element) => getTimelineElementIdentity(element)),
|
|
162
|
+
);
|
|
163
|
+
expect(queryTimelineClipIndex(index, 1, range, pinned)).toEqual(
|
|
164
|
+
linearOracle(elements, range, pinned),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
2
|
+
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
|
3
|
+
|
|
4
|
+
export interface TimelineTimeRange {
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface TimelineClipQueryWindow {
|
|
10
|
+
readonly range: TimelineTimeRange;
|
|
11
|
+
readonly identities: ReadonlySet<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TimelineClipInterval {
|
|
15
|
+
readonly element: TimelineElement;
|
|
16
|
+
readonly identity: string;
|
|
17
|
+
readonly ordinal: number;
|
|
18
|
+
readonly start: number;
|
|
19
|
+
readonly end: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface TimelineClipRowIndex {
|
|
23
|
+
readonly byStart: readonly TimelineClipInterval[];
|
|
24
|
+
readonly treeLeafCount: number;
|
|
25
|
+
readonly maxPositiveEndTree: readonly number[];
|
|
26
|
+
readonly maxPointStartTree: readonly number[];
|
|
27
|
+
readonly byIdentity: ReadonlyMap<string, readonly TimelineClipInterval[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TimelineClipIndex {
|
|
31
|
+
readonly rows: ReadonlyMap<number, TimelineClipRowIndex>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function clipInterval(element: TimelineElement, ordinal: number): TimelineClipInterval | null {
|
|
35
|
+
if (!Number.isFinite(element.start) || !Number.isFinite(element.duration)) return null;
|
|
36
|
+
const duration = Math.max(0, element.duration);
|
|
37
|
+
return Object.freeze({
|
|
38
|
+
element,
|
|
39
|
+
identity: getTimelineElementIdentity(element),
|
|
40
|
+
ordinal,
|
|
41
|
+
start: element.start,
|
|
42
|
+
end: element.start + duration,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function timelineClipTreeLeafCount(intervalCount: number): number {
|
|
47
|
+
let leafCount = 1;
|
|
48
|
+
while (leafCount < intervalCount) leafCount *= 2;
|
|
49
|
+
return leafCount;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function createTimelineClipMaxTrees(intervals: readonly TimelineClipInterval[]) {
|
|
53
|
+
const treeLeafCount = timelineClipTreeLeafCount(intervals.length);
|
|
54
|
+
const maxPositiveEndTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
|
|
55
|
+
const maxPointStartTree = Array<number>(treeLeafCount * 2).fill(Number.NEGATIVE_INFINITY);
|
|
56
|
+
for (let index = 0; index < intervals.length; index += 1) {
|
|
57
|
+
const interval = intervals[index];
|
|
58
|
+
if (!interval) continue;
|
|
59
|
+
const leaf = treeLeafCount + index;
|
|
60
|
+
if (interval.end > interval.start) maxPositiveEndTree[leaf] = interval.end;
|
|
61
|
+
else maxPointStartTree[leaf] = interval.start;
|
|
62
|
+
}
|
|
63
|
+
for (let node = treeLeafCount - 1; node > 0; node -= 1) {
|
|
64
|
+
maxPositiveEndTree[node] = Math.max(
|
|
65
|
+
maxPositiveEndTree[node * 2] ?? Number.NEGATIVE_INFINITY,
|
|
66
|
+
maxPositiveEndTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
|
|
67
|
+
);
|
|
68
|
+
maxPointStartTree[node] = Math.max(
|
|
69
|
+
maxPointStartTree[node * 2] ?? Number.NEGATIVE_INFINITY,
|
|
70
|
+
maxPointStartTree[node * 2 + 1] ?? Number.NEGATIVE_INFINITY,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
treeLeafCount,
|
|
75
|
+
maxPositiveEndTree: Object.freeze(maxPositiveEndTree),
|
|
76
|
+
maxPointStartTree: Object.freeze(maxPointStartTree),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build an immutable snapshot for one exact display-track projection.
|
|
82
|
+
* Rebuild only when that projection's array identity changes. Construction is
|
|
83
|
+
* O(n log n) overall because each row is sorted once.
|
|
84
|
+
*/
|
|
85
|
+
export function createTimelineClipIndex(
|
|
86
|
+
tracks: readonly (readonly [number, readonly TimelineElement[]])[],
|
|
87
|
+
): TimelineClipIndex {
|
|
88
|
+
const rows = new Map<number, TimelineClipRowIndex>();
|
|
89
|
+
for (const [rowKey, elements] of tracks) {
|
|
90
|
+
const intervals = elements
|
|
91
|
+
.map(clipInterval)
|
|
92
|
+
.filter((interval): interval is TimelineClipInterval => interval !== null);
|
|
93
|
+
const byStart = Object.freeze(
|
|
94
|
+
[...intervals].sort(
|
|
95
|
+
(left, right) => left.start - right.start || left.ordinal - right.ordinal,
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
const maxTrees = createTimelineClipMaxTrees(byStart);
|
|
99
|
+
const mutableByIdentity = new Map<string, TimelineClipInterval[]>();
|
|
100
|
+
for (const interval of intervals) {
|
|
101
|
+
const matches = mutableByIdentity.get(interval.identity) ?? [];
|
|
102
|
+
matches.push(interval);
|
|
103
|
+
mutableByIdentity.set(interval.identity, matches);
|
|
104
|
+
}
|
|
105
|
+
const byIdentity = new Map(
|
|
106
|
+
[...mutableByIdentity].map(([identity, matches]) => [identity, Object.freeze(matches)]),
|
|
107
|
+
);
|
|
108
|
+
rows.set(rowKey, Object.freeze({ byStart, ...maxTrees, byIdentity }));
|
|
109
|
+
}
|
|
110
|
+
return Object.freeze({ rows });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function upperBoundStart(intervals: readonly TimelineClipInterval[], end: number): number {
|
|
114
|
+
let low = 0;
|
|
115
|
+
let high = intervals.length;
|
|
116
|
+
while (low < high) {
|
|
117
|
+
const mid = Math.floor((low + high) / 2);
|
|
118
|
+
if ((intervals[mid]?.start ?? Number.POSITIVE_INFINITY) < end) low = mid + 1;
|
|
119
|
+
else high = mid;
|
|
120
|
+
}
|
|
121
|
+
return low;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function overlaps(interval: TimelineClipInterval, range: TimelineTimeRange): boolean {
|
|
125
|
+
if (interval.end <= interval.start)
|
|
126
|
+
return interval.start >= range.start && interval.start < range.end;
|
|
127
|
+
return interval.start < range.end && interval.end > range.start;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function collectTimelineClipOverlaps(
|
|
131
|
+
row: TimelineClipRowIndex,
|
|
132
|
+
candidateCount: number,
|
|
133
|
+
range: TimelineTimeRange,
|
|
134
|
+
selected: Set<TimelineClipInterval>,
|
|
135
|
+
identities?: ReadonlySet<string>,
|
|
136
|
+
): void {
|
|
137
|
+
const visit = (node: number, left: number, right: number) => {
|
|
138
|
+
if (
|
|
139
|
+
left >= candidateCount ||
|
|
140
|
+
((row.maxPositiveEndTree[node] ?? Number.NEGATIVE_INFINITY) <= range.start &&
|
|
141
|
+
(row.maxPointStartTree[node] ?? Number.NEGATIVE_INFINITY) < range.start)
|
|
142
|
+
) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (right - left === 1) {
|
|
146
|
+
const interval = row.byStart[left];
|
|
147
|
+
if (interval) selectTimelineClipOverlap(interval, range, selected, identities);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const middle = Math.floor((left + right) / 2);
|
|
151
|
+
visit(node * 2, left, middle);
|
|
152
|
+
visit(node * 2 + 1, middle, right);
|
|
153
|
+
};
|
|
154
|
+
visit(1, 0, row.treeLeafCount);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function selectTimelineClipOverlap(
|
|
158
|
+
interval: TimelineClipInterval,
|
|
159
|
+
range: TimelineTimeRange,
|
|
160
|
+
selected: Set<TimelineClipInterval>,
|
|
161
|
+
identities: ReadonlySet<string> | undefined,
|
|
162
|
+
): void {
|
|
163
|
+
if (!overlaps(interval, range)) return;
|
|
164
|
+
if (identities !== undefined && !identities.has(interval.identity)) return;
|
|
165
|
+
selected.add(interval);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Query one display row. The overlap set and explicit actor pins are returned
|
|
170
|
+
* in the row's original projection order, so windowing never changes z/DOM order.
|
|
171
|
+
* The start lookup is O(log n). Balanced max trees prune both children whose
|
|
172
|
+
* positive intervals and point clips cannot reach the window. The overlap walk
|
|
173
|
+
* is O((k + 1) log n) for k candidates across the primary and actor windows;
|
|
174
|
+
* pin lookup is O(p), followed by the projection-order sort of the unique
|
|
175
|
+
* result set.
|
|
176
|
+
*/
|
|
177
|
+
export function queryTimelineClipIndex(
|
|
178
|
+
index: TimelineClipIndex,
|
|
179
|
+
rowKey: number,
|
|
180
|
+
range: TimelineTimeRange,
|
|
181
|
+
pinnedIdentities: ReadonlySet<string> = new Set(),
|
|
182
|
+
actorWindows: readonly TimelineClipQueryWindow[] = [],
|
|
183
|
+
): readonly TimelineElement[] {
|
|
184
|
+
const row = index.rows.get(rowKey);
|
|
185
|
+
if (!row) return Object.freeze([]);
|
|
186
|
+
const selected = new Set<TimelineClipInterval>();
|
|
187
|
+
if (range.end > range.start) {
|
|
188
|
+
collectTimelineClipOverlaps(row, upperBoundStart(row.byStart, range.end), range, selected);
|
|
189
|
+
}
|
|
190
|
+
for (const actorWindow of actorWindows) {
|
|
191
|
+
if (actorWindow.range.end <= actorWindow.range.start) continue;
|
|
192
|
+
collectTimelineClipOverlaps(
|
|
193
|
+
row,
|
|
194
|
+
upperBoundStart(row.byStart, actorWindow.range.end),
|
|
195
|
+
actorWindow.range,
|
|
196
|
+
selected,
|
|
197
|
+
actorWindow.identities,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
for (const identity of pinnedIdentities) {
|
|
201
|
+
for (const interval of row.byIdentity.get(identity) ?? []) selected.add(interval);
|
|
202
|
+
}
|
|
203
|
+
return Object.freeze(
|
|
204
|
+
[...selected]
|
|
205
|
+
.sort((left, right) => left.ordinal - right.ordinal)
|
|
206
|
+
.map((interval) => interval.element),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineElementIndexes } from "./timelineElementIndexes";
|
|
4
|
+
|
|
5
|
+
describe("getTimelineElementIndexes", () => {
|
|
6
|
+
const elements: TimelineElement[] = [
|
|
7
|
+
{ id: "hero", tag: "img", src: "hero.png", start: 0, duration: 2, track: 0 },
|
|
8
|
+
{
|
|
9
|
+
id: "bgm",
|
|
10
|
+
tag: "audio",
|
|
11
|
+
src: "music.wav",
|
|
12
|
+
start: 0,
|
|
13
|
+
duration: 10,
|
|
14
|
+
track: 3,
|
|
15
|
+
timelineRole: "music",
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
it("indexes media and music identities", () => {
|
|
20
|
+
const indexes = getTimelineElementIndexes(elements);
|
|
21
|
+
expect(indexes.byKey.get("hero")).toBe(elements[0]);
|
|
22
|
+
expect(indexes.musicElement).toBe(elements[1]);
|
|
23
|
+
expect(indexes.mediaElements).toEqual(elements);
|
|
24
|
+
expect(indexes.audioTracks).toEqual(new Set([3]));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("reuses one index for the same immutable array snapshot", () => {
|
|
28
|
+
expect(getTimelineElementIndexes(elements)).toBe(getTimelineElementIndexes(elements));
|
|
29
|
+
expect(getTimelineElementIndexes([...elements])).not.toBe(getTimelineElementIndexes(elements));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
|
|
2
|
+
import type { TimelineElement } from "../store/playerStore";
|
|
3
|
+
import { getTimelineElementIdentity } from "./timelineElementHelpers";
|
|
4
|
+
|
|
5
|
+
export interface TimelineElementIndexes {
|
|
6
|
+
readonly byKey: ReadonlyMap<string, TimelineElement>;
|
|
7
|
+
readonly musicElement: TimelineElement | null;
|
|
8
|
+
readonly mediaElements: readonly TimelineElement[];
|
|
9
|
+
readonly audioTracks: ReadonlySet<number>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const indexCache = new WeakMap<readonly TimelineElement[], TimelineElementIndexes>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Index a store element snapshot once. Playback-only Zustand updates keep the
|
|
16
|
+
* same array identity, so selectors can reuse this object without rescanning a
|
|
17
|
+
* large timeline or triggering a component render.
|
|
18
|
+
*/
|
|
19
|
+
export function getTimelineElementIndexes(
|
|
20
|
+
elements: readonly TimelineElement[],
|
|
21
|
+
): TimelineElementIndexes {
|
|
22
|
+
const cached = indexCache.get(elements);
|
|
23
|
+
if (cached) return cached;
|
|
24
|
+
|
|
25
|
+
const byKey = new Map<string, TimelineElement>();
|
|
26
|
+
const mediaElements: TimelineElement[] = [];
|
|
27
|
+
const audioTracks = new Set<number>();
|
|
28
|
+
let musicElement: TimelineElement | null = null;
|
|
29
|
+
for (const element of elements) {
|
|
30
|
+
byKey.set(getTimelineElementIdentity(element), element);
|
|
31
|
+
if (element.src) mediaElements.push(element);
|
|
32
|
+
if (isAudioTimelineElement(element)) audioTracks.add(element.track);
|
|
33
|
+
if (!musicElement && isMusicTrack(element)) musicElement = element;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const indexes = Object.freeze({
|
|
37
|
+
byKey,
|
|
38
|
+
musicElement,
|
|
39
|
+
mediaElements: Object.freeze(mediaElements),
|
|
40
|
+
audioTracks,
|
|
41
|
+
});
|
|
42
|
+
indexCache.set(elements, indexes);
|
|
43
|
+
return indexes;
|
|
44
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
getTimelineResourceBudgetStatus,
|
|
5
|
+
readTimelinePerformanceDiagnostics,
|
|
6
|
+
resolveTimelineScrollStrategy,
|
|
7
|
+
} from "./timelinePerformanceDiagnostics";
|
|
8
|
+
import { resolveTimelineViewportBudgets } from "./timelineViewportBudgets";
|
|
9
|
+
|
|
10
|
+
describe("timeline performance diagnostics", () => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
document.body.replaceChildren();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("reads mounted resources without mutating the timeline", () => {
|
|
16
|
+
document.body.innerHTML = `
|
|
17
|
+
<div aria-label="Timeline" data-timeline-scheduler-queued="3"
|
|
18
|
+
data-timeline-scheduler-active="2" data-timeline-cache-bytes="4096">
|
|
19
|
+
<div data-timeline-row><div data-clip="true"></div><div data-clip="true"></div></div>
|
|
20
|
+
<div data-timeline-row><div data-clip="true"></div></div>
|
|
21
|
+
<div data-clip="true"></div>
|
|
22
|
+
<div data-timeline-grid-cell></div><div data-timeline-grid-cell></div>
|
|
23
|
+
<div data-timeline-poster-state="ready"></div>
|
|
24
|
+
<div data-timeline-poster-state="error"></div>
|
|
25
|
+
<div data-timeline-poster-state="constructor"></div>
|
|
26
|
+
</div>`;
|
|
27
|
+
const before = document.body.innerHTML;
|
|
28
|
+
|
|
29
|
+
expect(readTimelinePerformanceDiagnostics()).toMatchObject({
|
|
30
|
+
timelineRoots: 1,
|
|
31
|
+
mountedRows: 2,
|
|
32
|
+
mountedClipRoots: 4,
|
|
33
|
+
maxMountedClipRootsInOneRow: 2,
|
|
34
|
+
mountedTimeGridCells: 2,
|
|
35
|
+
schedulerQueued: 3,
|
|
36
|
+
schedulerActive: 2,
|
|
37
|
+
cacheBytes: 4096,
|
|
38
|
+
posterStates: { idle: 0, loading: 0, ready: 1, fallback: 0, error: 1 },
|
|
39
|
+
});
|
|
40
|
+
expect(document.body.innerHTML).toBe(before);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns the zero baseline after unmount or reset removes the DOM", () => {
|
|
44
|
+
document.body.innerHTML = '<div aria-label="Timeline"><div data-clip="true"></div></div>';
|
|
45
|
+
expect(readTimelinePerformanceDiagnostics().mountedClipRoots).toBe(1);
|
|
46
|
+
|
|
47
|
+
document.body.replaceChildren();
|
|
48
|
+
|
|
49
|
+
expect(readTimelinePerformanceDiagnostics()).toEqual({
|
|
50
|
+
timelineRoots: 0,
|
|
51
|
+
mountedRows: 0,
|
|
52
|
+
mountedClipRoots: 0,
|
|
53
|
+
maxMountedClipRootsInOneRow: 0,
|
|
54
|
+
mountedTimeGridCells: 0,
|
|
55
|
+
mountedTimelineDescendants: 0,
|
|
56
|
+
schedulerQueued: 0,
|
|
57
|
+
schedulerActive: 0,
|
|
58
|
+
cacheBytes: 0,
|
|
59
|
+
posterStates: { idle: 0, loading: 0, ready: 0, fallback: 0, error: 0 },
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("treats every DOM ceiling as inclusive", () => {
|
|
64
|
+
const budgets = resolveTimelineViewportBudgets({
|
|
65
|
+
maxMountedClipRoots: 2,
|
|
66
|
+
maxMountedClipRootsPerRow: 1,
|
|
67
|
+
maxMountedRows: 2,
|
|
68
|
+
maxMountedTimelineDescendants: 4,
|
|
69
|
+
});
|
|
70
|
+
expect(
|
|
71
|
+
getTimelineResourceBudgetStatus(
|
|
72
|
+
{
|
|
73
|
+
...readTimelinePerformanceDiagnostics(),
|
|
74
|
+
mountedClipRoots: 2,
|
|
75
|
+
maxMountedClipRootsInOneRow: 2,
|
|
76
|
+
mountedTimelineDescendants: 4,
|
|
77
|
+
},
|
|
78
|
+
budgets,
|
|
79
|
+
),
|
|
80
|
+
).toEqual({
|
|
81
|
+
timelineRoot: false,
|
|
82
|
+
rows: true,
|
|
83
|
+
clipRoots: true,
|
|
84
|
+
clipRootsPerRow: false,
|
|
85
|
+
descendants: true,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("fails the resource status when the timeline is absent", () => {
|
|
90
|
+
expect(getTimelineResourceBudgetStatus(readTimelinePerformanceDiagnostics())).toMatchObject({
|
|
91
|
+
timelineRoot: false,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("selects direct scrolling only through the configured safety envelope", () => {
|
|
96
|
+
expect(resolveTimelineScrollStrategy(8_000_000)).toBe("direct");
|
|
97
|
+
expect(resolveTimelineScrollStrategy(8_000_001)).toBe("segmented");
|
|
98
|
+
expect(() => resolveTimelineScrollStrategy(Number.NaN)).toThrow("content width");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { TIMELINE_VIEWPORT_BUDGETS, type TimelineViewportBudgets } from "./timelineViewportBudgets";
|
|
2
|
+
|
|
3
|
+
export type TimelinePosterState = "idle" | "loading" | "ready" | "fallback" | "error";
|
|
4
|
+
|
|
5
|
+
export interface TimelinePerformanceDiagnostics {
|
|
6
|
+
timelineRoots: number;
|
|
7
|
+
mountedRows: number;
|
|
8
|
+
mountedClipRoots: number;
|
|
9
|
+
maxMountedClipRootsInOneRow: number;
|
|
10
|
+
mountedTimeGridCells: number;
|
|
11
|
+
mountedTimelineDescendants: number;
|
|
12
|
+
schedulerQueued: number;
|
|
13
|
+
schedulerActive: number;
|
|
14
|
+
cacheBytes: number;
|
|
15
|
+
posterStates: Readonly<Record<TimelinePosterState, number>>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TimelineResourceBudgetStatus {
|
|
19
|
+
timelineRoot: boolean;
|
|
20
|
+
rows: boolean;
|
|
21
|
+
clipRoots: boolean;
|
|
22
|
+
clipRootsPerRow: boolean;
|
|
23
|
+
descendants: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readNonNegativeNumber(value: string | undefined): number {
|
|
27
|
+
const number = Number(value);
|
|
28
|
+
return Number.isFinite(number) && number >= 0 ? number : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countPosters(root: ParentNode): Readonly<Record<TimelinePosterState, number>> {
|
|
32
|
+
const counts: Record<TimelinePosterState, number> = {
|
|
33
|
+
idle: 0,
|
|
34
|
+
loading: 0,
|
|
35
|
+
ready: 0,
|
|
36
|
+
fallback: 0,
|
|
37
|
+
error: 0,
|
|
38
|
+
};
|
|
39
|
+
for (const node of root.querySelectorAll<HTMLElement>("[data-timeline-poster-state]")) {
|
|
40
|
+
const state = node.dataset.timelinePosterState;
|
|
41
|
+
if (state && Object.hasOwn(counts, state)) counts[state as TimelinePosterState] += 1;
|
|
42
|
+
}
|
|
43
|
+
return Object.freeze(counts);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function maxClipsInOneRow(root: ParentNode): number {
|
|
47
|
+
const byRow = new Map<Element, number>();
|
|
48
|
+
for (const clip of root.querySelectorAll<HTMLElement>('[data-clip="true"]')) {
|
|
49
|
+
const row = clip.closest("[data-timeline-row]");
|
|
50
|
+
if (!row) continue;
|
|
51
|
+
byRow.set(row, (byRow.get(row) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
return Math.max(0, ...byRow.values());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sumDataAttribute(root: ParentNode, selector: string, dataKey: string): number {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const node of root.querySelectorAll<HTMLElement>(selector)) {
|
|
59
|
+
total += readNonNegativeNumber(node.dataset[dataKey]);
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read current timeline costs directly from the mounted DOM. No counters are
|
|
66
|
+
* retained, so an unmount or project reset is reflected as a zero baseline on
|
|
67
|
+
* the next read rather than depending on cleanup ordering.
|
|
68
|
+
*/
|
|
69
|
+
export function readTimelinePerformanceDiagnostics(
|
|
70
|
+
root: ParentNode = document,
|
|
71
|
+
): Readonly<TimelinePerformanceDiagnostics> {
|
|
72
|
+
const timelineRoots = root.querySelectorAll<HTMLElement>('[aria-label="Timeline"]');
|
|
73
|
+
let mountedTimelineDescendants = 0;
|
|
74
|
+
for (const timelineRoot of timelineRoots) {
|
|
75
|
+
mountedTimelineDescendants += timelineRoot.querySelectorAll("*").length;
|
|
76
|
+
}
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
timelineRoots: timelineRoots.length,
|
|
79
|
+
mountedRows: root.querySelectorAll("[data-timeline-row]").length,
|
|
80
|
+
mountedClipRoots: root.querySelectorAll('[data-clip="true"]').length,
|
|
81
|
+
maxMountedClipRootsInOneRow: maxClipsInOneRow(root),
|
|
82
|
+
mountedTimeGridCells: root.querySelectorAll("[data-timeline-grid-cell]").length,
|
|
83
|
+
mountedTimelineDescendants,
|
|
84
|
+
schedulerQueued: sumDataAttribute(
|
|
85
|
+
root,
|
|
86
|
+
"[data-timeline-scheduler-queued]",
|
|
87
|
+
"timelineSchedulerQueued",
|
|
88
|
+
),
|
|
89
|
+
schedulerActive: sumDataAttribute(
|
|
90
|
+
root,
|
|
91
|
+
"[data-timeline-scheduler-active]",
|
|
92
|
+
"timelineSchedulerActive",
|
|
93
|
+
),
|
|
94
|
+
cacheBytes: sumDataAttribute(root, "[data-timeline-cache-bytes]", "timelineCacheBytes"),
|
|
95
|
+
posterStates: countPosters(root),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getTimelineResourceBudgetStatus(
|
|
100
|
+
diagnostics: TimelinePerformanceDiagnostics,
|
|
101
|
+
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
|
|
102
|
+
): Readonly<TimelineResourceBudgetStatus> {
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
timelineRoot: diagnostics.timelineRoots === 1,
|
|
105
|
+
rows: diagnostics.mountedRows <= budgets.maxMountedRows,
|
|
106
|
+
clipRoots: diagnostics.mountedClipRoots <= budgets.maxMountedClipRoots,
|
|
107
|
+
clipRootsPerRow: diagnostics.maxMountedClipRootsInOneRow <= budgets.maxMountedClipRootsPerRow,
|
|
108
|
+
descendants: diagnostics.mountedTimelineDescendants <= budgets.maxMountedTimelineDescendants,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveTimelineScrollStrategy(
|
|
113
|
+
contentWidthPx: number,
|
|
114
|
+
budgets: Readonly<TimelineViewportBudgets> = TIMELINE_VIEWPORT_BUDGETS,
|
|
115
|
+
): "direct" | "segmented" {
|
|
116
|
+
if (!Number.isFinite(contentWidthPx) || contentWidthPx < 0) {
|
|
117
|
+
throw new RangeError("Timeline content width must be a finite non-negative number");
|
|
118
|
+
}
|
|
119
|
+
return contentWidthPx <= budgets.directScrollSafetyPx ? "direct" : "segmented";
|
|
120
|
+
}
|