@hyperframes/studio 0.8.56 → 0.8.58

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 (91) hide show
  1. package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
  2. package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
  3. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
  6. package/dist/index.d.ts +124 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15686 -14882
  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 +4 -0
  13. package/src/components/EditorShell.tsx +3 -1
  14. package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
  15. package/src/components/ProjectUnreachableBanner.tsx +89 -0
  16. package/src/components/editor/DomEditOverlay.tsx +0 -15
  17. package/src/components/editor/GridOverlay.tsx +12 -23
  18. package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
  19. package/src/components/editor/MotionPathOverlay.tsx +3 -1
  20. package/src/components/editor/PreviewGuides.test.tsx +6 -1
  21. package/src/components/editor/PreviewGuides.tsx +8 -10
  22. package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
  23. package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
  24. package/src/components/editor/SnapToolbar.test.tsx +25 -12
  25. package/src/components/editor/SnapToolbar.tsx +30 -59
  26. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
  27. package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
  28. package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
  29. package/src/components/editor/usePreviewCompositionRect.ts +12 -12
  30. package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
  31. package/src/components/editor/useVolumeAutomation.ts +57 -16
  32. package/src/components/nle/NLEContext.tsx +48 -6
  33. package/src/components/nle/NLEPreview.test.ts +67 -1
  34. package/src/components/nle/NLEPreview.tsx +88 -44
  35. package/src/components/nle/PreviewOverlays.tsx +4 -16
  36. package/src/components/nle/PreviewPane.tsx +17 -2
  37. package/src/components/nle/previewReadOnly.test.tsx +1 -0
  38. package/src/components/nle/useCompositionStack.ts +1 -1
  39. package/src/hooks/useCompositionDimensions.test.tsx +37 -0
  40. package/src/hooks/useCompositionDimensions.ts +17 -1
  41. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  42. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  43. package/src/hooks/useFileTree.test.tsx +56 -0
  44. package/src/hooks/useFileTree.ts +8 -1
  45. package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
  46. package/src/hooks/useSdkSession.ts +77 -14
  47. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  48. package/src/hooks/useServerConnection.ts +81 -8
  49. package/src/index.ts +21 -1
  50. package/src/player/components/LayerDisclosureRow.tsx +3 -3
  51. package/src/player/components/Player.test.ts +175 -13
  52. package/src/player/components/Player.tsx +58 -2
  53. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  54. package/src/player/components/Timeline.tsx +23 -572
  55. package/src/player/components/TimelineCanvas.tsx +9 -30
  56. package/src/player/components/TimelineGroupHeader.tsx +4 -6
  57. package/src/player/components/TimelineOverlays.test.ts +67 -32
  58. package/src/player/components/TimelineOverlays.tsx +46 -107
  59. package/src/player/components/TimelineProvider.tsx +191 -0
  60. package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
  61. package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
  62. package/src/player/components/testRootHarness.ts +18 -0
  63. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  64. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  65. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  66. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  67. package/src/player/components/useTimelineProviderState.tsx +583 -0
  68. package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
  69. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  70. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  71. package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
  72. package/src/player/hooks/usePlayerHandle.ts +94 -0
  73. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  74. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  75. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  76. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  77. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  78. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  79. package/src/player/index.ts +7 -0
  80. package/src/telemetry/events.test.ts +18 -0
  81. package/src/telemetry/events.ts +10 -0
  82. package/src/telemetry/system.test.ts +49 -0
  83. package/src/telemetry/system.ts +6 -0
  84. package/src/telemetry/tabId.test.ts +43 -0
  85. package/src/telemetry/tabId.ts +44 -0
  86. package/src/utils/previewCompositionSize.ts +26 -0
  87. package/src/utils/reloadDebug.ts +2 -4
  88. package/src/utils/sourcePatcher.ts +1 -1
  89. package/src/utils/studioTelemetry.test.ts +14 -0
  90. package/src/utils/studioTelemetry.ts +4 -0
  91. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -1,13 +1,15 @@
