@hyperframes/studio 0.7.86 → 0.7.88
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-B03_wTTS.js} +1 -1
- package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
- package/dist/assets/index-BXkzp_OU.css +1 -0
- package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
- package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
- 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
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditing";
|
|
3
|
+
import { createTimelineResetState, usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import {
|
|
5
|
+
readTimelinePerformanceDiagnostics,
|
|
6
|
+
type TimelinePerformanceDiagnostics,
|
|
7
|
+
} from "../player/lib/timelinePerformanceDiagnostics";
|
|
8
|
+
import {
|
|
9
|
+
createTimelinePerformanceFixture,
|
|
10
|
+
setTimelinePerformanceFixtureLease,
|
|
11
|
+
type TimelinePerformanceFixtureSpec,
|
|
12
|
+
type TimelinePerformanceFixtureSummary,
|
|
13
|
+
} from "../player/lib/timelinePerformanceFixture";
|
|
14
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "../player/lib/timelineViewportBudgets";
|
|
15
|
+
import { STUDIO_RUNTIME_MODE, STUDIO_TEST_HOOKS_ENABLED } from "./studioTestMode";
|
|
3
16
|
|
|
4
17
|
interface StudioTestHookDeps {
|
|
5
18
|
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
@@ -11,7 +24,14 @@ interface StudioTestHookDeps {
|
|
|
11
24
|
}
|
|
12
25
|
|
|
13
26
|
interface StudioTestApi {
|
|
27
|
+
runtimeMode: typeof STUDIO_RUNTIME_MODE;
|
|
14
28
|
selectByDomId: (id: string) => Promise<boolean>;
|
|
29
|
+
loadTimelinePerformanceFixture: (
|
|
30
|
+
spec: TimelinePerformanceFixtureSpec,
|
|
31
|
+
) => TimelinePerformanceFixtureSummary;
|
|
32
|
+
resetTimelinePerformanceFixture: () => void;
|
|
33
|
+
readTimelinePerformanceDiagnostics: () => Readonly<TimelinePerformanceDiagnostics>;
|
|
34
|
+
timelineViewportBudgets: typeof TIMELINE_VIEWPORT_BUDGETS;
|
|
15
35
|
}
|
|
16
36
|
|
|
17
37
|
declare global {
|
|
@@ -36,14 +56,9 @@ export function useStudioTestHooks({
|
|
|
36
56
|
}: StudioTestHookDeps): void {
|
|
37
57
|
// eslint-disable-next-line no-restricted-syntax
|
|
38
58
|
useEffect(() => {
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
isDev = import.meta.env.DEV === true;
|
|
42
|
-
} catch {
|
|
43
|
-
isDev = false;
|
|
44
|
-
}
|
|
45
|
-
if (!isDev || typeof window === "undefined") return;
|
|
59
|
+
if (!STUDIO_TEST_HOOKS_ENABLED || typeof window === "undefined") return;
|
|
46
60
|
const api: StudioTestApi = {
|
|
61
|
+
runtimeMode: STUDIO_RUNTIME_MODE,
|
|
47
62
|
selectByDomId: async (id: string): Promise<boolean> => {
|
|
48
63
|
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
|
49
64
|
if (!element) return false;
|
|
@@ -52,9 +67,41 @@ export function useStudioTestHooks({
|
|
|
52
67
|
applyDomSelection(selection, { revealPanel: true });
|
|
53
68
|
return true;
|
|
54
69
|
},
|
|
70
|
+
loadTimelinePerformanceFixture: (spec) => {
|
|
71
|
+
const fixture = createTimelinePerformanceFixture(spec);
|
|
72
|
+
setTimelinePerformanceFixtureLease(true);
|
|
73
|
+
usePlayerStore.setState({
|
|
74
|
+
...createTimelineResetState(),
|
|
75
|
+
currentTime: 0,
|
|
76
|
+
duration: fixture.summary.duration,
|
|
77
|
+
timelineReady: true,
|
|
78
|
+
loopEnabled: false,
|
|
79
|
+
zoomMode: "manual",
|
|
80
|
+
manualZoomPercent: 2_000,
|
|
81
|
+
elements: fixture.elements,
|
|
82
|
+
selectedElementId: null,
|
|
83
|
+
selectedElementIds: new Set(),
|
|
84
|
+
selectedKeyframes: new Set(),
|
|
85
|
+
keyframeCache: fixture.keyframeCache,
|
|
86
|
+
gsapAnimations: fixture.gsapAnimations,
|
|
87
|
+
expandedClipIds: fixture.expandedClipIds,
|
|
88
|
+
});
|
|
89
|
+
return fixture.summary;
|
|
90
|
+
},
|
|
91
|
+
resetTimelinePerformanceFixture: () => {
|
|
92
|
+
usePlayerStore.getState().reset();
|
|
93
|
+
},
|
|
94
|
+
readTimelinePerformanceDiagnostics: () => readTimelinePerformanceDiagnostics(),
|
|
95
|
+
timelineViewportBudgets: TIMELINE_VIEWPORT_BUDGETS,
|
|
55
96
|
};
|
|
56
97
|
window.__studioTest = api;
|
|
57
98
|
return () => {
|
|
99
|
+
// The lease is deliberately NOT released here. Loading a fixture writes
|
|
100
|
+
// player state, which changes this effect's dependency identities and
|
|
101
|
+
// tears the effect down on the very next frame. Releasing on teardown
|
|
102
|
+
// therefore revoked the lease moments after it was taken, and live iframe
|
|
103
|
+
// discovery overwrote the fixture the loader had just installed. The
|
|
104
|
+
// lease belongs to the fixture, and a page reload clears it.
|
|
58
105
|
// delete, not `= undefined`: an own key holding undefined keeps
|
|
59
106
|
// `"__studioTest" in window` true, which defeats feature detection.
|
|
60
107
|
delete window.__studioTest;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { memo, useRef, useState } from "react";
|
|
2
2
|
import { moveBeatCompositionTime, deleteBeatAtCompositionTime } from "../../utils/beatEditActions";
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
|
-
import { CLIP_Y } from "./timelineLayout";
|
|
4
|
+
import { CLIP_Y, getTimelineBeatEntries } from "./timelineLayout";
|
|
5
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
5
6
|
|
|
6
7
|
export const BEAT_BAND_H = 14; // dark band height at top of track
|
|
7
8
|
const BEAT_HIT_W = 12; // grab width per beat (px)
|
|
@@ -24,23 +25,30 @@ export const BeatBackgroundLines = memo(function BeatBackgroundLines({
|
|
|
24
25
|
beatStrengths,
|
|
25
26
|
pps,
|
|
26
27
|
highlightTime,
|
|
28
|
+
renderTimeRange,
|
|
27
29
|
}: {
|
|
28
30
|
beatTimes: number[] | undefined;
|
|
29
31
|
beatStrengths: number[] | undefined;
|
|
30
32
|
pps: number;
|
|
31
33
|
/** Snap guide time — drawn as a bright line even when it is not a beat. */
|
|
32
34
|
highlightTime?: number | null;
|
|
35
|
+
renderTimeRange?: TimelineTimeRange;
|
|
33
36
|
}) {
|
|
34
37
|
const visibleBeatTimes = beatTimes && !beatsTooDense(beatTimes, pps) ? beatTimes : null;
|
|
35
38
|
const highlightIsBeat =
|
|
36
39
|
highlightTime != null &&
|
|
37
40
|
visibleBeatTimes?.some((t) => Math.abs(t - highlightTime) < 1e-3) === true;
|
|
38
41
|
if (!visibleBeatTimes && highlightTime == null) return null;
|
|
42
|
+
const beatEntries = getTimelineBeatEntries(
|
|
43
|
+
visibleBeatTimes ?? undefined,
|
|
44
|
+
beatStrengths,
|
|
45
|
+
renderTimeRange,
|
|
46
|
+
);
|
|
39
47
|
return (
|
|
40
48
|
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 0 }}>
|
|
41
|
-
{
|
|
49
|
+
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
|
42
50
|
const isHighlight = highlightTime != null && Math.abs(t - highlightTime) < 1e-3;
|
|
43
|
-
const strength = Math.pow(Math.min(1,
|
|
51
|
+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
|
44
52
|
const opacity = isHighlight ? 1 : 0.06 + strength * 0.16;
|
|
45
53
|
return (
|
|
46
54
|
<div
|
|
@@ -81,10 +89,12 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
81
89
|
beatTimes,
|
|
82
90
|
beatStrengths,
|
|
83
91
|
pps,
|
|
92
|
+
renderTimeRange,
|
|
84
93
|
}: {
|
|
85
94
|
beatTimes: number[] | undefined;
|
|
86
95
|
beatStrengths: number[] | undefined;
|
|
87
96
|
pps: number;
|
|
97
|
+
renderTimeRange?: TimelineTimeRange;
|
|
88
98
|
}) {
|
|
89
99
|
// Active drag: which beat and how far (px) it's been dragged.
|
|
90
100
|
const [drag, setDrag] = useState<{ index: number; dx: number } | null>(null);
|
|
@@ -92,15 +102,21 @@ export const BeatStrip = memo(function BeatStrip({
|
|
|
92
102
|
|
|
93
103
|
if (!beatTimes || beatsTooDense(beatTimes, pps)) return null;
|
|
94
104
|
const cy = BEAT_BAND_H / 2;
|
|
105
|
+
const beatEntries = getTimelineBeatEntries(
|
|
106
|
+
beatTimes,
|
|
107
|
+
beatStrengths,
|
|
108
|
+
renderTimeRange,
|
|
109
|
+
drag ? new Set([drag.index]) : undefined,
|
|
110
|
+
);
|
|
95
111
|
|
|
96
112
|
return (
|
|
97
113
|
<div
|
|
98
114
|
className="absolute left-0 right-0 pointer-events-none"
|
|
99
115
|
style={{ top: CLIP_Y, height: BEAT_BAND_H, background: "rgba(0,0,0,0.28)", zIndex: 11 }}
|
|
100
116
|
>
|
|
101
|
-
{
|
|
117
|
+
{beatEntries.map(({ time: t, index: i, strength: beatStrength }) => {
|
|
102
118
|
// Louder beats → larger, brighter dot. Gamma curve widens the contrast.
|
|
103
|
-
const strength = Math.pow(Math.min(1,
|
|
119
|
+
const strength = Math.pow(Math.min(1, beatStrength ?? 0.5), 2.2);
|
|
104
120
|
const r = 1.5 + strength * 2.5;
|
|
105
121
|
const opacity = 0.25 + strength * 0.75;
|
|
106
122
|
const dxPx = drag?.index === i ? drag.dx : 0;
|
|
@@ -1,5 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { buildCompositionThumbnailUrl, CompositionThumbnail } from "./CompositionThumbnail";
|
|
7
|
+
|
|
8
|
+
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
|
9
|
+
configurable: true,
|
|
10
|
+
value: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
class MockResizeObserver {
|
|
14
|
+
observe() {}
|
|
15
|
+
disconnect() {}
|
|
16
|
+
unobserve() {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class MockImage {
|
|
20
|
+
static instances: MockImage[] = [];
|
|
21
|
+
onload: (() => void) | null = null;
|
|
22
|
+
onerror: (() => void) | null = null;
|
|
23
|
+
naturalWidth = 0;
|
|
24
|
+
naturalHeight = 0;
|
|
25
|
+
src = "";
|
|
26
|
+
|
|
27
|
+
constructor() {
|
|
28
|
+
MockImage.instances.push(this);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const originalResizeObserver = globalThis.ResizeObserver;
|
|
33
|
+
const originalImage = globalThis.Image;
|
|
34
|
+
let host: HTMLDivElement;
|
|
35
|
+
let root: Root | null = null;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
39
|
+
globalThis.Image = MockImage as unknown as typeof Image;
|
|
40
|
+
MockImage.instances = [];
|
|
41
|
+
host = document.createElement("div");
|
|
42
|
+
document.body.append(host);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
act(() => root?.unmount());
|
|
47
|
+
root = null;
|
|
48
|
+
globalThis.ResizeObserver = originalResizeObserver;
|
|
49
|
+
globalThis.Image = originalImage;
|
|
50
|
+
document.body.replaceChildren();
|
|
51
|
+
});
|
|
3
52
|
|
|
4
53
|
describe("buildCompositionThumbnailUrl", () => {
|
|
5
54
|
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
@@ -17,3 +66,48 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
17
66
|
);
|
|
18
67
|
});
|
|
19
68
|
});
|
|
69
|
+
|
|
70
|
+
describe("CompositionThumbnail", () => {
|
|
71
|
+
function renderThumbnail(): MockImage {
|
|
72
|
+
root = createRoot(host);
|
|
73
|
+
act(() => {
|
|
74
|
+
root!.render(
|
|
75
|
+
React.createElement(CompositionThumbnail, {
|
|
76
|
+
previewUrl: "/api/projects/demo/preview",
|
|
77
|
+
label: "",
|
|
78
|
+
labelColor: "#fff",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
const probe = MockImage.instances[0];
|
|
83
|
+
if (!probe) throw new Error("Expected an image probe");
|
|
84
|
+
return probe;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it("renders visible tiles after the off-DOM probe loads", () => {
|
|
88
|
+
const probe = renderThumbnail();
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
probe.naturalWidth = 1920;
|
|
92
|
+
probe.naturalHeight = 1080;
|
|
93
|
+
probe.onload?.();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const tiles = [...host.querySelectorAll("img")];
|
|
97
|
+
expect(tiles.length).toBeGreaterThan(0);
|
|
98
|
+
expect(tiles.every((tile) => !tile.classList.contains("hidden"))).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("aborts its off-DOM image probe when unmounted", () => {
|
|
102
|
+
const probe = renderThumbnail();
|
|
103
|
+
expect(host.querySelector("img")).toBeNull();
|
|
104
|
+
expect(probe.src).toContain("/api/projects/demo/thumbnail/index.html");
|
|
105
|
+
|
|
106
|
+
act(() => root?.unmount());
|
|
107
|
+
root = null;
|
|
108
|
+
|
|
109
|
+
expect(probe.onload).toBeNull();
|
|
110
|
+
expect(probe.onerror).toBeNull();
|
|
111
|
+
expect(probe.src).toBe("");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { memo, useCallback, useState, useRef } from "react";
|
|
1
|
+
import { memo, useCallback, useEffect, useState, useRef } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
|
|
4
4
|
interface CompositionThumbnailProps {
|
|
@@ -88,26 +88,39 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
88
88
|
selectorIndex,
|
|
89
89
|
origin: window.location.origin,
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
// Probe outside React's DOM and explicitly abort on URL changes/unmount. A
|
|
93
|
+
// hidden React <img> keeps its Fiber reachable from Blink's pending-activity
|
|
94
|
+
// queue while a request is unresolved.
|
|
95
|
+
// eslint-disable-next-line no-restricted-syntax
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let cancelled = false;
|
|
98
|
+
setLoaded(false);
|
|
99
|
+
const probe = new Image();
|
|
100
|
+
probe.onload = () => {
|
|
101
|
+
if (cancelled) return;
|
|
102
|
+
if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
|
|
103
|
+
setAspect(probe.naturalWidth / probe.naturalHeight);
|
|
104
|
+
}
|
|
105
|
+
setLoaded(true);
|
|
106
|
+
};
|
|
107
|
+
probe.onerror = () => {
|
|
108
|
+
if (!cancelled) setLoaded(false);
|
|
109
|
+
};
|
|
110
|
+
probe.src = url;
|
|
111
|
+
return () => {
|
|
112
|
+
cancelled = true;
|
|
113
|
+
probe.onload = null;
|
|
114
|
+
probe.onerror = null;
|
|
115
|
+
probe.src = "";
|
|
116
|
+
};
|
|
117
|
+
}, [url]);
|
|
118
|
+
|
|
91
119
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
92
120
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
93
121
|
|
|
94
122
|
return (
|
|
95
123
|
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
|
|
96
|
-
<img
|
|
97
|
-
src={url}
|
|
98
|
-
alt=""
|
|
99
|
-
draggable={false}
|
|
100
|
-
loading="eager"
|
|
101
|
-
onLoad={(e) => {
|
|
102
|
-
const img = e.currentTarget;
|
|
103
|
-
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
104
|
-
setAspect(img.naturalWidth / img.naturalHeight);
|
|
105
|
-
}
|
|
106
|
-
setLoaded(true);
|
|
107
|
-
}}
|
|
108
|
-
className="hidden"
|
|
109
|
-
/>
|
|
110
|
-
|
|
111
124
|
{loaded && (
|
|
112
125
|
<div
|
|
113
126
|
className="absolute inset-0 flex"
|
|
@@ -12,11 +12,14 @@ import {
|
|
|
12
12
|
getTimelineCanvasHeight,
|
|
13
13
|
resolveTimelineAssetDrop,
|
|
14
14
|
getTimelinePlayheadLeft,
|
|
15
|
+
getTimelinePlaybackFollowScrollLeft,
|
|
15
16
|
getTimelineScrollLeftForZoomAnchor,
|
|
16
17
|
getTimelineScrollLeftForZoomTransition,
|
|
17
18
|
shouldShowTimelineShortcutHint,
|
|
18
19
|
shouldHandleTimelineDeleteKey,
|
|
19
20
|
shouldAutoScrollTimeline,
|
|
21
|
+
getTimelineVisibleTimeRange,
|
|
22
|
+
getTimelineScrollTopForGeometryChange,
|
|
20
23
|
} from "./Timeline";
|
|
21
24
|
import {
|
|
22
25
|
CLIP_Y,
|
|
@@ -32,11 +35,16 @@ import {
|
|
|
32
35
|
getTimelineDisplayContentWidth,
|
|
33
36
|
getTimelineFitPps,
|
|
34
37
|
getTimelineLaneTop,
|
|
38
|
+
createTimelineRowGeometry,
|
|
35
39
|
} from "./timelineLayout";
|
|
36
40
|
import { formatTime } from "../lib/time";
|
|
37
41
|
import { usePlayerStore } from "../store/playerStore";
|
|
38
42
|
import { TimelineEditProvider } from "../../contexts/TimelineEditContext";
|
|
39
43
|
|
|
44
|
+
vi.mock("./timelineRowVirtualizationFlag", () => ({
|
|
45
|
+
STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED: false,
|
|
46
|
+
}));
|
|
47
|
+
|
|
40
48
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
41
49
|
|
|
42
50
|
afterEach(() => {
|
|
@@ -44,6 +52,25 @@ afterEach(() => {
|
|
|
44
52
|
usePlayerStore.getState().reset();
|
|
45
53
|
});
|
|
46
54
|
|
|
55
|
+
describe("timeline viewport geometry", () => {
|
|
56
|
+
it("derives a clamped visible time range from the raw viewport", () => {
|
|
57
|
+
expect(
|
|
58
|
+
getTimelineVisibleTimeRange({ scrollLeft: 300, clientWidth: 500 }, 100, 200, 20),
|
|
59
|
+
).toEqual({ start: 1, end: 6 });
|
|
60
|
+
expect(getTimelineVisibleTimeRange({ scrollLeft: 0, clientWidth: 100 }, 100, 200, 20)).toEqual({
|
|
61
|
+
start: 0,
|
|
62
|
+
end: 0,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("keeps the same row anchored when a row above it expands", () => {
|
|
67
|
+
const previous = createTimelineRowGeometry([1, 2, 3], [48, 48, 48]);
|
|
68
|
+
const next = createTimelineRowGeometry([1, 2, 3], [104, 48, 48]);
|
|
69
|
+
const scrollTop = previous.getRowTop(2) - RULER_H + 6;
|
|
70
|
+
expect(getTimelineScrollTopForGeometryChange(previous, next, scrollTop)).toBe(scrollTop + 56);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
47
74
|
function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) {
|
|
48
75
|
const clip = host.querySelector<HTMLElement>(`[data-el-id="${clipId}"]`);
|
|
49
76
|
if (!clip) throw new Error(`Missing timeline clip ${clipId}`);
|
|
@@ -252,6 +279,33 @@ describe("Timeline provider boundary", () => {
|
|
|
252
279
|
act(() => root.unmount());
|
|
253
280
|
});
|
|
254
281
|
|
|
282
|
+
it("renders the complete track list while row virtualization is gated off", () => {
|
|
283
|
+
const host = createSizedTimelineHost(640);
|
|
284
|
+
usePlayerStore.setState({
|
|
285
|
+
duration: 4,
|
|
286
|
+
timelineReady: true,
|
|
287
|
+
elements: Array.from({ length: 12 }, (_, track) => ({
|
|
288
|
+
id: `clip-${track}`,
|
|
289
|
+
tag: "div",
|
|
290
|
+
start: 0,
|
|
291
|
+
duration: 2,
|
|
292
|
+
track,
|
|
293
|
+
})),
|
|
294
|
+
});
|
|
295
|
+
const root = createRoot(host);
|
|
296
|
+
act(() => root.render(React.createElement(Timeline)));
|
|
297
|
+
|
|
298
|
+
const list = host.querySelector<HTMLElement>('[role="list"]');
|
|
299
|
+
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
|
|
300
|
+
expect(rows).toHaveLength(12);
|
|
301
|
+
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
|
|
302
|
+
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
|
|
303
|
+
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
|
|
304
|
+
|
|
305
|
+
act(() => root.unmount());
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// fallow-ignore-next-line code-duplication
|
|
255
309
|
it("renders the gutter without legacy icons or hue dots", () => {
|
|
256
310
|
const { host, root } = renderBasicTimeline();
|
|
257
311
|
|
|
@@ -954,6 +1008,56 @@ describe("getTimelinePlayheadLeft", () => {
|
|
|
954
1008
|
});
|
|
955
1009
|
});
|
|
956
1010
|
|
|
1011
|
+
describe("getTimelinePlaybackFollowScrollLeft", () => {
|
|
1012
|
+
it("holds the viewport still while the playhead remains inside the comfort area", () => {
|
|
1013
|
+
expect(
|
|
1014
|
+
getTimelinePlaybackFollowScrollLeft({
|
|
1015
|
+
playheadX: 700,
|
|
1016
|
+
currentScrollLeft: 100,
|
|
1017
|
+
viewportWidth: 1000,
|
|
1018
|
+
contentOrigin: 264,
|
|
1019
|
+
maxScrollLeft: 2000,
|
|
1020
|
+
}),
|
|
1021
|
+
).toBe(100);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("follows forward playback at the right-side comfort line", () => {
|
|
1025
|
+
expect(
|
|
1026
|
+
getTimelinePlaybackFollowScrollLeft({
|
|
1027
|
+
playheadX: 1200,
|
|
1028
|
+
currentScrollLeft: 100,
|
|
1029
|
+
viewportWidth: 1000,
|
|
1030
|
+
contentOrigin: 264,
|
|
1031
|
+
maxScrollLeft: 2000,
|
|
1032
|
+
}),
|
|
1033
|
+
).toBe(384);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
it("returns to the matching earlier viewport after a playback loop", () => {
|
|
1037
|
+
expect(
|
|
1038
|
+
getTimelinePlaybackFollowScrollLeft({
|
|
1039
|
+
playheadX: 264,
|
|
1040
|
+
currentScrollLeft: 900,
|
|
1041
|
+
viewportWidth: 1000,
|
|
1042
|
+
contentOrigin: 264,
|
|
1043
|
+
maxScrollLeft: 2000,
|
|
1044
|
+
}),
|
|
1045
|
+
).toBe(0);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
it("clamps at the end of the scrollable timeline", () => {
|
|
1049
|
+
expect(
|
|
1050
|
+
getTimelinePlaybackFollowScrollLeft({
|
|
1051
|
+
playheadX: 5000,
|
|
1052
|
+
currentScrollLeft: 100,
|
|
1053
|
+
viewportWidth: 1000,
|
|
1054
|
+
contentOrigin: 264,
|
|
1055
|
+
maxScrollLeft: 1500,
|
|
1056
|
+
}),
|
|
1057
|
+
).toBe(1500);
|
|
1058
|
+
});
|
|
1059
|
+
});
|
|
1060
|
+
|
|
957
1061
|
describe("getTimelineCanvasHeight", () => {
|
|
958
1062
|
it("includes bottom scroll buffer below the last track", () => {
|
|
959
1063
|
expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan(
|