@hyperframes/studio 0.8.55 → 0.8.57

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 (145) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
  6. package/dist/index.d.ts +111 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +21534 -20683
  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 +18 -1
  13. package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
  14. package/src/components/EditorShell.selectionSync.test.tsx +57 -67
  15. package/src/components/EditorShell.tsx +62 -51
  16. package/src/components/EditorShellTestMocks.ts +46 -0
  17. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  18. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  19. package/src/components/editor/DomEditOverlay.tsx +19 -55
  20. package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
  21. package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
  22. package/src/components/editor/GridOverlay.tsx +12 -23
  23. package/src/components/editor/MotionPathOverlay.test.tsx +66 -0
  24. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  25. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  26. package/src/components/editor/PreviewGuides.tsx +8 -10
  27. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  28. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  29. package/src/components/editor/PropertyPanel.deselect.test.tsx +71 -0
  30. package/src/components/editor/PropertyPanel.tsx +4 -3
  31. package/src/components/editor/SnapToolbar.test.tsx +26 -12
  32. package/src/components/editor/SnapToolbar.tsx +30 -59
  33. package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
  34. package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
  35. package/src/components/editor/previewReadOnlyContext.tsx +59 -0
  36. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
  37. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  38. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  39. package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
  40. package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
  41. package/src/components/editor/useDomEditNudge.ts +9 -0
  42. package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
  43. package/src/components/editor/useOffCanvasIndicators.ts +50 -0
  44. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  45. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  46. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  47. package/src/components/editor/useVolumeAutomation.ts +57 -16
  48. package/src/components/nle/NLEContext.tsx +40 -4
  49. package/src/components/nle/NLEPreview.test.ts +66 -1
  50. package/src/components/nle/NLEPreview.tsx +88 -44
  51. package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
  52. package/src/components/nle/PreviewOverlays.tsx +19 -24
  53. package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
  54. package/src/components/nle/PreviewPane.tsx +17 -2
  55. package/src/components/nle/TimelinePane.tsx +5 -1
  56. package/src/components/nle/previewReadOnly.test.tsx +129 -0
  57. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
  58. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
  59. package/src/components/nle/usePreviewBlockDrop.ts +3 -1
  60. package/src/hooks/appHotkeysDispatch.ts +16 -1
  61. package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
  62. package/src/hooks/timelineAudioGroupCreate.ts +3 -4
  63. package/src/hooks/timelineTrackVisibility.ts +7 -8
  64. package/src/hooks/useAppHotkeys.test.ts +71 -3
  65. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
  66. package/src/hooks/useAppHotkeys.ts +4 -0
  67. package/src/hooks/useCompositionDimensions.test.tsx +38 -0
  68. package/src/hooks/useCompositionDimensions.ts +17 -1
  69. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  70. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  71. package/src/hooks/useDomEditCommits.test.tsx +1 -0
  72. package/src/hooks/useDomEditCommits.ts +3 -0
  73. package/src/hooks/useDomEditSession.test.tsx +1 -0
  74. package/src/hooks/useDomEditSession.ts +7 -4
  75. package/src/hooks/useDomEditWiring.ts +40 -40
  76. package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
  77. package/src/hooks/useDomGeometryCommits.ts +26 -20
  78. package/src/hooks/useFileTree.test.tsx +56 -0
  79. package/src/hooks/useFileTree.ts +8 -1
  80. package/src/hooks/useGestureCommit.test.tsx +29 -0
  81. package/src/hooks/useGestureCommit.ts +23 -0
  82. package/src/hooks/useInlineTextEdit.test.tsx +23 -2
  83. package/src/hooks/useInlineTextEdit.ts +11 -2
  84. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  85. package/src/hooks/useSdkSession.ts +46 -10
  86. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  87. package/src/hooks/useServerConnection.ts +81 -8
  88. package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
  89. package/src/hooks/useTimelineAssetDropOps.ts +1 -6
  90. package/src/hooks/useTimelineEditing.test.tsx +10 -7
  91. package/src/hooks/useTimelineEditing.ts +2 -7
  92. package/src/index.ts +12 -0
  93. package/src/player/components/Player.test.ts +141 -13
  94. package/src/player/components/Player.tsx +58 -2
  95. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  96. package/src/player/components/Timeline.tsx +23 -573
  97. package/src/player/components/TimelineCanvas.tsx +9 -30
  98. package/src/player/components/TimelineClip.test.tsx +52 -0
  99. package/src/player/components/TimelineClip.tsx +13 -2
  100. package/src/player/components/TimelineOverlays.test.ts +67 -32
  101. package/src/player/components/TimelineOverlays.tsx +46 -107
  102. package/src/player/components/TimelineProvider.tsx +191 -0
  103. package/src/player/components/TimelineTrackHeader.test.tsx +26 -5
  104. package/src/player/components/TimelineTrackHeader.tsx +1 -1
  105. package/src/player/components/testRootHarness.ts +18 -0
  106. package/src/player/components/timelineMotionStyles.test.ts +3 -3
  107. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  108. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  109. package/src/player/components/timelineTheme.test.ts +4 -4
  110. package/src/player/components/timelineTheme.ts +34 -30
  111. package/src/player/components/timelineTrackDisplay.ts +1 -4
  112. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  113. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  114. package/src/player/components/useTimelineProviderState.tsx +583 -0
  115. package/src/player/components/useTimelineTrackDerivations.ts +2 -2
  116. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  117. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  118. package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
  119. package/src/player/hooks/timelineSyncHydration.ts +0 -23
  120. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  121. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  122. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  123. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  124. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  125. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -91
  126. package/src/player/store/playerStore.ts +0 -4
  127. package/src/player/store/timelineElement.ts +2 -4
  128. package/src/player/store/timelineResetState.ts +0 -1
  129. package/src/styles/hex-baseline.json +3 -4
  130. package/src/styles/studio.css +7 -7
  131. package/src/styles/theme.css +29 -0
  132. package/src/telemetry/events.test.ts +18 -0
  133. package/src/telemetry/events.ts +10 -0
  134. package/src/telemetry/system.test.ts +49 -0
  135. package/src/telemetry/system.ts +6 -0
  136. package/src/telemetry/tabId.test.ts +43 -0
  137. package/src/telemetry/tabId.ts +44 -0
  138. package/src/utils/previewCompositionSize.ts +26 -0
  139. package/src/utils/reloadDebug.ts +2 -4
  140. package/src/utils/sourcePatcher.ts +1 -1
  141. package/src/utils/studioTelemetry.test.ts +14 -0
  142. package/src/utils/studioTelemetry.ts +4 -0
  143. package/dist/assets/hyperframes-player-BjAGc2ID.js +0 -464
  144. package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
  145. package/src/player/hooks/useTimelineRowElements.ts +0 -17