1
1
  /**
2
- * The volume lane's state and edits for the media section.
2
+ * The media section's lane state and edits: volume and speed.
3
3
  *
4
- * Volume lives in a different panel section from the FX chain, but is automated
5
- * the same way, so it reads and writes through the same helper the FX group uses
4
+ * Both live in a different panel section from the FX chain but are automated
5
+ * the same way, so they read and write through the same helper the FX group uses
6
6
  * rather than a second interpretation of the attribute.
7
7
  */
8
8
 
9
9
  import {
10
10
  HF_AUDIO_AUTOMATION_DATA_KEY,
11
+ RATE_TARGET,
12
+ resolveAutomationRange,
11
13
  sampleAutomationLane,
12
14
  VOLUME_TARGET,
13
15
  } from "@hyperframes/core/audio-automation";
@@ -16,30 +18,50 @@ import {
16
18
  automationAttrValue,
17
19
  HF_AUDIO_AUTOMATION_ATTR,
18
20
  readPanelAutomation,
21
+ withLane,
19
22
  withoutLane,
20
23
  withPointAt,
21
24
  withSeededLane,
22
25
  } from "./propertyPanelAutomation";
23
26
  import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation";
27
+ import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp";
24
28
  import { clampNumber } from "../../utils/studioHelpers";
25
29
 
30
+ export interface LaneBinding {
31
+ automated: boolean;
32
+ onAutomate: () => void;
33
+ onRemoveAutomation: () => void;
34
+ /** Write `v` as a keyframe at the playhead instead of the disabled fallback. */
35
+ onCommitAt: (v: number) => void;
36
+ /** The envelope's own value at the playhead, so the slider tracks it live. */
37
+ automatedValue: number | undefined;
38
+ }
39
+
40
+ export type RateBinding = LaneBinding & {
41
+ /** False while the clip has no known duration to stretch a preset over. */
42
+ canApplyPreset: boolean;
43
+ onApplyPreset: (id: SpeedPresetId) => void;
44
+ };
45
+
26
46
  export interface VolumeAutomationBinding {
27
47
  volumeAutomated: boolean;
28
48
  onAutomateVolume: () => void;
29
49
  onRemoveVolumeAutomation: () => void;
30
- /** Write `v` as a keyframe at the playhead instead of the disabled fallback. */
31
50
  onCommitVolumeAt: (v: number) => void;
32
- /** The envelope's own value at the playhead, so the slider tracks it live. */
33
51
  automatedVolumeValue: number | undefined;
52
+ /** Speed: the `rate` lane, plus the CapCut-style presets that seed it. */
53
+ rate: RateBinding;
34
54
  }
35
55
 
56
+ export const SPEED_PRESET_OPTIONS = SPEED_PRESETS.map(({ id, label }) => ({ value: id, label }));
57
+
36
58
  export function useVolumeAutomation(
37
59
  element: DomEditSelection,
38
60
  currentTime: number,
39
61
  onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
40
62
  ): VolumeAutomationBinding {
41
- // The chain is not needed to resolve a volume lane — volume is always a valid
42
- // target — so this deliberately does not parse it.
63
+ // The chain is not needed to resolve a volume or rate lane: both are always valid
64
+ // targets, so this deliberately does not parse it.
43
65
  const automation = readPanelAutomation(
44
66
  element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
45
67
  undefined,
@@ -55,21 +77,40 @@ export function useVolumeAutomation(
55
77
  // playing track, while the same click on an effect parameter did not.
56
78
  void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
57
79
  };
80
+ const laneBinding = (target: string, seed: number): LaneBinding => {
81
+ const lane = automation.lanes.find((l) => l.target === target);
82
+ return {
83
+ automated: lane !== undefined,
84
+ automatedValue: lane
85
+ ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale)
86
+ : undefined,
87
+ // Seeded at the level the control already shows, so automating does not change it.
88
+ onAutomate: () => write(withSeededLane(automation, target, seed)),
89
+ onRemoveAutomation: () => write(withoutLane(automation, target)),
90
+ onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)),
91
+ };
92
+ };
58
93
  // `??` alone would let an empty `data-volume` through as Number("") === 0, so
59
94
  // automating the track would seed its lane at silence. The engine reads the same
60
95
  // empty value as unity.
61
96
  const raw = element.dataAttributes?.["volume"];
62
97
  const parsed = raw ? Number(raw) : 1;
