@hyperframes/studio 0.8.73 → 0.8.75

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.73",
3
+ "version": "0.8.75",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,11 +52,11 @@
52
52
  "dockview-react": "8.3.1",
53
53
  "gsap": "^3.13.0",
54
54
  "mediabunny": "^1.45.3",
55
- "@hyperframes/core": "0.8.73",
56
- "@hyperframes/sdk": "0.8.73",
57
- "@hyperframes/player": "0.8.73",
58
- "@hyperframes/studio-server": "0.8.73",
59
- "@hyperframes/parsers": "0.8.73"
55
+ "@hyperframes/core": "0.8.75",
56
+ "@hyperframes/player": "0.8.75",
57
+ "@hyperframes/parsers": "0.8.75",
58
+ "@hyperframes/sdk": "0.8.75",
59
+ "@hyperframes/studio-server": "0.8.75"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@tailwindcss/vite": "^4.3.3",
@@ -72,7 +72,7 @@
72
72
  "vite": "^6.4.2",
73
73
  "vitest": "^4.1.11",
74
74
  "zustand": "^5.0.0",
75
- "@hyperframes/producer": "0.8.73"
75
+ "@hyperframes/producer": "0.8.75"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "react": "19",
@@ -182,6 +182,17 @@ export function patchIframeDomTiming(
182
182
  }
183
183
  }
184
184
 
185
+ /** Takes deleted clips out of the live preview at once: until the reload lands, anything that re-reads the
186
+ * preview (composition enrichment) would otherwise put them back on the timeline. */
187
+ export function removeIframeTimelineElements(
188
+ iframe: HTMLIFrameElement | null,
189
+ elements: TimelineElement[],
190
+ activeCompositionPath: string | null = null,
191
+ ): void {
192
+ for (const element of elements)
193
+ findTimelineElementInIframe(iframe, element, activeCompositionPath)?.remove();
194
+ }
195
+
185
196
  export function playbackStartAttributeForElement(
186
197
  element: Pick<TimelineElement, "kind" | "playbackStartAttr">,
187
198
  ): "data-media-start" | "data-playback-start" {
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { act } from "react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import type { TimelineElement } from "../player";
5
+ import { usePlayerStore, type TimelineElement } from "../player";
6
6
  import { applyRippleShifts, useTimelineDeleteOps } from "./useTimelineDeleteOps";
7
7
  import { installReactActEnvironment, mountReactHarness } from "./domSelectionTestHarness";
8
8
  import { useTrackPendingTimelineEdit } from "./useTrackPendingTimelineEdit";
@@ -78,9 +78,30 @@ describe("useTimelineDeleteOps: ripple undo label", () => {
78
78
  return null;
79
79
  }
80
80
  mountReactHarness(<Harness />);
81
- return { b: elements[1], getHook: () => hook! };
81
+ return { elements, b: elements[1], getHook: () => hook! };
82
82
  }
83
83
 
84
+ it("drops the deleted clip from the timeline while the ripple is still saving", async () => {
85
+ let finishRipple = () => {};
86
+ const handleTimelineGroupMove = vi.fn(
87
+ () => new Promise<void>((resolve) => (finishRipple = resolve)),
88
+ );
89
+ const { elements, b, getHook } = mountDeleteHarness({ handleTimelineGroupMove });
90
+ usePlayerStore.getState().setElements(elements);
91
+
92
+ let deleting = Promise.resolve();
93
+ await act(async () => {
94
+ deleting = getHook().handleTimelineElementDelete(b);
95
+ await vi.waitFor(() => expect(handleTimelineGroupMove).toHaveBeenCalled());
96
+ });
97
+ expect(usePlayerStore.getState().elements.map((e) => e.id)).toEqual(["hf-a", "hf-c"]);
98
+
99
+ await act(async () => {
100
+ finishRipple();
101
+ await deleting;
102
+ });
103
+ });
104
+
84
105
  // editHistory.ts's coalescing keeps the LAST recordEdit call's label, so the
85
106
  // folded ripple move must carry the delete's label, not its own.
86
107
  it("passes the delete's own label to the folded ripple move, not 'Move timeline clips'", async () => {
@@ -228,3 +249,65 @@ describe("useTimelineDeleteOps: undo race", () => {
228
249
  });
229
250
  });
230
251
  });
