@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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-BL3qfGEQ.js → hyperframes-player-B03_wTTS.js} +1 -1
  2. package/dist/assets/{index-B5QGai9B.js → index-BNshnsvB.js} +1 -1
  3. package/dist/assets/index-BXkzp_OU.css +1 -0
  4. package/dist/assets/{index-C0Vr8jjc.js → index-D7KcL_t3.js} +1 -1
  5. package/dist/assets/{index-CISVI7UC.js → index-DbY124Po.js} +221 -221
  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
@@ -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
- let isDev = false;
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
- {visibleBeatTimes?.map((t, i) => {
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, beatStrengths?.[i] ?? 0.5), 2.2);
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
- {beatTimes.map((t, i) => {
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, beatStrengths?.[i] ?? 0.5), 2.2);
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
- import { describe, expect, it } from "vitest";
2
- import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
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(