@hyperframes/studio 0.8.64 → 0.8.65

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 (37) hide show
  1. package/dist/assets/hyperframes-player-CwFrSySS.js +464 -0
  2. package/dist/assets/{index-DLjjm1nC.js → index-Cs1lQrQ6.js} +142 -142
  3. package/dist/assets/{index-BEyJVAiH.js → index-a5nEhdAU.js} +1 -1
  4. package/dist/assets/{index-RRyhgH2k.js → index-wQFEeTL3.js} +1 -1
  5. package/dist/index.d.ts +4 -3
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +823 -748
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/editor/manualEdits.ts +5 -1
  11. package/src/components/editor/manualEditsParsing.ts +15 -3
  12. package/src/components/sidebar/CompositionsPanel.masterComposition.test.tsx +1 -1
  13. package/src/components/sidebar/CompositionsTab.drag.test.tsx +2 -2
  14. package/src/components/sidebar/CompositionsTab.rootBadge.test.tsx +1 -1
  15. package/src/components/sidebar/CompositionsTab.tsx +3 -2
  16. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +30 -3
  17. package/src/hooks/useExternalFileChangeCoordinator.ts +12 -5
  18. package/src/hooks/useRenderClipContent.test.ts +21 -2
  19. package/src/hooks/useRenderClipContent.ts +10 -5
  20. package/src/hooks/useStudioExternalFileChanges.ts +6 -3
  21. package/src/player/components/CompositionThumbnail.tsx +6 -0
  22. package/src/player/components/Timeline.virtualization.test.tsx +49 -4
  23. package/src/player/components/TimelineLanes.test.tsx +28 -0
  24. package/src/player/components/TimelineLanes.tsx +5 -4
  25. package/src/player/components/TimelineTransitionOverlays.tsx +3 -8
  26. package/src/player/components/timelineClipChildren.tsx +11 -1
  27. package/src/player/components/timelineProviderStateBuilders.test.ts +0 -7
  28. package/src/player/components/timelineProviderStateBuilders.ts +0 -9
  29. package/src/player/components/timelineTransitionSeams.test.ts +21 -1
  30. package/src/player/components/timelineTransitionSeams.ts +21 -0
  31. package/src/player/components/useTimelineProviderState.tsx +11 -5
  32. package/src/player/hooks/useShadowPreviewReload.ts +7 -0
  33. package/src/player/lib/thumbnailScheduler.test.ts +33 -0
  34. package/src/player/lib/thumbnailScheduler.ts +34 -0
  35. package/src/player/store/playerStore.test.ts +22 -8
  36. package/src/player/store/thumbnailSlice.ts +28 -6
  37. package/dist/assets/hyperframes-player-pWkneUHC.js +0 -464
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.64",
3
+ "version": "0.8.65",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  "dockview-react": "8.3.1",
52
52
  "gsap": "^3.13.0",
53
53
  "mediabunny": "^1.45.3",
54
- "@hyperframes/core": "0.8.64",
55
- "@hyperframes/parsers": "0.8.64",
56
- "@hyperframes/player": "0.8.64",
57
- "@hyperframes/sdk": "0.8.64",
58
- "@hyperframes/studio-server": "0.8.64"
54
+ "@hyperframes/player": "0.8.65",
55
+ "@hyperframes/sdk": "0.8.65",
56
+ "@hyperframes/parsers": "0.8.65",
57
+ "@hyperframes/core": "0.8.65",
58
+ "@hyperframes/studio-server": "0.8.65"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@tailwindcss/vite": "^4.3.3",
@@ -71,7 +71,7 @@
71
71
  "vite": "^6.4.2",
72
72
  "vitest": "^4.1.11",
73
73
  "zustand": "^5.0.0",
74
- "@hyperframes/producer": "0.8.64"
74
+ "@hyperframes/producer": "0.8.65"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19",
@@ -9,7 +9,11 @@ export {
9
9
  type StudioPathOffsetSnapshot,
10
10
  } from "./manualEditsTypes";
