@hyperframes/studio 0.8.60 → 0.8.62

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 (53) hide show
  1. package/dist/assets/{hyperframes-player-Di7GyCOP.js → hyperframes-player-BMHU9-3w.js} +1 -1
  2. package/dist/assets/{index-Dx_pt9m4.js → index-Ce1SRT2i.js} +1 -1
  3. package/dist/assets/{index-Dpq2lkOm.js → index-DLYk_vu_.js} +186 -186
  4. package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
  5. package/dist/assets/{index-jAjhQTrB.js → index-Dg0Vjj_i.js} +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3308 -2895
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -0
  13. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  14. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  15. package/src/hooks/useRenderClipContent.test.ts +33 -4
  16. package/src/hooks/useRenderClipContent.ts +7 -3
  17. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  18. package/src/hooks/useSdkSession.ts +84 -14
  19. package/src/hooks/useStudioSdkSessions.ts +8 -1
  20. package/src/player/components/AudioWaveform.test.tsx +53 -1
  21. package/src/player/components/AudioWaveform.tsx +113 -63
  22. package/src/player/components/TimelineClipFades.tsx +64 -31
  23. package/src/player/components/TimelineLanes.tsx +15 -8
  24. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  25. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  26. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  27. package/src/player/components/VideoThumbnail.tsx +83 -27
  28. package/src/player/components/audioClipLink.test.ts +34 -0
  29. package/src/player/components/audioClipLink.ts +33 -0
  30. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  31. package/src/player/components/audioWaveformPeaks.ts +46 -0
  32. package/src/player/components/thumbnailUtils.test.ts +17 -0
  33. package/src/player/components/thumbnailUtils.ts +7 -0
  34. package/src/player/components/timelineTheme.ts +6 -0
  35. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  36. package/src/player/components/timelineTransitionSeams.ts +36 -0
  37. package/src/player/components/timelineZones.test.ts +18 -0
  38. package/src/player/components/timelineZones.ts +4 -1
  39. package/src/player/hooks/useShadowPreviewReload.ts +41 -6
  40. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
  41. package/src/player/lib/playbackTypes.ts +2 -0
  42. package/src/player/lib/timelineDOM.test.ts +28 -0
  43. package/src/player/lib/timelineDOM.ts +24 -2
  44. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  45. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  46. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  47. package/src/player/store/timelineElement.ts +1 -0
  48. package/src/styles/hex-baseline.json +4 -4
  49. package/src/styles/studio.css +84 -0
  50. package/src/styles/theme.css +10 -2
  51. package/src/utils/sdkCutover.test.ts +102 -1
  52. package/src/utils/sdkCutover.ts +16 -21
  53. package/src/utils/sdkEditTransaction.ts +50 -1
@@ -11,7 +11,7 @@ vi.mock("../../hooks/useThumbnailLease", () => ({
11
11
  useThumbnailLease: leaseSpy,
12
12
  }));
13
13
 
14
- import { AudioWaveform } from "./AudioWaveform";
14
+ import { AudioWaveform, drawWaveformCanvas } from "./AudioWaveform";
15
15
 
16
16
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
17
17
 
@@ -21,6 +21,30 @@ afterEach(() => {
21
21
  });
22
22
 