@@ -8,7 +8,7 @@
8
8
  * - onIframeLoad — orchestrates initializeAdapter with a message-based fallback
9
9
  */
10
10
 
11
- import { useCallback } from "react";
11
+ import { useCallback, useRef } from "react";
12
12
  import { liveTime, usePlayerStore } from "../store/playerStore";
13
13
  import type { TimelineElement } from "../store/playerStore";
14
14
  import type { PlaybackAdapter, IframeWindow } from "../lib/playbackTypes";
@@ -19,7 +19,6 @@ import {
19
19
  buildTimelineElementsFromClips,
20
20
  syncManifestTimeline,
21
21
  clipTreeParentMap,
22
- collectTopLevelElementIds,
23
22
  collectSubCompositionDomChildren,
24
23
  collectSubCompositionHostState,
25
24
  hydrateTimelineFromPreview,
@@ -34,7 +33,7 @@ import {
34
33
  // Re-exported for the tests and callers that have always imported it from here.
35
34
  export { resolveReloadSeekTime } from "./timelineSyncHydration";
36
35
 
37
- interface UseTimelineSyncCallbacksParams {
36
+ export interface UseTimelineSyncCallbacksParams {
38
37
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
39
38
  probeIntervalRef: React.MutableRefObject<ReturnType<typeof setInterval> | undefined>;
40
39
  pendingSeekRef: React.MutableRefObject<number | null>;
@@ -47,6 +46,19 @@ interface UseTimelineSyncCallbacksParams {
47
46
  setIsPlaying: (v: boolean) => void;
48
47
  attachIframeShortcutListeners: () => void;
49
48
  applyPreviewAudioState: () => void;
49
+ /**
50
+ * Fires once the restore-seek is issued and owns when `commit` (the store hydration) runs.
51
+ * The default reveals the iframe and commits at once; a shadow reload defers it to promotion.
52
+ */
53
+ onAdapterReady?: (
54
+ iframe: HTMLIFrameElement | null,
55
+ context: number | undefined,
56
+ commit: () => void,
57
+ ) => void;
58
+ /** False when the load for `context` was superseded: it must then cause no side effects. */
59
+ isCurrent?: (context?: number) => boolean;
60
+ /** Fires when no adapter appeared within the wait window. */
61
+ onLoadGiveUp?: (context?: number) => void;
50
62
  }
51
63
 
52
64
  /**
@@ -63,18 +75,54 @@ interface UseTimelineSyncCallbacksParams {
63
75
  * resets currentTime on project switch. Invariant: an edit NEVER moves the
64
76
  * playhead (the clamp below is the one sanctioned move — content shrank past it).
65
77
  */
66
- /**
67
- * Undo the `visibility: hidden` that refreshPlayer sets across a full reload.
68
- * Safe to call when the iframe was never hidden (idempotent no-op). Every reload
69
- * completion + failure path funnels through here so the preview can never get
70
- * stuck invisible.
71
- */
78
+ /** Undo a hidden `visibility` on an iframe; idempotent no-op otherwise. */
72
79
  export function revealIframe(iframe: HTMLIFrameElement | null): void {
73
80
  if (iframe && iframe.style.visibility === "hidden") {
74
81
  iframe.style.visibility = "";
75
82
  }
76
83
  }
77
84
 
85
+ function revealAndCommit(
86
+ iframe: HTMLIFrameElement | null,
87
+ _context: number | undefined,
88
+ commit: () => void,
89
+ ): void {
90
+ revealIframe(iframe);
91
+ commit();
92
+ }
93
+
94
+ export type PreviewIframeRole = "live" | "shadow";
95
+
96
+ export interface PreviewIframeSlot {
97
+ gen: number;
98
+ role: PreviewIframeRole;
99
+ url?: string;
100
+ }
101
+
102
+ /** Queue a hidden shadow slot for the reload URL, replacing any earlier unpromoted shadow. */
103
+ export function planShadowReload(
104
+ slots: PreviewIframeSlot[],
105
+ nextGen: number,
106
+ url: string,
107
+ ): PreviewIframeSlot[] {
108
+ const live = slots.find((slot) => slot.role === "live");
109
+ return live ? [live, { gen: nextGen, role: "shadow", url }] : [{ gen: nextGen, role: "live" }];
110
+ }
111
+
112
+ /** Drop any pending shadow; the live slot (and its key) is untouched. */
113
+ export function planShadowDiscard(slots: PreviewIframeSlot[]): PreviewIframeSlot[] {
114
+ return slots.filter((slot) => slot.role === "live");
115
+ }
116
+
117
+ /** Atomically make the ready shadow the only (live) slot; a stale readyGen is a no-op. */
118
+ export function planShadowPromotion(
119
+ slots: PreviewIframeSlot[],
120
+ readyGen: number,
121
+ ): PreviewIframeSlot[] {
122
+ const ready = slots.find((slot) => slot.gen === readyGen && slot.role === "shadow");
123
+ return ready ? [{ ...ready, role: "live" }] : slots;
124
+ }
125
+
78
126
  /**
79
127
  * The transport TOTAL a clip-manifest message should write to the store.
80
128
  *
@@ -111,6 +159,9 @@ export function useTimelineSyncCallbacks({
111
159
  setIsPlaying,
112
160
  attachIframeShortcutListeners,
113
161
  applyPreviewAudioState,
162
+ onAdapterReady = revealAndCommit,
163
+ isCurrent,
164
+ onLoadGiveUp,
114
165
  }: UseTimelineSyncCallbacksParams) {
115
166
  // Convert a runtime timeline message (from iframe postMessage) into TimelineElements
116
167
  const processTimelineMessage = useCallback(
@@ -130,13 +181,11 @@ export function useTimelineSyncCallbacks({
130
181
  const parentMap = clipTreeParentMap(iframeRef.current?.contentWindow ?? null);
131
182
  const domClipChildren = collectSubCompositionDomChildren(iframeDoc, data.clips, parentMap);
132
183
  usePlayerStore.getState().setClipParentMap(parentMap);
133
- usePlayerStore.getState().setTopLevelIds(collectTopLevelElementIds(iframeDoc));
134
184
  usePlayerStore.getState().setDomClipChildren(domClipChildren);
135
185
  usePlayerStore
136
186
  .getState()
137
187
  .setSubCompositionHostState(collectSubCompositionHostState(iframeDoc, data.clips));
138
188
  } catch {
139
- usePlayerStore.getState().setTopLevelIds(null);
140
189
  // cross-origin or __clipTree not available — maps stay empty
141
190
  }
142
191
 
@@ -186,94 +235,112 @@ export function useTimelineSyncCallbacks({
186
235
  } catch {}
187
236
  }, [iframeRef, syncTimelineElements]);
188
237
 
189
- const initializeAdapter = useCallback(() => {
190
- const adapter = getAdapter();
191
- if (!adapter || adapter.getDuration() <= 0) return false;
192
-
193
- adapter.pause();
194
- const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
195
- // The correct frame is now rendered — reveal the iframe that refreshPlayer hid
196
- // for the reload, so the user sees the restored frame directly (never the raw
197
- // all-clips DOM). Cleared unconditionally: any later failure path must not leave
198
- // the preview stuck invisible.
199
- revealIframe(iframeRef.current);
200
- // Keep non-React listeners such as the capture link and time display in sync
201
- // with the initial adapter seek on iframe load.
202
- liveTime.notify(startTime);
203
- syncAdapterDuration(adapter, setDuration);
204
- setCurrentTime(startTime);
205
- if (!isRefreshingRef.current) {
206
- // Enables Play from actual play-readiness, not just a known duration —
207
- // a click before this resolves used to start the timeline with media,
208
- // images or fonts still loading and never recover.
209
- requestTimelineReady(safeContentDocument(iframeRef.current));
210
- }
211
- isRefreshingRef.current = false;
212
- setIsPlaying(false);
213
-
214
- hydrateTimelineFromPreview({
215
- iframe: iframeRef.current,
216
- adapter,
238
+ const initializeAdapter = useCallback(
239
+ (context?: number) => {
240
+ if (isCurrent && !isCurrent(context)) return true;
241
+ const adapter = getAdapter();
242
+ if (!adapter || adapter.getDuration() <= 0) return false;
243
+
244
+ adapter.pause();
245
+ const startTime = seekAdapterToRestorePoint(adapter, pendingSeekRef);
246
+ const commit = () => {
247
+ // Keep non-React listeners such as the capture link and time display in sync
248
+ // with the initial adapter seek on iframe load.
249
+ liveTime.notify(startTime);
250
+ syncAdapterDuration(adapter, setDuration);
251
+ setCurrentTime(startTime);
252
+ if (!isRefreshingRef.current) {
253
+ // Enables Play from actual play-readiness, not just a known duration —
254
+ // a click before this resolves used to start the timeline with media,
255
+ // images or fonts still loading and never recover.
256
+ requestTimelineReady(safeContentDocument(iframeRef.current));
257
+ }
258
+ isRefreshingRef.current = false;
259
+ setIsPlaying(false);
260
+
261
+ hydrateTimelineFromPreview({
262
+ iframe: iframeRef.current,
263
+ adapter,
264
+ processTimelineMessage,
265
+ enrichMissingCompositions,
266
+ applyPreviewAudioState,
267
+ attachIframeShortcutListeners,
268
+ syncTimelineElements,
269
+ });
270
+ };
271
+ onAdapterReady(iframeRef.current, context, commit);
272
+ return true;
273
+ },
274
+ [
275
+ getAdapter,
276
+ setDuration,
277
+ setCurrentTime,
278
+ requestTimelineReady,
279
+ setIsPlaying,
217
280
  processTimelineMessage,
218
281
  enrichMissingCompositions,
219
- applyPreviewAudioState,
220
- attachIframeShortcutListeners,
221
282
  syncTimelineElements,
222
- });
223
- return true;
224
- }, [
225
- getAdapter,
226
- setDuration,
227
- setCurrentTime,
228
- requestTimelineReady,
229
- setIsPlaying,
230
- processTimelineMessage,
231
- enrichMissingCompositions,
232
- syncTimelineElements,
233
- attachIframeShortcutListeners,
234
- applyPreviewAudioState,
235
- iframeRef,
236
- isRefreshingRef,
237
- pendingSeekRef,
238
- ]);
239
-
240
- const onIframeLoad = useCallback(() => {
241
- applyPreviewAudioState();
242
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
283
+ attachIframeShortcutListeners,
284
+ applyPreviewAudioState,
285
+ onAdapterReady,
286
+ isCurrent,
287
+ iframeRef,
288
+ isRefreshingRef,
289
+ pendingSeekRef,
290
+ ],
291
+ );
243
292
 
244
- // Fast path: adapter already available (in-place reloads, cached compositions)
245
- if (initializeAdapter()) return;
293
+ // Drops the previous load's readiness listener so a superseded load cannot settle later.
294
+ const stopWaitingRef = useRef<(() => void) | null>(null);
246
295
 
247
- // The runtime posts "state" or "timeline" messages once ready.
248
- // Listen for those instead of polling.
249
- const iframe = iframeRef.current;
250
- let settled = false;
296
+ const onIframeLoad = useCallback(
297
+ (context?: number) => {
298
+ applyPreviewAudioState();
299
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
300
+ stopWaitingRef.current?.();
301
+ stopWaitingRef.current = null;
251
302
 
252
- const trySettle = () => {
253
- if (settled) return;
254
- if (initializeAdapter()) {
255
- settled = true;
256
- window.removeEventListener("message", onMessage);
257
- if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
258
- }
259
- };
303
+ // Fast path: adapter already available (in-place reloads, cached compositions)
304
+ if (initializeAdapter(context)) return;
260
305
 
261
- const onMessage = (e: MessageEvent) => {
262
- if (isPreviewReadinessMessage(e, iframe)) trySettle();
263
- };
264
- window.addEventListener("message", onMessage);
306
+ // The runtime posts "state" or "timeline" messages once ready.
307
+ // Listen for those instead of polling.
308
+ const iframe = iframeRef.current;
309
+ let settled = false;
265
310
 
266
- // Safety net: if no message arrives within 5s, try one last time then give up.
267
- probeIntervalRef.current = setTimeout(() => {
268
- if (!settled) {
269
- trySettle();
270
- }
271
- window.removeEventListener("message", onMessage);
272
- // Never leave the preview stuck invisible if the runtime never settled
273
- // (initializeAdapter reveals on success; this covers the give-up case).
274
- revealIframe(iframeRef.current);
275
- }, 5000) as unknown as ReturnType<typeof setInterval>;
276
- }, [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState]);
311
+ const trySettle = () => {
312
+ if (settled) return;
313
+ if (initializeAdapter(context)) {
314
+ settled = true;
315
+ window.removeEventListener("message", onMessage);
316
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
317
+ }
318
+ };
319
+
320
+ const onMessage = (e: MessageEvent) => {
321
+ if (isPreviewReadinessMessage(e, iframe)) trySettle();
322
+ };
323
+ window.addEventListener("message", onMessage);
324
+ stopWaitingRef.current = () => window.removeEventListener("message", onMessage);
325
+
326
+ // Safety net: if no message arrives within 5s, try one last time then give up.
327
+ probeIntervalRef.current = setTimeout(() => {
328
+ if (!settled) {
329
+ trySettle();
330
+ }
331
+ window.removeEventListener("message", onMessage);
332
+ if (!settled) onLoadGiveUp?.(context);
333
+ revealIframe(iframeRef.current);
334
+ }, 5000) as unknown as ReturnType<typeof setInterval>;
335
+ },
336
+ [initializeAdapter, iframeRef, probeIntervalRef, applyPreviewAudioState, onLoadGiveUp],
337
+ );
338
+
339
+ const cancelPendingLoad = useCallback(() => {
340
+ if (probeIntervalRef.current) clearInterval(probeIntervalRef.current);
341
+ stopWaitingRef.current?.();
342
+ stopWaitingRef.current = null;
343
+ }, [probeIntervalRef]);
277
344
 
278
345
  // Stable refs so mount-effect closures always call the latest version
279
346
  const processTimelineMessageRef = { current: processTimelineMessage };
@@ -286,5 +353,6 @@ export function useTimelineSyncCallbacks({
286
353
  enrichMissingCompositionsRef,
287
354
  initializeAdapter,
288
355
  onIframeLoad,
356
+ cancelPendingLoad,
289
357
  };
290
358
  }
@@ -202,8 +202,6 @@ interface PlayerState extends PlayerStoreSlices {
202
202
  clipManifest: ClipManifestClip[] | null;
203
203
  setClipManifest: (clips: ClipManifestClip[] | null) => void;
204
204
  clipParentMap: Map<string, string>;
205
- topLevelIds: ReadonlySet<string> | null;
206
- setTopLevelIds: (ids: ReadonlySet<string> | null) => void;
207
205
  setClipParentMap: (map: Map<string, string>) => void;
208
206
  /**
209
207
  * Sub-composition DOM descendants (groups + their children) that have no
@@ -394,8 +392,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
394
392
  setClipManifest: (clips) => set({ clipManifest: clips }),
395
393
  clipParentMap: new Map(),
396
394
  setClipParentMap: (map) => set({ clipParentMap: map }),
397
- topLevelIds: null,
398
- setTopLevelIds: (ids) => set({ topLevelIds: ids }),
399
395
  domClipChildren: [],
400
396
  setDomClipChildren: (children) => set({ domClipChildren: children }),
401
397
  subCompositionHostState: new Map(),
@@ -77,10 +77,8 @@ export interface TimelineElement {
77
77
  audioGroupFxChain?: string;
78
78
  audioGroupAutomation?: string;
79
79
  /**
80
- * Set by useTimelineRowElements on an inline-expanded sub-composition
81
- * child: the absolute master-timeline start of the sub-comp host the child
82
- * lives in. Presence marks the element as expanded; edits subtract it to get
83
- * the child's local (sourceFile-relative) time. Works at any nesting depth.
80
+ * Legacy source-coordinate marker for an inline sub-composition child. The
81
+ * current timeline keeps sub-compositions as single rows.
84
82
  */
85
83
  expandedParentStart?: number;
86
84
  expandedHostKey?: string;
@@ -44,7 +44,6 @@ export function createTimelineResetState() {
44
44
  beatPersist: null,
45
45
  clipManifest: null,
46
46
  clipParentMap: new Map<string, string>(),
47
- topLevelIds: null,
48
47
  domClipChildren: [],
49
48
  subCompositionHostState: new Map<string, SubCompositionHostState>(),
50
49
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "total": 418,
2
+ "total": 385,
3
3
  "files": {
4
4
  "packages/studio/src/captions/components/CaptionOverlay.tsx": 5,
5
5
  "packages/studio/src/captions/components/CaptionTimeline.tsx": 11,
@@ -61,7 +61,6 @@
61
61
  "packages/studio/src/player/components/SpeedMenu.tsx": 2,
62
62
  "packages/studio/src/player/components/TimelineAutomationLane.tsx": 4,
63
63
  "packages/studio/src/player/components/TimelineCanvas.tsx": 13,
64
- "packages/studio/src/player/components/TimelineClip.tsx": 2,
65
64
  "packages/studio/src/player/components/TimelineClipDiamonds.tsx": 2,
66
65
  "packages/studio/src/player/components/TimelineDiamondConnectors.tsx": 2,
67
66
  "packages/studio/src/player/components/TimelineEmptyState.tsx": 1,
@@ -75,9 +74,9 @@
75
74
  "packages/studio/src/player/components/TimelineTrackHeader.tsx": 2,
76
75
  "packages/studio/src/player/components/TimelineTrackPlainHeader.tsx": 2,
77
76
  "packages/studio/src/player/components/VideoThumbnail.tsx": 6,
78
- "packages/studio/src/player/components/timelineTheme.ts": 29,
77
+ "packages/studio/src/player/components/timelineTheme.ts": 5,
79
78
  "packages/studio/src/player/components/trackHeaderLabelRows.tsx": 6,
80
- "packages/studio/src/styles/studio.css": 70,
79
+ "packages/studio/src/styles/studio.css": 63,
81
80
  "packages/studio/src/telemetry/client.ts": 1,
82
81
  "packages/studio/src/webmcp/webmcpTestUtils.ts": 1
83
82
  }
@@ -200,8 +200,8 @@ body {
200
200
  }
201
201
 
202
202
  .timeline-clip {
203
- background-color: rgba(255, 255, 255, 0.055);
204
- border: 1px solid rgba(255, 255, 255, 0.09);
203
+ background-color: var(--clip-bg);
204
+ border: 1px solid var(--clip-border);
205
205
  border-radius: 8px;
206
206
  }
207
207
 
@@ -210,13 +210,13 @@ body {
210
210
  position: absolute;
211
211
  inset: 0;
212
212
  border-radius: inherit;
213
- background: rgba(60, 230, 172, 0.2);
213
+ background: var(--clip-bg-active);
214
214
  opacity: 0;
215
215
  pointer-events: none;
216
216
  }
217
217
 
218
218
  .timeline-clip[data-active] {
219
- border-color: rgba(60, 230, 172, 0.55);
219
+ border-color: var(--clip-border-active);
220
220
  }
221
221
 
222
222
  .timeline-clip[data-active]::before {
@@ -240,11 +240,11 @@ body {
240
240
  }
241
241
 
242
242
  .timeline-clip.is-hovered {
243
- background-color: rgba(255, 255, 255, 0.09);
243
+ background-color: var(--clip-bg-hover);
244
244
  }
245
245
 
246
246
  .timeline-clip[data-active].is-hovered {
247
- border-color: rgba(60, 230, 172, 0.75);
247
+ border-color: var(--clip-border-hover);
248
248
  }
249
249
 
250
250
  .timeline-clip.is-selected {
@@ -258,7 +258,7 @@ body {
258
258
  .timeline-clip.is-dragging {
259
259
  /* Solid background so the picked-up clip reads clearly while dragging — audio
260
260
  clips (waveform drawn on a separate layer) would otherwise look transparent. */
261
- background-color: rgba(38, 42, 52, 0.96);
261
+ background-color: var(--clip-bg-dragging);
262
262
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
263
263
  }
264
264
 
@@ -46,6 +46,35 @@
46
46
  --color-brand-gradient-from: #06e3fa;
47
47
  --color-brand-gradient-to: #4fdb5e;
48
48
 
49
+ /* The timeline's own surfaces: darker and bluer than the shared scale, so they
50
+ * get their own names instead of snapping to the nearest --color-bg/-surface
51
+ * step (a host themes the timeline by overriding these). */
52
+ --timeline-shell-bg: #0a0a0b;
53
+ --timeline-shell-border: rgba(255, 255, 255, 0.05);
54
+ --timeline-ruler-border: rgba(255, 255, 255, 0.16);
55
+ --timeline-row-bg: #101014;
56
+ --timeline-row-border: rgba(255, 255, 255, 0.06);
57
+ --timeline-gutter-bg: #0e0f12;
58
+ --timeline-gutter-border: rgba(255, 255, 255, 0.1);
59
+ --timeline-text-primary: rgba(255, 255, 255, 0.92);
60
+ --timeline-text-secondary: rgba(255, 255, 255, 0.62);
61
+ --timeline-tick-text: rgba(255, 255, 255, 0.34);
62
+ --timeline-tick-major: rgba(255, 255, 255, 0.1);
63
+ --timeline-tick-minor: rgba(255, 255, 255, 0.06);
64
+ --timeline-clip-bg: rgba(255, 255, 255, 0.12);
65
+ --timeline-clip-bg-active: rgba(60, 230, 172, 0.2);
66
+ --timeline-clip-bg-hover: rgba(255, 255, 255, 0.16);
67
+ --timeline-clip-bg-dragging: rgba(38, 42, 52, 0.96);
68
+ --timeline-clip-border: rgba(255, 255, 255, 0.22);
69
+ --timeline-clip-border-hover: rgba(60, 230, 172, 0.75);
70
+ --timeline-clip-border-active: rgba(60, 230, 172, 0.55);
71
+ --timeline-handle: rgba(255, 255, 255, 0.55);
72
+ --timeline-resize-seam: rgba(255, 255, 255, 0.12);
73
+ --timeline-resize-active: rgba(255, 255, 255, 0.24);
74
+ --timeline-track-clip-fill: rgba(255, 255, 255, 0.055);
75
+ --timeline-track-clip-active: rgba(60, 230, 172, 0.16);
76
+ --timeline-track-label: rgba(255, 255, 255, 0.5);
77
+
49
78
  /* -------------------------------------------------------------- geometry */
50
79
 
51
80
  --radius-sm: 0.25rem;
@@ -9,6 +9,7 @@ vi.mock("./client", () => ({
9
9
 
10
10
  const {
11
11
  trackStudioSessionStart,
12
+ trackPreviewFirstFrame,
12
13
  trackStudioRenderStart,
13
14
  trackStudioRazorSplit,
14
15
  trackStudioExpandedClipEdit,
@@ -34,6 +35,23 @@ describe("studio telemetry events", () => {
34
35
  expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
35
36
  });
36
37
 
38
+ it("trackPreviewFirstFrame emits timing, composition duration, and version", () => {
39
+ trackPreviewFirstFrame({
40
+ duration_ms: 842,
41
+ composition_seconds: 12.5,
42
+ clip_count: 8,
43
+ media_clip_count: 5,
44
+ studio_version: "0.8.56",
45
+ });
46
+ expect(trackEvent).toHaveBeenCalledWith("preview_first_frame", {
47
+ duration_ms: 842,
48
+ composition_seconds: 12.5,
49
+ clip_count: 8,
50
+ media_clip_count: 5,
51
+ studio_version: "0.8.56",
52
+ });
53
+ });
54
+
37
55
  it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
38
56
  trackStudioRenderStart({
39
57
  fps: 30,
@@ -11,6 +11,16 @@ export function trackStudioSessionStart(props: { has_project: boolean }): void {
11
11
  });
12
12
  }
13
13
 
14
+ export function trackPreviewFirstFrame(props: {
15
+ duration_ms: number;
16
+ composition_seconds: number;
17
+ clip_count: number;
18
+ media_clip_count: number;
19
+ studio_version: string;
20
+ }): void {
21
+ trackEvent("preview_first_frame", props);
22
+ }
23
+
14
24
  export function trackStudioRenderStart(props: {
15
25
  fps: number;
16
26
  quality: string;
@@ -0,0 +1,49 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ // The `studio_*` transport carries browser metadata built here, and this is its
5
+ // only test. tab_id has a module test of its own, but nothing there fails if
6
+ // this file stops attaching it — the two are separate facts.
7
+ describe("browser system meta", () => {
8
+ // Both modules are imported AFTER resetModules so they share one tabId
9
+ // instance; a top-level import would be a different copy of the memo.
10
+ beforeEach(() => {
11
+ vi.resetModules();
12
+ });
13
+
14
+ async function load() {
15
+ const [{ getBrowserSystemMeta }, tab] = await Promise.all([
16
+ import("./system"),
17
+ import("./tabId"),
18
+ ]);
19
+ return { getBrowserSystemMeta, resolveTabId: tab.resolveTabId };
20
+ }
21
+
22
+ it("carries the tab id, from the same source as the studio:* transport", async () => {
23
+ const { getBrowserSystemMeta, resolveTabId } = await load();
24
+ expect(getBrowserSystemMeta().tab_id).toBe(resolveTabId());
25
+ });
26
+
27
+ it("keeps the meta memoized, so one page reports one tab id", async () => {
28
+ const { getBrowserSystemMeta } = await load();
29
+ const first = getBrowserSystemMeta();
30
+ expect(getBrowserSystemMeta()).toBe(first);
31
+ expect(getBrowserSystemMeta().tab_id).toBe(first.tab_id);
32
+ });
33
+
34
+ it("has a tab id in the no-DOM fallback, not an empty string", async () => {
35
+ // The empty-meta branch is taken on SSR. An empty or missing tab_id would
36
+ // read in PostHog as "this event predates the property" rather than "this
37
+ // event has no page", which is the encoding mistake this property exists
38
+ // to avoid. Stubbing the globals is what actually reaches that branch —
39
+ // happy-dom otherwise satisfies the `typeof window` check every time.
40
+ vi.stubGlobal("navigator", undefined);
41
+ vi.stubGlobal("window", undefined);
42
+ const { getBrowserSystemMeta } = await load();
43
+ const meta = getBrowserSystemMeta();
44
+ expect(meta.user_agent).toBe("");
45
+ expect(typeof meta.tab_id).toBe("string");
46
+ expect(meta.tab_id.length).toBeGreaterThan(0);
47
+ vi.unstubAllGlobals();
48
+ });
49
+ });
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import { agentRuntimeProperty } from "./agentRuntime";
8
+ import { tabIdProperty } from "./tabId";
8
9
 
9
10
  export interface BrowserSystemMeta {
10
11
  user_agent: string;
@@ -17,6 +18,8 @@ export interface BrowserSystemMeta {
17
18
  studio_version: string;
18
19
  /** Which coding agent is driving this Studio, or null when a person is. */
19
20
  agent_runtime: string;
21
+ /** One id per page load. distinct_id identifies a browser, not a page. */
22
+ tab_id: string;
20
23
  }
21
24
 
22
25
  const EMPTY_META: BrowserSystemMeta = {
@@ -29,6 +32,7 @@ const EMPTY_META: BrowserSystemMeta = {
29
32
  is_mobile: false,
30
33
  studio_version: "dev",
31
34
  agent_runtime: "none",
35
+ tab_id: "unknown",
32
36
  };
33
37
 
34
38
  let cached: BrowserSystemMeta | null = null;
@@ -54,6 +58,8 @@ export function getBrowserSystemMeta(): BrowserSystemMeta {
54
58
  // Same value the `studio:*` transport attaches, from the same accessor —
55
59
  // two families that disagreed here would split every agent breakdown.
56
60
  agent_runtime: agentRuntimeProperty(),
61
+ // Same shared module as the `studio:*` transport, for the same reason.
62
+ tab_id: tabIdProperty(),
57
63
  };
58
64
  return cached;
59
65
  }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { resetTabIdForTests, resolveTabId, tabIdProperty } from "./tabId";
3
+
4
+ afterEach(() => {
5
+ resetTabIdForTests();
6
+ });
7
+
8
+ describe("tab id", () => {
9
+ it("is stable for the life of the page", () => {
10
+ // The whole value of the property: every event from one page load must
11
+ // carry the SAME id, or a burst still reads as several sources.
12
+ const first = resolveTabId();
13
+ expect(resolveTabId()).toBe(first);
14
+ expect(tabIdProperty()).toBe(first);
15
+ });
16
+
17
+ it("is not empty, since an empty value would read as an absent one", () => {
18
+ expect(resolveTabId().length).toBeGreaterThan(0);
19
+ });
20
+
21
+ it("mints a new id after a fresh page load", () => {
22
+ // resetTabIdForTests stands in for a reload: the memo is all that survives
23
+ // a page, so clearing it is exactly what a reload does.
24
+ const first = resolveTabId();
25
+ resetTabIdForTests();
26
+ expect(resolveTabId()).not.toBe(first);
27
+ });
28
+
29
+ it("never touches storage", () => {
30
+ // Deliberate: a stored id would drift into a tab-session id, silently
31
+ // changing what every query means. This asserts the intent directly rather
32
+ // than inferring it, so adding a read OR a write fails here.
33
+ const seen: string[] = [];
34
+ vi.stubGlobal("localStorage", {
35
+ getItem: (k: string) => (seen.push(`get:${k}`), null),
36
+ setItem: (k: string) => void seen.push(`set:${k}`),
37
+ removeItem: (k: string) => void seen.push(`remove:${k}`),
38
+ });
39
+ resolveTabId();
40
+ expect(seen).toEqual([]);
41
+ vi.unstubAllGlobals();
42
+ });
43
+ });