@hyperframes/studio 0.8.56 → 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 (82) hide show
  1. package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
  2. package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
  3. package/dist/assets/{index-DFlILHJe.js → index-DgkAzx3n.js} +1 -1
  4. package/dist/assets/{index-CxR7F2YQ.js → index-IDNr3X1H.js} +1 -1
  5. package/dist/assets/{index-BlkJ_Axe.js → index-gbOCDCuZ.js} +194 -194
  6. package/dist/index.d.ts +95 -9
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +15573 -14835
  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 +66 -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 +40 -4
  33. package/src/components/nle/NLEPreview.test.ts +66 -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/hooks/useCompositionDimensions.test.tsx +38 -0
  39. package/src/hooks/useCompositionDimensions.ts +17 -1
  40. package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
  41. package/src/hooks/useDomEditAttributeCommits.ts +14 -3
  42. package/src/hooks/useFileTree.test.tsx +56 -0
  43. package/src/hooks/useFileTree.ts +8 -1
  44. package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
  45. package/src/hooks/useSdkSession.ts +46 -10
  46. package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
  47. package/src/hooks/useServerConnection.ts +81 -8
  48. package/src/index.ts +12 -0
  49. package/src/player/components/Player.test.ts +141 -13
  50. package/src/player/components/Player.tsx +58 -2
  51. package/src/player/components/ShortcutsPanel.test.tsx +4 -10
  52. package/src/player/components/Timeline.tsx +23 -572
  53. package/src/player/components/TimelineCanvas.tsx +9 -30
  54. package/src/player/components/TimelineOverlays.test.ts +67 -32
  55. package/src/player/components/TimelineOverlays.tsx +46 -107
  56. package/src/player/components/TimelineProvider.tsx +191 -0
  57. package/src/player/components/testRootHarness.ts +18 -0
  58. package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
  59. package/src/player/components/timelineProviderStateBuilders.ts +83 -0
  60. package/src/player/components/useTimelineClipContextMenu.ts +1 -1
  61. package/src/player/components/useTimelineOverlaysState.ts +100 -0
  62. package/src/player/components/useTimelineProviderState.tsx +583 -0
  63. package/src/player/hooks/playbackAdapterResolution.ts +98 -0
  64. package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
  65. package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
  66. package/src/player/hooks/useShadowPreviewReload.ts +183 -0
  67. package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
  68. package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
  69. package/src/player/hooks/useTimelinePlayer.ts +65 -99
  70. package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
  71. package/src/telemetry/events.test.ts +18 -0
  72. package/src/telemetry/events.ts +10 -0
  73. package/src/telemetry/system.test.ts +49 -0
  74. package/src/telemetry/system.ts +6 -0
  75. package/src/telemetry/tabId.test.ts +43 -0
  76. package/src/telemetry/tabId.ts +44 -0
  77. package/src/utils/previewCompositionSize.ts +26 -0
  78. package/src/utils/reloadDebug.ts +2 -4
  79. package/src/utils/sourcePatcher.ts +1 -1
  80. package/src/utils/studioTelemetry.test.ts +14 -0
  81. package/src/utils/studioTelemetry.ts +4 -0
  82. package/dist/assets/hyperframes-player-k8LzMtXX.js +0 -464