252
+
253
+ // Regression: the live preview kept a deleted clip until its reload landed, and composition
254
+ // enrichment re-read it from there, so the clip stayed drawn after the file dropped it.
255
+ describe("useTimelineDeleteOps: live preview", () => {
256
+ const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
257
+ <div data-hf-id="hf-stage" data-composition-id="main" data-duration="4">
258
+ <div data-hf-id="hf-a" data-start="0" data-duration="2"></div>
259
+ <div data-hf-id="hf-b" data-start="2" data-duration="2"></div>
260
+ </div>
261
+ </body></html>`;
262
+
263
+ beforeEach(() => {
264
+ vi.stubGlobal(
265
+ "fetch",
266
+ vi.fn(async () => new Response(JSON.stringify({ changed: true, content: html }))),
267
+ );
268
+ });
269
+
270
+ afterEach(() => {
271
+ document.body.innerHTML = "";
272
+ vi.unstubAllGlobals();
273
+ });
274
+
275
+ it("takes the deleted clip out of the live preview before its reload", async () => {
276
+ const preview = document.implementation.createHTMLDocument();
277
+ preview.documentElement.innerHTML = html;
278
+ const iframe = { contentDocument: preview } as unknown as HTMLIFrameElement;
279
+ const clip = (id: string, start: number): TimelineElement => ({
280
+ id,
281
+ tag: "div",
282
+ start,
283
+ duration: 2,
284
+ track: 0,
285
+ hfId: id,
286
+ sourceFile: "index.html",
287
+ });
288
+ const elements = [clip("hf-a", 0), clip("hf-b", 2)];
289
+ let hook: ReturnType<typeof useTimelineDeleteOps> | null = null;
290
+ function Harness() {
291
+ hook = useTimelineDeleteOps({
292
+ projectIdRef: { current: "test-project" },
293
+ activeCompPath: "index.html",
294
+ timelineElements: elements,
295
+ showToast: vi.fn(),
296
+ writeProjectFile: vi.fn().mockResolvedValue(undefined),
297
+ recordEdit: vi.fn().mockResolvedValue(undefined),
298
+ reloadPreview: vi.fn(),
299
+ previewIframeRef: { current: iframe },
300
+ handleTimelineGroupMove: vi.fn().mockResolvedValue(undefined),
301
+ });
302
+ return null;
303
+ }
304
+ mountReactHarness(<Harness />);
305
+
306
+ await act(async () => {
307
+ await hook!.handleTimelineElementDelete(elements[1]);
308
+ });
309
+
310
+ expect(preview.querySelector('[data-hf-id="hf-b"]')).toBeNull();
311
+ expect(preview.querySelector('[data-hf-id="hf-a"]')).not.toBeNull();
312
+ });
313
+ });
@@ -8,7 +8,7 @@ import { usePlayerStore } from "../player";
8
8
  import { saveProjectFilesWithHistory, type RecordEditInput } from "../utils/studioFileHistory";
9
9
  import { studioWriteHeaders } from "../utils/studioFileVersion";
10
10
  import { getTimelineElementLabel } from "../utils/studioHelpers";
11
- import { buildPatchTarget } from "./timelineEditingHelpers";
11
+ import { buildPatchTarget, removeIframeTimelineElements } from "./timelineEditingHelpers";
12
12
  import { captureDurationRollback, readFileContent } from "./timelineTimingSync";
13
13
  import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
14
14
  import { furthestClipEndFromSource } from "../player/lib/timelineElementHelpers";
@@ -172,6 +172,7 @@ export function useTimelineDeleteOps({
172
172
  throw error;
173
173
  }
174
174
 
175
+ removeIframeTimelineElements(previewIframeRef.current, sameFile, activeCompPath);
175
176
  const deletedKeys = new Set(sameFile.map((te) => te.key ?? te.id));
176
177
  const survivors = timelineElements.filter((te) => !deletedKeys.has(te.key ?? te.id));
177
178
 
@@ -184,10 +185,11 @@ export function useTimelineDeleteOps({
184
185
  sameFile,
185
186
  usePlayerStore.getState().rippleEditEnabled,
186
187
  );
187
- let rippleApplied: TimelineGroupMoveChange[] | null = null;
188
+ const rippleChanges = rippleShifts ? resolveShiftedElements(survivors, rippleShifts) : null;
189
+ // The timeline follows the delete's write at once, as the owner of the ripple's optimistic update.
190
+ usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleChanges));
188
191
  let rippleFailed = false;
189
- if (rippleShifts) {
190
- const rippleChanges = resolveShiftedElements(survivors, rippleShifts);
192
+ if (rippleChanges) {
191
193
  try {
192
194
  await handleTimelineGroupMove(rippleChanges, {
193
195
  coalesceKey,
@@ -200,18 +202,17 @@ export function useTimelineDeleteOps({
200
202
  // the generic one would tell the user about one action twice.
201
203
  suppressFailureToast: true,
202
204
  });
203
- rippleApplied = rippleChanges;
204
205
  } catch (error) {
205
206
  // The delete already committed; a failed ripple leaves the gap the
206
207
  // toggle-off behaviour would have left anyway — not worth undoing
207
208
  // an otherwise-successful delete over.
208
209
  rippleFailed = true;
210
+ usePlayerStore.getState().setElements(survivors);
209
211
  console.error("[Timeline] ripple-edit failed to persist after delete", error);
210
212
  showToast("Clip deleted, but the gap could not be closed.", "error");
211
213
  }
212
214
  }
213
215
 
214
- usePlayerStore.getState().setElements(applyRippleShifts(survivors, rippleApplied));
215
216
  usePlayerStore.getState().setSelectedElementId(null);
216
217
  usePlayerStore.getState().setSelectedElementIds(new Set());
217
218
  forceReloadSdkSession?.();
@@ -224,7 +225,7 @@ export function useTimelineDeleteOps({
224
225
  "info",
225
226
  );
226
227
  }
227
- if (rippleApplied && !rippleNoticeShownRef.current) {
228
+ if (rippleChanges && !rippleFailed && !rippleNoticeShownRef.current) {
228
229
  rippleNoticeShownRef.current = true;
229
230
  showToast(
230
231
  "Ripple closed the gap on the main track. Undo (⌘Z) restores it, or turn off " +
@@ -11,6 +11,9 @@
11
11
  */
12
12
  import { PLAYHEAD_HEAD_W } from "./timelineLayout";
13
13
 
14
+ /** Widest part of the playhead, centred on its line. */
15
+ export const PLAYHEAD_GLOW_W = 13;
16
+
14
17
  interface PlayheadIndicatorProps {
15
18
  /** CSS color, defaults to the HF accent variable */
16
19
  color?: string;
@@ -50,7 +53,7 @@ export function PlayheadIndicator({
50
53
  className="absolute top-0 bottom-0"
51
54
  style={{
52
55
  left: "50%",
53
- width: 13,
56
+ width: PLAYHEAD_GLOW_W,
54
57
  transform: "translateX(-50%)",
55
58
  background: `radial-gradient(closest-side, ${glowColor}, transparent)`,
56
59
  }}
@@ -88,9 +88,7 @@ function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: str
88
88
  if (!ruler) throw new Error("Missing timeline ruler");
89
89
  const rulerOrigin = ruler.previousElementSibling;
90
90
  if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin");
91
- const playhead = Array.from(host.querySelectorAll<HTMLElement>("div")).find(
92
- (node) => node.style.zIndex === "100",
93
- );
91
+ const playhead = host.querySelector<HTMLElement>("[data-timeline-playhead-layer] > div");
94
92
  if (!playhead) throw new Error("Missing timeline playhead");
95
93
  return { clip, trackHeader, rulerTick, rulerOrigin, playhead };
96
94
  }
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
6
7
 
7
8
  (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
8
9
 
@@ -430,6 +431,26 @@ describe("Timeline without row virtualization", { timeout: 30_000 }, () => {
430
431
  };
431
432
  }
432
433
 
434
+ it("clips a scrolled-off playhead at the track headers once the scroll settles", async () => {
435
+ const { host, dispose } = await renderUnvirtualizedTimeline();
436
+ try {
437
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
438
+ const layer = () => host.querySelector<HTMLElement>("[data-timeline-playhead-layer]");
439
+ await scrollTimelineHorizontally(scroller, 166);
440
+ expect(layer()?.style.clipPath).toBe("");
441
+ await act(async () => {
442
+ await new Promise((resolve) => setTimeout(resolve, TIMELINE_SCROLL_SETTLE_MS + 20));
443
+ });
444
+ // The ruler's corner is as wide as the track headers; the glow may overhang by 6.5px.
445
+ const headerWidth = parseFloat(
446
+ host.querySelector<HTMLElement>(".sticky.left-0")!.style.width,
447
+ );
448
+ expect(layer()?.style.clipPath).toContain(`${166 + headerWidth - 6.5}px)`);
449
+ } finally {
450
+ dispose();
451
+ }
452
+ });
453
+
433
454
  it("mounts every clip rather than a window", async () => {
434
455
  const { host, dispose } = await renderUnvirtualizedTimeline();
435
456
  try {
@@ -1,6 +1,7 @@
1
1
  import { memo } from "react";
2
2
  import { TimelineRulerPart } from "./TimelineRulerPart";
3
3
  import { PlayheadIndicator } from "./PlayheadIndicator";
4
+ import { TimelinePlayheadLayer } from "./TimelinePlayheadLayer";
4
5
  import {
5
6
  RULER_H,
6
7
  CLIP_Y,
@@ -236,18 +237,19 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
236
237
  the half-head offset baked into getTimelinePlayheadLeft keep the
237
238
  inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler
238
239
  ticks' center), instead of relying on shrink-wrap sizing. */}
239
- <div
240
- ref={props.playheadRef}
241
- className="absolute top-0 bottom-0 pointer-events-none"
242
- style={{
243
- left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
244
- width: PLAYHEAD_HEAD_W,
245
- zIndex: 100,
246
- display: beatDragging ? "none" : undefined,
247
- }}
248
- >
249
- <PlayheadIndicator scrubbing={props.isScrubbing} />
250
- </div>
240
+ <TimelinePlayheadLayer scrollRef={props.scrollRef} contentOrigin={props.contentOrigin}>
241
+ <div
242
+ ref={props.playheadRef}
243
+ className="absolute top-0 bottom-0 pointer-events-none"
244
+ style={{
245
+ left: `${getTimelinePlayheadLeft(0, 0, props.contentOrigin)}px`,
246
+ width: PLAYHEAD_HEAD_W,
247
+ display: beatDragging ? "none" : undefined,
248
+ }}
249
+ >
250
+ <PlayheadIndicator scrubbing={props.isScrubbing} />
251
+ </div>
252
+ </TimelinePlayheadLayer>
251
253
  </div>
252
254
  );
253
255
  });
@@ -0,0 +1,33 @@
1
+ import type { ReactNode, RefObject } from "react";
2
+ import { PLAYHEAD_GLOW_W } from "./PlayheadIndicator";
3
+ import { useSettledScrollLeft } from "./useSettledScrollLeft";
4
+
5
+ // Clips the playhead at the track headers' edge once a scroll settles, so one scrolled off to
6
+ // the left does not draw over the track names. Half the glow may pass the edge so 00:00 looks
7
+ // as before; mid-scroll it is not clipped.
8
+ export function TimelinePlayheadLayer({
9
+ scrollRef,
10
+ contentOrigin,
11
+ children,
12
+ }: {
13
+ scrollRef: RefObject<HTMLDivElement | null>;
14
+ contentOrigin: number;
15
+ children: ReactNode;
16
+ }) {
17
+ const settledScrollLeft = useSettledScrollLeft(scrollRef);
18
+ return (
19
+ <div
20
+ data-timeline-playhead-layer=""
21
+ className="absolute inset-0 pointer-events-none"
22
+ style={{
23
+ zIndex: 100,
24
+ clipPath:
25
+ settledScrollLeft === null
26
+ ? undefined
27
+ : `inset(0 0 0 ${settledScrollLeft + contentOrigin - PLAYHEAD_GLOW_W / 2}px)`,
28
+ }}
29
+ >
30
+ {children}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,98 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TimelineRuler } from "./TimelineRuler";
7
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
8
+ import type { TimelineTheme } from "./timelineTheme";
9
+
10
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
11
+
12
+ afterEach(() => {
13
+ vi.useRealTimers();
14
+ });
15
+
16
+ describe("TimelineRuler", () => {
17
+ it("masks the one tick label the track header would slice, once the scroll settles", () => {
18
+ vi.useFakeTimers();
19
+ const scroller = document.createElement("div");
20
+ let scrollLeft = 0;
21
+ Object.defineProperty(scroller, "scrollLeft", { get: () => scrollLeft });
22
+ const host = document.createElement("div");
23
+ scroller.append(host);
24
+ const root = createRoot(host);
25
+ act(() =>
26
+ root.render(
27
+ <TimelineRuler
28
+ major={[0, 10, 20]}
29
+ minor={[]}
30
+ pps={20}
31
+ trackContentWidth={600}
32
+ totalH={100}
33
+ effectiveDuration={30}
34
+ majorTickInterval={10}
35
+ theme={{} as TimelineTheme}
36
+ contentOrigin={80}
37
+ scrollRef={{ current: scroller }}
38
+ />,
39
+ ),
40
+ );
41
+ const mask = () =>
42
+ host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left ?? null;
43
+ const scrollTo = (left: number) =>
44
+ act(() => {
45
+ scrollLeft = left;
46
+ scroller.dispatchEvent(new Event("scroll"));
47
+ });
48
+ const settle = () => act(() => vi.advanceTimersByTime(TIMELINE_SCROLL_SETTLE_MS));
49
+
50
+ expect(mask()).toBeNull();
51
+ // The 10s label starts at 200 - 0.5 + 5 = 204.5 in ruler space.
52
+ scrollTo(204);
53
+ expect(mask()).toBeNull();
54
+ settle();
55
+ expect(mask()).toBe("4.5px");
56
+ scrollTo(205);
57
+ expect(mask()).toBeNull();
58
+ settle();
59
+ expect(mask()).toBe("204.5px");
60
+ act(() => root.unmount());
61
+ });
62
+
63
+ it("masks right away when the scroll element is swapped mid-scroll", () => {
64
+ vi.useFakeTimers();
65
+ const make = (left: number) => {
66
+ const el = document.createElement("div");
67
+ Object.defineProperty(el, "scrollLeft", { get: () => left });
68
+ return el;
69
+ };
70
+ const first = make(205);
71
+ const host = document.createElement("div");
72
+ const root = createRoot(host);
73
+ const render = (el: HTMLDivElement) =>
74
+ act(() =>
75
+ root.render(
76
+ <TimelineRuler
77
+ major={[0, 10, 20]}
78
+ minor={[]}
79
+ pps={20}
80
+ trackContentWidth={600}
81
+ totalH={100}
82
+ effectiveDuration={30}
83
+ majorTickInterval={10}
84
+ theme={{} as TimelineTheme}
85
+ contentOrigin={80}
86
+ scrollRef={{ current: el }}
87
+ />,
88
+ ),
89
+ );
90
+ render(first);
91
+ act(() => first.dispatchEvent(new Event("scroll")));
92
+ render(make(205));
93
+ expect(host.querySelector<HTMLElement>("[data-timeline-ruler-label-mask]")?.style.left).toBe(
94
+ "204.5px",
95
+ );
96
+ act(() => root.unmount());
97
+ });
98
+ });
@@ -1,4 +1,5 @@
1
- import { memo } from "react";
1
+ import { memo, type RefObject } from "react";
2
+ import { useSettledScrollLeft } from "./useSettledScrollLeft";
2
3
  import type { TimelineTheme } from "./timelineTheme";
3
4
  import { RULER_H, getTimelineBeatEntries } from "./timelineLayout";
4
5
  import { formatTimelineTickLabel } from "./timelineRulerGeometry";
@@ -19,8 +20,62 @@ interface TimelineRulerProps {
19
20
  beatAnalysis?: MusicBeatAnalysis | null;
20
21
  contentOrigin: number;
21
22
  renderTimeRange?: TimelineTimeRange;
23
+ scrollRef?: RefObject<HTMLDivElement | null>;
22
24
  }
23
25
 
26
+ const TICK_LABEL_INSET_PX = 5;
27
+
28
+ // Index of the major tick whose label starts under the track-header corner, which
29
+ // covers ruler x < scrollLeft; -1 when none does.
30
+ function tickIndexUnderHeader(major: number[], pps: number, scrollLeft: number): number {
31
+ let lo = 0;
32
+ let hi = major.length - 1;
33
+ let found = -1;
34
+ while (lo <= hi) {
35
+ const mid = (lo + hi) >> 1;
36
+ if (major[mid]! * pps - 0.5 + TICK_LABEL_INSET_PX < scrollLeft) {
37
+ found = mid;
38
+ lo = mid + 1;
39
+ } else hi = mid - 1;
40
+ }
41
+ return found;
42
+ }
43
+
44
+ // Paints over the one label the header corner would slice, once a scroll settles; mid-scroll a
45
+ // fragment can still show.
46
+ const HeaderSlicedLabelMask = memo(function HeaderSlicedLabelMask({
47
+ scrollRef,
48
+ major,
49
+ pps,
50
+ background,
51
+ }: {
52
+ scrollRef: RefObject<HTMLDivElement | null>;
53
+ major: number[];
54
+ pps: number;
55
+ background: string;
56
+ }) {
57
+ const settledScrollLeft = useSettledScrollLeft(scrollRef);
58
+ const index =
59
+ settledScrollLeft === null ? -1 : tickIndexUnderHeader(major, pps, settledScrollLeft);
60
+ if (index < 0) return null;
61
+ const labelLeft = major[index]! * pps - 0.5 + TICK_LABEL_INSET_PX;
62
+ const next = major[index + 1];
63
+ return (
64
+ <div
65
+ data-timeline-ruler-label-mask=""
66
+ className="absolute pointer-events-none"
67
+ style={{
68
+ left: labelLeft,
69
+ top: 4,
70
+ height: 12,
71
+ // Up to the next label, so only this label's text band is covered.
72
+ width: next === undefined ? 80 : (next - major[index]!) * pps - TICK_LABEL_INSET_PX,
73
+ background,
74
+ }}
75
+ />
76
+ );
77
+ });
78
+
24
79
  export const TimelineRuler = memo(function TimelineRuler({
25
80
  major,
26
81
  minor,
@@ -33,6 +88,7 @@ export const TimelineRuler = memo(function TimelineRuler({
33
88
  beatAnalysis,
34
89
  contentOrigin,
35
90
  renderTimeRange,
91
+ scrollRef,
36
92
  }: TimelineRulerProps) {
37
93
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
38
94
  const beatTimes = beatAnalysis?.beatTimes ?? [];
@@ -131,7 +187,7 @@ export const TimelineRuler = memo(function TimelineRuler({
131
187
  className="absolute font-mono tabular-nums leading-none whitespace-nowrap"
132
188
  style={{
133
189
  color: theme.tickText,
134
- left: 5,
190
+ left: TICK_LABEL_INSET_PX,
135
191
  top: 5,
136
192
  fontSize: 10,
137
193
  }}
@@ -143,6 +199,14 @@ export const TimelineRuler = memo(function TimelineRuler({
143
199
  <div className="w-px" style={{ height: RULER_H, background: theme.tickMajor }} />
144
200
  </div>
145
201
  ))}
202
+ {scrollRef && (
203
+ <HeaderSlicedLabelMask
204
+ scrollRef={scrollRef}
205
+ major={major}
206
+ pps={pps}
207
+ background={theme.shellBackground}
208
+ />
209
+ )}
146
210
  </div>
147
211
  </div>
148
212
  </>
@@ -17,6 +17,7 @@ export function TimelineRulerPart() {
17
17
  beatAnalysis={props.beatAnalysis}
18
18
  contentOrigin={props.contentOrigin}
19
19
  renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
20
+ scrollRef={props.scrollRef}
20
21
  />
21
22
  );
22
23
  }
@@ -0,0 +1,38 @@
1
+ import { useCallback, useRef, useSyncExternalStore, type RefObject } from "react";
2
+ import { TIMELINE_SCROLL_SETTLE_MS } from "./useTimelineScrollViewport";
3
+
4
+ // The timeline viewport's scrollLeft once a scroll settles, or null while it is scrolling. Readers
5
+ // re-render twice per gesture, never per frame: per-frame work cost 2-3x the viewport gate's budget
6
+ // on a large unvirtualized timeline.
7
+ export function useSettledScrollLeft(scrollRef: RefObject<HTMLDivElement | null>): number | null {
8
+ const scrollingRef = useRef(false);
9
+ const subscribe = useCallback(
10
+ (onChange: () => void) => {
11
+ const el = scrollRef.current;
12
+ let settle: ReturnType<typeof setTimeout> | undefined;
13
+ const onScroll = () => {
14
+ if (!scrollingRef.current) {
15
+ scrollingRef.current = true;
16
+ onChange();
17
+ }
18
+ clearTimeout(settle);
19
+ settle = setTimeout(() => {
20
+ scrollingRef.current = false;
21
+ onChange();
22
+ }, TIMELINE_SCROLL_SETTLE_MS);
23
+ };
24
+ el?.addEventListener("scroll", onScroll, { passive: true });
25
+ return () => {
26
+ clearTimeout(settle);
27
+ scrollingRef.current = false;
28
+ el?.removeEventListener("scroll", onScroll);
29
+ };
30
+ },
31
+ [scrollRef],
32
+ );
33
+ return useSyncExternalStore(
34
+ subscribe,
35
+ () => (scrollingRef.current ? null : (scrollRef.current?.scrollLeft ?? 0)),
36
+ () => null,
37
+ );
38
+ }
@@ -3,6 +3,9 @@ import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { shouldShowTimelineShortcutHint } from "./timelineLayout";
4
4
  import { STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED } from "./timelineRowVirtualizationFlag";
5
5
 
6
+ /** How long the timeline waits after the last scroll event before treating the scroll as settled. */
7
+ export const TIMELINE_SCROLL_SETTLE_MS = 100;
8
+
6
9
  export interface TimelineScrollViewportSnapshot {
7
10
  readonly scrollLeft: number;
8
11
  readonly scrollTop: number;
@@ -91,7 +94,7 @@ export function useTimelineScrollViewport(
91
94
  scrollingRef.current = false;
92
95
  if (viewportRafRef.current) cancelAnimationFrame(viewportRafRef.current);
93
96
  publish();
94
- }, 100);
97
+ }, TIMELINE_SCROLL_SETTLE_MS);
95
98
  }, []);
96
99
 
97
100
  const syncShortcutHintVisibility = useCallback(() => {