@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
@@ -1,9 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
- import { act, useRef } from "react";
3
+ import { act, useRef, useState } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it } from "vitest";
6
- import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
6
+ import { resetOverlayFrameLoopForTests } from "./overlayFrameLoop";
7
7
  import {
8
8
  usePreviewCompositionRect,
9
9
  type PreviewCompositionRect,
@@ -12,7 +12,7 @@ import {
12
12
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
13
 
14
14
  afterEach(() => {
15
- usePreviewIframeStore.getState().setIframe(null);
15
+ resetOverlayFrameLoopForTests();
16
16
  document.body.innerHTML = "";
17
17
  });
18
18
 
@@ -24,9 +24,12 @@ function boxed<T extends HTMLElement>(el: T, box: [number, number, number, numbe
24
24
 
25
25
  function mountProbe() {
26
26
  let rect: PreviewCompositionRect | undefined;
27
+ let setIframe: (iframe: HTMLIFrameElement | null) => void = () => {};
27
28
  function Probe() {
28
29
  const ref = useRef<HTMLDivElement>(null);
29
- rect = usePreviewCompositionRect(ref);
30
+ const [iframe, updateIframe] = useState<HTMLIFrameElement | null>(null);
31
+ setIframe = updateIframe;
32
+ rect = usePreviewCompositionRect(ref, iframe);
30
33
  return (
31
34
  <div
32
35
  ref={(el) => {
@@ -38,8 +41,9 @@ function mountProbe() {
38
41
  }
39
42
  const host = document.createElement("div");
40
43
  document.body.append(host);
41
- act(() => createRoot(host).render(<Probe />));
42
- return () => rect;
44
+ const root = createRoot(host);
45
+ act(() => root.render(<Probe />));
46
+ return { read: () => rect, setIframe, unmount: () => act(() => root.unmount()) };
43
47
  }
44
48
 
45
49
  function compositionFrame(box: [number, number, number, number], width: number, height: number) {
@@ -57,13 +61,13 @@ const settle = () => new Promise((r) => setTimeout(r, 400));
57
61
 
58
62
  describe("usePreviewCompositionRect", () => {
59
63
  it("reports the live preview iframe's box relative to the overlay, scaled to the composition", async () => {
60
- const read = mountProbe();
64
+ const probe = mountProbe();
61
65
  const iframe = compositionFrame([110, 70, 960, 540], 1920, 1080);
62
66
  await act(async () => {
63
- usePreviewIframeStore.getState().setIframe(iframe);
67
+ probe.setIframe(iframe);
64
68
  await settle();
65
69
  });
66
- expect(read()).toEqual({
70
+ expect(probe.read()).toEqual({
67
71
  left: 100,
68
72
  top: 50,
69
73
  width: 960,
@@ -71,19 +75,20 @@ describe("usePreviewCompositionRect", () => {
71
75
  scaleX: 0.5,
72
76
  scaleY: 0.5,
73
77
  });
78
+ probe.unmount();
74
79
  });
75
80
 
76
81
  it("follows the iframe when a reload replaces it", async () => {
77
- const read = mountProbe();
82
+ const probe = mountProbe();
78
83
  await act(async () => {
79
- usePreviewIframeStore.getState().setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
84
+ probe.setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
80
85
  await settle();
81
86
  });
82
87
  await act(async () => {
83
- usePreviewIframeStore.getState().setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
88
+ probe.setIframe(compositionFrame([60, 40, 500, 500], 1000, 1000));
84
89
  await settle();
85
90
  });
86
- expect(read()).toEqual({
91
+ expect(probe.read()).toEqual({
87
92
  left: 50,
88
93
  top: 20,
89
94
  width: 500,
@@ -91,5 +96,6 @@ describe("usePreviewCompositionRect", () => {
91
96
  scaleX: 0.5,
92
97
  scaleY: 0.5,
93
98
  });
99
+ probe.unmount();
94
100
  });
95
101
  });
@@ -1,21 +1,21 @@
1
- import { useMemo, type RefObject } from "react";
2
- import { usePreviewIframeStore } from "../../player/store/previewIframeStore";
1
+ import { useEffect, useRef, type RefObject } from "react";
2
+ import { requestOverlayFrames } from "./overlayFrameLoop";
3
3
  import { useDomEditCompositionRect } from "./useDomEditCompositionRect";
4
4
 
5
5
  export type { DomEditCompositionRect as PreviewCompositionRect } from "./useDomEditCompositionRect";
6
6
 
7
7
  /**
8
8
  * Where the composition sits inside `overlayRef`, in overlay pixels, plus the scale from
9
- * composition units to pixels. Follows the live preview iframe across reloads.
9
+ * composition units to pixels.
10
10
  */
11
- export function usePreviewCompositionRect(overlayRef: RefObject<HTMLDivElement | null>) {
12
- const iframeRef = useMemo<RefObject<HTMLIFrameElement | null>>(
13
- () => ({
14
- get current() {
15
- return usePreviewIframeStore.getState().iframe;
16
- },
17
- }),
18
- [],
19
- );
11
+ export function usePreviewCompositionRect(
12
+ overlayRef: RefObject<HTMLDivElement | null>,
13
+ iframe: HTMLIFrameElement | null,
14
+ ) {
15
+ const iframeRef = useRef<HTMLIFrameElement | null>(iframe);
16
+ iframeRef.current = iframe;
17
+ useEffect(() => {
18
+ requestOverlayFrames();
19
+ }, [iframe]);
20
20
  return useDomEditCompositionRect({ iframeRef, overlayRef });
21
21
  }
@@ -191,3 +191,71 @@ describe("useVolumeAutomation", () => {
191
191
  expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1);
192
192
  });
193
193
  });
194
+
195
+ describe("useVolumeAutomation speed lane", () => {
196
+ const rateLane = JSON.stringify({
197
+ version: 1,
198
+ lanes: [
199
+ {
200
+ target: "rate",
201
+ points: [
202
+ { t: 0, v: 1 },
203
+ { t: 4, v: 4 },
204
+ ],
205
+ },
206
+ ],
207
+ });
208
+
209
+ it("seeds a rate lane at the clip's constant rate so automating does not change the speed", () => {
210
+ const { binding, onSetAttributeQuiet } = bind({ "playback-rate": "2" });
211
+ binding.rate.onAutomate();
212
+ expect(writtenAutomation(onSetAttributeQuiet).lanes).toEqual([
213
+ { target: "rate", points: [{ t: 0, v: 2 }] },
214
+ ]);
215
+ });
216
+
217
+ it("reports the lane's geometric value at the playhead", () => {
218
+ // 1x to 4x over 4s: halfway is 2x, not the linear 2.5x
219
+ const { binding } = bind({ automation: rateLane, start: "0", duration: "4" }, 2);
220
+ expect(binding.rate.automated).toBe(true);
221
+ expect(binding.rate.automatedValue).toBeCloseTo(2, 5);
222
+ });
223
+
224
+ it("writes a keyframe on the rate lane and leaves the volume lane alone", () => {
225
+ const both = JSON.stringify({
226
+ version: 1,
227
+ lanes: [
228
+ { target: "volume", points: [{ t: 0, v: 0.5 }] },
229
+ { target: "rate", points: [{ t: 0, v: 1 }] },
230
+ ],
231
+ });
232
+ const { binding, onSetAttributeQuiet } = bind(
233
+ { automation: both, start: "0", duration: "4" },
234
+ 2,
235
+ );
236
+ binding.rate.onCommitAt(3);
237
+ const lanes = writtenAutomation(onSetAttributeQuiet).lanes;
238
+ expect(lanes.find((l: { target: string }) => l.target === "volume").points).toEqual([
239
+ { t: 0, v: 0.5 },
240
+ ]);
241
+ expect(lanes.find((l: { target: string }) => l.target === "rate").points).toContainEqual({
242
+ t: 2,
243
+ v: 3,
244
+ });
245
+ });
246
+
247
+ it("stretches a preset over the clip's duration", () => {
248
+ const { binding, onSetAttributeQuiet } = bind({ start: "0", duration: "8" });
249
+ binding.rate.onApplyPreset("flash-out");
250
+ const lane = writtenAutomation(onSetAttributeQuiet).lanes[0];
251
+ expect(lane.target).toBe("rate");
252
+ expect(lane.points.at(-1).t).toBe(8);
253
+ });
254
+
255
+ it("offers no preset while the clip has no known duration, and writes nothing", () => {
256
+ const { binding, onSetAttributeQuiet } = bind({ start: "0" });
257
+ expect(binding.rate.canApplyPreset).toBe(false);
258
+ binding.rate.onApplyPreset("flash-out");
259
+ expect(onSetAttributeQuiet).not.toHaveBeenCalled();
260
+ });
261
+ });
@@ -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
  }
@@ -2,6 +2,7 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
3
3
  import { useTimelinePlayer, usePlayerStore } from "../../player";
4
4
  import type { TimelineElement } from "../../player";
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";
@@ -21,6 +22,14 @@ export interface NLEContextValue {
21
22
  seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
22
23
  refreshPlayer: () => void;
23
24
  onIframeLoad: () => void;
25
+ // The hidden reload iframe NLEPreview renders next to the live one during a full reload.
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;
32
+ containerRef: React.MutableRefObject<HTMLDivElement | null>;
24
33
  // composition stack (from useCompositionStack)
25
34
  compositionStack: CompositionLevel[];
26
35
  updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
@@ -55,6 +64,8 @@ export interface NLEProviderProps {
55
64
  onCompositionChange?: (compositionPath: string | null) => void;
56
65
  onCompIdToSrcChange?: (map: Map<string, string>) => void;
57
66
  onCompositionLoadingChange?: (loading: boolean) => void;
67
+ /** A preview reload was abandoned; the previous preview is still showing. */
68
+ onPreviewReloadFailed?: (message: string) => void;
58
69
  children: ReactNode;
59
70
  }
60
71
 
@@ -66,15 +77,27 @@ export function NLEProvider({
66
77
  onCompositionChange,
67
78
  onCompIdToSrcChange,
68
79
  onCompositionLoadingChange,
80
+ onPreviewReloadFailed,
69
81
  children,
70
82
  }: NLEProviderProps) {
83
+ const shadowPromotedRef = useRef<() => void>(() => {});
84
+ const containerRef = useRef<HTMLDivElement>(null);
71
85
  const {
72
86
  iframeRef,
73
87
  togglePlay,
74
88
  seek,
75
89
  onIframeLoad: baseOnIframeLoad,
76
90
  refreshPlayer,
77
- } = useTimelinePlayer();
91
+ previewSlots,
92
+ onShadowIframeLoad,
93
+ onShadowReadyChange,
94
+ onShadowError,
95
+ setShadowIframeNode,
96
+ resetPreviewSlots,
97
+ } = useTimelinePlayer({
98
+ onShadowPromoted: () => shadowPromotedRef.current(),
99
+ onPreviewReloadFailed,
100
+ });
78
101
 
79
102
  // Reset timeline state when the project changes. Done in an effect, not during
80
103
  // render: reset() updates the player store, and updating another store/component
@@ -107,14 +130,20 @@ export function NLEProvider({
107
130
  refreshPlayer();
108
131
  }, [refreshKey, refreshPlayer]);
109
132
 
110
- const onIframeLoad = useCallback(() => {
111
- baseOnIframeLoad();
133
+ // Steps that follow every load of the live iframe, including a reload promoted in place.
134
+ const afterLiveIframeLoad = useCallback(() => {
112
135
  // Pre-load + register MotionPathPlugin once so adding a motion path in the
113
136
  // studio doesn't take the async plugin-load flash path on the first soft
114
137
  // reload (the comp may not ship the plugin until it actually uses one).
115
138
  ensureMotionPathPluginLoaded(iframeRef.current);
116
139
  onIframeRef?.(iframeRef.current);
117
- }, [baseOnIframeLoad, iframeRef, onIframeRef]);
140
+ }, [iframeRef, onIframeRef]);
141
+ shadowPromotedRef.current = afterLiveIframeLoad;
142
+
143
+ const onIframeLoad = useCallback(() => {
144
+ baseOnIframeLoad();
145
+ afterLiveIframeLoad();
146
+ }, [baseOnIframeLoad, afterLiveIframeLoad]);
118
147
 
119
148
  const {
120
149
  compositionStack,
@@ -269,10 +298,17 @@ export function NLEProvider({
269
298
  const value: NLEContextValue = {
270
299
  projectId,
271
300
  iframeRef,
301
+ containerRef,
272
302
  togglePlay,
273
303
  seek,
274
304
  refreshPlayer,
275
305
  onIframeLoad,
306
+ previewSlots,
307
+ onShadowIframeLoad,
308
+ onShadowReadyChange,
309
+ onShadowError,
310
+ setShadowIframeNode,
311
+ resetPreviewSlots,
276
312
  compositionStack,
277
313
  updateCompositionStack,
278
314
  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,46 @@ 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
+ view.cleanup();
265
+ });
266
+
267
+ it("mounts the live player once when the composition switches", () => {
268
+ playerMounts.length = 0;
269
+ const Harness = ({ projectId }: { projectId: string }) => {
270
+ const api = useTimelinePlayer();
271
+ return React.createElement(NLEPreview, {
272
+ projectId,
273
+ iframeRef: api.iframeRef,
274
+ onIframeLoad: api.onIframeLoad,
275
+ previewSlots: api.previewSlots,
276
+ onShadowIframeLoad: api.onShadowIframeLoad,
277
+ onShadowReadyChange: api.onShadowReadyChange,
278
+ onShadowError: api.onShadowError,
279
+ setShadowIframeNode: api.setShadowIframeNode,
280
+ resetPreviewSlots: api.resetPreviewSlots,
281
+ });
282
+ };
283
+ const host = document.createElement("div");
284
+ document.body.append(host);
285
+ const root = createRoot(host);
286
+ act(() => root.render(React.createElement(Harness, { projectId: "a" })));
287
+ expect(playerMounts).toEqual(["live"]);
288
+
289
+ act(() => root.render(React.createElement(Harness, { projectId: "b" })));
290
+ expect(playerMounts).toEqual(["live", "live"]);
291
+
292
+ act(() => root.unmount());
293
+ host.remove();
294
+ });
230
295
  });