@@ -0,0 +1,86 @@
1
+ import { createContext, useContext, useRef, useState, type ReactNode, type RefObject } from "react";
2
+ import {
3
+ usePreviewCompositionRect,
4
+ type PreviewCompositionRect,
5
+ } from "./usePreviewCompositionRect";
6
+ import { usePreviewGuidesStore } from "./previewGuidesStore";
7
+ import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
8
+ import { useLivePreviewIframe } from "../../player/store/previewIframeStore";
9
+
10
+ export interface PreviewSnapPreferences {
11
+ snapEnabled: boolean;
12
+ gridVisible: boolean;
13
+ gridSpacing: number;
14
+ snapToGrid: boolean;
15
+ }
16
+
17
+ interface PreviewOverlayContextValue {
18
+ state: {
19
+ snapPrefs: PreviewSnapPreferences;
20
+ rulerVisible: boolean;
21
+ safeMarginsVisible: boolean;
22
+ iframeRef: RefObject<HTMLIFrameElement | null>;
23
+ compositionRect: PreviewCompositionRect;
24
+ };
25
+ actions: {
26
+ setSnapPrefs: (patch: Partial<PreviewSnapPreferences>) => void;
27
+ toggleRulers: () => void;
28
+ toggleSafeMargins: () => void;
29
+ };
30
+ }
31
+
32
+ const PreviewOverlayContext = createContext<PreviewOverlayContextValue | null>(null);
33
+
34
+ function readSnapPrefs(): PreviewSnapPreferences {
35
+ const prefs = readStudioUiPreferences();
36
+ return {
37
+ snapEnabled: prefs.snapEnabled ?? true,
38
+ gridVisible: prefs.gridVisible ?? false,
39
+ gridSpacing: prefs.gridSpacing ?? 50,
40
+ snapToGrid: prefs.snapToGrid ?? false,
41
+ };
42
+ }
43
+
44
+ export interface PreviewOverlayProviderProps {
45
+ iframe?: HTMLIFrameElement | null;
46
+ children: ReactNode;
47
+ }
48
+
49
+ export function PreviewOverlayProvider({ iframe, children }: PreviewOverlayProviderProps) {
50
+ const overlayRef = useRef<HTMLDivElement | null>(null);
51
+ const [snapPrefs, setSnapPrefs] = useState(readSnapPrefs);
52
+ const rulerVisible = usePreviewGuidesStore((state) => state.rulerVisible);
53
+ const safeMarginsVisible = usePreviewGuidesStore((state) => state.safeMarginsVisible);
54
+ const liveIframe = useLivePreviewIframe();
55
+ const resolvedIframe = iframe ?? liveIframe;
56
+ const iframeRef = useRef<HTMLIFrameElement | null>(resolvedIframe);
57
+ iframeRef.current = resolvedIframe;
58
+ const compositionRect = usePreviewCompositionRect(overlayRef, resolvedIframe);
59
+
60
+ const contextValue: PreviewOverlayContextValue = {
61
+ state: { snapPrefs, rulerVisible, safeMarginsVisible, iframeRef, compositionRect },
62
+ actions: {
63
+ setSnapPrefs: (patch) => {
64
+ writeStudioUiPreferences(patch);
65
+ setSnapPrefs((current) => ({ ...current, ...patch }));
66
+ },
67
+ toggleRulers: () => usePreviewGuidesStore.getState().toggle("rulerVisible"),
68
+ toggleSafeMargins: () => usePreviewGuidesStore.getState().toggle("safeMarginsVisible"),
69
+ },
70
+ };
71
+
72
+ return (
73
+ <PreviewOverlayContext.Provider value={contextValue}>
74
+ <div ref={overlayRef} className="absolute inset-0">
75
+ {children}
76
+ </div>
77
+ </PreviewOverlayContext.Provider>
78
+ );
79
+ }
80
+
81
+ export function usePreviewOverlayContext(): PreviewOverlayContextValue {
82
+ const context = useContext(PreviewOverlayContext);
83
+ if (!context)
84
+ throw new Error("usePreviewOverlayContext must be used within PreviewOverlayProvider");
85
+ return context;
86
+ }
@@ -7,6 +7,7 @@ import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
8
  import type { DomEditSelection } from "./domEditing";
9
9
  import { SnapToolbar } from "./SnapToolbar";
10
+ import { PreviewOverlayProvider } from "./PreviewOverlayProvider";
10
11
  import { usePreviewGuidesStore } from "./previewGuidesStore";
11
12
 
12
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,14 +18,18 @@ afterEach(() => {
17
18
  usePlayerStore.getState().reset();
18
19
  });
19
20
 
