@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.
- package/dist/assets/hyperframes-player-c23wnIfc.js +464 -0
- package/dist/assets/{index-DrHQAuGO.css → index-DKJpHYYd.css} +1 -1
- package/dist/assets/{index-BEIRytvV.js → index-DgkAzx3n.js} +1 -1
- package/dist/assets/{index-DaNpNFJC.js → index-IDNr3X1H.js} +1 -1
- package/dist/assets/{index-TcBZyzNA.js → index-gbOCDCuZ.js} +202 -202
- package/dist/index.d.ts +111 -17
- package/dist/index.html +2 -2
- package/dist/index.js +21534 -20683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +18 -1
- package/src/components/EditorShell.readOnlyHostMount.test.tsx +116 -0
- package/src/components/EditorShell.selectionSync.test.tsx +57 -67
- package/src/components/EditorShell.tsx +62 -51
- package/src/components/EditorShellTestMocks.ts +46 -0
- package/src/components/ProjectUnreachableBanner.test.tsx +113 -0
- package/src/components/ProjectUnreachableBanner.tsx +89 -0
- package/src/components/editor/DomEditOverlay.tsx +19 -55
- package/src/components/editor/DomEditOverlayHover.test.tsx +1 -15
- package/src/components/editor/DomEditSelectionChrome.tsx +11 -9
- package/src/components/editor/GridOverlay.tsx +12 -23
- package/src/components/editor/MotionPathOverlay.test.tsx +66 -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/PropertyPanel.deselect.test.tsx +71 -0
- package/src/components/editor/PropertyPanel.tsx +4 -3
- package/src/components/editor/SnapToolbar.test.tsx +26 -12
- package/src/components/editor/SnapToolbar.tsx +30 -59
- package/src/components/editor/domEditOverlayTestMocks.ts +16 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +260 -0
- package/src/components/editor/previewReadOnlyContext.tsx +59 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +9 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +2 -2
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +46 -12
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +4 -1
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +3 -13
- package/src/components/editor/useDomEditNudge.ts +9 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +4 -0
- package/src/components/editor/useOffCanvasIndicators.ts +50 -0
- 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 +40 -4
- package/src/components/nle/NLEPreview.test.ts +66 -1
- package/src/components/nle/NLEPreview.tsx +88 -44
- package/src/components/nle/PreviewOverlays.topologyLens.test.tsx +4 -35
- package/src/components/nle/PreviewOverlays.tsx +19 -24
- package/src/components/nle/PreviewOverlaysTestMocks.ts +40 -0
- package/src/components/nle/PreviewPane.tsx +17 -2
- package/src/components/nle/TimelinePane.tsx +5 -1
- package/src/components/nle/previewReadOnly.test.tsx +129 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +1 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +3 -6
- package/src/components/nle/usePreviewBlockDrop.ts +3 -1
- package/src/hooks/appHotkeysDispatch.ts +16 -1
- package/src/hooks/newTweenTargetHooks.test.tsx +1 -0
- package/src/hooks/timelineAudioGroupCreate.ts +3 -4
- package/src/hooks/timelineTrackVisibility.ts +7 -8
- package/src/hooks/useAppHotkeys.test.ts +71 -3
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -0
- package/src/hooks/useAppHotkeys.ts +4 -0
- package/src/hooks/useCompositionDimensions.test.tsx +38 -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/useDomEditCommits.test.tsx +1 -0
- package/src/hooks/useDomEditCommits.ts +3 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -0
- package/src/hooks/useDomEditSession.ts +7 -4
- package/src/hooks/useDomEditWiring.ts +40 -40
- package/src/hooks/useDomGeometryCommits.test.tsx +85 -2
- package/src/hooks/useDomGeometryCommits.ts +26 -20
- package/src/hooks/useFileTree.test.tsx +56 -0
- package/src/hooks/useFileTree.ts +8 -1
- package/src/hooks/useGestureCommit.test.tsx +29 -0
- package/src/hooks/useGestureCommit.ts +23 -0
- package/src/hooks/useInlineTextEdit.test.tsx +23 -2
- package/src/hooks/useInlineTextEdit.ts +11 -2
- package/src/hooks/useSdkSession.lifecycle.test.tsx +80 -0
- package/src/hooks/useSdkSession.ts +46 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +303 -0
- package/src/hooks/useServerConnection.ts +81 -8
- package/src/hooks/useTimelineAssetDropOps.test.tsx +3 -4
- package/src/hooks/useTimelineAssetDropOps.ts +1 -6
- package/src/hooks/useTimelineEditing.test.tsx +10 -7
- package/src/hooks/useTimelineEditing.ts +2 -7
- package/src/index.ts +12 -0
- package/src/player/components/Player.test.ts +141 -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 -573
- package/src/player/components/TimelineCanvas.tsx +9 -30
- package/src/player/components/TimelineClip.test.tsx +52 -0
- package/src/player/components/TimelineClip.tsx +13 -2
- 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/TimelineTrackHeader.test.tsx +26 -5
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/testRootHarness.ts +18 -0
- package/src/player/components/timelineMotionStyles.test.ts +3 -3
- package/src/player/components/timelineProviderStateBuilders.test.ts +109 -0
- package/src/player/components/timelineProviderStateBuilders.ts +83 -0
- package/src/player/components/timelineTheme.test.ts +4 -4
- package/src/player/components/timelineTheme.ts +34 -30
- package/src/player/components/timelineTrackDisplay.ts +1 -4
- 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/components/useTimelineTrackDerivations.ts +2 -2
- package/src/player/hooks/playbackAdapterResolution.ts +98 -0
- package/src/player/hooks/timelinePlayerTestHarness.ts +120 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +0 -18
- package/src/player/hooks/timelineSyncHydration.ts +0 -23
- 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 -91
- package/src/player/store/playerStore.ts +0 -4
- package/src/player/store/timelineElement.ts +2 -4
- package/src/player/store/timelineResetState.ts +0 -1
- package/src/styles/hex-baseline.json +3 -4
- package/src/styles/studio.css +7 -7
- package/src/styles/theme.css +29 -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-BjAGc2ID.js +0 -464
- package/src/player/hooks/useTimelineRowElements.test.ts +0 -28
- 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 {
|
|
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
|
+
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The media section's lane state and edits: volume and speed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the same way, so
|
|
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
|
|
42
|
-
//
|
|
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
|
|
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:
|
|
67
|
-
automatedVolumeValue:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
}, [
|
|
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
|
});
|