@hyperframes/studio 0.7.86 → 0.7.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-BFqB5AWJ.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-B1Vf4vCl.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-CISVI7UC.js → index-BblzZ6Av.js} +221 -221
  5. package/dist/assets/{index-C0Vr8jjc.js → index-D_JU4EeH.js} +1 -1
  6. package/dist/index.d.ts +14 -6
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6265 -4922
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -8
  11. package/src/components/TimelineToolbar.tsx +14 -5
  12. package/src/components/nle/NLEContext.tsx +4 -1
  13. package/src/components/nle/TimelinePane.tsx +2 -0
  14. package/src/hooks/studioTestMode.test.ts +35 -0
  15. package/src/hooks/studioTestMode.ts +23 -0
  16. package/src/hooks/useGestureRecording.test.tsx +120 -0
  17. package/src/hooks/useGestureRecording.ts +49 -40
  18. package/src/hooks/useStudioTestHooks.test.tsx +161 -0
  19. package/src/hooks/useStudioTestHooks.ts +54 -7
  20. package/src/player/components/BeatStrip.tsx +21 -5
  21. package/src/player/components/CompositionThumbnail.test.ts +96 -2
  22. package/src/player/components/CompositionThumbnail.tsx +29 -16
  23. package/src/player/components/Timeline.test.ts +104 -0
  24. package/src/player/components/Timeline.tsx +141 -131
  25. package/src/player/components/Timeline.virtualization.test.tsx +400 -0
  26. package/src/player/components/TimelineCanvas.tsx +18 -10
  27. package/src/player/components/TimelineClip.tsx +4 -0
  28. package/src/player/components/TimelineClipDiamonds.test.tsx +85 -5
  29. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  30. package/src/player/components/TimelineLanes.test.tsx +15 -4
  31. package/src/player/components/TimelineLanes.tsx +81 -26
  32. package/src/player/components/TimelinePropertyLanes.test.tsx +16 -20
  33. package/src/player/components/TimelinePropertyLanes.tsx +1 -0
  34. package/src/player/components/TimelineRuler.tsx +21 -5
  35. package/src/player/components/TimelineTrackRow.tsx +46 -0
  36. package/src/player/components/TimelineTypes.ts +2 -0
  37. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +97 -0
  38. package/src/player/components/timelineClipDragGestureLifecycle.ts +261 -0
  39. package/src/player/components/timelineDiamondTypes.ts +5 -0
  40. package/src/player/components/timelineDragDrop.ts +5 -5
  41. package/src/player/components/timelineEditing.test.ts +32 -0
  42. package/src/player/components/timelineEditing.ts +47 -5
  43. package/src/player/components/timelineFocusIdentity.test.ts +21 -0
  44. package/src/player/components/timelineFocusIdentity.ts +21 -0
  45. package/src/player/components/timelineLaneProps.ts +9 -0
  46. package/src/player/components/timelineLayout.test.ts +59 -0
  47. package/src/player/components/timelineLayout.ts +202 -176
  48. package/src/player/components/timelineRowVirtualizationFlag.test.ts +23 -0
  49. package/src/player/components/timelineRowVirtualizationFlag.ts +10 -0
  50. package/src/player/components/timelineRulerGeometry.ts +142 -0
  51. package/src/player/components/timelineViewModel.ts +44 -0
  52. package/src/player/components/timelineViewportGeometry.ts +51 -0
  53. package/src/player/components/timelineZoom.test.ts +42 -28
  54. package/src/player/components/timelineZoom.ts +42 -25
  55. package/src/player/components/useTimelineActiveClips.test.ts +82 -79
  56. package/src/player/components/useTimelineActiveClips.ts +49 -55
  57. package/src/player/components/useTimelineClipDrag.ts +40 -230
  58. package/src/player/components/useTimelineClipRenderWindow.ts +95 -0
  59. package/src/player/components/useTimelineGeometry.ts +4 -11
  60. package/src/player/components/useTimelinePlayhead.ts +30 -14
  61. package/src/player/components/useTimelineRangeSelection.ts +5 -4
  62. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  63. package/src/player/components/useTimelineRevealClip.test.tsx +139 -0
  64. package/src/player/components/useTimelineRevealClip.ts +171 -47
  65. package/src/player/components/useTimelineRowVirtualization.ts +140 -0
  66. package/src/player/components/useTimelineScrollViewport.test.tsx +202 -0
  67. package/src/player/components/useTimelineScrollViewport.ts +87 -9
  68. package/src/player/components/useTimelineSelectionLifecycle.ts +29 -0
  69. package/src/player/components/useTimelineShiftModifier.ts +20 -0
  70. package/src/player/components/useTimelineTicks.ts +22 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +32 -26
  72. package/src/player/components/useTimelineTrackLayout.ts +35 -18
  73. package/src/player/components/useTimelineVirtualRows.test.tsx +174 -0
  74. package/src/player/components/useTimelineVirtualRows.ts +126 -0
  75. package/src/player/hooks/timelinePlayerSync.ts +20 -0
  76. package/src/player/hooks/useTimelinePlayer.seek.test.ts +90 -51
  77. package/src/player/hooks/useTimelinePlayer.ts +8 -20
  78. package/src/player/lib/playbackScrub.ts +2 -2
  79. package/src/player/lib/timelineClipIndex.test.ts +169 -0
  80. package/src/player/lib/timelineClipIndex.ts +208 -0
  81. package/src/player/lib/timelineElementIndexes.test.ts +31 -0
  82. package/src/player/lib/timelineElementIndexes.ts +44 -0
  83. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +100 -0
  84. package/src/player/lib/timelinePerformanceDiagnostics.ts +120 -0
  85. package/src/player/lib/timelinePerformanceFixture.ts +167 -0
  86. package/src/player/lib/timelineViewportBudgets.test.ts +60 -0
  87. package/src/player/lib/timelineViewportBudgets.ts +151 -0
  88. package/src/player/store/playerStore.test.ts +20 -3
  89. package/src/player/store/playerStore.ts +73 -45
  90. package/src/utils/studioHelpers.test.ts +48 -1
  91. package/src/utils/studioHelpers.ts +31 -34
  92. package/src/utils/studioUiPreferences.ts +3 -1
  93. package/dist/assets/index-D379YOKT.css +0 -1
