@hyperframes/studio 0.7.94 → 0.7.96

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 (61) hide show
  1. package/dist/assets/{hyperframes-player-D4mryRja.js → hyperframes-player-CogOkBTX.js} +1 -1
  2. package/dist/assets/{index-B0NWErIq.js → index-DfZlc9RM.js} +1 -1
  3. package/dist/assets/{index-BWh5m2-P.js → index-OMTw4iIl.js} +1 -1
  4. package/dist/assets/{index-CH_dyqrx.js → index-YO-rhwSW.js} +195 -195
  5. package/dist/assets/index-tBPidglp.css +1 -0
  6. package/dist/index.d.ts +27 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +2648 -1838
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/EditorShell.selectionSync.test.tsx +109 -0
  12. package/src/components/EditorShell.tsx +31 -5
  13. package/src/components/TimelineToolbar.test.tsx +20 -1
  14. package/src/components/TimelineToolbar.tsx +29 -1
  15. package/src/hooks/useRenderClipContent.test.ts +77 -2
  16. package/src/hooks/useRenderClipContent.ts +78 -14
  17. package/src/hooks/useThumbnailLease.test.tsx +150 -0
  18. package/src/hooks/useThumbnailLease.ts +44 -0
  19. package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
  20. package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
  21. package/src/player/components/AudioWaveform.test.tsx +53 -0
  22. package/src/player/components/AudioWaveform.tsx +137 -140
  23. package/src/player/components/CompositionThumbnail.test.ts +33 -10
  24. package/src/player/components/CompositionThumbnail.tsx +78 -61
  25. package/src/player/components/ImageThumbnail.test.tsx +26 -14
  26. package/src/player/components/ImageThumbnail.tsx +58 -101
  27. package/src/player/components/Timeline.tsx +20 -19
  28. package/src/player/components/TimelineGestureOverlay.tsx +3 -0
  29. package/src/player/components/TimelineLanes.test.tsx +24 -2
  30. package/src/player/components/TimelineLanes.tsx +12 -12
  31. package/src/player/components/TimelineTypes.ts +6 -0
  32. package/src/player/components/VideoThumbnail.test.tsx +46 -100
  33. package/src/player/components/VideoThumbnail.tsx +74 -156
  34. package/src/player/components/thumbnailUtils.test.ts +35 -0
  35. package/src/player/components/thumbnailUtils.ts +86 -8
  36. package/src/player/components/timelineClipChildren.test.ts +31 -0
  37. package/src/player/components/timelineClipChildren.tsx +21 -2
  38. package/src/player/components/timelineLaneProps.ts +3 -0
  39. package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
  40. package/src/player/hooks/useTimelinePlayer.ts +14 -9
  41. package/src/player/lib/mediaProbe.test.ts +143 -0
  42. package/src/player/lib/mediaProbe.ts +117 -25
  43. package/src/player/lib/thumbnailPolicy.test.ts +14 -0
  44. package/src/player/lib/thumbnailPolicy.ts +24 -0
  45. package/src/player/lib/thumbnailScheduler.test.ts +388 -0
  46. package/src/player/lib/thumbnailScheduler.ts +483 -0
  47. package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
  48. package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
  49. package/src/player/lib/timelineViewportBudgets.ts +2 -0
  50. package/src/player/store/playerStore.ts +3 -1
  51. package/src/player/store/thumbnailSlice.ts +18 -0
  52. package/src/telemetry/canary.test.ts +25 -0
  53. package/src/telemetry/canary.ts +14 -4
  54. package/src/utils/frameCapture.test.ts +1 -1
  55. package/src/utils/frameCapture.ts +1 -0
  56. package/src/utils/projectRouting.test.ts +1 -1
  57. package/src/utils/studioSelectionSnapshot.test.ts +1 -1
  58. package/src/utils/studioSelectionSnapshot.ts +1 -0
  59. package/src/utils/studioUiPreferences.test.ts +14 -0
  60. package/src/utils/studioUiPreferences.ts +6 -0
  61. package/dist/assets/index-D78KEjgB.css +0 -1
