@hyperframes/studio 0.8.139 → 0.8.141

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 (76) hide show
  1. package/dist/assets/hyperframes-player-5Vx2AiUz.js +473 -0
  2. package/dist/assets/{index-DsEkE5_G.js → index-BAnh7cSQ.js} +1 -1
  3. package/dist/assets/{index-Br1Whc0f.js → index-C0thd4eM.js} +204 -204
  4. package/dist/assets/{index-BmIPrb8I.js → index-P8fgj_Xa.js} +1 -1
  5. package/dist/{chunk-E422JDF5.js → chunk-H2DUOXTF.js} +2 -1
  6. package/dist/index.d.ts +19 -3
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +6518 -5813
  9. package/dist/index.js.map +1 -1
  10. package/dist/player.js +1 -1
  11. package/package.json +8 -8
  12. package/src/components/TimelineToolbar.test.tsx +79 -1
  13. package/src/components/TimelineToolbar.tsx +14 -29
  14. package/src/components/editor/DomEditOverlay.tsx +2 -2
  15. package/src/components/editor/PropertyPanel.tsx +1 -0
  16. package/src/components/editor/PropertyPanelFlat.tsx +1 -0
  17. package/src/components/editor/manualEdits.ts +1 -1
  18. package/src/components/editor/manualEditsParsing.ts +14 -2
  19. package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
  20. package/src/components/editor/propertyPanelFill.tsx +36 -13
  21. package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
  23. package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
  24. package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
  25. package/src/components/nle/NLEPreview.test.ts +60 -1
  26. package/src/components/nle/NLEPreview.tsx +9 -2
  27. package/src/components/sidebar/CompositionsTab.thumbnails.test.tsx +43 -0
  28. package/src/components/sidebar/CompositionsTab.tsx +17 -0
  29. package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
  30. package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
  31. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
  32. package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
  33. package/src/hooks/useMusicBeatAnalysis.ts +2 -1
  34. package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
  35. package/src/hooks/useSdkSession.ts +4 -1
  36. package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
  37. package/src/hooks/useThumbnailStripSize.ts +23 -1
  38. package/src/index.ts +1 -0
  39. package/src/player/components/AudioWaveform.test.tsx +153 -1
  40. package/src/player/components/AudioWaveform.tsx +93 -37
  41. package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
  42. package/src/player/components/Timeline.test.ts +3 -1
  43. package/src/player/components/TimelineCanvas.tsx +4 -0
  44. package/src/player/components/TimelineClip.tsx +20 -6
  45. package/src/player/components/TimelineClipFades.test.tsx +11 -0
  46. package/src/player/components/TimelineClipFades.tsx +4 -4
  47. package/src/player/components/TimelineClipJoins.tsx +23 -18
  48. package/src/player/components/TimelineGestureOverlay.tsx +30 -27
  49. package/src/player/components/TimelineGroupRow.tsx +1 -0
  50. package/src/player/components/TimelineLaneClip.tsx +127 -0
  51. package/src/player/components/TimelineLanes.test.tsx +51 -6
  52. package/src/player/components/TimelineLanes.tsx +166 -178
  53. package/src/player/components/TimelineRuler.tsx +2 -0
  54. package/src/player/components/TimelineTimeLayer.tsx +25 -0
  55. package/src/player/components/VideoThumbnail.test.tsx +204 -12
  56. package/src/player/components/VideoThumbnail.tsx +37 -9
  57. package/src/player/components/thumbnailUtils.test.ts +17 -3
  58. package/src/player/components/thumbnailUtils.ts +17 -1
  59. package/src/player/components/timelineLayout.test.ts +3 -3
  60. package/src/player/components/timelineMotion.test.ts +27 -0
  61. package/src/player/components/timelineMotion.ts +62 -0
  62. package/src/player/components/timelineZoomInput.test.ts +438 -0
  63. package/src/player/components/timelineZoomInput.ts +456 -0
  64. package/src/player/components/useTimelineGeometry.ts +0 -3
  65. package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
  66. package/src/player/components/useTimelinePlayhead.ts +53 -59
  67. package/src/player/components/useTimelineProviderState.tsx +16 -21
  68. package/src/player/components/useTimelineZoom.ts +7 -12
  69. package/src/player/lib/runtimeProtocol.ts +6 -0
  70. package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
  71. package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
  72. package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
  73. package/src/player/lib/timelineViewportBudgets.ts +1 -1
  74. package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
  75. package/dist/assets/hyperframes-player-CckOoCJ8.js +0 -473
  76. /package/dist/{chunk-E422JDF5.js.map → chunk-H2DUOXTF.js.map} +0 -0
