@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,167 @@
|
|
|
1
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
|
+
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
|
|
3
|
+
|
|
4
|
+
export type TimelinePerformanceFixtureProfile =
|
|
5
|
+
| "dense-short"
|
|
6
|
+
| "long-overlap"
|
|
7
|
+
| "keyframe-heavy-expanded"
|
|
8
|
+
| "composition-heavy"
|
|
9
|
+
| "remote-unsupported";
|
|
10
|
+
|
|
11
|
+
export interface TimelinePerformanceFixtureSpec {
|
|
12
|
+
elementCount: 1_000 | 50_000;
|
|
13
|
+
profile: TimelinePerformanceFixtureProfile;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TimelinePerformanceFixtureSummary extends TimelinePerformanceFixtureSpec {
|
|
17
|
+
duration: number;
|
|
18
|
+
trackCount: number;
|
|
19
|
+
keyframedElementCount: number;
|
|
20
|
+
expandedElementCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TimelinePerformanceFixture {
|
|
24
|
+
summary: Readonly<TimelinePerformanceFixtureSummary>;
|
|
25
|
+
elements: TimelineElement[];
|
|
26
|
+
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
27
|
+
gsapAnimations: Map<string, GsapAnimation[]>;
|
|
28
|
+
expandedClipIds: Set<string>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TRACK_COUNT = 1_000;
|
|
32
|
+
let fixtureLeaseActive = false;
|
|
33
|
+
const PROFILE_GEOMETRY: Readonly<
|
|
34
|
+
Record<TimelinePerformanceFixtureProfile, { duration: number; clipDuration: number }>
|
|
35
|
+
> = Object.freeze({
|
|
36
|
+
"dense-short": { duration: 120, clipDuration: 1.5 },
|
|
37
|
+
"long-overlap": { duration: 7_200, clipDuration: 120 },
|
|
38
|
+
"keyframe-heavy-expanded": { duration: 600, clipDuration: 8 },
|
|
39
|
+
"composition-heavy": { duration: 900, clipDuration: 12 },
|
|
40
|
+
"remote-unsupported": { duration: 900, clipDuration: 12 },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Prevent live iframe discovery from replacing an explicitly loaded dev fixture. */
|
|
44
|
+
export function setTimelinePerformanceFixtureLease(active: boolean): void {
|
|
45
|
+
fixtureLeaseActive = active;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hasTimelinePerformanceFixtureLease(): boolean {
|
|
49
|
+
return fixtureLeaseActive;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateFixtureSpec(spec: TimelinePerformanceFixtureSpec) {
|
|
53
|
+
if (spec.elementCount !== 1_000 && spec.elementCount !== 50_000) {
|
|
54
|
+
throw new RangeError("Timeline performance fixture elementCount must be 1000 or 50000");
|
|
55
|
+
}
|
|
56
|
+
if (!Object.hasOwn(PROFILE_GEOMETRY, spec.profile)) {
|
|
57
|
+
throw new RangeError(`Unknown timeline performance fixture profile: ${spec.profile}`);
|
|
58
|
+
}
|
|
59
|
+
const geometry = PROFILE_GEOMETRY[spec.profile];
|
|
60
|
+
return geometry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fixtureTrack(index: number, spec: TimelinePerformanceFixtureSpec): number {
|
|
64
|
+
if (index < TRACK_COUNT) return index;
|
|
65
|
+
if (spec.profile !== "dense-short") return index % TRACK_COUNT;
|
|
66
|
+
// Keep the dense profile inside the declared 128-roots-per-row envelope while
|
|
67
|
+
// still representing every one of the 1,000 logical tracks.
|
|
68
|
+
const denseTrackCount = Math.ceil((spec.elementCount - TRACK_COUNT) / 127);
|
|
69
|
+
return (index - TRACK_COUNT) % Math.max(1, denseTrackCount);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fixtureStart(
|
|
73
|
+
index: number,
|
|
74
|
+
profile: TimelinePerformanceFixtureProfile,
|
|
75
|
+
duration: number,
|
|
76
|
+
clipDuration: number,
|
|
77
|
+
): number {
|
|
78
|
+
const available = Math.max(0, duration - clipDuration);
|
|
79
|
+
if (profile === "dense-short") return (index % 128) * 0.5;
|
|
80
|
+
if (profile === "long-overlap") return (index * 37) % Math.max(1, available);
|
|
81
|
+
return (index * 17) % Math.max(1, available);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function keyframeData(): KeyframeCacheEntry {
|
|
85
|
+
return {
|
|
86
|
+
format: "percentage",
|
|
87
|
+
keyframes: [0, 33, 66, 100].map((percentage) => ({
|
|
88
|
+
percentage,
|
|
89
|
+
propertyGroup: "position",
|
|
90
|
+
properties: { x: percentage },
|
|
91
|
+
ease: "power2.inOut",
|
|
92
|
+
})),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fixtureAnimation(id: string, start: number, duration: number): GsapAnimation {
|
|
97
|
+
return {
|
|
98
|
+
id: `animation-${id}`,
|
|
99
|
+
targetSelector: `#${id}`,
|
|
100
|
+
method: "to",
|
|
101
|
+
position: start,
|
|
102
|
+
resolvedStart: start,
|
|
103
|
+
duration,
|
|
104
|
+
propertyGroup: "position",
|
|
105
|
+
fromProperties: { x: 0 },
|
|
106
|
+
properties: { x: 100 },
|
|
107
|
+
ease: "power2.inOut",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Pure deterministic generator; the dev test hook performs the one store mutation. */
|
|
112
|
+
export function createTimelinePerformanceFixture(
|
|
113
|
+
spec: TimelinePerformanceFixtureSpec,
|
|
114
|
+
): TimelinePerformanceFixture {
|
|
115
|
+
const geometry = validateFixtureSpec(spec);
|
|
116
|
+
const elements: TimelineElement[] = [];
|
|
117
|
+
const keyframeCache = new Map<string, KeyframeCacheEntry>();
|
|
118
|
+
const gsapAnimations = new Map<string, GsapAnimation[]>();
|
|
119
|
+
const expandedClipIds = new Set<string>();
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < spec.elementCount; index += 1) {
|
|
122
|
+
const id = `perf-${spec.profile}-${spec.elementCount}-${index}`;
|
|
123
|
+
const start = fixtureStart(index, spec.profile, geometry.duration, geometry.clipDuration);
|
|
124
|
+
const track = fixtureTrack(index, spec);
|
|
125
|
+
const element: TimelineElement = {
|
|
126
|
+
id,
|
|
127
|
+
key: id,
|
|
128
|
+
domId: id,
|
|
129
|
+
selector: `#${id}`,
|
|
130
|
+
label: `Fixture ${index + 1}`,
|
|
131
|
+
tag: spec.profile === "remote-unsupported" && index % 2 === 0 ? "video" : "div",
|
|
132
|
+
start,
|
|
133
|
+
duration: geometry.clipDuration,
|
|
134
|
+
track,
|
|
135
|
+
authoredTrack: track,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (spec.profile === "composition-heavy") {
|
|
139
|
+
element.compositionSrc = `compositions/perf-${index % 32}.html`;
|
|
140
|
+
} else if (spec.profile === "remote-unsupported") {
|
|
141
|
+
element.src =
|
|
142
|
+
index % 2 === 0
|
|
143
|
+
? `https://media.invalid/perf-${index % 32}.mp4`
|
|
144
|
+
: `assets/perf-${index % 32}.unsupported`;
|
|
145
|
+
}
|
|
146
|
+
if (spec.profile === "keyframe-heavy-expanded") {
|
|
147
|
+
keyframeCache.set(id, keyframeData());
|
|
148
|
+
gsapAnimations.set(id, [fixtureAnimation(id, start, geometry.clipDuration)]);
|
|
149
|
+
expandedClipIds.add(id);
|
|
150
|
+
}
|
|
151
|
+
elements.push(element);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
summary: Object.freeze({
|
|
156
|
+
...spec,
|
|
157
|
+
duration: geometry.duration,
|
|
158
|
+
trackCount: TRACK_COUNT,
|
|
159
|
+
keyframedElementCount: keyframeCache.size,
|
|
160
|
+
expandedElementCount: expandedClipIds.size,
|
|
161
|
+
}),
|
|
162
|
+
elements,
|
|
163
|
+
keyframeCache,
|
|
164
|
+
gsapAnimations,
|
|
165
|
+
expandedClipIds,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
TIMELINE_VIEWPORT_BUDGETS,
|
|
4
|
+
resolveTimelineViewportBudgets,
|
|
5
|
+
} from "./timelineViewportBudgets";
|
|
6
|
+
|
|
7
|
+
describe("timeline viewport budgets", () => {
|
|
8
|
+
it("owns the agreed direct-scroll, DOM, media, and measurement ceilings", () => {
|
|
9
|
+
expect(TIMELINE_VIEWPORT_BUDGETS).toMatchObject({
|
|
10
|
+
directScrollSafetyPx: 8_000_000,
|
|
11
|
+
rowOverscanPerSide: 2,
|
|
12
|
+
timeOverscanViewportRatio: 0.25,
|
|
13
|
+
maxMountedRows: 64,
|
|
14
|
+
maxMountedClipRoots: 512,
|
|
15
|
+
maxMountedClipRootsPerRow: 128,
|
|
16
|
+
maxMountedTimelineDescendants: 5_000,
|
|
17
|
+
thumbnailCacheBytes: 64 * 1024 * 1024,
|
|
18
|
+
waveformCacheBytes: 16 * 1024 * 1024,
|
|
19
|
+
interactionP95Ms: 50,
|
|
20
|
+
constrainedInteractionP95Ms: 75,
|
|
21
|
+
constrainedFrameIntervalP95Ms: 75,
|
|
22
|
+
longTaskLimitMs: 50,
|
|
23
|
+
constrainedLongTaskLimitMs: 300,
|
|
24
|
+
posterCoverageRatio: 0.9,
|
|
25
|
+
supportedFixtureFallbackRatio: 0.02,
|
|
26
|
+
scrollSamplesPerRun: 21,
|
|
27
|
+
warmupRuns: 3,
|
|
28
|
+
measuredRuns: 5,
|
|
29
|
+
requiredPassingRuns: 4,
|
|
30
|
+
});
|
|
31
|
+
expect(Object.isFrozen(TIMELINE_VIEWPORT_BUDGETS)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("creates an immutable test override without changing production defaults", () => {
|
|
35
|
+
const resolved = resolveTimelineViewportBudgets({
|
|
36
|
+
directScrollSafetyPx: 256,
|
|
37
|
+
measuredRuns: 1,
|
|
38
|
+
requiredPassingRuns: 1,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(resolved.directScrollSafetyPx).toBe(256);
|
|
42
|
+
expect(resolved.maxMountedClipRoots).toBe(512);
|
|
43
|
+
expect(TIMELINE_VIEWPORT_BUDGETS.directScrollSafetyPx).toBe(8_000_000);
|
|
44
|
+
expect(Object.isFrozen(resolved)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it.each([
|
|
48
|
+
[{ maxMountedClipRoots: -1 }, "maxMountedClipRoots"],
|
|
49
|
+
[{ frameIntervalP95Ms: Number.NaN }, "frameIntervalP95Ms"],
|
|
50
|
+
[{ warmupRuns: 0.5 }, "warmupRuns"],
|
|
51
|
+
[{ measuredRuns: 0 }, "measuredRuns"],
|
|
52
|
+
[{ requiredPassingRuns: 0 }, "requiredPassingRuns"],
|
|
53
|
+
[{ measuredRuns: 1.5, requiredPassingRuns: 1 }, "measuredRuns"],
|
|
54
|
+
[{ measuredRuns: 4, requiredPassingRuns: 5 }, "requiredPassingRuns"],
|
|
55
|
+
[{ scrollSamplesPerRun: 19 }, "scrollSamplesPerRun"],
|
|
56
|
+
[{ posterCoverageRatio: 1.1 }, "posterCoverageRatio"],
|
|
57
|
+
] as const)("rejects an invalid override %#", (overrides, message) => {
|
|
58
|
+
expect(() => resolveTimelineViewportBudgets(overrides)).toThrow(message);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export interface TimelineViewportBudgets {
|
|
2
|
+
directScrollSafetyPx: number;
|
|
3
|
+
rowOverscanPerSide: number;
|
|
4
|
+
timeOverscanViewportRatio: number;
|
|
5
|
+
maxMountedRows: number;
|
|
6
|
+
maxMountedClipRoots: number;
|
|
7
|
+
maxMountedClipRootsPerRow: number;
|
|
8
|
+
maxMountedTimelineDescendants: number;
|
|
9
|
+
posterMaxPhysicalWidth: number;
|
|
10
|
+
posterMaxPhysicalHeight: number;
|
|
11
|
+
posterDprCap: number;
|
|
12
|
+
richPreviewFrameCount: number;
|
|
13
|
+
concurrentVideoDecodes: number;
|
|
14
|
+
concurrentMetadataJobs: number;
|
|
15
|
+
concurrentCompositionFetches: number;
|
|
16
|
+
concurrentServerPages: number;
|
|
17
|
+
thumbnailCacheBytes: number;
|
|
18
|
+
thumbnailCacheEntries: number;
|
|
19
|
+
thumbnailCacheEntriesPerProject: number;
|
|
20
|
+
metadataRegistryEntries: number;
|
|
21
|
+
metadataFailureTtlMs: number;
|
|
22
|
+
waveformCacheBytes: number;
|
|
23
|
+
waveformCacheEntries: number;
|
|
24
|
+
compositionDiskCacheBytes: number;
|
|
25
|
+
compositionDiskCacheMaxAgeMs: number;
|
|
26
|
+
interactionP95Ms: number;
|
|
27
|
+
frameIntervalP95Ms: number;
|
|
28
|
+
constrainedInteractionP95Ms: number;
|
|
29
|
+
constrainedFrameIntervalP95Ms: number;
|
|
30
|
+
longTaskLimitMs: number;
|
|
31
|
+
constrainedLongTaskLimitMs: number;
|
|
32
|
+
memoryReturnToleranceRatio: number;
|
|
33
|
+
posterColdP95Ms: number;
|
|
34
|
+
posterCachedP95Ms: number;
|
|
35
|
+
constrainedPosterColdP95Ms: number;
|
|
36
|
+
constrainedPosterCachedP95Ms: number;
|
|
37
|
+
posterCoverageRatio: number;
|
|
38
|
+
posterCoverageSettleMs: number;
|
|
39
|
+
constrainedPosterCoverageSettleMs: number;
|
|
40
|
+
richPreviewP95Ms: number;
|
|
41
|
+
constrainedRichPreviewP95Ms: number;
|
|
42
|
+
supportedFixtureFallbackRatio: number;
|
|
43
|
+
scrollSamplesPerRun: number;
|
|
44
|
+
warmupRuns: number;
|
|
45
|
+
measuredRuns: number;
|
|
46
|
+
requiredPassingRuns: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MEBIBYTE = 1024 * 1024;
|
|
50
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The sole default budget owner for timeline viewport and media virtualization.
|
|
54
|
+
* Consumers may resolve an immutable per-test override; production defaults are
|
|
55
|
+
* never mutated globally.
|
|
56
|
+
*/
|
|
57
|
+
export const TIMELINE_VIEWPORT_BUDGETS: Readonly<TimelineViewportBudgets> = Object.freeze({
|
|
58
|
+
directScrollSafetyPx: 8_000_000,
|
|
59
|
+
rowOverscanPerSide: 2,
|
|
60
|
+
timeOverscanViewportRatio: 0.25,
|
|
61
|
+
maxMountedRows: 64,
|
|
62
|
+
maxMountedClipRoots: 512,
|
|
63
|
+
maxMountedClipRootsPerRow: 128,
|
|
64
|
+
maxMountedTimelineDescendants: 5_000,
|
|
65
|
+
posterMaxPhysicalWidth: 240,
|
|
66
|
+
posterMaxPhysicalHeight: 135,
|
|
67
|
+
posterDprCap: 1.5,
|
|
68
|
+
richPreviewFrameCount: 6,
|
|
69
|
+
concurrentVideoDecodes: 2,
|
|
70
|
+
concurrentMetadataJobs: 4,
|
|
71
|
+
concurrentCompositionFetches: 2,
|
|
72
|
+
concurrentServerPages: 1,
|
|
73
|
+
thumbnailCacheBytes: 64 * MEBIBYTE,
|
|
74
|
+
thumbnailCacheEntries: 256,
|
|
75
|
+
thumbnailCacheEntriesPerProject: 96,
|
|
76
|
+
metadataRegistryEntries: 512,
|
|
77
|
+
metadataFailureTtlMs: 30_000,
|
|
78
|
+
waveformCacheBytes: 16 * MEBIBYTE,
|
|
79
|
+
waveformCacheEntries: 256,
|
|
80
|
+
compositionDiskCacheBytes: 512 * MEBIBYTE,
|
|
81
|
+
compositionDiskCacheMaxAgeMs: 14 * DAY_MS,
|
|
82
|
+
interactionP95Ms: 50,
|
|
83
|
+
frameIntervalP95Ms: 33.3,
|
|
84
|
+
constrainedInteractionP95Ms: 75,
|
|
85
|
+
constrainedFrameIntervalP95Ms: 75,
|
|
86
|
+
longTaskLimitMs: 50,
|
|
87
|
+
constrainedLongTaskLimitMs: 300,
|
|
88
|
+
memoryReturnToleranceRatio: 0.15,
|
|
89
|
+
posterColdP95Ms: 750,
|
|
90
|
+
posterCachedP95Ms: 250,
|
|
91
|
+
constrainedPosterColdP95Ms: 1_200,
|
|
92
|
+
constrainedPosterCachedP95Ms: 400,
|
|
93
|
+
posterCoverageRatio: 0.9,
|
|
94
|
+
posterCoverageSettleMs: 1_500,
|
|
95
|
+
constrainedPosterCoverageSettleMs: 2_500,
|
|
96
|
+
richPreviewP95Ms: 750,
|
|
97
|
+
constrainedRichPreviewP95Ms: 1_200,
|
|
98
|
+
supportedFixtureFallbackRatio: 0.02,
|
|
99
|
+
scrollSamplesPerRun: 21,
|
|
100
|
+
warmupRuns: 3,
|
|
101
|
+
measuredRuns: 5,
|
|
102
|
+
requiredPassingRuns: 4,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function assertValidBudget(name: keyof TimelineViewportBudgets, value: number): void {
|
|
106
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
107
|
+
throw new RangeError(`Timeline viewport budget ${name} must be a finite non-negative number`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveTimelineViewportBudgets(
|
|
112
|
+
overrides: Partial<TimelineViewportBudgets> = {},
|
|
113
|
+
): Readonly<TimelineViewportBudgets> {
|
|
114
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
115
|
+
assertValidBudget(name as keyof TimelineViewportBudgets, value);
|
|
116
|
+
}
|
|
117
|
+
const resolved = { ...TIMELINE_VIEWPORT_BUDGETS, ...overrides };
|
|
118
|
+
for (const name of [
|
|
119
|
+
"scrollSamplesPerRun",
|
|
120
|
+
"warmupRuns",
|
|
121
|
+
"measuredRuns",
|
|
122
|
+
"requiredPassingRuns",
|
|
123
|
+
] as const) {
|
|
124
|
+
if (!Number.isInteger(resolved[name])) {
|
|
125
|
+
throw new RangeError(`Timeline viewport budget ${name} must be an integer`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (resolved.scrollSamplesPerRun < 20) {
|
|
129
|
+
throw new RangeError(
|
|
130
|
+
"Timeline viewport budget scrollSamplesPerRun must be at least 20 for p95",
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (resolved.measuredRuns === 0 || resolved.requiredPassingRuns === 0) {
|
|
134
|
+
throw new RangeError(
|
|
135
|
+
"Timeline viewport budget measuredRuns and requiredPassingRuns must be greater than zero",
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (resolved.requiredPassingRuns > resolved.measuredRuns) {
|
|
139
|
+
throw new RangeError("Timeline viewport budget requiredPassingRuns cannot exceed measuredRuns");
|
|
140
|
+
}
|
|
141
|
+
for (const name of [
|
|
142
|
+
"memoryReturnToleranceRatio",
|
|
143
|
+
"posterCoverageRatio",
|
|
144
|
+
"supportedFixtureFallbackRatio",
|
|
145
|
+
] as const) {
|
|
146
|
+
if (resolved[name] > 1) {
|
|
147
|
+
throw new RangeError(`Timeline viewport budget ${name} cannot exceed 1`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return Object.freeze(resolved);
|
|
151
|
+
}
|
|
@@ -408,9 +408,11 @@ describe("usePlayerStore", () => {
|
|
|
408
408
|
expect(usePlayerStore.getState().manualZoomPercent).toBe(10);
|
|
409
409
|
});
|
|
410
410
|
|
|
411
|
-
it("clamps to the
|
|
412
|
-
usePlayerStore.getState().
|
|
413
|
-
|
|
411
|
+
it("clamps to the frame-level zoom for the current fit scale", () => {
|
|
412
|
+
usePlayerStore.getState().setTimelineScale(12, 12);
|
|
413
|
+
usePlayerStore.getState().setManualZoomPercent(100_000);
|
|
414
|
+
expect(usePlayerStore.getState().manualZoomPercent).toBe(12_000);
|
|
415
|
+
usePlayerStore.getState().setTimelineScale(100, 100);
|
|
414
416
|
});
|
|
415
417
|
});
|
|
416
418
|
|
|
@@ -441,6 +443,21 @@ describe("usePlayerStore", () => {
|
|
|
441
443
|
});
|
|
442
444
|
|
|
443
445
|
describe("reset", () => {
|
|
446
|
+
it("increments the session epoch only for a hard project switch", () => {
|
|
447
|
+
usePlayerStore.getState().beginTimelineSession("project-a");
|
|
448
|
+
const firstEpoch = usePlayerStore.getState().timelineSessionEpoch;
|
|
449
|
+
|
|
450
|
+
usePlayerStore.getState().reset();
|
|
451
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch);
|
|
452
|
+
|
|
453
|
+
usePlayerStore.getState().beginTimelineSession("project-a");
|
|
454
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch);
|
|
455
|
+
|
|
456
|
+
usePlayerStore.getState().beginTimelineSession("project-b");
|
|
457
|
+
expect(usePlayerStore.getState().timelineSessionEpoch).toBe(firstEpoch + 1);
|
|
458
|
+
expect(usePlayerStore.getState().timelineProjectId).toBe("project-b");
|
|
459
|
+
});
|
|
460
|
+
|
|
444
461
|
it("resets all state to defaults", () => {
|
|
445
462
|
// Mutate everything
|
|
446
463
|
const store = usePlayerStore.getState();
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { create } from "zustand";
|
|
2
2
|
import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
|
|
3
|
+
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
3
4
|
import type { BeatEditState } from "../../utils/beatEditing";
|
|
4
5
|
import type { ClipManifestClip } from "../lib/playbackTypes";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import {
|
|
7
|
+
readStudioUiPreferences,
|
|
8
|
+
writeStudioUiPreferences,
|
|
9
|
+
type TimelineTimeDisplayMode,
|
|
10
|
+
} from "../../utils/studioUiPreferences";
|
|
11
|
+
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
|
|
12
|
+
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
|
|
8
13
|
|
|
9
14
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
10
15
|
|
|
@@ -101,6 +106,10 @@ interface PlayerState extends KeyframeSlice {
|
|
|
101
106
|
currentTime: number;
|
|
102
107
|
duration: number;
|
|
103
108
|
timelineReady: boolean;
|
|
109
|
+
/** Increments exactly once when the Studio switches to a different project. */
|
|
110
|
+
timelineSessionEpoch: number;
|
|
111
|
+
/** Project owning the current timeline session; null outside a project-scoped reset. */
|
|
112
|
+
timelineProjectId: string | null;
|
|
104
113
|
/** True while a beat dot is being dragged — hides the playhead guideline. */
|
|
105
114
|
beatDragging: boolean;
|
|
106
115
|
elements: TimelineElement[];
|
|
@@ -155,18 +164,15 @@ interface PlayerState extends KeyframeSlice {
|
|
|
155
164
|
timelineSnapEnabled: boolean;
|
|
156
165
|
setTimelineSnapEnabled: (enabled: boolean) => void;
|
|
157
166
|
/** Transport + ruler readout: timecode ("time") or frame number ("frame"). */
|
|
158
|
-
timeDisplayMode:
|
|
159
|
-
setTimeDisplayMode: (mode:
|
|
167
|
+
timeDisplayMode: TimelineTimeDisplayMode;
|
|
168
|
+
setTimeDisplayMode: (mode: TimelineTimeDisplayMode) => void;
|
|
160
169
|
/**
|
|
161
170
|
* Pin the timeline zoom to its current visual scale before a duration-changing
|
|
162
171
|
* edit, so a subsequent duration change (which recomputes fit-pps) stops
|
|
163
172
|
* rescaling every clip. No-op once already pinned (mode is "manual").
|
|
164
173
|
*/
|
|
165
174
|
pinTimelineZoom: (currentPixelsPerSecond: number, fitPixelsPerSecond: number) => void;
|
|
166
|
-
/**
|
|
167
|
-
* The timeline's live pixels-per-second + fit basis, published by <Timeline> on
|
|
168
|
-
* every render. Non-reactive scratch state (never read as a render input).
|
|
169
|
-
*/
|
|
175
|
+
/** The timeline's live pixels-per-second + fit basis, published by <Timeline>. */
|
|
170
176
|
timelinePps: number;
|
|
171
177
|
timelineFitPps: number;
|
|
172
178
|
setTimelineScale: (pps: number, fitPps: number) => void;
|
|
@@ -201,6 +207,9 @@ interface PlayerState extends KeyframeSlice {
|
|
|
201
207
|
bumpZEditVersion: () => void;
|
|
202
208
|
setInPoint: (time: number | null) => void;
|
|
203
209
|
setOutPoint: (time: number | null) => void;
|
|
210
|
+
/** Owns the hard project boundary; repeated calls for one project are no-ops. */
|
|
211
|
+
beginTimelineSession: (projectId: string) => void;
|
|
212
|
+
/** Clears project data without creating a new hard-project session. */
|
|
204
213
|
reset: () => void;
|
|
205
214
|
|
|
206
215
|
/**
|
|
@@ -283,11 +292,49 @@ export const liveTime = {
|
|
|
283
292
|
},
|
|
284
293
|
};
|
|
285
294
|
|
|
295
|
+
export function createTimelineResetState() {
|
|
296
|
+
return {
|
|
297
|
+
isPlaying: false,
|
|
298
|
+
currentTime: 0,
|
|
299
|
+
duration: 0,
|
|
300
|
+
timelineReady: false,
|
|
301
|
+
beatDragging: false,
|
|
302
|
+
elements: [],
|
|
303
|
+
selectedElementId: null,
|
|
304
|
+
zEditVersion: 0,
|
|
305
|
+
inPoint: null,
|
|
306
|
+
outPoint: null,
|
|
307
|
+
activeTool: "select" as const,
|
|
308
|
+
activeKeyframePct: null,
|
|
309
|
+
motionPathArmed: false,
|
|
310
|
+
motionPathCreateAvailable: false,
|
|
311
|
+
selectedKeyframes: new Set<string>(),
|
|
312
|
+
expandedClipIds: new Set<string>(),
|
|
313
|
+
focusedEaseSegment: null,
|
|
314
|
+
selectedElementIds: new Set<string>(),
|
|
315
|
+
requestedSeekTime: null,
|
|
316
|
+
clipRevealRequest: null,
|
|
317
|
+
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
|
|
318
|
+
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
|
319
|
+
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
|
320
|
+
beatAnalysis: null,
|
|
321
|
+
beatEdits: null,
|
|
322
|
+
beatUndo: [],
|
|
323
|
+
beatRedo: [],
|
|
324
|
+
beatPersist: null,
|
|
325
|
+
clipManifest: null,
|
|
326
|
+
clipParentMap: new Map<string, string>(),
|
|
327
|
+
domClipChildren: [],
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
286
331
|
export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
287
332
|
isPlaying: false,
|
|
288
333
|
currentTime: 0,
|
|
289
334
|
duration: 0,
|
|
290
335
|
timelineReady: false,
|
|
336
|
+
timelineSessionEpoch: 0,
|
|
337
|
+
timelineProjectId: null,
|
|
291
338
|
beatDragging: false,
|
|
292
339
|
elements: [],
|
|
293
340
|
selectedElementId: null,
|
|
@@ -436,12 +483,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
436
483
|
return { zoomMode: "manual", manualZoomPercent: percent };
|
|
437
484
|
}),
|
|
438
485
|
setTimelineScale: (pps, fitPps) => {
|
|
439
|
-
// Non-reactive publish: mutate in place + reuse the same object identity so no
|
|
440
|
-
// subscriber re-renders (these fields are never a render input, only read
|
|
441
|
-
// imperatively before pinning).
|
|
442
486
|
const state = get();
|
|
443
|
-
state.timelinePps
|
|
444
|
-
|
|
487
|
+
if (state.timelinePps === pps && state.timelineFitPps === fitPps) return;
|
|
488
|
+
set({ timelinePps: pps, timelineFitPps: fitPps });
|
|
445
489
|
},
|
|
446
490
|
setInPoint: (time) =>
|
|
447
491
|
set((state) => {
|
|
@@ -465,7 +509,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
465
509
|
};
|
|
466
510
|
}),
|
|
467
511
|
setManualZoomPercent: (percent) =>
|
|
468
|
-
set(
|
|
512
|
+
set((state) => ({
|
|
513
|
+
manualZoomPercent: clampTimelineZoomPercent(percent, state.timelineFitPps),
|
|
514
|
+
})),
|
|
469
515
|
bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
|
|
470
516
|
setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
|
|
471
517
|
setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
|
|
@@ -514,37 +560,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
514
560
|
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
|
515
561
|
),
|
|
516
562
|
})),
|
|
517
|
-
//
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
set({
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
selectedElementId: null,
|
|
529
|
-
inPoint: null,
|
|
530
|
-
outPoint: null,
|
|
531
|
-
activeTool: "select",
|
|
532
|
-
selectedKeyframes: new Set(),
|
|
533
|
-
expandedClipIds: new Set(),
|
|
534
|
-
focusedEaseSegment: null,
|
|
535
|
-
selectedElementIds: new Set(),
|
|
536
|
-
clipRevealRequest: null,
|
|
537
|
-
keyframeCache: new Map(),
|
|
538
|
-
gsapAnimations: new Map(),
|
|
539
|
-
beatAnalysis: null,
|
|
540
|
-
beatEdits: null,
|
|
541
|
-
beatUndo: [],
|
|
542
|
-
beatRedo: [],
|
|
543
|
-
beatPersist: null,
|
|
544
|
-
clipManifest: null,
|
|
545
|
-
clipParentMap: new Map(),
|
|
546
|
-
domClipChildren: [],
|
|
563
|
+
// playbackRate, audioMuted, loopEnabled, zoomMode, and manualZoomPercent are
|
|
564
|
+
// intentionally absent from createTimelineResetState because they are user
|
|
565
|
+
// preferences that survive both source refreshes and project switches.
|
|
566
|
+
beginTimelineSession: (projectId) =>
|
|
567
|
+
set((state) => {
|
|
568
|
+
if (state.timelineProjectId === projectId) return state;
|
|
569
|
+
return {
|
|
570
|
+
...createTimelineResetState(),
|
|
571
|
+
timelineSessionEpoch: state.timelineSessionEpoch + 1,
|
|
572
|
+
timelineProjectId: projectId,
|
|
573
|
+
};
|
|
547
574
|
}),
|
|
575
|
+
reset: () => set(createTimelineResetState()),
|
|
548
576
|
}));
|
|
549
577
|
|
|
550
578
|
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
4
|
import {
|
|
5
5
|
findMatchingTimelineElementId,
|
|
6
6
|
findTimelineIdByAncestor,
|
|
7
|
+
resolveDroppedAssetDimensions,
|
|
7
8
|
resolveTimelineIdForSelection,
|
|
8
9
|
resolveTimelineSelectionSeekTime,
|
|
9
10
|
} from "./studioHelpers";
|
|
10
11
|
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
vi.restoreAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
11
18
|
describe("resolveTimelineSelectionSeekTime", () => {
|
|
12
19
|
it("keeps the current time when it is already inside the clip range", () => {
|
|
13
20
|
expect(resolveTimelineSelectionSeekTime(3, { start: 0, duration: 5 })).toBe(3);
|
|
@@ -148,3 +155,43 @@ describe("resolveTimelineIdForSelection", () => {
|
|
|
148
155
|
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
|
|
149
156
|
});
|
|
150
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("resolveDroppedAssetDimensions", () => {
|
|
160
|
+
it("aborts an image probe when metadata times out", async () => {
|
|
161
|
+
vi.useFakeTimers();
|
|
162
|
+
const probe = {
|
|
163
|
+
addEventListener: vi.fn(),
|
|
164
|
+
naturalHeight: 0,
|
|
165
|
+
naturalWidth: 0,
|
|
166
|
+
src: "",
|
|
167
|
+
};
|
|
168
|
+
vi.stubGlobal(
|
|
169
|
+
"Image",
|
|
170
|
+
vi.fn(() => probe),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const result = resolveDroppedAssetDimensions("demo", "assets/hung.png", "image");
|
|
174
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
175
|
+
|
|
176
|
+
await expect(result).resolves.toBeNull();
|
|
177
|
+
expect(probe.src).toBe("");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("aborts a video probe when metadata times out", async () => {
|
|
181
|
+
vi.useFakeTimers();
|
|
182
|
+
const video = document.createElement("video");
|
|
183
|
+
const load = vi.fn();
|
|
184
|
+
Object.defineProperty(video, "load", { configurable: true, value: load });
|
|
185
|
+
const createElement = document.createElement.bind(document);
|
|
186
|
+
vi.spyOn(document, "createElement").mockImplementation((tagName, options) =>
|
|
187
|
+
tagName === "video" ? video : createElement(tagName, options),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const result = resolveDroppedAssetDimensions("demo", "assets/hung.mp4", "video");
|
|
191
|
+
await vi.advanceTimersByTimeAsync(3000);
|
|
192
|
+
|
|
193
|
+
await expect(result).resolves.toBeNull();
|
|
194
|
+
expect(video.getAttribute("src")).toBe("");
|
|
195
|
+
expect(load).toHaveBeenCalledOnce();
|
|
196
|
+
});
|
|
197
|
+
});
|