@@ -0,0 +1,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(5000)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
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", MAX_TIMELINE_ZOOM_PERCENT)).toBe(
63
- MAX_TIMELINE_ZOOM_PERCENT,
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(MIN_TIMELINE_ZOOM_PERCENT);
84
- expect(getPinchTimelineZoomPercent(-10000, "manual", 100)).toBe(MAX_TIMELINE_ZOOM_PERCENT);
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(MAX_TIMELINE_ZOOM_PERCENT)).toBeCloseTo(100, 5);
107
+ expect(timelineZoomPercentToSlider(MAX_ZOOM_PERCENT, FIT_PPS)).toBeCloseTo(100, 5);
95
108
  });
96
109
 
97
- it("maps 100% to the log midpoint between 10 and 2000", () => {
98
- const expected = ((Math.log(100) - Math.log(10)) / (Math.log(2000) - Math.log(10))) * 100;
99
- expect(timelineZoomPercentToSlider(100)).toBeCloseTo(expected, 3);
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(MAX_TIMELINE_ZOOM_PERCENT);
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(MAX_TIMELINE_ZOOM_PERCENT);
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
- export const MAX_TIMELINE_ZOOM_PERCENT = 2000;
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 clampTimelineZoomPercent(percent: number): number {
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
- return Math.max(
16
- MIN_TIMELINE_ZOOM_PERCENT,
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(zoomMode: ZoomMode, manualZoomPercent: number): number {
22
- return zoomMode === "fit" ? 100 : clampTimelineZoomPercent(manualZoomPercent);
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((currentPixelsPerSecond / fitPixelsPerSecond) * 100);
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(current * Math.exp(-deltaY * PINCH_ZOOM_SENSITIVITY));
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 a zoom percent (10–2000) to a slider position (0–100) using a log scale.
88
- * Log scale is used because the range spans 200×; linear would compress the
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
- return ((Math.log(clamped) - LOG_MIN) / (LOG_MAX - LOG_MIN)) * 100;
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 a zoom percent (10–2000) using a log scale.
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 logValue = LOG_MIN + (clampedSlider / 100) * (LOG_MAX - LOG_MIN);
103
- return clampTimelineZoomPercent(Math.exp(logValue));
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 { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
6
+ import { liveTime, type TimelineElement } from "../store/playerStore";
7
+ import {
8
+ isTimelineClipActive,
9
+ updateTimelineActiveClipClasses,
10
+ useTimelineActiveClips,
11
+ } from "./useTimelineActiveClips";
5
12
 
6
- function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
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
- describe("updateTimelineActiveClipClasses", () => {
17
- it("toggles data-active only for clips containing the current time", () => {
18
- const container = document.createElement("div");
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
- updateTimelineActiveClipClasses(container, previous, 2.25);
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
- expect(intro.hasAttribute("data-active")).toBe(false);
27
- expect(hero.hasAttribute("data-active")).toBe(true);
28
- expect(outro.hasAttribute("data-active")).toBe(false);
29
- expect(previous).toEqual(new Set(["hero"]));
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
- it("never marks hidden clips active inside their time window", () => {
33
- const container = document.createElement("div");
34
- const hidden = appendClip(container, "hidden", "0", "5");
35
- const visible = appendClip(container, "visible", "0", "5");
36
- hidden.dataset.clipHidden = "true";
37
- const previous = new Set<string>();
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("diffs against the previous active set", () => {
47
- const container = document.createElement("div");
48
- const intro = appendClip(container, "intro", "0", "2");
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("keeps a clip active through its inclusive end boundary", () => {
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, 0);
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(previous).toEqual(new Set());
77
+ expect(hero.hasAttribute("data-active")).toBe(true);
78
+ expect(previous).toEqual(new Set(["hero"]));
79
79
  });
80
80
 
81
- it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => {
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 heroReborn = appendClip(container, "hero", "0", "5");
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
- expect(heroReborn.hasAttribute("data-active")).toBe(true);
90
+
91
+ expect(remounted.hasAttribute("data-active")).toBe(true);
99
92
  });
100
93
 
101
- it("ignores clips with invalid timing data", () => {
102
- const container = document.createElement("div");
103
- const missingId = appendClip(container, "", "0", "2");
104
- const missingTiming = appendClip(container, "bad", "", "2");
105
- const previous = new Set<string>();
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
- updateTimelineActiveClipClasses(container, previous, 1);
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
- expect(missingId.hasAttribute("data-active")).toBe(false);
110
- expect(missingTiming.hasAttribute("data-active")).toBe(false);
111
- expect(previous).toEqual(new Set());
113
+ await act(async () => root.unmount());
114
+ host.remove();
112
115
  });
113
116
  });