20
- function renderToolbar(onSnapChange = vi.fn()) {
21
+ function renderToolbar() {
21
22
  const host = document.createElement("div");
22
23
  document.body.append(host);
23
24
  const root = createRoot(host);
24
25
  act(() => {
25
- root.render(<SnapToolbar onSnapChange={onSnapChange} />);
26
+ root.render(
27
+ <PreviewOverlayProvider>
28
+ <SnapToolbar />
29
+ </PreviewOverlayProvider>,
30
+ );
26
31
  });
27
- return { root, onSnapChange };
32
+ return { root };
28
33
  }
29
34
 
30
35
  function AppHotkeyHarness() {
@@ -60,7 +65,7 @@ function AppHotkeyHarness() {
60
65
  return null;
61
66
  }
62
67
 
63
- function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
68
+ function renderToolbarWithAppHotkeys() {
64
69
  const host = document.createElement("div");
65
70
  document.body.append(host);
66
71
  const root = createRoot(host);
@@ -68,16 +73,18 @@ function renderToolbarWithAppHotkeys(onSnapChange = vi.fn()) {
68
73
  root.render(
69
74
  <>
70
75
  <AppHotkeyHarness />
71
- <SnapToolbar onSnapChange={onSnapChange} />
76
+ <PreviewOverlayProvider>
77
+ <SnapToolbar />
78
+ </PreviewOverlayProvider>
72
79
  </>,
73
80
  );
74
81
  });
75
- return { root, onSnapChange };
82
+ return { root };
76
83
  }
77
84
 
78
85
  describe("SnapToolbar keyboard shortcuts", () => {
79
86
  it("toggles snap on an unclaimed S keypress", () => {
80
- const { root, onSnapChange } = renderToolbar();
87
+ const { root } = renderToolbar();
81
88
 
82
89
  act(() => {
83
90
  document.dispatchEvent(
@@ -85,12 +92,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
85
92
  );
86
93
  });
87
94
 
88
- expect(onSnapChange).toHaveBeenCalledWith(expect.objectContaining({ snapEnabled: false }));
95
+ expect(
96
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
97
+ ).toBe(false);
89
98
  act(() => root.unmount());
90
99
  });
91
100
 
92
101
  it("does not toggle snap when another handler already prevented S", () => {
93
- const { root, onSnapChange } = renderToolbar();
102
+ const { root } = renderToolbar();
94
103
  const event = new KeyboardEvent("keydown", {
95
104
  key: "s",
96
105
  bubbles: true,
@@ -102,12 +111,14 @@ describe("SnapToolbar keyboard shortcuts", () => {
102
111
  document.dispatchEvent(event);
103
112
  });
104
113
 
105
- expect(onSnapChange).not.toHaveBeenCalled();
114
+ expect(
115
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
116
+ ).not.toBe(false);
106
117
  act(() => root.unmount());
107
118
  });
108
119
 
109
120
  it("does not toggle snap when the app split shortcut claims S without a selected clip", () => {
110
- const { root, onSnapChange } = renderToolbarWithAppHotkeys();
121
+ const { root } = renderToolbarWithAppHotkeys();
111
122
 
112
123
  act(() => {
113
124
  document.dispatchEvent(
@@ -115,7 +126,9 @@ describe("SnapToolbar keyboard shortcuts", () => {
115
126
  );
116
127
  });
117
128
 
118
- expect(onSnapChange).not.toHaveBeenCalled();
129
+ expect(
130
+ JSON.parse(window.localStorage.getItem("hf-studio-ui-preferences") ?? "{}").snapEnabled,
131
+ ).not.toBe(false);
119
132
  act(() => root.unmount());
120
133
  });
121
134
  });
@@ -1,59 +1,26 @@
1
1
  import { memo, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { MagnetStraight, GridFour, Path, Ruler, FrameCorners } from "@phosphor-icons/react";
3
- import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
4
3
  import { usePlayerStore } from "../../player/store/playerStore";
5
- import { usePreviewGuidesStore } from "./previewGuidesStore";
6
-
7
- const SNAP_DEFAULTS = {
8
- snapEnabled: true,
9
- gridVisible: false,
10
- gridSpacing: 50,
11
- snapToGrid: false,
12
- };
13
-
14
- // fallow-ignore-next-line complexity
15
- function readSnapPrefs() {
16
- const prefs = readStudioUiPreferences();
17
- return {
18
- snapEnabled: prefs.snapEnabled ?? SNAP_DEFAULTS.snapEnabled,
19
- gridVisible: prefs.gridVisible ?? SNAP_DEFAULTS.gridVisible,
20
- gridSpacing: prefs.gridSpacing ?? SNAP_DEFAULTS.gridSpacing,
21
- snapToGrid: prefs.snapToGrid ?? SNAP_DEFAULTS.snapToGrid,
22
- };
23
- }
24
-
25
- interface SnapToolbarProps {
26
- onSnapChange?: (prefs: {
27
- snapEnabled: boolean;
28
- gridVisible: boolean;
29
- gridSpacing: number;
30
- snapToGrid: boolean;
31
- }) => void;
32
- }
4
+ import { usePreviewOverlayContext } from "./PreviewOverlayProvider";
33
5
 
34
6
  // fallow-ignore-next-line complexity
35
- export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolbarProps) {
36
- const [prefs, setPrefs] = useState(readSnapPrefs);
7
+ export const SnapToolbar = memo(function SnapToolbar() {
37
8
  const [gridPopoverOpen, setGridPopoverOpen] = useState(false);
9
+ const { state, actions } = usePreviewOverlayContext();
10
+ const { snapPrefs: prefs, rulerVisible, safeMarginsVisible } = state;
38
11
  // Motion-path "set destination" toggle — shown only when the selected element
39
12
  // can take a path; arms a single canvas click to place it (MotionPathOverlay).
40
13
  const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable);
41
14
  const motionPathArmed = usePlayerStore((s) => s.motionPathArmed);
42
15
  const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed);
43
- const guides = usePreviewGuidesStore();
44
16
  const popoverRef = useRef<HTMLDivElement>(null);
45
17
  const gridButtonRef = useRef<HTMLButtonElement>(null);
46
18
 
47
19
  const updatePrefs = useCallback(
48
20
  (patch: Partial<typeof prefs>) => {
49
- setPrefs((prev) => {
50
- const next = { ...prev, ...patch };
51
- writeStudioUiPreferences(patch);
52
- onSnapChange?.(next);
53
- return next;
54
- });
21
+ actions.setSnapPrefs(patch);
55
22
  },
56
- [onSnapChange],
23
+ [actions],
57
24
  );
58
25
 
59
26
  const toggleSnap = useCallback(() => {
@@ -74,16 +41,16 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
74
41
  if (t instanceof HTMLIFrameElement) return;
75
42
  if (e.key === "s" && !e.metaKey && !e.ctrlKey && !e.altKey) {
76
43
  e.preventDefault();
77
- updatePrefs({ snapEnabled: !readSnapPrefs().snapEnabled });
44
+ updatePrefs({ snapEnabled: !prefs.snapEnabled });
78
45
  }
79
46
  if (e.key === "g" && !e.metaKey && !e.ctrlKey && !e.altKey) {
80
47
  e.preventDefault();
81
- updatePrefs({ gridVisible: !readSnapPrefs().gridVisible });
48
+ updatePrefs({ gridVisible: !prefs.gridVisible });
82
49
  }
83
50
  };
84
51
  document.addEventListener("keydown", handleKeyDown);
85
52
  return () => document.removeEventListener("keydown", handleKeyDown);
86
- }, [updatePrefs]);
53
+ }, [prefs.gridVisible, prefs.snapEnabled, updatePrefs]);
87
54
 
88
55
  useEffect(() => {
89
56
  if (!gridPopoverOpen) return;
@@ -123,23 +90,27 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb
123
90
  ["rulerVisible", "Ruler", Ruler],
124
91
  ["safeMarginsVisible", "Safe margins", FrameCorners],
125
92
  ] as const
126
- ).map(([key, label, Icon]) => (
127
- <button
128
- key={key}
129
- type="button"
130
- className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
131
- guides[key]
132
- ? "bg-studio-accent/20 text-studio-accent"
133
- : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
134
- }`}
135
- onClick={() => guides.toggle(key)}
136
- title={`${label} ${guides[key] ? "on" : "off"}`}
137
- aria-label={`Toggle ${label.toLowerCase()}`}
138
- aria-pressed={guides[key]}
139
- >
140
- <Icon size={16} weight={guides[key] ? "fill" : "regular"} />
141
- </button>
142
- ))}
93
+ ).map(([key, label, Icon]) => {
94
+ const visible = key === "rulerVisible" ? rulerVisible : safeMarginsVisible;
95
+ const toggle = key === "rulerVisible" ? actions.toggleRulers : actions.toggleSafeMargins;
96
+ return (
97
+ <button
98
+ key={key}
99
+ type="button"
100
+ className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
101
+ visible
102
+ ? "bg-studio-accent/20 text-studio-accent"
103
+ : "bg-black/40 text-white/60 hover:bg-black/60 hover:text-white/80"
104
+ }`}
105
+ onClick={toggle}
106
+ title={`${label} ${visible ? "on" : "off"}`}
107
+ aria-label={`Toggle ${label.toLowerCase()}`}
108
+ aria-pressed={visible}
109
+ >
110
+ <Icon size={16} weight={visible ? "fill" : "regular"} />
111
+ </button>
112
+ );
113
+ })}
143
114
  <button