@@ -1,7 +1,7 @@
1
- import { memo, useCallback, useContext, useEffect, useMemo, useRef } from "react";
1
+ import { memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { fadeGain } from "@hyperframes/core/audio-fade";
3
- import { useMountEffect } from "../../hooks/useMountEffect";
4
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
+ import { useThumbnailStripSize } from "../../hooks/useThumbnailStripSize";
5
5
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
6
6
  import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
7
7
  import { ClipFadesContext, type ClipFadeShape } from "./TimelineClipFades";
@@ -30,6 +30,8 @@ export const WAVEFORM_LAYER_Z = 10;
30
30
 
31
31
  type BarGeometry = { x: number; width: number; height: number; gain: number };
32
32
 
33
+ type ClipSpan = { from: number; to: number };
34
+ const WHOLE_CLIP: ClipSpan = { from: 0, to: 1 };
33
35
  function paintWaveformBars(
34
36
  context: CanvasRenderingContext2D,
35
37
  bars: readonly BarGeometry[],
@@ -61,6 +63,7 @@ export function drawWaveformCanvas(
61
63
  trimStartFraction: number,
62
64
  trimEndFraction: number,
63
65
  fades: ClipFadeShape | null = null,
66
+ span: ClipSpan = WHOLE_CLIP,
64
67
  ) {
65
68
  const width = Math.max(1, canvas.clientWidth);
66
69
  const height = Math.max(1, canvas.clientHeight);
@@ -71,19 +74,20 @@ export function drawWaveformCanvas(
71
74
  if (!context) return;
72
75
  context.scale(scale, scale);
73
76
  context.clearRect(0, 0, width, height);
77
+ const trim = trimEndFraction - trimStartFraction;
74
78
  const amplitudes = decimatePeaks(
75
79
  peaks,
76
- trimStartFraction,
77
- trimEndFraction,
80
+ trimStartFraction + trim * span.from,
81
+ trimStartFraction + trim * span.to,
78
82
  Math.max(1, Math.ceil(width / BAR_STEP)),
79
83
  );
84
+ const clipFraction = (index: number) =>
85
+ span.from + ((index + 0.5) / amplitudes.length) * (span.to - span.from);
80
86
  const bars = amplitudes.map((amplitude, index) => ({
81
87
  x: (index * width) / amplitudes.length,
82
88
  width: Math.max(1, width / amplitudes.length),
83
89
  height: Math.max(3, amplitude * height),
84
- gain: fades
85
- ? fadeGain(((index + 0.5) / amplitudes.length) * fades.duration, fades.duration, fades)
86
- : 1,
90
+ gain: fades ? fadeGain(clipFraction(index) * fades.duration, fades.duration, fades) : 1,
87
91
  }));
88
92
  const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
89
93
  const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
@@ -115,11 +119,8 @@ async function loadWaveform(
115
119
  waveformUrl: string | undefined,
116
120
  signal: AbortSignal,
117
121
  ): Promise<number[]> {
118
- // Failures propagate. Synthesised peaks are worse than an honest gap: an
119
- // author trims and beat-aligns against this waveform, and a plausible
120
- // fabrication is indistinguishable from the real thing while being wrong.
121
- // The scheduler caches the failure (metadataFailureTtlMs) so the degraded
122
- // state neither refetch-loops nor pins itself past a transient error.
122
+ // Failures propagate: authors trim and beat-align against this, so made-up peaks beat no gap.
123
+ // The scheduler caches the failure (metadataFailureTtlMs): no refetch loop, no stuck state.
123
124
  return waveformUrl
124
125
  ? await fetchWaveformPeaks(waveformUrl, signal)
125
126
  : await decodeWaveformPeaks(audioUrl, signal);
@@ -157,6 +158,32 @@ async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<nu
157
158
  }
158
159
 
159
160
  /** Bounded waveform subscriber; cache, cancellation and dedupe live in one scheduler. */
161
+ const NEAR_SCREEN: IntersectionObserverInit & { scrollMargin: string } = {
162
+ rootMargin: "50% 100%",
163
+ scrollMargin: "50% 100%",
164
+ };
165
+ const nearScreenListeners = new Map<Element, (near: boolean) => void>();
166
+ let nearScreen: IntersectionObserver | null = null;
167
+
168
+ function useNearScreen(element: Element | null): boolean {
169
+ const [near, setNear] = useState(() => typeof IntersectionObserver === "undefined");
170
+ useEffect(() => {
171
+ if (!element || typeof IntersectionObserver === "undefined") return;
172
+ nearScreen ??= new IntersectionObserver(
173
+ (entries) =>
174
+ entries.forEach((entry) => nearScreenListeners.get(entry.target)?.(entry.isIntersecting)),
175
+ NEAR_SCREEN,
176
+ );
177
+ nearScreenListeners.set(element, setNear);
178
+ nearScreen.observe(element);
179
+ return () => {
180
+ nearScreenListeners.delete(element);
181
+ nearScreen?.unobserve(element);
182
+ };
183
+ }, [element]);
184
+ return near;
185
+ }
186
+
160
187
  export const AudioWaveform = memo(function AudioWaveform({
161
188
  audioUrl,
162
189
  waveformUrl,
@@ -170,9 +197,8 @@ export const AudioWaveform = memo(function AudioWaveform({
170
197
  muted = false,
171
198
  labelInset = 16,
172
199
  }: AudioWaveformProps) {
173
- const rootRef = useRef<HTMLDivElement | null>(null);
200
+ const [root, setRoot] = useState<HTMLDivElement | null>(null);
174
201
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
175
- const observerRef = useRef<ResizeObserver | null>(null);
176
202
  const cacheKey = waveformUrl ?? audioUrl;
177
203
  const request = useMemo(
178
204
  () => ({
@@ -197,23 +223,31 @@ export const AudioWaveform = memo(function AudioWaveform({
197
223
  snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null;
198
224
 
199
225
  const fades = useContext(ClipFadesContext);
226
+ const [strip, setStripRef, watchGap] = useThumbnailStripSize();
227
+ const from = strip.width > 0 ? strip.inViewStart / strip.width : 0;
228
+ const to = strip.width > 0 ? strip.inViewEnd / strip.width : 1;
200
229
  const draw = useCallback(() => {
201
230
  const canvas = canvasRef.current;
202
231
  if (!canvas || !peaks) return;
203
- drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1, fades);
204
- }, [fades, muted, peaks, trimEndFraction, trimStartFraction]);
205
-
206
- const setCanvasRef = useCallback(
207
- (canvas: HTMLCanvasElement | null) => {
208
- observerRef.current?.disconnect();
209
- canvasRef.current = canvas;
210
- if (!canvas) return;
211
- draw();
212
- observerRef.current = new ResizeObserver(draw);
213
- observerRef.current.observe(canvas);
214
- },
215
- [draw],
216
- );
232
+ const span = { from, to };
233
+ // Placed in fractions of the clip, so a zoom preview stretches the drawn bars with time.
234
+ canvas.style.left = `${span.from * 100}%`;
235
+ canvas.style.width = `${(span.to - span.from) * 100}%`;
236
+ if (span.to <= span.from) return;
237
+ drawWaveformCanvas(
238
+ canvas,
239
+ peaks,
240
+ muted,
241
+ trimStartFraction ?? 0,
242
+ trimEndFraction ?? 1,
243
+ fades,
244
+ span,
245
+ );
246
+ }, [fades, from, muted, peaks, to, trimEndFraction, trimStartFraction]);
247
+ const near = useNearScreen(root);
248
+ useEffect(() => {
249
+ if (near) draw();
250
+ }, [draw, near, strip.width, strip.height]);
217
251
 
218
252
  useEffect(() => {
219
253
  const root = document.documentElement;
@@ -225,23 +259,45 @@ export const AudioWaveform = memo(function AudioWaveform({
225
259
  return () => observer.disconnect();
226
260
  }, [draw]);
227
261
 
228
- useMountEffect(() => () => observerRef.current?.disconnect());
229
-
230
262
  useEffect(() => {
231
- const clip = rootRef.current?.closest(".timeline-clip");
263
+ const clip = root?.closest(".timeline-clip");
232
264
  if (!(clip instanceof HTMLElement)) return;
233
265
  if (muted) clip.setAttribute("data-audio-muted", "true");
234
266
  else clip.removeAttribute("data-audio-muted");
235
267
  return () => clip.removeAttribute("data-audio-muted");
236
- }, [muted]);
268
+ }, [muted, root]);
237
269
 
238
270
  return (
239
- <div ref={rootRef} className="absolute inset-0">
240
- <div className="absolute inset-0 overflow-hidden" style={{ zIndex: WAVEFORM_LAYER_Z }}>
271
+ <div ref={setRoot} className="absolute inset-0">
272
+ <div
273
+ ref={setStripRef}
274
+ className="absolute inset-0 overflow-hidden"
275
+ style={{ zIndex: WAVEFORM_LAYER_Z }}
276
+ >
277
+ {/* The undrawn ends: one coming near the screen, as a move carries the clip, re-measures. */}
278
+ {from > 0 && (
279
+ <div
280
+ ref={watchGap}
281
+ className="pointer-events-none absolute inset-y-0 left-0"
282
+ style={{ width: `${from * 100}%` }}
283
+ />
284
+ )}
285
+ {to < 1 && (
286
+ <div
287
+ ref={watchGap}
288
+ className="pointer-events-none absolute inset-y-0 right-0"
289
+ style={{ left: `${to * 100}%` }}
290
+ />
291
+ )}
241
292
  <canvas
242
- ref={setCanvasRef}
243
- className="absolute inset-x-0 bottom-0 w-full"
244
- style={{ top: labelInset, height: `calc(100% - ${labelInset}px)` }}
293
+ ref={canvasRef}
294
+ className="absolute bottom-0"
295
+ style={{
296
+ left: 0,
297
+ width: "100%",
298
+ top: labelInset,
299
+ height: `calc(100% - ${labelInset}px)`,
300
+ }}
245
301
  />
246
302
  {snapshot.status === "loading" && (
247
303
  <div
@@ -0,0 +1,22 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, expect, it } from "vitest";
5
+ import { mountThreeTrackTimeline } from "./timelineMountFixtures";
6
+ import { installTimelineMountEnv } from "./timelineMountTestEnv";
7
+ import { isTimelineMoving } from "./timelineMotion";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ installTimelineMountEnv();
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ });
16
+
17
+ it("keeps the timeline at rest through a scroll, as playback follow makes every frame", async () => {
18
+ const { root, viewport } = await mountThreeTrackTimeline();
19
+ act(() => viewport.dispatchEvent(new Event("scroll")));
20
+ expect(isTimelineMoving()).toBe(false);
21
+ act(() => root.unmount());
22
+ });
@@ -208,7 +208,9 @@ describe("Timeline provider boundary", () => {
208
208
  renderTimelineGeometry("clip-1");
209
209
 
210
210
  expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
211
- expect(clip.style.left).toBe("1000px");
211
+ // 10 s at 100 px/s: 1000% of a one-second layer 100px wide.
212
+ expect(clip.style.left).toBe("1000%");
213
+ expect((clip.closest("[data-timeline-time-layer]") as HTMLElement).style.width).toBe("100px");
212
214
  expect(clip.style.height).toBe("");
213
215
  expect(clip.style.bottom).toBe(`${CLIP_Y}px`);
214
216
  expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`);
@@ -116,6 +116,7 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
116
116
  return visibleIntervals.map((gap) => (
117
117
  <div
118
118
  key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
119
+ data-timeline-zoom-scale={-gap.start * props.pps}
119
120
  className="pointer-events-none absolute"
120
121
  style={{
121
122
  top: props.rowGeometry.getRowTop(rowIndex) + CLIP_Y,
@@ -246,6 +247,9 @@ export const TimelineCanvas = memo(function TimelineCanvas() {
246
247
  {/* Range highlight */}
247
248
  {props.rangeSelection && (
248
249
  <div
250
+ data-timeline-zoom-scale={
251
+ -Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps
252
+ }
249
253
  className="absolute pointer-events-none"
250
254
  style={{
251
255
  left:
@@ -17,6 +17,7 @@ import { linkLabelColor } from "./linkLabelColor";
17
17
  import { OutOfSyncBadge } from "./OutOfSyncBadge";
18
18
  import { clipSpeedSuffix } from "./clipToolAttrs";
19
19
  import { ClipPeakTooltip } from "./ClipPeakTooltip";
20
+ import { timeLayerPercent } from "./TimelineTimeLayer";
20
21
 
21
22
  interface TimelineClipProps {
22
23
  el: TimelineElement;
@@ -44,6 +45,20 @@ interface TimelineClipProps {
44
45
  children?: ReactNode;
45
46
  }
46
47
 
48
+ const HANDLES_MIN_PX = 32;
49
+
50
+ const CLIP_MIN_WIDTH_PX = 4;
51
+ export const clipWidthPx = (el: TimelineElement, pps: number) =>
52
+ Math.max(el.duration * pps, CLIP_MIN_WIDTH_PX);
53
+
54
+ /** Zoom-dependent drawing as one value: width tier and trim fit, or the scale while fades show. */
55
+ export function clipZoomKey(el: TimelineElement, pps: number, interacting: boolean) {
56
+ const fadesLive = interacting || (el.fadeIn ?? 0) > 0 || (el.fadeOut ?? 0) > 0;
57
+ if (fadesLive) return pps;
58
+ const widthPx = clipWidthPx(el, pps);
59
+ return `${clipWidthLadder(widthPx)}${widthPx >= HANDLES_MIN_PX ? "+handles" : ""}`;
60
+ }
61
+
47
62
  // fallow-ignore-next-line complexity
48
63
  export const TimelineClip = memo(function TimelineClip({
49
64
  el,
@@ -70,13 +85,12 @@ export const TimelineClip = memo(function TimelineClip({
70
85
  onContextMenu,
71
86
  children,
72
87
  }: TimelineClipProps) {
73
- const leftPx = el.start * pps;
74
- const widthPx = Math.max(el.duration * pps, 4);
88
+ const widthPx = clipWidthPx(el, pps);
75
89
  const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
76
90
  const displayLabel = `${el.label || el.id || el.tag}${clipSpeedSuffix(el.playbackRate, el.automation)}`;
77
91
  const isAudioClip = isAudioTimelineElement(el);
78
92
  const ladder = clipWidthLadder(widthPx);
79
- const showHandles = handleOpacity > 0.01 && (widthPx >= 32 || isSelected);
93
+ const showHandles = handleOpacity > 0.01 && (widthPx >= HANDLES_MIN_PX || isSelected);
80
94
  const showLabel = !isAudioClip || ladder === "labeled";
81
95
  const showDefaultText = !hasCustomContent && ladder === "labeled";
82
96
  const startLabel = el.start.toFixed(1);
@@ -111,14 +125,14 @@ export const TimelineClip = memo(function TimelineClip({
111
125
  .filter((className) => className.length > 0)
112
126
  .join(" ");
113
127
  const style: CSSProperties = {
114
- left: leftPx,
115
- width: widthPx,
128
+ left: timeLayerPercent(el.start),
129
+ width: timeLayerPercent(el.duration),
130
+ minWidth: CLIP_MIN_WIDTH_PX,
116
131
  top: clipY,
117
132
  ...(clipHeight === undefined ? { bottom: clipY } : { height: clipHeight }),
118
133
  borderRadius: isAudioClip ? theme.audioClipRadius : theme.clipRadius,
119
134
  ...themeVariables,
120
135
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
121
- // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
122
136
  cursor: "default",
123
137
  appearance: "none",
124
138
  color: "inherit",
@@ -129,6 +129,17 @@ describe("TimelineClipFades", () => {
129
129
  act(() => root.unmount());
130
130
  });
131
131
 
132
+ it("measures the clip only while its handles show, so a zoom step forces no layout", () => {
133
+ const measure = vi.spyOn(window, "getComputedStyle");
134
+ const { rerender } = render(clip, { showHandles: false });
135
+ rerender({ ...clip, duration: 11 });
136
+ expect(measure).not.toHaveBeenCalled();
137
+ act(() => mounted[0]?.unmount());
138
+ render(clip, { showHandles: true });
139
+ expect(measure).toHaveBeenCalled();
140
+ measure.mockRestore();
141
+ });
142
+
132
143
  it("renders nothing for a clip without fades when the handles are hidden", () => {
133
144
  const { host, root } = render(
134
145
  { ...clip, fadeIn: undefined, fadeOut: undefined },
@@ -139,10 +139,12 @@ export function TimelineClipFades({
139
139
  radius: 0,
140
140
  toolsLeft: null,
141
141
  });
142
+ const handlesVisible = showHandles || dragging !== null || focused !== null;
143
+ // Measured only while the handles show: a zoom step must not force a layout per clip.
142
144
  useLayoutEffect(() => {
143
145
  const root = rootRef.current;
144
146
  const clip = root?.parentElement;
145
- if (!root || !clip) return;
147
+ if (!handlesVisible || !root || !clip) return;
146
148
  const radius = parseFloat(getComputedStyle(clip).borderTopLeftRadius) || 0;
147
149
  const { clientHeight: height, clientWidth: width } = root;
148
150
  const fx = clip.querySelector('[data-badge="fx"]');
@@ -157,7 +159,7 @@ export function TimelineClipFades({
157
159
  ? box
158
160
  : { height, width, radius, toolsLeft },
159
161
  );
160
- }, [widthPx, visible]);
162
+ }, [widthPx, handlesVisible]);
161
163
 
162
164
  const gesture = useRef<{
163
165
  edge: FadeEdge;
@@ -205,7 +207,6 @@ export function TimelineClipFades({
205
207
  const g = gesture.current;
206
208
  if (!g) return 0;
207
209
  const deltaSeconds = (clientX - g.originClientX) / Math.max(pps, 1e-6);
208
- // Fade-in grows to the right, fade-out grows to the left.
209
210
  const raw = g.edge === "in" ? g.originSeconds + deltaSeconds : g.originSeconds - deltaSeconds;
210
211
  const limit = dragLimit(g);
211
212
  const clamped = Math.min(limit, Math.max(0, raw));
@@ -342,7 +343,6 @@ export function TimelineClipFades({
342
343
 
343
344
  const showIn = fades.fadeIn > 0;
344
345
  const showOut = fades.fadeOut > 0;
345
- const handlesVisible = showHandles || dragging !== null || focused !== null;
346
346
  if (!visible) return null;
347
347
 
348
348
  // A handle with no 0.01 s step to move is not drawn, unless in use: its twin owns the spot.
@@ -1,6 +1,7 @@
1
1
  import { CLIP_Y } from "./timelineLayout";
2
2
  import { sortedLaneClips, TRACK_GAP_EPSILON_S } from "./timelineGaps";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
+ import { TimelineTimeLayer, timeLayerPercent } from "./TimelineTimeLayer";
4
5
 
5
6
  /** Times where one clip on a row ends exactly where the next begins. */
6
7
  export function deriveTimelineClipJoins(elements: readonly TimelineElement[]): number[] {
@@ -31,22 +32,26 @@ export function TimelineClipJoins({
31
32
  color,
32
33
  }: TimelineClipJoinsProps) {
33
34
  const height = clipBarHeight ?? rowHeight - 2 * CLIP_Y;
34
- return deriveTimelineClipJoins(elements).map((time) => (
35
- <div
36
- key={time}
37
- data-timeline-clip-join=""
38
- aria-hidden="true"
39
- className="absolute pointer-events-none"
40
- style={{
41
- left: time * pixelsPerSecond,
42
- top: CLIP_Y,
43
- width: 1,
44
- height,
45
- transform: "translateX(-50%)",
46
- background: color,
47
- // Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
48
- zIndex: 5,
49
- }}
50
- />
51
- ));
35
+ return (
36
+ <TimelineTimeLayer pixelsPerSecond={pixelsPerSecond}>
37
+ {deriveTimelineClipJoins(elements).map((time) => (
38
+ <div
39
+ key={time}
40
+ data-timeline-clip-join=""
41
+ aria-hidden="true"
42
+ className="absolute pointer-events-none"
43
+ style={{
44
+ left: timeLayerPercent(time),
45
+ top: CLIP_Y,
46
+ width: 1,
47
+ height,
48
+ transform: "translateX(-50%)",
49
+ background: color,
50
+ // Over an idle or hovered clip, under the diamonds (6) and a selected clip (10).
51
+ zIndex: 5,
52
+ }}
53
+ />
54
+ ))}
55
+ </TimelineTimeLayer>
56
+ );
52
57
  }
@@ -10,6 +10,7 @@ import type { DraggedClipState } from "./timelineClipDragTypes";
10
10
  import type { TrackVisualStyle } from "./timelineIcons";
11
11
  import { isTimelineClipActive } from "./useTimelineActiveClips";
12
12
  import type { TimelineProps } from "./TimelineTypes";
13
+ import { TimelineTimeLayer } from "./TimelineTimeLayer";
13
14
 
14
15
  interface TimelineGestureOverlayProps {
15
16
  drag: DraggedClipState | null;
@@ -103,33 +104,35 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
103
104
  + New track<span className="sr-only">, before track {insertRow + 1}</span>
104
105
  </span>
105
106
  )}
106
- <TimelineClip
107
- el={{ ...element, start: 0 }}
108
- pps={pixelsPerSecond}
109
- clipY={0}
110
- isSelected={selectedElementId === (element.key ?? element.id)}
111
- isHovered={false}
112
- isDragging
113
- isGestureActor
114
- isActive={isTimelineClipActive(element, currentTime)}
115
- hasCustomContent={!!renderClipContent}
116
- capabilities={getTimelineEditCapabilities(element)}
117
- theme={theme}
118
- isComposition={!!element.compositionSrc}
119
- onHoverStart={() => {}}
120
- onHoverEnd={() => {}}
121
- onResizeStart={() => {}}
122
- onClick={() => {}}
123
- onDoubleClick={() => {}}
124
- >
125
- {renderClipChildren(
126
- element,
127
- getTrackStyle(element.tag),
128
- renderClipContent,
129
- renderClipOverlay,
130
- { priority: "interaction", rich: false },
131
- )}
132
- </TimelineClip>
107
+ <TimelineTimeLayer pixelsPerSecond={pixelsPerSecond}>
108
+ <TimelineClip
109
+ el={{ ...element, start: 0 }}
110
+ pps={pixelsPerSecond}
111
+ clipY={0}
112
+ isSelected={selectedElementId === (element.key ?? element.id)}
113
+ isHovered={false}
114
+ isDragging
115
+ isGestureActor
116
+ isActive={isTimelineClipActive(element, currentTime)}
117
+ hasCustomContent={!!renderClipContent}
118
+ capabilities={getTimelineEditCapabilities(element)}
119
+ theme={theme}
120
+ isComposition={!!element.compositionSrc}
121
+ onHoverStart={() => {}}
122
+ onHoverEnd={() => {}}
123
+ onResizeStart={() => {}}
124
+ onClick={() => {}}
125
+ onDoubleClick={() => {}}
126
+ >
127
+ {renderClipChildren(
128
+ element,
129
+ getTrackStyle(element.tag),
130
+ renderClipContent,
131
+ renderClipOverlay,
132
+ { priority: "interaction", rich: false },
133
+ )}
134
+ </TimelineClip>
135
+ </TimelineTimeLayer>
133
136
  </div>
134
137
  )}
135
138
  </div>
@@ -201,6 +201,7 @@ export function TimelineGroupRow({
201
201
  // from x=0.
202
202
  <div
203
203
  role="gridcell"
204
+ data-timeline-zoom-scale=""
204
205
  aria-colindex={2}
205
206
  style={{ width: trackContentWidth, marginLeft: contentGutter }}
206
207
  className="relative"
@@ -0,0 +1,127 @@
1
+ import { memo, type CSSProperties, type RefObject } from "react";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import type { ThumbnailPriority } from "../lib/thumbnailScheduler";
4
+ import { TimelineClip, clipZoomKey } from "./TimelineClip";
5
+ import { createClipGestureHandlers, type ClipGestureDeps } from "./timelineClipGestureHandlers";
6
+ import { renderClipChildren } from "./timelineClipChildren";
7
+ import type { TimelineEditCapabilities } from "./timelineEditCapabilities";
8
+ import type { TrackVisualStyle } from "./timelineIcons";
9
+ import { CLIP_Y } from "./timelineLayout";
10
+ import type { TimelineTheme } from "./timelineTheme";
11
+ import type { TimelineProps } from "./TimelineTypes";
12
+
13
+ /** The lane's callbacks as of its last render, read when a clip is pressed rather than drawn. */
14
+ export interface TimelineLaneClipActions {
15
+ gestures: ClipGestureDeps;
16
+ setHoveredClip(key: string | null): void;
17
+ onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
18
+ onDrillDown?: (element: TimelineElement) => void;
19
+ }
20
+
21
+ interface TimelineLaneClipProps {
22
+ el: TimelineElement;
23
+ previewElement: TimelineElement;
24
+ elementKey: string;
25
+ capabilities: TimelineEditCapabilities;
26
+ pps: number;
27
+ passengerStyle?: CSSProperties;
28
+ clipBarHeight?: number;
29
+ isSelected: boolean;
30
+ isHovered: boolean;
31
+ isActive: boolean;
32
+ tabIndex: 0 | -1;
33
+ priority: ThumbnailPriority;
34
+ rich: boolean;
35
+ theme: TimelineTheme;
36
+ clipStyle: TrackVisualStyle;
37
+ renderClipContent: TimelineProps["renderClipContent"];
38
+ renderClipOverlay: TimelineProps["renderClipOverlay"];
39
+ actions: RefObject<TimelineLaneClipActions>;
40
+ }
41
+
42
+ const sameCapabilities = (a: TimelineEditCapabilities, b: TimelineEditCapabilities) =>
43
+ a.canMove === b.canMove &&
44
+ a.canTrimStart === b.canTrimStart &&
45
+ a.canTrimEnd === b.canTrimEnd &&
46
+ a.readOnly === b.readOnly;
47
+
48
+ const zoomKey = (p: TimelineLaneClipProps) =>
49
+ clipZoomKey(p.previewElement, p.pps, p.isSelected || p.isHovered);
50
+
51
+ /** A zoom step re-renders only the clips whose drawing it changes. */
52
+ function sameClip(prev: TimelineLaneClipProps, next: TimelineLaneClipProps): boolean {
53
+ for (const key of Object.keys(next) as (keyof TimelineLaneClipProps)[]) {
54
+ if (key === "pps" || key === "capabilities" || prev[key] === next[key]) continue;
55
+ return false;
56
+ }
57
+ return sameCapabilities(prev.capabilities, next.capabilities) && zoomKey(prev) === zoomKey(next);
58
+ }
59
+
60
+ /** One clip bar in a lane; its handlers read the lane's current callbacks when pressed. */
61
+ export const TimelineLaneClip = memo(function TimelineLaneClip({
62
+ el,
63
+ previewElement,
64
+ elementKey,
65
+ capabilities,
66
+ pps,
67
+ passengerStyle,
68
+ clipBarHeight,
69
+ isSelected,
70
+ isHovered,
71
+ isActive,
72
+ tabIndex,
73
+ priority,
74
+ rich,
75
+ theme,
76
+ clipStyle,
77
+ renderClipContent,
78
+ renderClipOverlay,
79
+ actions,
80
+ }: TimelineLaneClipProps) {
81
+ const gestures = () =>
82
+ createClipGestureHandlers(
83
+ el,
84
+ elementKey,
85
+ previewElement,
86
+ capabilities,
87
+ actions.current.gestures,
88
+ );
89
+ const isComposition = !!el.compositionSrc;
90
+ return (
91
+ <TimelineClip
92
+ onContextMenu={(e: React.MouseEvent) => {
93
+ e.preventDefault();
94
+ actions.current.onContextMenuClip?.(e, el);
95
+ }}
96
+ el={previewElement}
97
+ pps={pps}
98
+ passengerStyle={passengerStyle}
99
+ clipY={CLIP_Y}
100
+ clipHeight={clipBarHeight}
101
+ isSelected={isSelected}
102
+ isHovered={isHovered}
103
+ isDragging={false}
104
+ isActive={isActive}
105
+ hasCustomContent={!!renderClipContent}
106
+ capabilities={capabilities}
107
+ theme={theme}
108
+ isComposition={isComposition}
109
+ tabIndex={tabIndex}
110
+ onHoverStart={() => actions.current.setHoveredClip(elementKey)}
111
+ onHoverEnd={() => actions.current.setHoveredClip(null)}
112
+ onResizeStart={(edge, e) => gestures().onResizeStart(edge, e)}
113
+ onPointerDown={(e) => gestures().onPointerDown(e)}
114
+ onClick={(e) => gestures().onClick(e)}
115
+ onDoubleClick={(e) => {
116
+ e.stopPropagation();
117
+ if (actions.current.gestures.suppressClickRef.current) return;
118
+ if (isComposition) actions.current.onDrillDown?.(el);
119
+ }}
120
+ >
121
+ {renderClipChildren(previewElement, clipStyle, renderClipContent, renderClipOverlay, {
122
+ priority,
123
+ rich,
124
+ })}
125
+ </TimelineClip>
126
+ );
127
+ }, sameClip);