23
23
  describe("AudioWaveform", () => {
24
+ it("paints a baseline and peak bar for every mapped waveform bin", () => {
25
+ const fillRect = vi.fn();
26
+ const context = {
27
+ scale: vi.fn(),
28
+ clearRect: vi.fn(),
29
+ fillRect,
30
+ fillStyle: "",
31
+ } as unknown as CanvasRenderingContext2D;
32
+ const canvas = document.createElement("canvas");
33
+ Object.defineProperties(canvas, {
34
+ clientWidth: { value: 6 },
35
+ clientHeight: { value: 20 },
36
+ });
37
+ vi.spyOn(canvas, "getContext").mockReturnValue(context);
38
+ drawWaveformCanvas(canvas, [0.25, 1], false, 0, 1);
39
+ expect(fillRect).toHaveBeenCalledTimes(4);
40
+ expect(fillRect.mock.calls.map(([x, y, width, height]) => [x, y, width, height])).toEqual([
41
+ [0, 18, 3, 2],
42
+ [0, 15, 3, 5],
43
+ [3, 18, 3, 2],
44
+ [3, 0, 3, 20],
45
+ ]);
46
+ });
47
+
24
48
  it("leases waveform decoding with the clip's project, session, and viewport priority", () => {
25
49
  const host = document.createElement("div");
26
50
  document.body.append(host);
@@ -50,4 +74,32 @@ describe("AudioWaveform", () => {
50
74
 
51
75
  act(() => root.unmount());
52
76
  });
77
+
78
+ it("greys the clip in place when muted and draws the parent tick when linked", () => {
79
+ const host = document.createElement("div");
80
+ host.className = "timeline-clip is-audio";
81
+ document.body.append(host);
82
+ const root = createRoot(host);
83
+
84
+ act(() => {
85
+ root.render(
86
+ <AudioWaveform
87
+ audioUrl="/media/voice.wav"
88
+ label=""
89
+ labelColor="#fff"
90
+ projectId="project-a"
91
+ sessionEpoch={1}
92
+ priority="visible"
93
+ muted
94
+ linked
95
+ />,
96
+ );
97
+ });
98
+
99
+ expect(host.getAttribute("data-audio-muted")).toBe("true");
100
+ expect(host.querySelector(".timeline-audio-link")).not.toBeNull();
101
+
102
+ act(() => root.unmount());
103
+ expect(host.hasAttribute("data-audio-muted")).toBe(false);
104
+ });
53
105
  });
@@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
4
  import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
5
+ import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
5
6
 
6
7
  interface AudioWaveformProps {
7
8
  audioUrl: string;
@@ -13,11 +14,68 @@ interface AudioWaveformProps {
13
14
  projectId: string;
14
15
  sessionEpoch: number;
15
16
  priority: ThumbnailPriority;
17
+ /** `data-hidden` or a muted audio group. Greys the pill; the clip stays. */
18
+ muted?: boolean;
19
+ /** Same media file as a video clip. Draws the 1px parent tick. */
20
+ linked?: boolean;
16
21
  }
17
22
 
18
- const BAR_WIDTH = 2;
19
23
  const BAR_STEP = 3;
20
24
 
25
+ type BarGeometry = { x: number; width: number; height: number };
26
+
27
+ function paintWaveformBars(
28
+ context: CanvasRenderingContext2D,
29
+ bars: readonly BarGeometry[],
30
+ height: number,
31
+ waveformBarRgb: string,
32
+ waveformBaselineRgb: string,
33
+ amplitudes: readonly number[],
34
+ ) {
35
+ bars.forEach((bar, index) => {
36
+ const amplitude = amplitudes[index] ?? 0;
37
+ context.fillStyle = `rgb(${waveformBaselineRgb})`;
38
+ context.fillRect(bar.x, height - 2, bar.width, 2);
39
+ context.fillStyle = `rgba(${waveformBarRgb},${loudnessToOpacity(amplitude).toFixed(2)})`;
40
+ context.fillRect(bar.x, height - bar.height, bar.width, bar.height);
41
+ });
42
+ }
43
+
44
+ export function drawWaveformCanvas(
45
+ canvas: HTMLCanvasElement,
46
+ peaks: readonly number[],
47
+ muted: boolean,
48
+ trimStartFraction: number,
49
+ trimEndFraction: number,
50
+ ) {
51
+ const width = Math.max(1, canvas.clientWidth);
52
+ const height = Math.max(1, canvas.clientHeight);
53
+ const scale = window.devicePixelRatio || 1;
54
+ canvas.width = Math.ceil(width * scale);
55
+ canvas.height = Math.ceil(height * scale);
56
+ const context = canvas.getContext("2d");
57
+ if (!context) return;
58
+ context.scale(scale, scale);
59
+ context.clearRect(0, 0, width, height);
60
+ const amplitudes = decimatePeaks(
61
+ peaks,
62
+ trimStartFraction,
63
+ trimEndFraction,
64
+ Math.max(1, Math.ceil(width / BAR_STEP)),
65
+ );
66
+ const bars = amplitudes.map((amplitude, index) => ({
67
+ x: (index * width) / amplitudes.length,
68
+ width: Math.max(1, width / amplitudes.length),
69
+ height: Math.max(3, amplitude * height),
70
+ }));
71
+ const channelToken = muted ? "--timeline-waveform-muted-rgb" : "--timeline-waveform-bar-rgb";
72
+ const waveformBarRgb = getComputedStyle(canvas).getPropertyValue(channelToken);
73
+ const waveformBaselineRgb = getComputedStyle(canvas).getPropertyValue(
74
+ "--timeline-waveform-baseline-rgb",
75
+ );
76
+ paintWaveformBars(context, bars, height, waveformBarRgb, waveformBaselineRgb, amplitudes);
77
+ }
78
+
21
79
  function extractPeaks(channelData: Float32Array, barCount: number): number[] {
22
80
  const peaks: number[] = [];
23
81
  const samplesPerBar = Math.floor(channelData.length / barCount);
@@ -92,7 +150,10 @@ export const AudioWaveform = memo(function AudioWaveform({
92
150
  projectId,
93
151
  sessionEpoch,
94
152
  priority,
153
+ muted = false,
154
+ linked = false,
95
155
  }: AudioWaveformProps) {
156
+ const rootRef = useRef<HTMLDivElement | null>(null);
96
157
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
97
158
  const observerRef = useRef<ResizeObserver | null>(null);
98
159
  const cacheKey = waveformUrl ?? audioUrl;
@@ -121,30 +182,8 @@ export const AudioWaveform = memo(function AudioWaveform({
121
182
  const draw = useCallback(() => {
122
183
  const canvas = canvasRef.current;
123
184
  if (!canvas || !peaks) return;
124
- const width = Math.max(1, canvas.clientWidth);
125
- const height = Math.max(1, canvas.clientHeight);
126
- const scale = window.devicePixelRatio || 1;
127
- canvas.width = Math.ceil(width * scale);
128
- canvas.height = Math.ceil(height * scale);
129
- const context = canvas.getContext("2d");
130
- if (!context) return;
131
- context.scale(scale, scale);
132
- context.clearRect(0, 0, width, height);
133
- const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0));
134
- const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1));
135
- const start = Math.floor(startFraction * peaks.length);
136
- const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length));
137
- const span = end - start;
138
- const barCount = Math.floor(width / BAR_STEP);
139
- const waveformBarRgb = getComputedStyle(canvas).getPropertyValue("--timeline-waveform-bar-rgb");
140
- for (let index = 0; index < barCount; index++) {
141
- const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span));
142
- const amplitude = peaks[peakIndex] ?? 0;
143
- const barHeight = Math.max(2, amplitude * height);
144
- context.fillStyle = `rgba(${waveformBarRgb},${(0.45 + amplitude * 0.4).toFixed(2)})`;
145
- context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight);
146
- }
147
- }, [peaks, trimEndFraction, trimStartFraction]);
185
+ drawWaveformCanvas(canvas, peaks, muted, trimStartFraction ?? 0, trimEndFraction ?? 1);
186
+ }, [muted, peaks, trimEndFraction, trimStartFraction]);
148
187
 
