@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,51 @@
|
|
|
1
|
+
import { RULER_H, type TimelineRowGeometry } from "./timelineLayout";
|
|
2
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
|
|
3
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
4
|
+
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
5
|
+
|
|
6
|
+
export function getTimelineRenderTimeRange(
|
|
7
|
+
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
|
|
8
|
+
pixelsPerSecond: number,
|
|
9
|
+
contentOrigin: number,
|
|
10
|
+
duration: number,
|
|
11
|
+
): TimelineTimeRange {
|
|
12
|
+
if (!(pixelsPerSecond > 0) || !(duration > 0) || !(viewport.clientWidth > 0)) {
|
|
13
|
+
return { start: 0, end: 0 };
|
|
14
|
+
}
|
|
15
|
+
const overscanPx = viewport.clientWidth * TIMELINE_VIEWPORT_BUDGETS.timeOverscanViewportRatio;
|
|
16
|
+
const startPx = viewport.scrollLeft - contentOrigin - overscanPx;
|
|
17
|
+
const endPx = viewport.scrollLeft + viewport.clientWidth - contentOrigin + overscanPx;
|
|
18
|
+
return {
|
|
19
|
+
start: Math.min(duration, Math.max(0, startPx / pixelsPerSecond)),
|
|
20
|
+
end: Math.min(duration, Math.max(0, endPx / pixelsPerSecond)),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getTimelineVisibleTimeRange(
|
|
25
|
+
viewport: Pick<TimelineScrollViewportSnapshot, "scrollLeft" | "clientWidth">,
|
|
26
|
+
pixelsPerSecond: number,
|
|
27
|
+
contentOrigin: number,
|
|
28
|
+
duration: number,
|
|
29
|
+
): { start: number; end: number } {
|
|
30
|
+
if (!(pixelsPerSecond > 0) || !(duration > 0)) return { start: 0, end: 0 };
|
|
31
|
+
const start = Math.max(0, (viewport.scrollLeft - contentOrigin) / pixelsPerSecond);
|
|
32
|
+
const end = Math.min(
|
|
33
|
+
duration,
|
|
34
|
+
Math.max(start, (viewport.scrollLeft + viewport.clientWidth - contentOrigin) / pixelsPerSecond),
|
|
35
|
+
);
|
|
36
|
+
return { start: Math.min(start, duration), end };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getTimelineScrollTopForGeometryChange(
|
|
40
|
+
previous: TimelineRowGeometry,
|
|
41
|
+
next: TimelineRowGeometry,
|
|
42
|
+
scrollTop: number,
|
|
43
|
+
): number {
|
|
44
|
+
const anchor = previous.getRowPositionFromY(scrollTop + RULER_H);
|
|
45
|
+
if (anchor.row < 0 || anchor.row >= previous.rowKeys.length) return scrollTop;
|
|
46
|
+
const anchorKey = previous.rowKeys[anchor.row];
|
|
47
|
+
if (anchorKey === undefined) return scrollTop;
|
|
48
|
+
const nextRow = next.getRowIndex(anchorKey);
|
|
49
|
+
if (nextRow < 0) return scrollTop;
|
|
50
|
+
return Math.max(0, scrollTop + next.getRowTop(nextRow) - previous.getRowTop(anchor.row));
|
|
51
|
+
}
|
|
@@ -2,34 +2,45 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
clampTimelineZoomPercent,
|
|
4
4
|
computePinnedZoomPercent,
|
|
5
|
+
getMaxTimelineZoomPercent,
|
|
5
6
|
getNextTimelineZoomPercent,
|
|
6
7
|
getPinchTimelineZoomPercent,
|
|
7
8
|
getTimelinePixelsPerSecond,
|
|
8
9
|
getTimelineZoomPercent,
|
|
9
|
-
MAX_TIMELINE_ZOOM_PERCENT,
|
|
10
10
|
MIN_TIMELINE_ZOOM_PERCENT,
|
|
11
11
|
timelineZoomPercentToSlider,
|
|
12
12
|
timelineSliderToZoomPercent,
|
|
13
13
|
} from "./timelineZoom";
|
|
14
14
|
|
|
15
|
+
const FIT_PPS = 12;
|
|
16
|
+
const MAX_ZOOM_PERCENT = 12_000;
|
|
17
|
+
|
|
15
18
|
describe("clampTimelineZoomPercent", () => {
|
|
16
19
|
it("defaults invalid values to 100", () => {
|
|
17
|
-
expect(clampTimelineZoomPercent(Number.NaN)).toBe(100);
|
|
20
|
+
expect(clampTimelineZoomPercent(Number.NaN, FIT_PPS)).toBe(100);
|
|
18
21
|
});
|
|
19
22
|
|
|
20
23
|
it("clamps to the supported percent bounds", () => {
|
|
21
|
-
expect(clampTimelineZoomPercent(1)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
22
|
-
expect(clampTimelineZoomPercent(
|
|
24
|
+
expect(clampTimelineZoomPercent(1, FIT_PPS)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
25
|
+
expect(clampTimelineZoomPercent(100_000, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("zooms to a readable width for each 30fps frame instead of stopping at 2000%", () => {
|
|
29
|
+
const fitPixelsPerSecond = 12;
|
|
30
|
+
const percent = clampTimelineZoomPercent(100_000, fitPixelsPerSecond);
|
|
31
|
+
|
|
32
|
+
expect(percent).toBe(12_000);
|
|
33
|
+
expect(getTimelinePixelsPerSecond(fitPixelsPerSecond, "manual", percent)).toBe(1_440);
|
|
23
34
|
});
|
|
24
35
|
});
|
|
25
36
|
|
|
26
37
|
describe("getTimelineZoomPercent", () => {
|
|
27
38
|
it("treats fit mode as 100 percent", () => {
|
|
28
|
-
expect(getTimelineZoomPercent("fit", 375)).toBe(100);
|
|
39
|
+
expect(getTimelineZoomPercent("fit", 375, FIT_PPS)).toBe(100);
|
|
29
40
|
});
|
|
30
41
|
|
|
31
42
|
it("returns the clamped manual zoom percent", () => {
|
|
32
|
-
expect(getTimelineZoomPercent("manual", 125.2)).toBe(125);
|
|
43
|
+
expect(getTimelineZoomPercent("manual", 125.2, FIT_PPS)).toBe(125);
|
|
33
44
|
});
|
|
34
45
|
});
|
|
35
46
|
|
|
@@ -45,68 +56,71 @@ describe("getTimelinePixelsPerSecond", () => {
|
|
|
45
56
|
|
|
46
57
|
describe("getNextTimelineZoomPercent", () => {
|
|
47
58
|
it("zooms out from fit relative to 100 percent", () => {
|
|
48
|
-
expect(getNextTimelineZoomPercent("out", "fit", 375)).toBe(50);
|
|
59
|
+
expect(getNextTimelineZoomPercent("out", "fit", 375, FIT_PPS)).toBe(50);
|
|
49
60
|
});
|
|
50
61
|
|
|
51
62
|
it("zooms in from fit relative to 100 percent", () => {
|
|
52
|
-
expect(getNextTimelineZoomPercent("in", "fit", 375)).toBe(200);
|
|
63
|
+
expect(getNextTimelineZoomPercent("in", "fit", 375, FIT_PPS)).toBe(200);
|
|
53
64
|
});
|
|
54
65
|
|
|
55
66
|
it("clamps the lower bound", () => {
|
|
56
|
-
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT)).toBe(
|
|
67
|
+
expect(getNextTimelineZoomPercent("out", "manual", MIN_TIMELINE_ZOOM_PERCENT, FIT_PPS)).toBe(
|
|
57
68
|
MIN_TIMELINE_ZOOM_PERCENT,
|
|
58
69
|
);
|
|
59
70
|
});
|
|
60
71
|
|
|
61
72
|
it("clamps the upper bound", () => {
|
|
62
|
-
expect(getNextTimelineZoomPercent("in", "manual",
|
|
63
|
-
|
|
73
|
+
expect(getNextTimelineZoomPercent("in", "manual", MAX_ZOOM_PERCENT, FIT_PPS)).toBe(
|
|
74
|
+
MAX_ZOOM_PERCENT,
|
|
64
75
|
);
|
|
65
76
|
});
|
|
66
77
|
});
|
|
67
78
|
|
|
68
79
|
describe("getPinchTimelineZoomPercent", () => {
|
|
69
80
|
it("zooms in for upward pinch wheel deltas", () => {
|
|
70
|
-
expect(getPinchTimelineZoomPercent(-80, "fit", 100)).toBeGreaterThan(100);
|
|
81
|
+
expect(getPinchTimelineZoomPercent(-80, "fit", 100, FIT_PPS)).toBeGreaterThan(100);
|
|
71
82
|
});
|
|
72
83
|
|
|
73
84
|
it("zooms out for downward pinch wheel deltas", () => {
|
|
74
|
-
expect(getPinchTimelineZoomPercent(80, "manual", 200)).toBeLessThan(200);
|
|
85
|
+
expect(getPinchTimelineZoomPercent(80, "manual", 200, FIT_PPS)).toBeLessThan(200);
|
|
75
86
|
});
|
|
76
87
|
|
|
77
88
|
it("keeps the current zoom for zero or invalid deltas", () => {
|
|
78
|
-
expect(getPinchTimelineZoomPercent(0, "manual", 180)).toBe(180);
|
|
79
|
-
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180)).toBe(180);
|
|
89
|
+
expect(getPinchTimelineZoomPercent(0, "manual", 180, FIT_PPS)).toBe(180);
|
|
90
|
+
expect(getPinchTimelineZoomPercent(Number.NaN, "manual", 180, FIT_PPS)).toBe(180);
|
|
80
91
|
});
|
|
81
92
|
|
|
82
93
|
it("clamps pinch zoom to the supported range", () => {
|
|
83
|
-
expect(getPinchTimelineZoomPercent(10000, "manual", 100)).toBe(
|
|
84
|
-
|
|
94
|
+
expect(getPinchTimelineZoomPercent(10000, "manual", 100, FIT_PPS)).toBe(
|
|
95
|
+
MIN_TIMELINE_ZOOM_PERCENT,
|
|
96
|
+
);
|
|
97
|
+
expect(getPinchTimelineZoomPercent(-10000, "manual", 100, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
|
85
98
|
});
|
|
86
99
|
});
|
|
87
100
|
|
|
88
101
|
describe("timelineZoomPercentToSlider", () => {
|
|
89
102
|
it("maps min zoom to slider position 0", () => {
|
|
90
|
-
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(0, 5);
|
|
103
|
+
expect(timelineZoomPercentToSlider(MIN_TIMELINE_ZOOM_PERCENT, FIT_PPS)).toBeCloseTo(0, 5);
|
|
91
104
|
});
|
|
92
105
|
|
|
93
106
|
it("maps max zoom to slider position 100", () => {
|
|
94
|
-
expect(timelineZoomPercentToSlider(
|
|
107
|
+
expect(timelineZoomPercentToSlider(MAX_ZOOM_PERCENT, FIT_PPS)).toBeCloseTo(100, 5);
|
|
95
108
|
});
|
|
96
109
|
|
|
97
|
-
it("maps 100%
|
|
98
|
-
const expected =
|
|
99
|
-
|
|
110
|
+
it("maps 100% onto the dynamic log range", () => {
|
|
111
|
+
const expected =
|
|
112
|
+
((Math.log(100) - Math.log(10)) / (Math.log(MAX_ZOOM_PERCENT) - Math.log(10))) * 100;
|
|
113
|
+
expect(timelineZoomPercentToSlider(100, FIT_PPS)).toBeCloseTo(expected, 3);
|
|
100
114
|
});
|
|
101
115
|
});
|
|
102
116
|
|
|
103
117
|
describe("timelineSliderToZoomPercent", () => {
|
|
104
118
|
it("maps slider 0 to min zoom", () => {
|
|
105
|
-
expect(timelineSliderToZoomPercent(0)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
119
|
+
expect(timelineSliderToZoomPercent(0, FIT_PPS)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
106
120
|
});
|
|
107
121
|
|
|
108
122
|
it("maps slider 100 to max zoom", () => {
|
|
109
|
-
expect(timelineSliderToZoomPercent(100)).toBe(
|
|
123
|
+
expect(timelineSliderToZoomPercent(100, FIT_PPS)).toBe(MAX_ZOOM_PERCENT);
|
|
110
124
|
});
|
|
111
125
|
});
|
|
112
126
|
|
|
@@ -126,7 +140,7 @@ describe("computePinnedZoomPercent", () => {
|
|
|
126
140
|
|
|
127
141
|
it("clamps a pin that would exceed the manual-zoom bounds", () => {
|
|
128
142
|
// currentPps 10000 / fitPps 1 = 1_000_000% → clamped to MAX.
|
|
129
|
-
expect(computePinnedZoomPercent(10000, 1)).toBe(
|
|
143
|
+
expect(computePinnedZoomPercent(10000, 1)).toBe(getMaxTimelineZoomPercent(1));
|
|
130
144
|
// Tiny ratio → clamped up to MIN.
|
|
131
145
|
expect(computePinnedZoomPercent(0.001, 1000)).toBe(MIN_TIMELINE_ZOOM_PERCENT);
|
|
132
146
|
});
|
|
@@ -140,10 +154,10 @@ describe("computePinnedZoomPercent", () => {
|
|
|
140
154
|
});
|
|
141
155
|
|
|
142
156
|
describe("timelineZoomPercentToSlider / timelineSliderToZoomPercent round-trip", () => {
|
|
143
|
-
for (const percent of [10, 100, 500, 2000]) {
|
|
157
|
+
for (const percent of [10, 100, 500, 2000, MAX_ZOOM_PERCENT]) {
|
|
144
158
|
it(`round-trips ${percent}% within ±1%`, () => {
|
|
145
|
-
const slider = timelineZoomPercentToSlider(percent);
|
|
146
|
-
const back = timelineSliderToZoomPercent(slider);
|
|
159
|
+
const slider = timelineZoomPercentToSlider(percent, FIT_PPS);
|
|
160
|
+
const back = timelineSliderToZoomPercent(slider, FIT_PPS);
|
|
147
161
|
expect(Math.abs(back - percent) / percent).toBeLessThan(0.01);
|
|
148
162
|
});
|
|
149
163
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ZoomMode } from "../store/playerStore";
|
|
2
|
+
import { STUDIO_PREVIEW_FPS } from "../lib/time";
|
|
2
3
|
|
|
3
4
|
export const MIN_TIMELINE_ZOOM_PERCENT = 10;
|
|
4
|
-
|
|
5
|
+
const MAX_TIMELINE_FRAME_WIDTH_PX = 48;
|
|
5
6
|
// CapCut-strength steps: one button press / pinch gesture moves the zoom
|
|
6
7
|
// meaningfully (user feedback, twice-doubled: 1.25×/0.8× + 0.0035 felt like
|
|
7
8
|
// "zooming several times to get anywhere", then 1.5× + 0.007 still too soft).
|
|
@@ -10,16 +11,24 @@ const ZOOM_OUT_FACTOR = 0.5;
|
|
|
10
11
|
const ZOOM_IN_FACTOR = 2;
|
|
11
12
|
const PINCH_ZOOM_SENSITIVITY = 0.014;
|
|
12
13
|
|
|
13
|
-
export function
|
|
14
|
+
export function getMaxTimelineZoomPercent(fitPixelsPerSecond: number): number {
|
|
15
|
+
if (!Number.isFinite(fitPixelsPerSecond) || fitPixelsPerSecond <= 0) return 100;
|
|
16
|
+
const frameLevelPixelsPerSecond = STUDIO_PREVIEW_FPS * MAX_TIMELINE_FRAME_WIDTH_PX;
|
|
17
|
+
return Math.max(100, Math.round((frameLevelPixelsPerSecond / fitPixelsPerSecond) * 100));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clampTimelineZoomPercent(percent: number, fitPixelsPerSecond: number): number {
|
|
14
21
|
if (!Number.isFinite(percent)) return 100;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
Math.min(MAX_TIMELINE_ZOOM_PERCENT, Math.round(percent)),
|
|
18
|
-
);
|
|
22
|
+
const maxZoomPercent = getMaxTimelineZoomPercent(fitPixelsPerSecond);
|
|
23
|
+
return Math.max(MIN_TIMELINE_ZOOM_PERCENT, Math.min(maxZoomPercent, Math.round(percent)));
|
|
19
24
|
}
|
|
20
25
|
|
|
21
|
-
export function getTimelineZoomPercent(
|
|
22
|
-
|
|
26
|
+
export function getTimelineZoomPercent(
|
|
27
|
+
zoomMode: ZoomMode,
|
|
28
|
+
manualZoomPercent: number,
|
|
29
|
+
fitPixelsPerSecond: number,
|
|
30
|
+
): number {
|
|
31
|
+
return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent, fitPixelsPerSecond);
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -47,7 +56,10 @@ export function computePinnedZoomPercent(
|
|
|
47
56
|
) {
|
|
48
57
|
return 100;
|
|
49
58
|
}
|
|
50
|
-
return clampTimelineZoomPercent(
|
|
59
|
+
return clampTimelineZoomPercent(
|
|
60
|
+
(currentPixelsPerSecond / fitPixelsPerSecond) * 100,
|
|
61
|
+
fitPixelsPerSecond,
|
|
62
|
+
);
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
export function getTimelinePixelsPerSecond(
|
|
@@ -56,7 +68,7 @@ export function getTimelinePixelsPerSecond(
|
|
|
56
68
|
manualZoomPercent: number,
|
|
57
69
|
): number {
|
|
58
70
|
if (!Number.isFinite(fitPixelsPerSecond) || fitPixelsPerSecond <= 0) return 100;
|
|
59
|
-
const zoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
71
|
+
const zoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
|
60
72
|
return zoomMode === "fit" ? fitPixelsPerSecond : fitPixelsPerSecond * (zoomPercent / 100);
|
|
61
73
|
}
|
|
62
74
|
|
|
@@ -64,41 +76,46 @@ export function getNextTimelineZoomPercent(
|
|
|
64
76
|
direction: "in" | "out",
|
|
65
77
|
zoomMode: ZoomMode,
|
|
66
78
|
manualZoomPercent: number,
|
|
79
|
+
fitPixelsPerSecond: number,
|
|
67
80
|
): number {
|
|
68
|
-
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
81
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
|
69
82
|
const next = direction === "in" ? current * ZOOM_IN_FACTOR : current * ZOOM_OUT_FACTOR;
|
|
70
|
-
return clampTimelineZoomPercent(next);
|
|
83
|
+
return clampTimelineZoomPercent(next, fitPixelsPerSecond);
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
export function getPinchTimelineZoomPercent(
|
|
74
87
|
deltaY: number,
|
|
75
88
|
zoomMode: ZoomMode,
|
|
76
89
|
manualZoomPercent: number,
|
|
90
|
+
fitPixelsPerSecond: number,
|
|
77
91
|
): number {
|
|
78
|
-
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent);
|
|
92
|
+
const current = getTimelineZoomPercent(zoomMode, manualZoomPercent, fitPixelsPerSecond);
|
|
79
93
|
if (!Number.isFinite(deltaY) || deltaY === 0) return current;
|
|
80
|
-
return clampTimelineZoomPercent(
|
|
94
|
+
return clampTimelineZoomPercent(
|
|
95
|
+
current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY),
|
|
96
|
+
fitPixelsPerSecond,
|
|
97
|
+
);
|
|
81
98
|
}
|
|
82
99
|
|
|
83
100
|
const LOG_MIN = Math.log(MIN_TIMELINE_ZOOM_PERCENT);
|
|
84
|
-
const LOG_MAX = Math.log(MAX_TIMELINE_ZOOM_PERCENT);
|
|
85
101
|
|
|
86
102
|
/**
|
|
87
|
-
* Maps
|
|
88
|
-
*
|
|
89
|
-
* low end (10–100%) into a tiny sliver of the slider.
|
|
103
|
+
* Maps the frame-level zoom range to a slider position (0–100) using a log scale.
|
|
104
|
+
* Linear would compress the useful low end into a tiny sliver of the slider.
|
|
90
105
|
*/
|
|
91
|
-
export function timelineZoomPercentToSlider(percent: number): number {
|
|
92
|
-
const clamped = clampTimelineZoomPercent(percent);
|
|
93
|
-
|
|
106
|
+
export function timelineZoomPercentToSlider(percent: number, fitPixelsPerSecond: number): number {
|
|
107
|
+
const clamped = clampTimelineZoomPercent(percent, fitPixelsPerSecond);
|
|
108
|
+
const logMax = Math.log(getMaxTimelineZoomPercent(fitPixelsPerSecond));
|
|
109
|
+
return ((Math.log(clamped) - LOG_MIN) / (logMax - LOG_MIN)) * 100;
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
/**
|
|
97
|
-
* Maps a slider position (0–100) to
|
|
113
|
+
* Maps a slider position (0–100) to the frame-level zoom range using a log scale.
|
|
98
114
|
* Inverse of `timelineZoomPercentToSlider`.
|
|
99
115
|
*/
|
|
100
|
-
export function timelineSliderToZoomPercent(slider: number): number {
|
|
116
|
+
export function timelineSliderToZoomPercent(slider: number, fitPixelsPerSecond: number): number {
|
|
101
117
|
const clampedSlider = Math.max(0, Math.min(100, slider));
|
|
102
|
-
const
|
|
103
|
-
|
|
118
|
+
const logMax = Math.log(getMaxTimelineZoomPercent(fitPixelsPerSecond));
|
|
119
|
+
const logValue = LOG_MIN + (clampedSlider / 100) * (logMax - LOG_MIN);
|
|
120
|
+
return clampTimelineZoomPercent(Math.exp(logValue), fitPixelsPerSecond);
|
|
104
121
|
}
|
|
@@ -1,113 +1,116 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
|
|
3
|
+
import React, { act, useRef } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
3
5
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import {
|
|
6
|
+
import { liveTime, type TimelineElement } from "../store/playerStore";
|
|
7
|
+
import {
|
|
8
|
+
isTimelineClipActive,
|
|
9
|
+
updateTimelineActiveClipClasses,
|
|
10
|
+
useTimelineActiveClips,
|
|
11
|
+
} from "./useTimelineActiveClips";
|
|
5
12
|
|
|
6
|
-
|
|
7
|
-
const clip = document.createElement("div");
|
|
8
|
-
clip.dataset.clip = "true";
|
|
9
|
-
clip.dataset.elId = id;
|
|
10
|
-
clip.dataset.clipStart = start;
|
|
11
|
-
clip.dataset.clipEnd = end;
|
|
12
|
-
container.append(clip);
|
|
13
|
-
return clip;
|
|
14
|
-
}
|
|
13
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const intro = appendClip(container, "intro", "0", "2");
|
|
20
|
-
const hero = appendClip(container, "hero", "2", "5");
|
|
21
|
-
const outro = appendClip(container, "outro", "5", "8");
|
|
22
|
-
const previous = new Set<string>();
|
|
15
|
+
function clip(id: string, start: number, duration: number, hidden = false): TimelineElement {
|
|
16
|
+
return { id, tag: "div", start, duration, track: 1, hidden };
|
|
17
|
+
}
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
|
|
20
|
+
const element = document.createElement("div");
|
|
21
|
+
element.dataset.clip = "true";
|
|
22
|
+
element.dataset.elId = id;
|
|
23
|
+
element.dataset.clipStart = start;
|
|
24
|
+
element.dataset.clipEnd = end;
|
|
25
|
+
container.append(element);
|
|
26
|
+
return element;
|
|
27
|
+
}
|
|
25
28
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
function Harness({ version, heroStart = 2 }: { version: number; heroStart?: number }) {
|
|
30
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
useTimelineActiveClips({
|
|
32
|
+
scrollRef,
|
|
33
|
+
currentTime: 0,
|
|
34
|
+
clipStateVersion: 0,
|
|
35
|
+
elementStateVersion: version,
|
|
30
36
|
});
|
|
37
|
+
return React.createElement(
|
|
38
|
+
"div",
|
|
39
|
+
{ ref: scrollRef },
|
|
40
|
+
React.createElement("div", {
|
|
41
|
+
"data-clip": "true",
|
|
42
|
+
"data-el-id": "intro",
|
|
43
|
+
"data-clip-start": "0",
|
|
44
|
+
"data-clip-end": "1",
|
|
45
|
+
}),
|
|
46
|
+
React.createElement("div", {
|
|
47
|
+
"data-clip": "true",
|
|
48
|
+
"data-el-id": "hero",
|
|
49
|
+
"data-clip-start": String(heroStart),
|
|
50
|
+
"data-clip-end": String(heroStart + 1),
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
31
54
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
updateTimelineActiveClipClasses(container, previous, 2);
|
|
40
|
-
|
|
41
|
-
expect(hidden.hasAttribute("data-active")).toBe(false);
|
|
42
|
-
expect(visible.hasAttribute("data-active")).toBe(true);
|
|
43
|
-
expect(previous).toEqual(new Set(["visible"]));
|
|
55
|
+
describe("timeline active clips", () => {
|
|
56
|
+
it("uses model timing and keeps the end boundary inclusive", () => {
|
|
57
|
+
const element = clip("hero", 2, 3);
|
|
58
|
+
expect(isTimelineClipActive(element, 2)).toBe(true);
|
|
59
|
+
expect(isTimelineClipActive(element, 5)).toBe(true);
|
|
60
|
+
expect(isTimelineClipActive(element, 5.001)).toBe(false);
|
|
44
61
|
});
|
|
45
62
|
|
|
46
|
-
it("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const hero = appendClip(container, "hero", "2", "5");
|
|
50
|
-
const previous = new Set(["intro"]);
|
|
51
|
-
intro.toggleAttribute("data-active", true);
|
|
52
|
-
|
|
53
|
-
updateTimelineActiveClipClasses(container, previous, 2);
|
|
54
|
-
|
|
55
|
-
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
56
|
-
expect(hero.hasAttribute("data-active")).toBe(true);
|
|
57
|
-
expect(previous).toEqual(new Set(["intro", "hero"]));
|
|
63
|
+
it("never activates hidden or invalid clips", () => {
|
|
64
|
+
expect(isTimelineClipActive(clip("hidden", 0, 5, true), 2)).toBe(false);
|
|
65
|
+
expect(isTimelineClipActive(clip("invalid", Number.NaN, 5), 2)).toBe(false);
|
|
58
66
|
});
|
|
59
67
|
|
|
60
|
-
it("
|
|
68
|
+
it("synchronizes mounted clip attributes", () => {
|
|
61
69
|
const container = document.createElement("div");
|
|
62
70
|
const intro = appendClip(container, "intro", "0", "2");
|
|
71
|
+
const hero = appendClip(container, "hero", "2", "5");
|
|
63
72
|
const previous = new Set<string>();
|
|
64
73
|
|
|
65
|
-
updateTimelineActiveClipClasses(container, previous,
|
|
66
|
-
|
|
67
|
-
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
68
|
-
expect(previous).toEqual(new Set(["intro"]));
|
|
69
|
-
|
|
70
|
-
updateTimelineActiveClipClasses(container, previous, 2);
|
|
71
|
-
|
|
72
|
-
expect(intro.hasAttribute("data-active")).toBe(true);
|
|
73
|
-
expect(previous).toEqual(new Set(["intro"]));
|
|
74
|
-
|
|
75
|
-
updateTimelineActiveClipClasses(container, previous, 2.001);
|
|
74
|
+
updateTimelineActiveClipClasses(container, previous, 2.25);
|
|
76
75
|
|
|
77
76
|
expect(intro.hasAttribute("data-active")).toBe(false);
|
|
78
|
-
expect(
|
|
77
|
+
expect(hero.hasAttribute("data-active")).toBe(true);
|
|
78
|
+
expect(previous).toEqual(new Set(["hero"]));
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
it("re-applies
|
|
82
|
-
// A clip that moves lanes on a reorder remounts as a new element. It stays
|
|
83
|
-
// in the previous active set, so the plain diff would skip it and leave the
|
|
84
|
-
// new node without data-active. syncAll must force the attribute on.
|
|
81
|
+
it("re-applies active state when a clip remounts inside the current window", () => {
|
|
85
82
|
const container = document.createElement("div");
|
|
86
83
|
appendClip(container, "hero", "0", "5");
|
|
87
84
|
const previous = new Set<string>();
|
|
88
85
|
updateTimelineActiveClipClasses(container, previous, 2);
|
|
89
|
-
expect(previous).toEqual(new Set(["hero"]));
|
|
90
86
|
|
|
91
|
-
// Simulate a remount: replace the hero clip's DOM node (no data-active).
|
|
92
87
|
container.replaceChildren();
|
|
93
|
-
const
|
|
94
|
-
expect(heroReborn.hasAttribute("data-active")).toBe(false);
|
|
95
|
-
|
|
96
|
-
// Diff-only would skip it (still active → unchanged); syncAll re-applies.
|
|
88
|
+
const remounted = appendClip(container, "hero", "0", "5");
|
|
97
89
|
updateTimelineActiveClipClasses(container, previous, 2, true);
|
|
98
|
-
|
|
90
|
+
|
|
91
|
+
expect(remounted.hasAttribute("data-active")).toBe(true);
|
|
99
92
|
});
|
|
100
93
|
|
|
101
|
-
it("
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
94
|
+
it("moves active state with live playback without changing store time", async () => {
|
|
95
|
+
const host = document.createElement("div");
|
|
96
|
+
document.body.append(host);
|
|
97
|
+
const root = createRoot(host);
|
|
98
|
+
await act(async () => root.render(React.createElement(Harness, { version: 0 })));
|
|
99
|
+
|
|
100
|
+
const intro = host.querySelector<HTMLElement>('[data-el-id="intro"]');
|
|
101
|
+
const hero = host.querySelector<HTMLElement>('[data-el-id="hero"]');
|
|
102
|
+
expect(intro?.hasAttribute("data-active")).toBe(true);
|
|
103
|
+
expect(hero?.hasAttribute("data-active")).toBe(false);
|
|
104
|
+
|
|
105
|
+
act(() => liveTime.notify(2.5));
|
|
106
|
+
expect(intro?.hasAttribute("data-active")).toBe(false);
|
|
107
|
+
expect(hero?.hasAttribute("data-active")).toBe(true);
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
await act(async () => root.render(React.createElement(Harness, { version: 1, heroStart: 4 })));
|
|
110
|
+
act(() => liveTime.notify(2.5));
|
|
111
|
+
expect(host.querySelector('[data-el-id="hero"]')?.hasAttribute("data-active")).toBe(false);
|
|
108
112
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
expect(previous).toEqual(new Set());
|
|
113
|
+
await act(async () => root.unmount());
|
|
114
|
+
host.remove();
|
|
112
115
|
});
|
|
113
116
|
});
|