63
98
  const current = Number.isFinite(parsed) ? parsed : 1;
64
- const volumeLane = automation.lanes.find((lane) => lane.target === VOLUME_TARGET);
99
+ const volume = laneBinding(VOLUME_TARGET, current);
100
+ const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? "");
101
+ const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1);
65
102
  return {
66
- volumeAutomated: volumeLane !== undefined,
67
- automatedVolumeValue: volumeLane ? sampleAutomationLane(volumeLane, clipTimeSec) : undefined,
68
- // Seeded at the level the slider already shows, so automating the track does
69
- // not change how loud it is.
70
- onAutomateVolume: () =>
71
- write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
72
- onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
73
- onCommitVolumeAt: (v: number) => write(withPointAt(automation, VOLUME_TARGET, clipTimeSec, v)),
103
+ volumeAutomated: volume.automated,
104
+ automatedVolumeValue: volume.automatedValue,
105
+ onAutomateVolume: volume.onAutomate,
106
+ onRemoveVolumeAutomation: volume.onRemoveAutomation,
107
+ onCommitVolumeAt: volume.onCommitAt,
108
+ rate: {
109
+ ...rate,
110
+ canApplyPreset: elDuration > 0,
111
+ onApplyPreset: (id) => {
112
+ if (elDuration > 0) write(withLane(automation, speedPresetLane(id, elDuration)));
113
+ },
114
+ },
74
115
  };
75
116
  }
@@ -1,7 +1,8 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
3
- import { useTimelinePlayer, usePlayerStore } from "../../player";
4
- import type { TimelineElement } from "../../player";
3
+ import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
4
+ import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
5
+ import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
5
6
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
7
  import { useCompositionStack } from "./useCompositionStack";
7
8
  import { setCompositionSourceMap } from "../editor/domEditingDom";