144
115
  type="button"
145
116
  className={`rounded-md p-1.5 transition-colors active:scale-[0.95] ${
@@ -250,8 +250,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
250
250
  rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
251
251
  rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
252
252
  });
253
- // min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
254
- expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
253
+ // the speed slider is log-scaled 0.1x..10x, so the far end of the track is 10x
254
+ expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "10");
255
255
  act(() => root.unmount());
256
256
  });
257
257
 
@@ -13,6 +13,10 @@ import {
13
13
  import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
14
14
  import { FlatToggle } from "./propertyPanelFlatToggle";
15
15
  import { AutomationToggle } from "./propertyPanelFxControls";
16
+ import { RATE_RANGE } from "@hyperframes/core/audio-automation";
17
+ import { type SpeedPresetId } from "@hyperframes/core/speed-ramp";
18
+ import { SPEED_PRESET_OPTIONS, type RateBinding } from "./useVolumeAutomation";
19
+ import { fromUnit, toUnit } from "../../player/components/automationLaneGeometry";
16
20
  import {
17
21
  AUDIO_GAIN_FADER_MAX,
18
22
  AUDIO_GAIN_FADER_MIN,
@@ -36,6 +40,7 @@ export function FlatMediaSection({
36
40
  onRemoveVolumeAutomation,
37
41
  onCommitVolumeAt,
38
42
  automatedVolumeValue,
43
+ rate,
39
44
  }: {
40
45
  projectDir: string | null;
41
46
  element: DomEditSelection;
@@ -49,6 +54,8 @@ export function FlatMediaSection({
49
54
  onRemoveVolumeAutomation?: () => void;
50
55
  onCommitVolumeAt?: (v: number) => void;
51
56
  automatedVolumeValue?: number;
57
+ /** Speed lane binding and presets; absent outside the Studio panel. */
58
+ rate?: RateBinding;
52
59
  onRemoveBackground?: (
53
60
  inputPath: string,
54
61
  options: {
@@ -77,7 +84,9 @@ export function FlatMediaSection({
77
84
  Number.parseFloat(
78
85
  element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
79
86
  ) || 0;
80
- const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
87
+ const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
88
+ const playbackRate =
89
+ rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
81
90
  const sourceDuration =
82
91
  Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
83
92
  (el as HTMLMediaElement).duration ||
@@ -259,17 +268,42 @@ export function FlatMediaSection({
259
268
  }
260
269
  />
261
270
  </div>
262
- <FlatSlider
263
- label="Rate"
264
- value={playbackRate * 100}
265
- min={25}
266
- max={300}
267
- tier={playbackRate === 1 ? "default" : "explicitCustom"}
268
- displayValue={`${formatNumericValue(playbackRate)}x`}
269
- onCommit={(next) =>
270
- void onSetAttribute("playback-rate", formatNumericValue(next / 100))
271
- }
272
- />
271
+ <div className="flex items-center gap-1">
272
+ <div className="min-w-0 flex-1">
273
+ <FlatSlider
274
+ label="Speed"
275
+ value={Math.round(toUnit(RATE_RANGE, playbackRate) * 1000)}
276
+ min={0}
277
+ max={1000}
278
+ tier={playbackRate === 1 ? "default" : "explicitCustom"}
279
+ displayValue={`${formatNumericValue(playbackRate)}x`}
280
+ onCommit={(next) => {
281
+ const speed = fromUnit(RATE_RANGE, next / 1000);
282
+ if (rate?.automated) {
283
+ rate.onCommitAt(speed);
284
+ } else {
285
+ void onSetAttribute("playback-rate", formatNumericValue(speed));
286
+ }
287
+ }}
288
+ />
289
+ </div>
290
+ <AutomationToggle
291
+ paramKey="rate"
292
+ label="Speed"
293
+ automated={Boolean(rate?.automated)}
294
+ onAutomate={rate ? () => rate.onAutomate() : undefined}
295
+ onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
296
+ />
297
+ </div>
298
+ {rate?.canApplyPreset && (
299
+ <FlatSelectRow
300
+ label="Speed preset"
301
+ value=""
302
+ options={[{ value: "", label: "Choose…" }, ...SPEED_PRESET_OPTIONS]}
303
+ tier="default"
304
+ onChange={(id) => id && rate.onApplyPreset(id as SpeedPresetId)}
305
+ />
306
+ )}
273
307
  <FlatSlider
274
308
  label="Media start"
275
309
  value={Math.round(mediaStart * 100)}
@@ -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
+ });