11
11
 
12
- export { readFileChangeField, readStudioFileChangePath } from "./manualEditsParsing";
12
+ export {
13
+ readFileChangeAffectedCompositions,
14
+ readFileChangeField,
15
+ readStudioFileChangePath,
16
+ } from "./manualEditsParsing";
13
17
 
14
18
  export {
15
19
  beginStudioManualEditGesture,
@@ -15,15 +15,17 @@ function normalizeStudioFileChangePath(path: string): string {
15
15
  .replace(/^\.?\//, "");
16
16
  }
17
17
 
18
+ function asPayloadRecord(payload: unknown): Record<string, unknown> | null {
19
+ return payload && typeof payload === "object" ? (payload as Record<string, unknown>) : null;
20
+ }
21
+
18
22
  /**
19
23
  * Read one string field out of an ALREADY-DECODED file-change payload. Every
20
24
  * reader of that payload goes through here, so no reader can disagree with its
21
25
  * siblings about the shape. Decoding a raw delivery is the transport's job.
22
26
  */
23
27
  export function readFileChangeField(payload: unknown, key: string): string | null {
24
- if (!payload || typeof payload !== "object") return null;
25
- const record = payload as Record<string, unknown>;
26
- const value = record[key];
28
+ const value = asPayloadRecord(payload)?.[key];
27
29
  return typeof value === "string" ? value : null;
28
30
  }
29
31
 
@@ -31,3 +33,13 @@ export function readStudioFileChangePath(payload: unknown): string | null {
31
33
  const path = readFileChangeField(payload, "path") ?? readFileChangeField(payload, "filePath");
32
34
  return path === null ? null : normalizeStudioFileChangePath(path);
33
35
  }
36
+
37
+ /**
38
+ * The compositions whose thumbnails a change can alter, or `null` for all of them. Anything
39
+ * but a list of paths (an older server, the Vite dev host) reads as "all".
40
+ */
41
+ export function readFileChangeAffectedCompositions(payload: unknown): readonly string[] | null {
42
+ const value = asPayloadRecord(payload)?.affectedCompositions;
43
+ if (!Array.isArray(value) || !value.every((path) => typeof path === "string")) return null;
44
+ return value.map(normalizeStudioFileChangePath);
45
+ }
@@ -17,7 +17,7 @@ afterEach(() => {
17
17
  if (root) act(() => root?.unmount());
18
18
  root = null;
19
19
  document.body.innerHTML = "";
20
- usePlayerStore.setState({ thumbnailContentRevision: 0 });
20
+ usePlayerStore.setState({ thumbnailRevisions: {} });
21
21
  });
22
22
 
23
23
  describe("CompositionsPanel root badge source", () => {
@@ -18,7 +18,7 @@ afterEach(() => {
18
18
  if (root) act(() => root?.unmount());
19
19
  root = null;
20
20
  document.body.innerHTML = "";
21
- usePlayerStore.setState({ thumbnailContentRevision: 0 });
21
+ usePlayerStore.setState({ thumbnailRevisions: {} });
22
22
  });
23
23
 
24
24
  function mount(onSelect = vi.fn(), onAddToTimeline = vi.fn()) {
@@ -68,7 +68,7 @@ describe("composition card drag", () => {
68
68
  act(() => thumbnail.dispatchEvent(new Event("error")));
69
69
  expect(host.textContent).toContain("Preview unavailable");
70
70
 
71
- act(() => usePlayerStore.getState().bumpThumbnailContentRevision());
71
+ act(() => usePlayerStore.getState().bumpThumbnailRevisions(null));
72
72
 
73
73
  const retry = host.querySelector<HTMLImageElement>('img[src*="/thumbnail/"]');
74
74
  expect(retry).not.toBeNull();
@@ -18,7 +18,7 @@ afterEach(() => {
18
18
  if (root) act(() => root?.unmount());
19
19
  root = null;
20
20
  document.body.innerHTML = "";
21
- usePlayerStore.setState({ thumbnailContentRevision: 0 });
21
+ usePlayerStore.setState({ thumbnailRevisions: {} });
22
22
  });
23
23
 
24
24
  function mount(compositions: string[], masterCompositionPath: string | null) {
@@ -3,6 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
3
3
  import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
4
4
  import { setPreviewMediaMuted } from "../../player/lib/timelineIframeHelpers";
5
5
  import { usePlayerStore } from "../../player/store/playerStore";
6
+ import { thumbnailRevisionOf } from "../../player/store/thumbnailSlice";
6
7
  import { TIMELINE_COMPOSITION_MIME } from "../../utils/timelineCompositionDrop";
7
8
  import { Tooltip } from "../ui/Tooltip";
8
9
 
@@ -388,7 +389,7 @@ export const CompositionsTab = memo(function CompositionsTab({
388
389
  isRendering,
389
390
  lintFindingsByFile,
390
391
  }: CompositionsTabProps) {
391
- const contentRevision = usePlayerStore((state) => state.thumbnailContentRevision);
392
+ const thumbnailRevisions = usePlayerStore((state) => state.thumbnailRevisions);
392
393
  if (compositions.length === 0) {
393
394
  return (
394
395
  <div className="flex-1 flex items-center justify-center px-4">
@@ -411,7 +412,7 @@ export const CompositionsTab = memo(function CompositionsTab({
411
412
  onAddToTimeline={onAddToTimeline ? () => onAddToTimeline(comp) : undefined}
412
413
  isRendering={isRendering}
413
414
  lintInfo={lintFindingsByFile?.get(comp)}
414
- contentRevision={contentRevision}
415
+ contentRevision={thumbnailRevisionOf(thumbnailRevisions, comp)}
415
416
  />
416
417
  ))}
417
418
  </div>
@@ -104,7 +104,7 @@ describe("external file change coordinator", () => {
104
104
  expect(reloadPreview).not.toHaveBeenCalled();
105
105
  expect(reloadSdkSession).not.toHaveBeenCalled();
106
106
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
107
- expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
107
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
108
108
  });
109
109
 
110
110
  it("accepts a matching content echo without a write token and suppresses every reload", async () => {
@@ -125,7 +125,7 @@ describe("external file change coordinator", () => {
125
125
  );
126
126
 
127
127
  expect(onAcceptedPersistedFileChange).toHaveBeenCalledOnce();
128
- expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
128
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
129
129
  expect(drainPendingChanges).not.toHaveBeenCalled();
130
130
  expect(reloadPreview).not.toHaveBeenCalled();
131
131
  expect(reloadSdkSession).not.toHaveBeenCalled();
@@ -345,7 +345,34 @@ describe("external file change coordinator", () => {
345
345
 
346
346
  expect(drainPendingChanges).not.toHaveBeenCalled();
347
347
  expect(reloadPreview).not.toHaveBeenCalled();
348
- expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html");
348
+ expect(onAcceptedPersistedFileChange).toHaveBeenCalledWith("index.html", null);
349
+ });
350
+
351
+ it("hands the server's affected compositions to the thumbnail refresh", async () => {
352
+ const onAcceptedPersistedFileChange = vi.fn();
353
+ await mountCoordinator({ onAcceptedPersistedFileChange });
354
+
355
+ await act(async () =>
356
+ handler?.(
357
+ sseDelivery({
358
+ path: "compositions/scene-a.html",
359
+ version: "v3",
360
+ affectedCompositions: ["index.html", "compositions/scene-a.html"],
361
+ }),
362
+ ),
363
+ );
364
+ await act(async () =>
365
+ handler?.(
366
+ sseDelivery({ path: "assets/logo.svg", version: "v4", affectedCompositions: "all" }),
367
+ ),
368
+ );
369
+
370
+ expect(onAcceptedPersistedFileChange).toHaveBeenNthCalledWith(
371
+ 1,
372
+ "compositions/scene-a.html",
373
+ ["index.html", "compositions/scene-a.html"],
374
+ );
375
+ expect(onAcceptedPersistedFileChange).toHaveBeenNthCalledWith(2, "assets/logo.svg", null);
349
376
  });
350
377
 
351
378
  it("reloads once when one watcher event reaches two subscribers", async () => {
@@ -1,5 +1,9 @@
1
1
  import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
2
- import { readFileChangeField, readStudioFileChangePath } from "../components/editor/manualEdits";
2
+ import {
3
+ readFileChangeAffectedCompositions,
4
+ readFileChangeField,
5
+ readStudioFileChangePath,
6
+ } from "../components/editor/manualEdits";
3
7
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
4
8
  import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
5
9
  import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
@@ -56,7 +60,10 @@ interface ExternalFileChangeCoordinatorOptions {
56
60
  readProjectFile: (path: string) => Promise<string>;
57
61
  onUseExternalFile?: (path: string, content: string) => void;
58
62
  resetSaveQueues?: () => void;
59
- onAcceptedPersistedFileChange: (path: string) => void;
63
+ onAcceptedPersistedFileChange: (
64
+ path: string,
65
+ affectedCompositions: readonly string[] | null,
66
+ ) => void;
60
67
  }
61
68
 
62
69
  export interface ExternalFileChangeCoordinatorHandle {
@@ -253,7 +260,7 @@ export function useExternalFileChangeCoordinator({
253
260
  }
254
261
  if (!mountedRef.current || generation !== generationRef.current) return;
255
262
  setBlocked(null);
256
- onAcceptedPersistedFileChange(path);
263
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
257
264
  reloadAcceptedGeneration(path);
258
265
  return;
259
266
  }
@@ -377,7 +384,7 @@ export function useExternalFileChangeCoordinator({
377
384
  const ownWriteToken = consumeStudioWriteToken(token);
378
385
  const ownContentEcho = content != null && isSelfWriteEcho(path, content);
379
386
  if (ownWriteToken || ownContentEcho) {
380
- onAcceptedPersistedFileChange(path);
387
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(payload));
381
388
  logReload("suppressed", {
382
389
  path,
383
390
  why: ownWriteToken ? "own write token" : "own content echo",
@@ -430,7 +437,7 @@ export function useExternalFileChangeCoordinator({
430
437
  onUseExternalFile?.(path, external);
431
438
  await deleteConflictSnapshot?.(projectId, path);
432
439
  setBlocked(null);
433
- onAcceptedPersistedFileChange(path);
440
+ onAcceptedPersistedFileChange(path, readFileChangeAffectedCompositions(current.payload));
434
441
  reloadAcceptedGeneration(path);
435
442
  },
436
443
  [
@@ -271,11 +271,29 @@ describe("useRenderClipContent", () => {
271
271
  }
272
272
  });
273
273
 
274
+ it("finds the revision of a composition mounted with a ./ path", () => {
275
+ usePlayerStore.setState({
276
+ thumbnailMode: "adaptive",
277
+ thumbnailRevisions: { "compositions/nested.html": 2 },
278
+ });
279
+
280
+ const content = renderClipContent({
281
+ id: "nested",
282
+ tag: "div",
283
+ start: 0,
284
+ duration: 4,
285
+ track: 0,
286
+ compositionSrc: "./compositions/nested.html",
287
+ });
288
+
289
+ expect(isValidElement(content) && content.props).toMatchObject({ contentRevision: 2 });
290
+ });
291
+
274
292
  it("forwards persisted content revision to mounted composition thumbnails", () => {
275
293
  usePlayerStore.setState({
276
294
  thumbnailMode: "adaptive",
277
295
  timelineSessionEpoch: 7,
278
- thumbnailContentRevision: 11,
296
+ thumbnailRevisions: { "*": 11, "compositions/nested.html": 2, "compositions/other.html": 5 },
279
297
  });
280
298
 
281
299
  const content = renderClipContent({
@@ -293,7 +311,8 @@ describe("useRenderClipContent", () => {
293
311
  expect(content.props).toMatchObject({
294
312
  projectId: "my-project",
295
313
  sessionEpoch: 7,
296
- contentRevision: 11,
314
+ // its own composition's revision plus the all-compositions one, not a sibling's
315
+ contentRevision: 13,
297
316
  });
298
317
  }
299
318
  });
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "../utils/projectRouting";
2
2
  import { useCallback, type ReactNode } from "react";
3
3
  import { createElement } from "react";
4
4
  import { CompositionThumbnail, VideoThumbnail } from "../player";
5
+ import { compositionPathOfPreviewUrl } from "../player/components/CompositionThumbnail";
5
6
  import type { TimelineElement } from "../player";
6
7
  import type { TimelineClipRenderContext } from "../player/components/TimelineTypes";
7
8
  import { audioPillFlags } from "../player/components/audioClipLink";
@@ -9,6 +10,7 @@ import { AudioWaveform } from "../player/components/AudioWaveform";
9
10
  import { ImageThumbnail } from "../player/components/ImageThumbnail";
10
11
  import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils";
11
12
  import { usePlayerStore } from "../player/store/playerStore";
13
+ import { thumbnailRevisionOf } from "../player/store/thumbnailSlice";
12
14
  import { effectiveThumbnailMode } from "../player/lib/thumbnailPolicy";
13
15
 
14
16
  export function normalizeCompositionSrc(
@@ -114,7 +116,7 @@ export function useRenderClipContent({
114
116
  const thumbnailMode = usePlayerStore((s) => s.thumbnailMode);
115
117
  const effectiveMode = effectiveThumbnailMode(thumbnailMode);
116
118
  const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
117
- const contentRevision = usePlayerStore((s) => s.thumbnailContentRevision);
119
+ const thumbnailRevisions = usePlayerStore((s) => s.thumbnailRevisions);
118
120
  const elements = usePlayerStore((s) => s.elements);
119
121
  return useCallback(
120
122
  // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip.
@@ -159,7 +161,7 @@ export function useRenderClipContent({
159
161
  duration: el.duration,
160
162
  projectId: pid,
161
163
  sessionEpoch,
162
- contentRevision,
164
+ contentRevision: thumbnailRevisionOf(thumbnailRevisions, compSrc),
163
165
  priority: context.priority,
164
166
  rich: context.rich,
165
167
  });
@@ -186,7 +188,10 @@ export function useRenderClipContent({
186
188
  duration: el.duration,
187
189
  projectId: pid,
188
190
  sessionEpoch,
189
- contentRevision,
191
+ contentRevision: thumbnailRevisionOf(
192
+ thumbnailRevisions,
193
+ compositionPathOfPreviewUrl(activePreviewUrl),
194
+ ),
190
195
  priority: context.priority,
191
196
  rich: context.rich,
192
197
  });
@@ -239,7 +244,7 @@ export function useRenderClipContent({
239
244
  duration: el.duration,
240
245
  projectId: pid,
241
246
  sessionEpoch,
242
- contentRevision,
247
+ contentRevision: thumbnailRevisionOf(thumbnailRevisions, "index.html"),
243
248
  priority: context.priority,
244
249
  rich: context.rich,
245
250
  });
@@ -254,7 +259,7 @@ export function useRenderClipContent({
254
259
  effectiveTimelineDuration,
255
260
  effectiveMode,
256
261
  sessionEpoch,
257
- contentRevision,
262
+ thumbnailRevisions,
258
263
  elements,
259
264
  ],
260
265
  );
@@ -49,8 +49,11 @@ export function useStudioExternalFileChanges({
49
49
  }: UseStudioExternalFileChangesOptions) {
50
50
  const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
51
51
  const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
52
- const bumpThumbnailContentRevision = usePlayerStore(
53
- (state) => state.bumpThumbnailContentRevision,
52
+ const bumpThumbnailRevisions = usePlayerStore((state) => state.bumpThumbnailRevisions);
53
+ const onAcceptedPersistedFileChange = useCallback(
54
+ (_path: string, affectedCompositions: readonly string[] | null) =>
55
+ bumpThumbnailRevisions(affectedCompositions),
56
+ [bumpThumbnailRevisions],
54
57
  );
55
58
  const drainPendingChanges = useCallback(async () => {
56
59
  const source = await flushPendingSourceSave();
@@ -81,6 +84,6 @@ export function useStudioExternalFileChanges({
81
84
  readProjectFile: fileManager.readProjectFile,
82
85
  onUseExternalFile: fileManager.updateEditingFileContent,
83
86
  resetSaveQueues: resetDomEditSaveQueueBreaker,
84
- onAcceptedPersistedFileChange: bumpThumbnailContentRevision,
87
+ onAcceptedPersistedFileChange,
85
88
  });
86
89
  }
@@ -66,6 +66,12 @@ export function buildCompositionThumbnailUrl({
66
66
  return thumbnailUrl.toString();
67
67
  }
68
68
 
69
+ /** The composition a preview URL renders: `/preview/comp/<path>`, or the root for `/preview`. */
70
+ export function compositionPathOfPreviewUrl(previewUrl: string): string {
71
+ const match = /\/preview\/comp\/([^?#]+)/.exec(previewUrl);
72
+ return match?.[1] ? decodeURIComponent(match[1]) : "index.html";
73
+ }
74
+
69
75
  async function loadCompositionImage(url: string, signal: AbortSignal) {
70
76
  const response = await fetch(url, { signal });
71
77
  if (!response.ok) throw new Error(`Composition thumbnail failed (${response.status})`);
@@ -140,7 +140,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
140
140
  usePlayerStore.getState().reset();
141
141
  });
142
142
 
143
- it("defers rich clip content while scrolling without replacing the clip shell", async () => {
143
+ it("keeps a clip's picture on screen while the timeline scrolls", async () => {
144
144
  const [{ Timeline }, { usePlayerStore }] = await Promise.all([
145
145
  import("./Timeline"),
146
146
  import("../store/playerStore"),
@@ -167,17 +167,62 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
167
167
  expect(scroller).not.toBeNull();
168
168
  expect(clip).not.toBeNull();
169
169
  expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
170
- expect(host.querySelector("[data-rich-content]")).not.toBeNull();
170
+ const picture = host.querySelector("[data-rich-content]");
171
+ expect(picture).not.toBeNull();
171
172
 
172
173
  if (scroller) await dispatchScroll(scroller);
173
174
  expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
174
- expect(host.querySelector("[data-rich-content]")).toBeNull();
175
+ expect(host.querySelector("[data-rich-content]")).toBe(picture);
175
176
 
176
177
  await act(async () => {
177
178
  await new Promise((resolve) => setTimeout(resolve, 110));
178
179
  });
179
180
  expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
180
- expect(host.querySelector("[data-rich-content]")).not.toBeNull();
181
+ expect(host.querySelector("[data-rich-content]")).toBe(picture);
182
+ } finally {
183
+ act(() => root.unmount());
184
+ usePlayerStore.getState().reset();
185
+ }
186
+ });
187
+
188
+ it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
189
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
190
+ import("./Timeline"),
191
+ import("../store/playerStore"),
192
+ ]);
193
+ const clip = (id: string, start: number) => ({
194
+ id,
195
+ label: id,
196
+ tag: "div",
197
+ start,
198
+ duration: 5,
199
+ track: 0,
200
+ });
201
+ usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
202
+
203
+ const { host, root } = await mountTimeline(
204
+ React.createElement(Timeline, {
205
+ renderClipContent: (element: { id: string }) =>
206
+ React.createElement("span", { "data-rich-content": element.id }),
207
+ }),
208
+ );
209
+ try {
210
+ await act(async () => {
211
+ await new Promise((resolve) => setTimeout(resolve, 110));
212
+ });
213
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
214
+ if (scroller) await dispatchScroll(scroller);
215
+ act(() => {
216
+ usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
217
+ });
218
+ expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
219
+ expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
220
+ expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
221
+
222
+ await act(async () => {
223
+ await new Promise((resolve) => setTimeout(resolve, 110));
224
+ });
225
+ expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
181
226
  } finally {
182
227
  act(() => root.unmount());
183
228
  usePlayerStore.getState().reset();
@@ -14,6 +14,16 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
14
14
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
15
15
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
16
16
  import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
17
+ import * as transitionSeams from "./timelineTransitionSeams";
18
+
19
+ vi.mock("./timelineTransitionSeams", async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import("./timelineTransitionSeams")>();
21
+ return {
22
+ ...actual,
23
+ deriveTimelineTransitionSeams: vi.fn(actual.deriveTimelineTransitionSeams),
24
+ deriveTimelineTransitionSeamsByTrack: vi.fn(actual.deriveTimelineTransitionSeamsByTrack),
25
+ };
26
+ });
17
27
 
18
28
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
19
29
 
@@ -497,3 +507,21 @@ describe("TimelineLanes clip joins", () => {
497
507
  act(() => view.root.unmount());
498
508
  });
499
509
  });
510
+
511
+ describe("TimelineLanes transition seams", () => {
512
+ it("derives transition seams once for every row, not once per row", () => {
513
+ const derivations = [
514
+ transitionSeams.deriveTimelineTransitionSeams,
515
+ transitionSeams.deriveTimelineTransitionSeamsByTrack,
516
+ ].map((derive) => vi.mocked(derive));
517
+ for (const derive of derivations) derive.mockClear();
518
+
519
+ const view = renderLanes({
520
+ elements: [element("clip-a", 0), element("clip-b", TRACK_A), element("clip-c", TRACK_B)],
521
+ });
522
+
523
+ expect(view.host.querySelectorAll("[data-timeline-row]").length).toBeGreaterThanOrEqual(3);
524
+ expect(derivations.reduce((calls, derive) => calls + derive.mock.calls.length, 0)).toBe(1);
525
+ act(() => view.root.unmount());
526
+ });
527
+ });
@@ -33,6 +33,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
33
33
  import { timelineClipFocusId } from "./timelineNavigationIdentity";
34
34
  import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
35
35
  import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
36
+ import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
36
37
 
37
38
  export function TimelineLanes({
38
39
  pps,
@@ -105,8 +106,9 @@ export function TimelineLanes({
105
106
  const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
106
107
  useTimelineGroupDisclosure();
107
108
  const automationLanes = useAutomationLanes();
108
- const allTransitionElements = useMemo(
109
- () => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
109
+ const transitionSeamsByTrack = useMemo(
110
+ () =>
111
+ deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
110
112
  [getPreviewElement, tracks],
111
113
  );
112
114
  // A group's automation clock is COMPOSITION time (groups doc §1.3), so its
@@ -551,8 +553,7 @@ export function TimelineLanes({
551
553
  })
552
554
  }
553
555
  <TimelineTransitionOverlays
554
- transitionElements={allTransitionElements}
555
- track={trackNum}
556
+ seams={transitionSeamsByTrack.get(trackNum) ?? []}
556
557
  rowElements={draggedClip?.started ? [] : automationElements}
557
558
  rowBackground={rowBackground}
558
559
  pixelsPerSecond={pps}
@@ -1,13 +1,12 @@
1
1
  import { CLIP_Y } from "./timelineLayout";
2
2
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3
3
  import type { TimelineElement } from "../store/playerStore";
4
- import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
4
+ import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
5
5
  import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
6
6
  import { TimelineClipJoins } from "./TimelineClipJoins";
7
7
 
8
8
  interface TimelineTransitionOverlaysProps {
9
- transitionElements: readonly TimelineElement[];
10
- track: number;
9
+ seams: readonly TimelineTransitionSeam[];
11
10
  rowElements: readonly TimelineElement[];
12
11
  rowBackground: string;
13
12
  pixelsPerSecond: number;
@@ -17,8 +16,7 @@ interface TimelineTransitionOverlaysProps {
17
16
 
18
17
  /** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
19
18
  export function TimelineTransitionOverlays({
20
- transitionElements,
21
- track,
19
+ seams,
22
20
  rowElements,
23
21
  rowBackground,
24
22
  pixelsPerSecond,
@@ -26,9 +24,6 @@ export function TimelineTransitionOverlays({
26
24
  clipBarHeight,
27
25
  }: TimelineTransitionOverlaysProps) {
28
26
  const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
29
- const seams = deriveTimelineTransitionSeams(transitionElements).filter(
30
- (seam) => seam.incoming.track === track,
31
- );
32
27
  return (
33
28
  <>
34
29
  <TimelineClipJoins
@@ -1,4 +1,4 @@
1
- import { type ReactNode } from "react";
1
+ import { useState, type ReactNode } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { TimelineTimeRange } from "../lib/timelineClipIndex";
4
4
  import type { TrackVisualStyle } from "./timelineIcons";
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
29
29
  );
30
30
  }
31
31
 
32
+ /**
33
+ * Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
34
+ * so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
35
+ */
36
+ export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
37
+ const [shown, setShown] = useState(!hold);
38
+ if (!shown && !hold) setShown(true);
39
+ return shown ? children : null;
40
+ }
41
+
32
42
  export function renderClipChildren(
33
43
  element: TimelineElement,
34
44
  clipStyle: TrackVisualStyle,
@@ -3,7 +3,6 @@
3
3
  import { describe, expect, it, vi } from "vitest";
4
4
  import {
5
5
  buildTimelineMeta,
6
- resolveRenderClipContent,
7
6
  resolveResizingElementIds,
8
7
  shouldIgnoreTimelinePointerDown,
9
8
  type TimelineMetaBuilderInputs,
@@ -85,12 +84,6 @@ describe("buildTimelineMeta", () => {
85
84
  });
86
85
 
87
86
  describe("timeline provider branch helpers", () => {
88
- it("resolves virtualized clip content", () => {
89
- const render = () => null;
90
- expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
91
- expect(resolveRenderClipContent(false, true, render)).toBe(render);
92
- });
93
-
94
87
  it("resolves resizing element ids", () => {
95
88
  expect(resolveResizingElementIds(null)).toBeUndefined();
96
89
  expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
@@ -4,7 +4,6 @@ import type {
4
4
  TimelineViewportProps,
5
5
  } from "./TimelineProvider";
6
6
  import type { ResizingClipState } from "./useTimelineClipDrag";
7
- import type { TimelineLaneBaseProps } from "./timelineLaneProps";
8
7
  import type { DraggedClipState } from "./timelineClipDragTypes";
9
8
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
10
9
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
@@ -34,14 +33,6 @@ export function resolveResizingElementIds(
34
33
  return undefined;
35
34
  }
36
35
 
37
- export function resolveRenderClipContent(
38
- rowVirtualizationActive: boolean,
39
- isScrolling: boolean,
40
- renderClipContent: TimelineLaneBaseProps["renderClipContent"],
41
- ): TimelineLaneBaseProps["renderClipContent"] {
42
- return rowVirtualizationActive && isScrolling ? undefined : renderClipContent;
43
- }
44
-
45
36
  export function shouldIgnoreTimelinePointerDown(target: EventTarget | null): boolean {
46
37
  return target instanceof Element && target.closest("button, input, select, a") !== null;
47
38
  }