@@ -17,10 +18,20 @@ export interface NLEContextValue {
17
18
  projectId: string;
18
19
  // player (from useTimelinePlayer — single instance for the whole shell)
19
20
  iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
21
+ play: () => void;
22
+ pause: () => void;
20
23
  togglePlay: () => void;
21
24
  seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
22
25
  refreshPlayer: () => void;
23
26
  onIframeLoad: () => void;
27
+ // The hidden reload iframe NLEPreview renders next to the live one during a full reload.
28
+ previewSlots: PreviewIframeSlot[];
29
+ onShadowIframeLoad: (gen: number) => void;
30
+ onShadowReadyChange: (gen: number, ready: boolean) => void;
31
+ onShadowError: (gen: number, message: string) => void;
32
+ setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
33
+ resetPreviewSlots: () => void;
34
+ containerRef: React.MutableRefObject<HTMLDivElement | null>;
24
35
  // composition stack (from useCompositionStack)
25
36
  compositionStack: CompositionLevel[];
26
37
  updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
@@ -55,6 +66,8 @@ export interface NLEProviderProps {
55
66
  onCompositionChange?: (compositionPath: string | null) => void;
56
67
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
57
68
  onCompositionLoadingChange?: (loading: boolean) => void;
69
+ /** A preview reload was abandoned; the previous preview is still showing. */
70
+ onPreviewReloadFailed?: (message: string) => void;
58
71
  children: ReactNode;
59
72
  }
60
73
 
@@ -66,15 +79,29 @@ export function NLEProvider({
66
79
  onCompositionChange,
67
80
  onCompIdToSrcChange,
68
81
  onCompositionLoadingChange,
82
+ onPreviewReloadFailed,
69
83
  children,
70
84
  }: NLEProviderProps) {
85
+ const shadowPromotedRef = useRef<() => void>(() => {});
86
+ const containerRef = useRef<HTMLDivElement>(null);
71
87
  const {
72
88
  iframeRef,
89
+ play,
90
+ pause,
73
91
  togglePlay,
74
92
  seek,
75
93
  onIframeLoad: baseOnIframeLoad,
76
94
  refreshPlayer,
77
- } = useTimelinePlayer();
95
+ previewSlots,
96
+ onShadowIframeLoad,
97
+ onShadowReadyChange,
98
+ onShadowError,
99
+ setShadowIframeNode,
100
+ resetPreviewSlots,
101
+ } = useTimelinePlayer({
102
+ onShadowPromoted: () => shadowPromotedRef.current(),
103
+ onPreviewReloadFailed,
104
+ });
78
105
 
79
106
  // Reset timeline state when the project changes. Done in an effect, not during
80
107
  // render: reset() updates the player store, and updating another store/component
@@ -107,14 +134,20 @@ export function NLEProvider({
107
134
  refreshPlayer();
108
135
  }, [refreshKey, refreshPlayer]);
109
136
 
110
- const onIframeLoad = useCallback(() => {
111
- baseOnIframeLoad();
137
+ // Steps that follow every load of the live iframe, including a reload promoted in place.
138
+ const afterLiveIframeLoad = useCallback(() => {
112
139
  // Pre-load + register MotionPathPlugin once so adding a motion path in the
113
140
  // studio doesn't take the async plugin-load flash path on the first soft
114
141
  // reload (the comp may not ship the plugin until it actually uses one).
115
142
  ensureMotionPathPluginLoaded(iframeRef.current);
116
143
  onIframeRef?.(iframeRef.current);
117
- }, [baseOnIframeLoad, iframeRef, onIframeRef]);
144
+ }, [iframeRef, onIframeRef]);
145
+ shadowPromotedRef.current = afterLiveIframeLoad;
146
+
147
+ const onIframeLoad = useCallback(() => {
148
+ baseOnIframeLoad();
149
+ afterLiveIframeLoad();
150
+ }, [baseOnIframeLoad, afterLiveIframeLoad]);
118
151
 
119
152
  const {
120
153
  compositionStack,
@@ -269,10 +302,19 @@ export function NLEProvider({
269
302
  const value: NLEContextValue = {
270
303
  projectId,
271
304
  iframeRef,
305
+ play,
306
+ pause,
307
+ containerRef,
272
308
  togglePlay,
273
309
  seek,
274
310
  refreshPlayer,
275
311
  onIframeLoad,
312
+ previewSlots,
313
+ onShadowIframeLoad,
314
+ onShadowReadyChange,
315
+ onShadowError,
316
+ setShadowIframeNode,
317
+ resetPreviewSlots,
276
318
  compositionStack,
277
319
  updateCompositionStack,
278
320
  handleNavigateComposition,
@@ -3,10 +3,14 @@
3
3
  import React, { act, createRef } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
6
7
  import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
8
+ import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTelemetry";
7
9
 
8
10
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
9
11
 
12
+ const playerMounts: string[] = [];
13
+
10
14
  vi.mock("../../player", async () => {
11
15
  const React = await import("react");
12
16
 
@@ -14,6 +18,7 @@ vi.mock("../../player", async () => {
14
18
  Player: React.forwardRef(function MockPlayer(
15
19
  props: {
16
20
  onLoad?: () => void;
21
+ suppressLoadingOverlay?: boolean;
17
22
  style?: React.CSSProperties;
18
23
  },
19
24
  ref: React.ForwardedRef<HTMLIFrameElement>,
@@ -21,6 +26,7 @@ vi.mock("../../player", async () => {
21
26
  React.useEffect(() => {
22
27
  props.onLoad?.();
23
28
  }, [props]);
29
+ React.useState(() => playerMounts.push(props.suppressLoadingOverlay ? "shadow" : "live"));
24
30
 
25
31
  return React.createElement("div", {
26
32
  ref: ref as React.ForwardedRef<HTMLDivElement>,
@@ -70,7 +76,11 @@ function setRect(node: Element, rect: { width: number; height: number }) {
70
76
  });
71
77
  }
72
78
 
73
- function renderPreview() {
79
+ function renderPreview(
80
+ previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [
81
+ { gen: 0, role: "live" },
82
+ ],
83
+ ) {
74
84
  resizeCallbacks = [];
75
85
  const host = document.createElement("div");
76
86
  document.body.append(host);
@@ -83,6 +93,12 @@ function renderPreview() {
83
93
  projectId: "timeline-edit-playground",
84
94
  iframeRef,
85
95
  onIframeLoad: () => {},
96
+ previewSlots,
97
+ onShadowIframeLoad: () => {},
98
+ onShadowReadyChange: () => {},
99
+ onShadowError: () => {},
100
+ setShadowIframeNode: () => {},
101
+ resetPreviewSlots: () => {},
86
102
  }),
87
103
  );
88
104
  });
@@ -163,6 +179,13 @@ describe("resolvePreviewStageSize", () => {
163
179
  });
164
180
 
165
181
  describe("NLEPreview", () => {
182
+ it("counts timeline clips and media clips from the painted document", () => {
183
+ const doc = new DOMParser().parseFromString(
184
+ '<div data-composition-id="main" data-duration="10"><video data-start="0" data-duration="2"></video><audio data-start="2" data-duration="3"></audio><div data-start="5" data-duration="1"></div></div>',
185
+ "text/html",
186
+ );
187
+ expect(readPreviewComplexity(doc)).toEqual({ clip_count: 3, media_clip_count: 2 });
188
+ });
166
189
  beforeEach(() => {
167
190
  globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
168
191
  });
@@ -227,4 +250,47 @@ describe("NLEPreview", () => {
227
250
  expect(view.stage.style.transform).toContain("translate3d(30px, -24px, 0)");
228
251
  view.cleanup();
229
252
  });
253
+
254
+ it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
255
+ const view = renderPreview([
256
+ { gen: 0, role: "live" },
257
+ { gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" },
258
+ ]);
259
+ const players = [...view.stage.querySelectorAll<HTMLElement>('[data-testid="mock-player"]')];
260
+ expect(players).toHaveLength(2);
261
+ expect(players[0].style.clipPath).toBe("");
262
+ expect(players[1].style.clipPath).toBe("inset(100%)");
263
+ expect(players[1].style.visibility).toBe("hidden");
264
+ expect(players[1].style.pointerEvents).toBe("none");
265
+ view.cleanup();
266
+ });
267
+
268
+ it("mounts the live player once when the composition switches", () => {
269
+ playerMounts.length = 0;
270
+ const Harness = ({ projectId }: { projectId: string }) => {
271
+ const api = useTimelinePlayer();
272
+ return React.createElement(NLEPreview, {
273
+ projectId,
274
+ iframeRef: api.iframeRef,
275
+ onIframeLoad: api.onIframeLoad,
276
+ previewSlots: api.previewSlots,
277
+ onShadowIframeLoad: api.onShadowIframeLoad,
278
+ onShadowReadyChange: api.onShadowReadyChange,
279
+ onShadowError: api.onShadowError,
280
+ setShadowIframeNode: api.setShadowIframeNode,
281
+ resetPreviewSlots: api.resetPreviewSlots,
282
+ });
283
+ };
284
+ const host = document.createElement("div");
285
+ document.body.append(host);
286
+ const root = createRoot(host);
287
+ act(() => root.render(React.createElement(Harness, { projectId: "a" })));
288
+ expect(playerMounts).toEqual(["live"]);
289
+
290
+ act(() => root.render(React.createElement(Harness, { projectId: "b" })));
291
+ expect(playerMounts).toEqual(["live", "live"]);
292
+
293
+ act(() => root.unmount());
294
+ host.remove();
295
+ });
230
296
  });
@@ -1,5 +1,10 @@
1
+ import {
2
+ readPreviewCompositionSize,
3
+ type PreviewCompositionSize,
4
+ } from "../../utils/previewCompositionSize";
1
5
  import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
2
6
  import { Player } from "../../player";
7
+ import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
3
8
  import {
4
9
  DEFAULT_PREVIEW_ZOOM,
5
10
  canStartPreviewPan,
@@ -13,10 +18,17 @@ import {
13
18
  } from "./previewZoom";
14
19
  import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
15
20
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
21
+ import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
16
22
  interface NLEPreviewProps {
17
23
  projectId: string;
18
24
  iframeRef: RefObject<HTMLIFrameElement | null>;
19
25
  onIframeLoad: () => void;
26
+ previewSlots: PreviewIframeSlot[];
27
+ onShadowIframeLoad: (gen: number) => void;
28
+ onShadowReadyChange: (gen: number, ready: boolean) => void;
29
+ onShadowError: (gen: number, message: string) => void;
30
+ setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
31
+ resetPreviewSlots: () => void;
20
32
  onCompositionLoadingChange?: (loading: boolean) => void;
21
33
  portrait?: boolean;
22
34
  directUrl?: string;
@@ -40,10 +52,15 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
40
52
  const ZOOM_SETTLE_MS = 200;
41
53
  const PREVIEW_STAGE_INSET_PX = 16;
42
54
 
43
- interface PreviewCompositionSize {
44
- width: number;
45
- height: number;
46
- }
55
+ // clip-path as well as visibility: the player's loading overlay sets its own
56
+ // visibility:visible and would otherwise paint over the live frame.
57
+ const SHADOW_IFRAME_STYLE: React.CSSProperties = {
58
+ position: "absolute",
59
+ inset: 0,
60
+ visibility: "hidden",
61
+ clipPath: "inset(100%)",
62
+ pointerEvents: "none",
63
+ };
47
64
 
48
65
  function isPreviewAtFit(state: PreviewZoomState): boolean {
49
66
  return (
@@ -64,27 +81,6 @@ function loadInitialZoom(): PreviewZoomState {
64
81
  : DEFAULT_PREVIEW_ZOOM;
65
82
  }
66
83
 
67
- // fallow-ignore-next-line complexity
68
- function readPreviewCompositionSize(
69
- iframe: HTMLIFrameElement | null,
70
- ): PreviewCompositionSize | null {
71
- try {
72
- const doc = iframe?.contentDocument;
73
- const root =
74
- doc?.querySelector("[data-composition-id][data-width][data-height]") ??
75
- doc?.querySelector("[data-width][data-height]");
76
- if (!root) return null;
77
- const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
78
- const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
79
- if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
80
- return null;
81
- }
82
- return { width, height };
83
- } catch {
84
- return null;
85
- }
86
- }
87
-
88
84
  export function resolvePreviewStageSize(
89
85
  viewportWidth: number,
90
86
  viewportHeight: number,
@@ -122,6 +118,12 @@ export const NLEPreview = memo(function NLEPreview({
122
118
  projectId,
123
119
  iframeRef,
124
120
  onIframeLoad,
121
+ previewSlots,
122
+ onShadowIframeLoad,
123
+ onShadowReadyChange,
124
+ onShadowError,
125
+ setShadowIframeNode,
126
+ resetPreviewSlots,
125
127
  onCompositionLoadingChange,
126
128
  portrait,
127
129
  directUrl,
@@ -136,6 +138,17 @@ export const NLEPreview = memo(function NLEPreview({
136
138
  useEffect(() => {
137
139
  onStageRef?.(stageRef);
138
140
  }, [onStageRef]);
141
+
142
+ // Composition switch: drop any in-flight shadow reload (skipped on first mount).
143
+ const previousActiveKeyRef = useRef(activeKey);
144
+ useEffect(() => {
145
+ if (previousActiveKeyRef.current === activeKey) return;
146
+ previousActiveKeyRef.current = activeKey;
147
+ resetPreviewSlots();
148
+ }, [activeKey, resetPreviewSlots]);
149
+
150
+ const liveGenRef = useRef<number | null>(null);
151
+ const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
139
152
  const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
140
153
  const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
141
154
  const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
@@ -299,6 +312,18 @@ export const NLEPreview = memo(function NLEPreview({
299
312
  }
300
313
  }, [writeTransform]);
301
314
 
315
+ // A promotion does not re-fire Player.onLoad: re-sync the local iframe ref, size and zoom.
316
+ useEffect(() => {
317
+ const live = previewSlots.find((slot) => slot.role === "live");
318
+ if (!live || live.gen === liveGenRef.current) return;
319
+ const isPromotion = liveGenRef.current !== null;
320
+ liveGenRef.current = live.gen;
321
+ if (!isPromotion) return;
322
+ previewIframeRef.current = iframeRef.current;
323
+ updateCompositionSizeFromPreview();
324
+ applyInitialZoom();
325
+ }, [previewSlots, iframeRef, updateCompositionSizeFromPreview, applyInitialZoom]);
326
+
302
327
  useEffect(() => {
303
328
  const viewport = viewportRef.current;
304
329
  if (!viewport) return;
@@ -469,25 +494,44 @@ export const NLEPreview = memo(function NLEPreview({
469
494
  style={{ position: "absolute", inset: 0, zIndex: 0 }}
470
495
  />
471
496
  )}
472
- <Player
473
- key={activeKey}
474
- ref={setPreviewIframeRef}
475
- projectId={directUrl ? undefined : projectId}
476
- directUrl={directUrl}
477
- onLoad={() => {
478
- updateCompositionSizeFromPreview();
479
- onIframeLoad();
480
- applyInitialZoom();
481
- }}
482
- onCompositionLoadingChange={onCompositionLoadingChange}
483
- portrait={portrait}
484
- suppressLoadingOverlay={suppressLoadingOverlay}
485
- style={
486
- directUrl?.includes("/components/")
487
- ? { position: "absolute", inset: 0, zIndex: 1 }
488
- : undefined
489
- }
490
- />
497
+ {previewSlots.map((slot) =>
498
+ slot.role === "live" ? (
499
+ <Player
500
+ key={`${activeKey}-${slot.gen}`}
501
+ ref={setPreviewIframeRef}
502
+ projectId={directUrl ? undefined : projectId}
503
+ directUrl={directUrl}
504
+ onLoad={() => {
505
+ updateCompositionSizeFromPreview();
506
+ onIframeLoad();
507
+ applyInitialZoom();
508
+ }}
509
+ onCompositionLoadingChange={onCompositionLoadingChange}
510
+ onPainted={(details) => reportPreviewFirstFrame(slot, details)}
511
+ portrait={portrait}
512
+ suppressLoadingOverlay={suppressLoadingOverlay}
513
+ style={
514
+ directUrl?.includes("/components/")
515
+ ? { position: "absolute", inset: 0, zIndex: 1 }
516
+ : undefined
517
+ }
518
+ />
519
+ ) : (
520
+ // Loads hidden behind the live slot until promoted.
521
+ <Player
522
+ key={`${activeKey}-${slot.gen}`}
523
+ ref={setShadowIframeNode}
524
+ directUrl={slot.url}
525
+ onLoad={() => onShadowIframeLoad(slot.gen)}
526
+ onReadyToShowChange={(ready) => onShadowReadyChange(slot.gen, ready)}
527
+ onPainted={(details) => reportPreviewFirstFrame(slot, details)}
528
+ onPreviewError={(message) => onShadowError(slot.gen, message)}
529
+ portrait={portrait}
530
+ suppressLoadingOverlay
531
+ style={SHADOW_IFRAME_STYLE}
532
+ />
533
+ ),
534
+ )}
491
535
  </div>
492
536
  </div>
493
537
  <div
@@ -1,10 +1,11 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback } from "react";
2
2
  import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
3
3
  import { useCaptionStore } from "../../captions/store";
4
4
  import { DomEditOverlay } from "../editor/DomEditOverlay";
5
5
  import { TopologyLens } from "../editor/TopologyLens";
6
6
  import { MotionPathOverlay } from "../editor/MotionPathOverlay";
7
7
  import { SnapToolbar } from "../editor/SnapToolbar";
8
+ import { GridOverlay } from "../editor/GridOverlay";
8
9
  import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
9
10
  import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
10
11
  import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
@@ -12,7 +13,6 @@ import {
12
13
  useDomEditActionsContext,
13
14
  useDomEditSelectionContext,
14
15
  } from "../../contexts/DomEditContext";
15
- import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
16
16
  import { readHfId, type DomEditSelection } from "../editor/domEditing";
17
17
  import { buildStableSelector } from "../editor/domEditingDom";
18
18
  import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
@@ -182,17 +182,6 @@ export function PreviewOverlays({
182
182
  } = useDomEditActionsContext();
183
183
  const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
184
184
 
185
- // fallow-ignore-next-line complexity
186
- const [snapPrefs, setSnapPrefs] = useState(() => {
187
- const p = readStudioUiPreferences();
188
- return {
189
- snapEnabled: p.snapEnabled ?? true,
190
- gridVisible: p.gridVisible ?? false,
191
- gridSpacing: p.gridSpacing ?? 50,
192
- snapToGrid: p.snapToGrid ?? false,
193
- };
194
- });
195
-
196
185
  if (blockPreview) {
197
186
  return (
198
187
  <>
@@ -266,6 +255,7 @@ export function PreviewOverlays({
266
255
  return (
267
256
  <>
268
257
  <TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
258
+ <GridOverlay />
269
259
  <DomEditOverlay
270
260
  iframeRef={previewIframeRef}
271
261
  activeCompositionPath={activeCompPath}
@@ -325,13 +315,11 @@ export function PreviewOverlays({
325
315
  }),
326
316
  }).catch(() => undefined);
327
317
  }}
328
- gridVisible={snapPrefs.gridVisible}
329
- gridSpacing={snapPrefs.gridSpacing}
330
318
  recordingState={recordingState}
331
319
  onToggleRecording={onToggleRecording}
332
320
  onMarqueeSelect={applyMarqueeSelection}
333
321
  />
334
- <SnapToolbar onSnapChange={setSnapPrefs} />
322
+ <SnapToolbar />
335
323
  {!readOnly && (
336
324
  <MotionPathOverlay
337
325
  iframeRef={previewIframeRef}
@@ -7,6 +7,7 @@ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
7
7
  import { useNLEContext } from "./NLEContext";
8
8
  import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
9
9
  import { PreviewGuides } from "../editor/PreviewGuides";
10
+ import { PreviewOverlayProvider } from "../editor/PreviewOverlayProvider";
10
11
 
11
12
  function subscribeFullscreen(cb: () => void) {
12
13
  document.addEventListener("fullscreenchange", cb);
@@ -60,6 +61,12 @@ export function PreviewPane({
60
61
  togglePlay,
61
62
  seek,
62
63
  onIframeLoad,
64
+ previewSlots,
65
+ onShadowIframeLoad,
66
+ onShadowReadyChange,
67
+ onShadowError,
68
+ setShadowIframeNode,
69
+ resetPreviewSlots,
63
70
  compositionStack,
64
71
  handleNavigateComposition,
65
72
  setCompositionLoading,
@@ -131,6 +138,12 @@ export function PreviewPane({
131
138
  projectId={projectId}
132
139
  iframeRef={iframeRef}
133
140
  onIframeLoad={onIframeLoad}
141
+ previewSlots={previewSlots}
142
+ onShadowIframeLoad={onShadowIframeLoad}
143
+ onShadowReadyChange={onShadowReadyChange}
144
+ onShadowError={onShadowError}
145
+ setShadowIframeNode={setShadowIframeNode}
146
+ resetPreviewSlots={resetPreviewSlots}
134
147
  onCompositionLoadingChange={setCompositionLoading}
135
148
  portrait={portrait}
136
149
  directUrl={directUrl}
@@ -143,8 +156,10 @@ export function PreviewPane({
143
156
  )}
144
157
  <AssetPreviewOverlay />
145
158
  </div>
146
- <PreviewGuides iframeRef={iframeRef} />
147
- {!isFullscreen && previewOverlay}
159
+ <PreviewOverlayProvider>
160
+ <PreviewGuides />
161
+ {!isFullscreen && previewOverlay}
162
+ </PreviewOverlayProvider>
148
163
  </div>
149
164
  {/* Transport row: no own background or border — the controls sit flat on
150
165
  the preview panel's surface (CapCut-style). */}
@@ -32,6 +32,7 @@ vi.mock("../editor/MotionPathOverlay", () => ({
32
32
  MotionPathOverlay: () => <i data-testid="motion-path" />,
33
33
  }));
34
34
  vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
35
+ vi.mock("../editor/GridOverlay", () => ({ GridOverlay: () => null }));
35
36
 
36
37
  let root: Root;
37
38
  let host: HTMLDivElement;
@@ -1,7 +1,7 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  // Composition drill-down stack management for NLEContext/EditorShell
3
3
  import { useState, useCallback, useRef, useEffect } from "react";
4
- import { usePlayerStore } from "../../player";
4
+ import { usePlayerStore } from "../../player/store/playerStore";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { encodePreviewPath } from "../../player/components/thumbnailUtils";
7
7