@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.
- package/dist/assets/hyperframes-player-zGyOcwwh.js +464 -0
- package/dist/assets/{index-DFlILHJe.js → index-BcE2OSVA.js} +1 -1
- package/dist/assets/{index-ZmBtVrEm.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-CxR7F2YQ.js → index-ZJaRCDYV.js} +1 -1
- package/dist/assets/{index-BlkJ_Axe.js → index-d8ferjRc.js} +194 -194
- package/dist/index.d.ts +124 -9
- package/dist/index.html +2 -2
- package/dist/index.js +15686 -14882
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -0
- package/src/components/EditorShell.tsx +3 -1
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +0 -15
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +65 -0
- package/src/components/editor/MotionPathOverlay.tsx +3 -1
- package/src/components/editor/PreviewGuides.test.tsx +6 -1
- package/src/components/editor/PreviewGuides.tsx +8 -10
- package/src/components/editor/PreviewOverlayProvider.test.tsx +90 -0
- package/src/components/editor/PreviewOverlayProvider.tsx +86 -0
- package/src/components/editor/SnapToolbar.test.tsx +25 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/usePreviewCompositionRect.test.tsx +19 -13
- package/src/components/editor/usePreviewCompositionRect.ts +12 -12
- package/src/components/editor/useVolumeAutomation.test.tsx +68 -0
- package/src/components/editor/useVolumeAutomation.ts +57 -16
- package/src/components/nle/NLEContext.tsx +48 -6
- package/src/components/nle/NLEPreview.test.ts +67 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.tsx +4 -16
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/previewReadOnly.test.tsx +1 -0
- package/src/components/nle/useCompositionStack.ts +1 -1
- package/src/hooks/useCompositionDimensions.test.tsx +37 -0
- package/src/hooks/useCompositionDimensions.ts +17 -1
- package/src/hooks/useDomEditAttributeCommits.test.ts +23 -0
- package/src/hooks/useDomEditAttributeCommits.ts +14 -3
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useSdkSession.lifecycle.test.tsx +108 -0
- package/src/hooks/useSdkSession.ts +77 -14
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/index.ts +21 -1
- package/src/player/components/LayerDisclosureRow.tsx +3 -3
- package/src/player/components/Player.test.ts +175 -13
- package/src/player/components/Player.tsx +58 -2
- package/src/player/components/ShortcutsPanel.test.tsx +4 -10
- package/src/player/components/Timeline.tsx +23 -572
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineGroupHeader.tsx +4 -6
- package/src/player/components/TimelineOverlays.test.ts +67 -32
- package/src/player/components/TimelineOverlays.tsx +46 -107
- package/src/player/components/TimelineProvider.tsx +191 -0
- package/src/player/components/TimelineTrackPlainHeader.test.tsx +10 -0
- package/src/player/components/TimelineTrackPlainHeader.tsx +4 -7
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/useTimelineClipContextMenu.ts +1 -1
- package/src/player/components/useTimelineOverlaysState.ts +100 -0
- package/src/player/components/useTimelineProviderState.tsx +583 -0
- package/src/player/hooks/playbackAdapterResolution.test.ts +41 -0
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/usePlayerHandle.test.tsx +152 -0
- package/src/player/hooks/usePlayerHandle.ts +94 -0
- package/src/player/hooks/usePreviewFirstFrameTelemetry.ts +51 -0
- package/src/player/hooks/useShadowPreviewReload.ts +183 -0
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +8 -88
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +457 -0
- package/src/player/hooks/useTimelinePlayer.ts +65 -99
- package/src/player/hooks/useTimelineSyncCallbacks.ts +159 -88
- package/src/player/index.ts +7 -0
- package/src/telemetry/events.test.ts +18 -0
- package/src/telemetry/events.ts +10 -0
- package/src/telemetry/system.test.ts +49 -0
- package/src/telemetry/system.ts +6 -0
- package/src/telemetry/tabId.test.ts +43 -0
- package/src/telemetry/tabId.ts +44 -0
- package/src/utils/previewCompositionSize.ts +26 -0
- package/src/utils/reloadDebug.ts +2 -4
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioTelemetry.test.ts +14 -0
- package/src/utils/studioTelemetry.ts +4 -0
- 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(
|
|
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(
|
|
26
|
+
root.render(
|
|
27
|
+
<PreviewOverlayProvider>
|
|
28
|
+
<SnapToolbar />
|
|
29
|
+
</PreviewOverlayProvider>,
|
|
30
|
+
);
|
|
26
31
|
});
|
|
27
|
-
return { root
|
|
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(
|
|
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
|
-
<
|
|
76
|
+
<PreviewOverlayProvider>
|
|
77
|
+
<SnapToolbar />
|
|
78
|
+
</PreviewOverlayProvider>
|
|
72
79
|
</>,
|
|
73
80
|
);
|
|
74
81
|
});
|
|
75
|
-
return { root
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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(
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
50
|
-
const next = { ...prev, ...patch };
|
|
51
|
-
writeStudioUiPreferences(patch);
|
|
52
|
-
onSnapChange?.(next);
|
|
53
|
-
return next;
|
|
54
|
-
});
|
|
21
|
+
actions.setSnapPrefs(patch);
|
|
55
22
|
},
|
|
56
|
-
[
|
|
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: !
|
|
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: !
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
254
|
-
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "
|
|
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
|
|
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
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
64
|
+
const probe = mountProbe();
|
|
61
65
|
const iframe = compositionFrame([110, 70, 960, 540], 1920, 1080);
|
|
62
66
|
await act(async () => {
|
|
63
|
-
|
|
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
|
|
82
|
+
const probe = mountProbe();
|
|
78
83
|
await act(async () => {
|
|
79
|
-
|
|
84
|
+
probe.setIframe(compositionFrame([110, 70, 960, 540], 1920, 1080));
|
|
80
85
|
await settle();
|
|
81
86
|
});
|
|
82
87
|
await act(async () => {
|
|
83
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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.
|
|
9
|
+
* composition units to pixels.
|
|
10
10
|
*/
|
|
11
|
-
export function usePreviewCompositionRect(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
});
|