149
188
  const setCanvasRef = useCallback(
150
189
  (canvas: HTMLCanvasElement | null) => {
@@ -170,51 +209,62 @@ export const AudioWaveform = memo(function AudioWaveform({
170
209
 
171
210
  useMountEffect(() => () => observerRef.current?.disconnect());
172
211
 
212
+ useEffect(() => {
213
+ const clip = rootRef.current?.closest(".timeline-clip");
214
+ if (!(clip instanceof HTMLElement)) return;
215
+ if (muted) clip.setAttribute("data-audio-muted", "true");
216
+ else clip.removeAttribute("data-audio-muted");
217
+ return () => clip.removeAttribute("data-audio-muted");
218
+ }, [muted]);
219
+
173
220
  return (
174
- <div className="absolute inset-0 overflow-hidden">
175
- <canvas
176
- ref={setCanvasRef}
177
- className="absolute inset-x-0 bottom-0 w-full"
178
- style={{ top: 16 }}
179
- />
180
- {snapshot.status === "loading" && (
181
- <div
182
- className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
183
- style={{
184
- background: "var(--timeline-thumbnail-shimmer)",
185
- }}
221
+ <div ref={rootRef} className="absolute inset-0">
222
+ {linked ? <span className="timeline-audio-link" aria-hidden="true" /> : null}
223
+ <div className="absolute inset-0 overflow-hidden" style={{ zIndex: 10 }}>
224
+ <canvas
225
+ ref={setCanvasRef}
226
+ className="absolute inset-x-0 bottom-0 w-full"
227
+ style={{ top: 16, height: "calc(100% - 16px)" }}
186
228
  />
187
- )}
188
- {/* Degraded state — the decode failed; say so rather than paint a
189
- waveform the author could edit against. */}
190
- {snapshot.status === "error" && (
191
- <div
192
- className="absolute inset-x-0 flex items-center justify-center gap-1.5"
193
- style={{ top: 16, bottom: 0 }}
194
- >
229
+ {snapshot.status === "loading" && (
195
230
  <div
196
- className="absolute inset-x-0"
231
+ className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
197
232
  style={{
198
- bottom: "20%",
199
- height: 2,
200
- background: "var(--timeline-waveform-error)",
233
+ background: "var(--timeline-thumbnail-shimmer)",
201
234
  }}
202
235
  />
203
- <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
204
- waveform unavailable
205
- </span>
206
- </div>
207
- )}
208
- {label && (
209
- <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
210
- <span
211
- className="block truncate text-[9px] font-semibold leading-tight"
212
- style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
236
+ )}
237
+ {/* Degraded state — the decode failed; say so rather than paint a
238
+ waveform the author could edit against. */}
239
+ {snapshot.status === "error" && (
240
+ <div
241
+ className="absolute inset-x-0 flex items-center justify-center gap-1.5"
242
+ style={{ top: 16, bottom: 0 }}
213
243
  >
214
- {label}
215
- </span>
216
- </div>
217
- )}
244
+ <div
245
+ className="absolute inset-x-0"
246
+ style={{
247
+ bottom: "20%",
248
+ height: 2,
249
+ background: "var(--timeline-waveform-error)",
250
+ }}
251
+ />
252
+ <span className="relative rounded-sm bg-black/50 px-1 text-[8px] text-neutral-500">
253
+ waveform unavailable
254
+ </span>
255
+ </div>
256
+ )}
257
+ {label && (
258
+ <div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
259
+ <span
260
+ className="block truncate text-[9px] font-semibold leading-tight"
261
+ style={{ color: labelColor, textShadow: "var(--timeline-waveform-label-shadow)" }}
262
+ >
263
+ {label}
264
+ </span>
265
+ </div>
266
+ )}
267
+ </div>
218
268
  </div>
219
269
  );
220
270
  });
@@ -192,13 +192,14 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
192
192
  viewBox={`0 0 ${Math.max(widthPx, 1)} 100`}
193
193
  preserveAspectRatio="none"
194
194
  >
195
- {/* The shaded wedge is the gain that is NOT there: above the ramp. */}
195
+ {/* Keep the #4250 wedge geometry, but let the real waveform read through it. */}
196
196
  {showIn && (
197
197
  <>
198
198
  <polygon
199
199
  data-testid="clip-fade-in"
200
200
  points={`0,0 ${inPx},0 0,100`}
201
201
  fill="var(--timeline-fade-shade)"
202
+ fillOpacity={0.35}
202
203
  />
203
204
  <line
204
205
  x1={0}
@@ -218,6 +219,7 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
218
219
  data-testid="clip-fade-out"
219
220
  points={`${widthPx - outPx},0 ${widthPx},0 ${widthPx},100`}
220
221
  fill="var(--timeline-fade-shade)"
222
+ fillOpacity={0.35}
221
223
  />
222
224
  <line
223
225
  x1={widthPx - outPx}
@@ -235,54 +237,85 @@ export function TimelineClipFades({ el, pps, widthPx, showHandles }: TimelineCli
235
237
  )}
236
238
  {canEdit && (
237
239
  <>
238
- <div
239
- role="slider"
240
- tabIndex={-1}
241
- aria-label="Fade in"
242
- aria-valuemin={0}
243
- aria-valuemax={Math.max(0, el.duration - fades.fadeOut)}
244
- aria-valuenow={fades.fadeIn}
245
- aria-valuetext={`${formatFadeSeconds(fades.fadeIn)}s`}
246
- data-testid="clip-fade-handle-in"
247
- title={`Fade in: ${formatFadeSeconds(fades.fadeIn)}s — drag to change`}
240
+ <FadeHandle
241
+ direction="in"
242
+ value={fades.fadeIn}
243
+ max={Math.max(0, el.duration - fades.fadeOut)}
248
244
  style={handleStyle(inPx)}
245
+ dragging={dragging === "in"}
249
246
  onPointerDown={onHandlePointerDown("in")}
250
247
  onPointerMove={onHandlePointerMove}
251
248
  onPointerUp={(e) => finish(e, false)}
252
249
  onPointerCancel={(e) => finish(e, true)}
253
250
  onKeyDown={onHandleKeyDown}
254
- onClick={(e) => e.stopPropagation()}
255
- onDoubleClick={(e) => e.stopPropagation()}
256
- >
257
- <FadeDot active={dragging === "in"} />
258
- </div>
259
- <div
260
- role="slider"
261
- tabIndex={-1}
262
- aria-label="Fade out"
263
- aria-valuemin={0}
264
- aria-valuemax={Math.max(0, el.duration - fades.fadeIn)}
265
- aria-valuenow={fades.fadeOut}
266
- aria-valuetext={`${formatFadeSeconds(fades.fadeOut)}s`}
267
- data-testid="clip-fade-handle-out"
268
- title={`Fade out: ${formatFadeSeconds(fades.fadeOut)}s — drag to change`}
251
+ />
252
+ <FadeHandle
253
+ direction="out"
254
+ value={fades.fadeOut}
255
+ max={Math.max(0, el.duration - fades.fadeIn)}
269
256
  style={handleStyle(widthPx - outPx)}
257
+ dragging={dragging === "out"}
270
258
  onPointerDown={onHandlePointerDown("out")}
271
259
  onPointerMove={onHandlePointerMove}
272
260
  onPointerUp={(e) => finish(e, false)}
273
261
  onPointerCancel={(e) => finish(e, true)}
274
262
  onKeyDown={onHandleKeyDown}
275
- onClick={(e) => e.stopPropagation()}
276
- onDoubleClick={(e) => e.stopPropagation()}
277
- >
278
- <FadeDot active={dragging === "out"} />
279
- </div>
263
+ />
280
264
  </>
281
265
  )}
282
266
  </>
283
267
  );
284
268
  }
285
269
 
270
+ function FadeHandle({
271
+ direction,
272
+ value,
273
+ max,
274
+ style,
275
+ dragging,
276
+ onPointerDown,
277
+ onPointerMove,
278
+ onPointerUp,
279
+ onPointerCancel,
280
+ onKeyDown,
281
+ }: {
282
+ direction: "in" | "out";
283
+ value: number;
284
+ max: number;
285
+ style: CSSProperties;
286
+ dragging: boolean;
287
+ onPointerDown: (event: PointerEvent<HTMLDivElement>) => void;
288
+ onPointerMove: (event: PointerEvent<HTMLDivElement>) => void;
289
+ onPointerUp: (event: PointerEvent<HTMLDivElement>) => void;
290
+ onPointerCancel: (event: PointerEvent<HTMLDivElement>) => void;
291
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => void;
292
+ }) {
293
+ const label = direction === "in" ? "Fade in" : "Fade out";
294
+ return (
295
+ <div
296
+ role="slider"
297
+ tabIndex={-1}
298
+ aria-label={label}
299
+ aria-valuemin={0}
300
+ aria-valuemax={max}
301
+ aria-valuenow={value}
302
+ aria-valuetext={`${formatFadeSeconds(value)}s`}
303
+ data-testid={`clip-fade-handle-${direction}`}
304
+ title={`${label}: ${formatFadeSeconds(value)}s — drag to change`}
305
+ style={style}
306
+ onPointerDown={onPointerDown}
307
+ onPointerMove={onPointerMove}
308
+ onPointerUp={onPointerUp}
309
+ onPointerCancel={onPointerCancel}
310
+ onKeyDown={onKeyDown}
311
+ onClick={(e) => e.stopPropagation()}
312
+ onDoubleClick={(e) => e.stopPropagation()}
313
+ >
314
+ <FadeDot active={dragging} />
315
+ </div>
316
+ );
317
+ }
318
+
286
319
  function FadeDot({ active }: { active: boolean }) {
287
320
  return (
288
321
  <span
@@ -32,6 +32,8 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
32
32
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
33
33
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
34
34
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
35
+ import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
36
+ import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
35
37
 
36
38
  export function TimelineLanes({
37
39
  pps,
@@ -104,13 +106,15 @@ export function TimelineLanes({
104
106
  const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
105
107
  useTimelineGroupDisclosure();
106
108
  const automationLanes = useAutomationLanes();
109
+ const allTransitionElements = useMemo(
110
+ () => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
111
+ [getPreviewElement, tracks],
112
+ );
107
113
  // A group's automation clock is COMPOSITION time (groups doc §1.3), so its
108
114
  // synthetic lane element spans the whole composition rather than a clip.
109
115
  const compositionDuration = usePlayerStore((s) => s.duration);
110
116
  useAutomationSelectionKeyboard({ lanes: automationLanes });
111
117
  const { logicalRowsByTrack, groupByAnchor } = useTimelineLaneRowIndexes(logicalRows, groups);
112
- // Which tracks are group MEMBERS, so their headers can render the level-2
113
- // nesting their `aria-level` already reports.
114
118
  const groupMemberTracks = useMemo(
115
119
  () => new Set(groups.flatMap((group) => group.memberTracks)),
116
120
  [groups],
@@ -197,11 +201,7 @@ export function TimelineLanes({
197
201
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
198
202
  const isPendingTrack =
199
203
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
200
- // All lanes use the same uniform color — no alternating stripes.
201
204
  const rowBackground = theme.rowBackground;
202
- // The beat-dot strip occupies the top of this track's lane (active track,
203
- // or the music track when nothing is selected). When shown, keyframe
204
- // diamonds shrink + drop to the bottom half so they don't collide with it.
205
205
  const beatStripOnTrack = trackShowsBeatStrip(els, beatAnalysis?.beatTimes, {
206
206
  selectedElementId,
207
207
  isMusicTrack,
@@ -223,9 +223,10 @@ export function TimelineLanes({
223
223
  // property lanes are showing. Undefined means "fill the row", which is
224
224
  // right only while it is collapsed and the row is nothing but bar.
225
225
  const clipBarHeight = rowExpanded ? TRACK_H - 2 * CLIP_Y : undefined;
226
- // The clips whose envelopes this row draws, at their dragged positions.
227
- // Once per row, not once per clip in the map below.
228
226
  const automationElements = els.map(getPreviewElement);
227
+ const transitionSeams = deriveTimelineTransitionSeams(allTransitionElements).filter(
228
+ (seam) => seam.incoming.track === trackNum,
229
+ );
229
230
  // Minted here because this is the only place that sees BOTH ends of
230
231
  // the disclosure: the caret in the sticky header and the diamond lanes
231
232
  // on the canvas. Keyed by display row, not by `trackNum`, which is a
@@ -553,6 +554,12 @@ export function TimelineLanes({
553
554
  );
554
555
  })
555
556
  }
557
+ <TimelineTransitionOverlays
558
+ seams={transitionSeams}
559
+ pixelsPerSecond={pps}
560
+ rowHeight={rowHeight}
561
+ clipBarHeight={clipBarHeight}
562
+ />
556
563
  {/* The automation lanes belong to the ROW, so they are mounted
557
564
  here rather than under the active clip's property lanes.
558
565
  Hanging off that clip meant selecting a sibling moved the
@@ -0,0 +1,59 @@
1
+ import type { CSSProperties } from "react";
2
+
3
+ interface TimelineTransitionBadgeProps {
4
+ centerPx: number;
5
+ top: number;
6
+ widthPx: number;
7
+ outgoingSrc?: string;
8
+ incomingSrc?: string;
9
+ }
10
+
11
+ export function TimelineTransitionBadge({
12
+ centerPx,
13
+ top,
14
+ widthPx,
15
+ outgoingSrc,
16
+ incomingSrc,
17
+ }: TimelineTransitionBadgeProps) {
18
+ const style = {
19
+ left: centerPx,
20
+ top,
21
+ "--timeline-transition-zone-width": `${widthPx}px`,
22
+ } as CSSProperties;
23
+ return (
24
+ <div className="timeline-transition" style={style} aria-hidden="true">
25
+ <div className="timeline-transition__zone">
26
+ <div
27
+ className="timeline-transition__image timeline-transition__image--out"
28
+ style={outgoingSrc ? { backgroundImage: `url("${outgoingSrc}")` } : undefined}
29
+ />
30
+ <div
31
+ className="timeline-transition__image timeline-transition__image--in"
32
+ style={incomingSrc ? { backgroundImage: `url("${incomingSrc}")` } : undefined}
33
+ />
34
+ </div>
35
+ <div className="timeline-transition__badge">
36
+ <svg viewBox="0 0 20 20" role="presentation">
37
+ <defs>
38
+ <filter id="timeline-transition-glyph-blur">
39
+ <feGaussianBlur stdDeviation="0.55" />
40
+ </filter>
41
+ </defs>
42
+ <path
43
+ className="timeline-transition__frame timeline-transition__frame--out"
44
+ d="M3 5h8v10H3z"
45
+ />
46
+ <path
47
+ className="timeline-transition__frame timeline-transition__frame--in"
48
+ d="M9 5h8v10H9z"
49
+ />
50
+ <path
51
+ className="timeline-transition__intersection"
52
+ d="M9 5h2v10H9z"
53
+ filter="url(#timeline-transition-glyph-blur)"
54
+ />
55
+ </svg>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
@@ -0,0 +1,30 @@
1
+ import { CLIP_Y } from "./timelineLayout";
2
+ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
+ import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
4
+ import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
5
+
6
+ interface TimelineTransitionOverlaysProps {
7
+ seams: readonly TimelineTransitionSeam[];
8
+ pixelsPerSecond: number;
9
+ rowHeight: number;
10
+ clipBarHeight?: number;
11
+ }
12
+
13
+ export function TimelineTransitionOverlays({
14
+ seams,
15
+ pixelsPerSecond,
16
+ rowHeight,
17
+ clipBarHeight,
18
+ }: TimelineTransitionOverlaysProps) {
19
+ const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
20
+ return seams.map((seam) => (
21
+ <TimelineTransitionBadge
22
+ key={`${getTimelineElementIdentity(seam.outgoing)}-${getTimelineElementIdentity(seam.incoming)}`}
23
+ centerPx={seam.centerTime * pixelsPerSecond}
24
+ top={top}
25
+ widthPx={Math.min(Math.max(seam.duration * pixelsPerSecond, 24), 32)}
26
+ outgoingSrc={seam.outgoing.src}
27
+ incomingSrc={seam.incoming.src}
28
+ />
29
+ ));
30
+ }
@@ -36,7 +36,8 @@ afterEach(() => {
36
36
  document.body.innerHTML = "";
37
37
  });
38
38
 
39
- async function render(rich = false) {
39
+ async function render(width = 0) {
40
+ Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
40
41
  root = createRoot(host);
41
42
  await act(async () => {
42
43
  root!.render(
@@ -47,7 +48,6 @@ async function render(rich = false) {
47
48
  projectId="p"
48
49
  sessionEpoch={1}
49
50
  priority="visible"
50
- rich={rich}
51
51
  />,
52
52
  );
53
53
  await Promise.resolve();
@@ -55,7 +55,7 @@ async function render(rich = false) {
55
55
  }
56
56
 
57
57
  describe("VideoThumbnail", () => {
58
- it("renders a scheduler-provided sparse poster", async () => {
58
+ it("does not acquire a thumbnail lease before the clip is measured", async () => {
59
59
  vi.mocked(decodeVideoThumbnail).mockResolvedValue({
60
60
  value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
61
61
  weight: 128,
@@ -63,29 +63,40 @@ describe("VideoThumbnail", () => {
63
63
 
64
64
  await render();
65
65
 
66
- expect(decodeVideoThumbnail).toHaveBeenCalledWith(
67
- expect.objectContaining({ frameCount: 1 }),
68
- expect.any(AbortSignal),
69
- );
70
- expect(host.querySelector('img[src="blob:poster"]')).not.toBeNull();
71
- expect(host.querySelector(".animate-pulse")).toBeNull();
66
+ expect(decodeVideoThumbnail).not.toHaveBeenCalled();
72
67
  });
73
68
 
74
- it("requests a rich filmstrip only for interaction actors", async () => {
69
+ it("requests a geometry-sized filmstrip by default", async () => {
75
70
  vi.mocked(decodeVideoThumbnail).mockResolvedValue({
76
71
  value: { kind: "filmstrip", urls: ["blob:a", "blob:b"], aspect: 16 / 9 },
77
72
  weight: 256,
78
73
  });
79
74
 
80
- await render(true);
75
+ await render(500);
81
76
 
77
+ expect(decodeVideoThumbnail).toHaveBeenNthCalledWith(
78
+ 1,
79
+ expect.objectContaining({ frameCount: 1 }),
80
+ expect.any(AbortSignal),
81
+ );
82
82
  expect(decodeVideoThumbnail).toHaveBeenCalledWith(
83
- expect.objectContaining({ frameCount: 6 }),
83
+ expect.objectContaining({ frameCount: 8 }),
84
84
  expect.any(AbortSignal),
85
85
  );
86
86
  expect(host.querySelectorAll("img").length).toBeGreaterThan(0);
87
87
  });
88
88
 
89
+ it("issues a single decode job for a narrow clip", async () => {
90
+ vi.mocked(decodeVideoThumbnail).mockResolvedValue({
91
+ value: { kind: "image", url: "blob:poster", aspect: 16 / 9 },
92
+ weight: 128,
93
+ });
94
+
95
+ await render(100);
96
+
97
+ expect(decodeVideoThumbnail).toHaveBeenCalledTimes(1);
98
+ });
99
+
89
100
  it("clears the loading shimmer when the scheduled decode fails", async () => {
90
101
  vi.mocked(decodeVideoThumbnail).mockRejectedValue(new Error("decode failed"));
91
102