@@ -3,6 +3,7 @@
3
3
  import React, { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { thumbnailScheduler } from "../lib/thumbnailScheduler";
6
7
  import { ImageThumbnail } from "./ImageThumbnail";
7
8
 
8
9
  Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
@@ -68,6 +69,7 @@ beforeEach(() => {
68
69
  afterEach(() => {
69
70
  act(() => root?.unmount());
70
71
  root = null;
72
+ thumbnailScheduler.invalidateProject("p");
71
73
  globalThis.IntersectionObserver = originalIO;
72
74
  globalThis.ResizeObserver = originalRO;
73
75
  globalThis.Image = originalImage;
@@ -82,6 +84,10 @@ function render(props: { imageSrc: string; label?: string; labelColor?: string }
82
84
  imageSrc={props.imageSrc}
83
85
  label={props.label ?? ""}
84
86
  labelColor={props.labelColor ?? "#fff"}
87
+ projectId="p"
88
+ sessionEpoch={1}
89
+ priority="visible"
90
+ rich={false}
85
91
  />,
86
92
  );
87
93
  });
@@ -93,6 +99,13 @@ function lastProbe(): MockImage {
93
99
  return probe!;
94
100
  }
95
101
 
102
+ async function resolveProbe(update: (probe: MockImage) => void): Promise<void> {
103
+ await act(async () => {
104
+ update(lastProbe());
105
+ await new Promise((resolve) => setTimeout(resolve, 0));
106
+ });
107
+ }
108
+
96
109
  /** Assert at least one tile rendered and the first tile serves `expectedSrc`. */
97
110
  function expectFirstTileSrc(expectedSrc: string): void {
98
111
  const imgs = [...host.querySelectorAll("img")];
@@ -107,15 +120,15 @@ describe("ImageThumbnail", () => {
107
120
  expect(host.querySelectorAll("img").length).toBe(0);
108
121
  });
109
122
 
110
- it("probes the resolved src and renders repeated object-cover tiles on load", () => {
123
+ it("probes the resolved src and renders repeated object-cover tiles on load", async () => {
111
124
  render({ imageSrc: "/api/projects/p/preview/assets/pic.png" });
112
125
  const probe = lastProbe();
113
126
  expect(probe.src).toBe("/api/projects/p/preview/assets/pic.png");
114
127
 
115
- act(() => {
116
- probe.naturalWidth = 1920;
117
- probe.naturalHeight = 1080;
118
- probe.onload?.();
128
+ await resolveProbe((current) => {
129
+ current.naturalWidth = 1920;
130
+ current.naturalHeight = 1080;
131
+ current.onload?.();
119
132
  });
120
133
 
121
134
  const imgs = [...host.querySelectorAll("img")];
@@ -127,18 +140,18 @@ describe("ImageThumbnail", () => {
127
140
  expect(host.querySelector(".animate-pulse")).toBeNull();
128
141
  });
129
142
 
130
- it("drops the shimmer and renders no tiles when a raster image fails to load", () => {
143
+ it("drops the shimmer and renders no tiles when a raster image fails to load", async () => {
131
144
  render({ imageSrc: "/api/projects/p/preview/assets/missing.png" });
132
- act(() => lastProbe().onerror?.());
145
+ await resolveProbe((probe) => probe.onerror?.());
133
146
  expect(host.querySelectorAll("img").length).toBe(0);
134
147
  expect(host.querySelector(".animate-pulse")).toBeNull();
135
148
  });
136
149
 
137
- it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", () => {
150
+ it("renders tiles at 16:9 when an SVG has no intrinsic dimensions (naturalWidth=0)", async () => {
138
151
  render({ imageSrc: "/api/projects/p/preview/assets/logo.svg" });
139
152
  const probe = lastProbe();
140
153
 
141
- act(() => {
154
+ await resolveProbe(() => {
142
155
  // naturalWidth stays 0 — SVG with no width/height attribute
143
156
  probe.onload?.();
144
157
  });
@@ -147,21 +160,20 @@ describe("ImageThumbnail", () => {
147
160
  expect(host.querySelector(".animate-pulse")).toBeNull();
148
161
  });
149
162
 
150
- it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", () => {
163
+ it("renders SVG tiles at 16:9 fallback even when the probe fires onerror", async () => {
151
164
  // Some browser/sandbox environments fire onerror for SVGs even though the
152
165
  // <img> element itself can render the file — we must not blank the strip.
153
166
  render({ imageSrc: "/api/projects/p/preview/assets/icon.svg" });
154
167
 
155
- act(() => lastProbe().onerror?.());
168
+ await resolveProbe((probe) => probe.onerror?.());
156
169
 
157
170
  expectFirstTileSrc("/api/projects/p/preview/assets/icon.svg");
158
171
  expect(host.querySelector(".animate-pulse")).toBeNull();
159
172
  });
160
173
 
161
- it("renders the label above the strip when provided", () => {
174
+ it("renders the label above the strip when provided", async () => {
162
175
  render({ imageSrc: "/x.png", label: "hero", labelColor: "#abc" });
163
- act(() => {
164
- const probe = lastProbe();
176
+ await resolveProbe((probe) => {
165
177
  probe.naturalWidth = 100;
166
178
  probe.naturalHeight = 100;
167
179
  probe.onload?.();
@@ -1,135 +1,93 @@
1
- import { memo, useRef, useState, useCallback, useEffect } from "react";
1
+ import { memo, useCallback, useMemo, useRef, useState } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
- import { computeThumbnailStrip } from "./thumbnailUtils";
3
+ import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
+ import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
+ import { TIMELINE_VIEWPORT_BUDGETS } from "../lib/timelineViewportBudgets";
6
+ import { computeThumbnailStrip, probeImageAspect } from "./thumbnailUtils";
4
7
 
5
8
  interface ImageThumbnailProps {
6
9
  imageSrc: string;
7
10
  label: string;
8
11
  labelColor: string;
12
+ projectId?: string;
13
+ sessionEpoch?: number;
14
+ priority?: ThumbnailPriority;
15
+ rich?: boolean;
9
16
  }
10
17
 
11
- /**
12
- * Renders a film-strip of a still image for a timeline clip. The image is a
13
- * fixed-width tile (sized by its natural aspect ratio) repeated to fill the
14
- * clip width — matching VideoThumbnail's visual pattern. Loading is lazy
15
- * (IntersectionObserver) with the same shimmer fallback while decoding.
16
- */
18
+ /** A scheduler-backed still-image strip. Mounting is the sole work trigger. */
17
19
  export const ImageThumbnail = memo(function ImageThumbnail({
18
20
  imageSrc,
19
21
  label,
20
22
  labelColor,
23
+ projectId = imageSrc,
24
+ sessionEpoch = 0,
25
+ priority = "visible",
26
+ rich = false,
21
27
  }: ImageThumbnailProps) {
22
28
  const [containerWidth, setContainerWidth] = useState(0);
23
- const [visible, setVisible] = useState(false);
24
- const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");
25
- const [aspect, setAspect] = useState(16 / 9);
26
- const ioRef = useRef<IntersectionObserver | null>(null);
27
- const roRef = useRef<ResizeObserver | null>(null);
28
-
29
- const setContainerRef = useCallback((el: HTMLDivElement | null) => {
30
- ioRef.current?.disconnect();
31
- roRef.current?.disconnect();
32
- if (!el) return;
33
-
34
- const measured = el.parentElement?.clientWidth || el.clientWidth;
35
- setContainerWidth(measured);
36
-
37
- ioRef.current = new IntersectionObserver(
38
- ([entry]) => {
39
- if (entry.isIntersecting) {
40
- setVisible(true);
41
- ioRef.current?.disconnect();
42
- }
29
+ const observerRef = useRef<ResizeObserver | null>(null);
30
+ const request = useMemo(
31
+ () => ({
32
+ key: createThumbnailKey({ kind: "image", source: imageSrc, rich: Number(rich) }),
33
+ projectId,
34
+ sessionEpoch,
35
+ kind: "image" as const,
36
+ priority,
37
+ rich,
38
+ load: async (signal: AbortSignal) => {
39
+ const aspect = await probeImageAspect(imageSrc, signal, true);
40
+ return {
41
+ value: { kind: "image" as const, url: imageSrc, aspect },
42
+ weight:
43
+ TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalWidth *
44
+ TIMELINE_VIEWPORT_BUDGETS.posterMaxPhysicalHeight *
45
+ 4,
46
+ };
43
47
  },
44
- { rootMargin: "200px" },
45
- );
46
- // fallow-ignore-next-line code-duplication
47
- ioRef.current.observe(el);
48
+ }),
49
+ [imageSrc, priority, projectId, rich, sessionEpoch],
50
+ );
51
+ const snapshot = useThumbnailLease(request);
52
+ const value = snapshot.status === "ready" ? snapshot.value : null;
53
+ const aspect = value?.kind === "image" ? value.aspect : 16 / 9;
54
+ const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
48
55
 
49
- const target = el.parentElement || el;
50
- roRef.current = new ResizeObserver(([entry]) => {
51
- setContainerWidth(entry.contentRect.width);
52
- });
53
- roRef.current.observe(target);
56
+ const setContainerRef = useCallback((element: HTMLDivElement | null) => {
57
+ observerRef.current?.disconnect();
58
+ if (!element) return;
59
+ const target = element.parentElement ?? element;
60
+ setContainerWidth(target.clientWidth);
61
+ observerRef.current = new ResizeObserver(([entry]) =>
62
+ setContainerWidth(entry.contentRect.width),
63
+ );
64
+ observerRef.current.observe(target);
54
65
  }, []);
55
66
 
56
- useMountEffect(() => () => {
57
- ioRef.current?.disconnect();
58
- roRef.current?.disconnect();
59
- });
60
-
61
- // Probe the image once visible — measures the natural aspect ratio so the
62
- // tile width matches, and flips to the error state (plain clip background)
63
- // if the src can't load. The browser cache makes the tile <img>s free.
64
- //
65
- // SVG handling: SVGs without intrinsic width/height report naturalWidth=0 on
66
- // load (treat as success with the 16:9 default aspect) and may fire onerror
67
- // in some environments even though the file is valid and can be displayed —
68
- // fall back to loaded-at-16:9 rather than hiding the strip entirely.
69
- // eslint-disable-next-line no-restricted-syntax
70
- useEffect(() => {
71
- if (!visible) return;
72
- let cancelled = false;
73
- setStatus("loading");
74
-
75
- const isSvg = /\.svg($|\?)/i.test(imageSrc);
76
-
77
- const probe = new Image();
78
- probe.onload = () => {
79
- if (cancelled) return;
80
- if (probe.naturalWidth > 0 && probe.naturalHeight > 0) {
81
- setAspect(probe.naturalWidth / probe.naturalHeight);
82
- }
83
- // naturalWidth===0 (e.g. SVG with no intrinsic dimensions) falls through
84
- // to "loaded" with the default 16:9 aspect already set in state.
85
- setStatus("loaded");
86
- };
87
- probe.onerror = () => {
88
- if (cancelled) return;
89
- // SVGs can fail the probe in certain browser/sandbox environments even
90
- // though the <img> tiles themselves render fine (different security
91
- // context). Show the strip at the 16:9 fallback rather than blanking.
92
- if (isSvg) {
93
- setStatus("loaded");
94
- } else {
95
- setStatus("error");
96
- }
97
- };
98
- probe.src = imageSrc;
99
-
100
- return () => {
101
- cancelled = true;
102
- probe.onload = null;
103
- probe.onerror = null;
104
- probe.src = "";
105
- };
106
- }, [visible, imageSrc]);
107
-
108
- const { frameW, frameCount } = computeThumbnailStrip(containerWidth, aspect);
67
+ useMountEffect(() => () => observerRef.current?.disconnect());
109
68
 
110
69
  return (
111
70
  <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
112
- {visible && status === "loaded" && (
71
+ {value?.kind === "image" && (
113
72
  <div className="absolute inset-0 flex">
114
- {Array.from({ length: frameCount }).map((_, i) => (
73
+ {Array.from({ length: frameCount }, (_, index) => (
115
74
  <div
116
- key={i}
117
- className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
75
+ key={index}
76
+ className="relative h-full flex-shrink-0 overflow-hidden bg-neutral-900"
118
77
  style={{ width: frameW }}
119
78
  >
120
79
  <img
121
- src={imageSrc}
80
+ src={value.url}
122
81
  alt=""
123
82
  draggable={false}
124
83
  loading="lazy"
125
- className="absolute inset-0 w-full h-full object-cover"
84
+ className="absolute inset-0 h-full w-full object-cover"
126
85
  />
127
86
  </div>
128
87
  ))}
129
88
  </div>
130
89
  )}
131
-
132
- {visible && status === "loading" && (
90
+ {snapshot.status === "loading" && (
133
91
  <div
134
92
  className="absolute inset-0 animate-pulse"
135
93
  style={{
@@ -138,17 +96,16 @@ export const ImageThumbnail = memo(function ImageThumbnail({
138
96
  }}
139
97
  />
140
98
  )}
141
-
142
99
  {label && (
143
100
  <div
144
- className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
101
+ className="absolute inset-x-0 bottom-0 z-10 px-1.5 pb-0.5 pt-3"
145
102
  style={{
146
103
  background:
147
104
  "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
148
105
  }}
149
106
  >
150
107
  <span
151
- className="text-[9px] font-semibold truncate block leading-tight"
108
+ className="block truncate text-[9px] font-semibold leading-tight"
152
109
  style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
153
110
  >
154
111
  {label}
@@ -64,7 +64,6 @@ export {
64
64
  getTimelineScrollTopForGeometryChange,
65
65
  getTimelineVisibleTimeRange,
66
66
  } from "./timelineViewportGeometry";
67
-
68
67
  export const Timeline = memo(function Timeline({
69
68
  onSeek,
70
69
  onDrillDown,
@@ -317,24 +316,25 @@ export const Timeline = memo(function Timeline({
317
316
  toggleSelectedKeyframe,
318
317
  });
319
318
 
320
- const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
321
- tracks,
322
- viewport,
323
- pixelsPerSecond: pps,
324
- contentOrigin,
325
- duration: displayDuration,
326
- selectedElementId: selectedElementId ?? undefined,
327
- draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
328
- resizingElementIds,
329
- focusedElementId: timelineFocus.pinnedElementId,
330
- focusedEaseElementId: focusedEaseSegment?.elementId,
331
- clipContextMenuElementId: clipContextMenu
332
- ? getTimelineElementIdentity(clipContextMenu.element)
333
- : undefined,
334
- keyframeContextMenuElementId: kfContextMenu
335
- ? getTimelineElementIdentity(kfContextMenu.element)
336
- : undefined,
337
- });
319
+ const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
320
+ useTimelineClipRenderWindow({
321
+ tracks,
322
+ viewport,
323
+ pixelsPerSecond: pps,
324
+ contentOrigin,
325
+ duration: displayDuration,
326
+ selectedElementId: selectedElementId ?? undefined,
327
+ draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
328
+ resizingElementIds,
329
+ focusedElementId: timelineFocus.pinnedElementId,
330
+ focusedEaseElementId: focusedEaseSegment?.elementId,
331
+ clipContextMenuElementId: clipContextMenu
332
+ ? getTimelineElementIdentity(clipContextMenu.element)
333
+ : undefined,
334
+ keyframeContextMenuElementId: kfContextMenu
335
+ ? getTimelineElementIdentity(kfContextMenu.element)
336
+ : undefined,
337
+ });
338
338
  useTimelineActiveClips({
339
339
  scrollRef,
340
340
  currentTime,
@@ -504,6 +504,7 @@ export const Timeline = memo(function Timeline({
504
504
  rowsVirtualized={timelineFocus.rowVirtualizationActive}
505
505
  clipIndex={clipIndex}
506
506
  renderTimeRange={renderTimeRange}
507
+ visibleTimeRange={visibleTimeRange}
507
508
  pinnedClipIdentities={pinnedClipIdentities}
508
509
  trackOrder={trackOrder}
509
510
  tracks={tracks}
@@ -9,6 +9,7 @@ import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview";
9
9
  import type { DraggedClipState } from "./timelineClipDragTypes";
10
10
  import type { TrackVisualStyle } from "./timelineIcons";
11
11
  import { isTimelineClipActive } from "./useTimelineActiveClips";
12
+ import type { TimelineClipRenderContext } from "./TimelineTypes";
12
13
 
13
14
  interface TimelineGestureOverlayProps {
14
15
  drag: DraggedClipState | null;
@@ -22,6 +23,7 @@ interface TimelineGestureOverlayProps {
22
23
  renderClipContent?: (
23
24
  element: TimelineElement,
24
25
  style: { clip: string; label: string },
26
+ context: TimelineClipRenderContext,
25
27
  ) => ReactNode;
26
28
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
27
29
  }
@@ -87,6 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({
87
89
  getTrackStyle(element.tag),
88
90
  renderClipContent,
89
91
  renderClipOverlay,
92
+ { priority: "interaction", rich: true },
90
93
  )}
91
94
  </TimelineClip>
92
95
  </div>
@@ -76,10 +76,14 @@ function renderLanes(options: RenderLanesOptions = {}): {
76
76
  host: HTMLDivElement;
77
77
  root: Root;
78
78
  rerender: (next: RenderLanesOptions) => void;
79
+ setSelectedElementId: ReturnType<typeof vi.fn>;
80
+ onSelectElement: ReturnType<typeof vi.fn>;
79
81
  } {
80
82
  const host = document.createElement("div");
81
83
  document.body.append(host);
82
84
  const root = createRoot(host);
85
+ const setSelectedElementId = vi.fn();
86
+ const onSelectElement = vi.fn();
83
87
  const render = (next: RenderLanesOptions) => {
84
88
  const elements = next.elements ?? [element("clip-a", TRACK_A)];
85
89
  const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
@@ -118,6 +122,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
118
122
  })}
119
123
  clipIndex={createTimelineClipIndex(tracks)}
120
124
  renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
125
+ visibleTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
121
126
  pinnedClipIdentities={new Set()}
122
127
  trackOrder={displayTrackOrder}
123
128
  tracks={tracks}
@@ -137,7 +142,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
137
142
  setRangeSelection={vi.fn()}
138
143
  setResizingClip={vi.fn()}
139
144
  setDraggedClip={vi.fn()}
140
- setSelectedElementId={vi.fn()}
145
+ setSelectedElementId={setSelectedElementId}
141
146
  shiftClickClipRef={createRef()}
142
147
  getPreviewElement={(el) => el}
143
148
  getTrackStyle={getTrackStyle}
@@ -149,6 +154,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
149
154
  onTogglePropertyGroupKeyframe={vi.fn()}
150
155
  onResizeElement={vi.fn()}
151
156
  onMoveElement={vi.fn()}
157
+ onSelectElement={onSelectElement}
152
158
  onRazorSplit={vi.fn()}
153
159
  onRazorSplitAll={vi.fn()}
154
160
  />,
@@ -156,7 +162,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
156
162
  });
157
163
  };
158
164
  render(options);
159
- return { host, root, rerender: render };
165
+ return { host, root, rerender: render, setSelectedElementId, onSelectElement };
160
166
  }
161
167
 
162
168
  function visibilityLabels(host: HTMLElement): (string | null)[] {
@@ -347,3 +353,19 @@ describe("TimelineLanes disclosure target", () => {
347
353
  act(() => view.root.unmount());
348
354
  });
349
355
  });
356
+
357
+ describe("TimelineLanes selection", () => {
358
+ it("keeps a selected clip selected when it is clicked again", () => {
359
+ const selected = element("clip-a", TRACK_A);
360
+ const view = renderLanes({
361
+ elements: [selected],
362
+ selectedElementIds: new Set([selected.id]),
363
+ });
364
+
365
+ act(() => view.host.querySelector<HTMLButtonElement>('[data-el-id="clip-a"]')?.click());
366
+
367
+ expect(view.setSelectedElementId).toHaveBeenCalledWith(selected.id);
368
+ expect(view.onSelectElement).toHaveBeenCalledWith(selected);
369
+ act(() => view.root.unmount());
370
+ });
371
+ });
@@ -22,7 +22,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
22
22
  import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
23
23
  import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
24
24
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
25
- import { renderClipChildren } from "./timelineClipChildren";
25
+ import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
26
26
  import { TimelineTrackRow } from "./TimelineTrackRow";
27
27
  import { isTimelineClipActive } from "./useTimelineActiveClips";
28
28
  import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
@@ -57,6 +57,7 @@ export function TimelineLanes({
57
57
  rowsVirtualized,
58
58
  clipIndex,
59
59
  renderTimeRange,
60
+ visibleTimeRange,
60
61
  pinnedClipIdentities,
61
62
  trackOrder,
62
63
  tracks,
@@ -323,8 +324,7 @@ export function TimelineLanes({
323
324
  const isSelected =
324
325
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
325
326
  const isComposition = !!el.compositionSrc;
326
- // The element identity is already unique per clip. Never fold in the map
327
- // index, or a splice/reorder remounts every clip at/after the change.
327
+ // Element identity stays stable across clip splices and reorders.
328
328
  const clipKey = elementKey;
329
329
  const isDraggingClip =
330
330
  draggedClip?.started === true &&
@@ -332,6 +332,11 @@ export function TimelineLanes({
332
332
  getTimelineElementIdentity(draggedElement) === elementKey;
333
333
  if (isDraggingClip) return null;
334
334
  const previewElement = getPreviewElement(el);
335
+ const renderContext = resolveClipRenderContext(
336
+ previewElement,
337
+ visibleTimeRange,
338
+ isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
339
+ );
335
340
  // Passenger of a live multi-drag: preserve the formation without changing
336
341
  // the passenger's timeline data until the owning drag commits.
337
342
  const isPassenger =
@@ -475,15 +480,9 @@ export function TimelineLanes({
475
480
  }
476
481
  return;
477
482
  }
478
- // Plain click single-selects: drop any marquee multi-selection.
479
- // Only a click on the PRIMARY selection toggles it off — a click
480
- // on a marquee-selected clip narrows the selection to that clip.
481
- const hadMultiSelection = selectedElementIds.size > 0;
482
- usePlayerStore.getState().clearSelectedElementIds();
483
- const nextElement =
484
- selectedElementId === elementKey && !hadMultiSelection ? null : el;
485
- setSelectedElementId(nextElement ? elementKey : null);
486
- onSelectElement?.(nextElement);
483
+ // Clip selection is idempotent; empty timeline space owns deselection.
484
+ setSelectedElementId(elementKey);
485
+ onSelectElement?.(el);
487
486
  }
488
487
  }
489
488
  onDoubleClick={(e) => {
@@ -497,6 +496,7 @@ export function TimelineLanes({
497
496
  clipStyle,
498
497
  renderClipContent,
499
498
  renderClipOverlay,
499
+ renderContext,
500
500
  )}
501
501
  </TimelineClip>
502
502
  );
@@ -4,6 +4,11 @@ import type { TimelineDropCallbacks } from "./timelineCallbacks";
4
4
  import type { TimelineTheme } from "./timelineTheme";
5
5
  import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks";
6
6
 
7
+ export interface TimelineClipRenderContext {
8
+ priority: "overscan" | "visible" | "interaction";
9
+ rich: boolean;
10
+ }
11
+
7
12
  export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides {
8
13
  /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */
9
14
  sessionEpoch?: number;
@@ -12,6 +17,7 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri
12
17
  renderClipContent?: (
13
18
  element: TimelineElement,
14
19
  style: { clip: string; label: string },
20
+ context: TimelineClipRenderContext,
15
21
  ) => ReactNode;
16
22
  renderClipOverlay?: (element: TimelineElement) => ReactNode;
17
23
  onDeleteElement?: (element: TimelineElement) => Promise<void> | void;