@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
|
@@ -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
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
import { useContext, useState, useCallback, useRef, useEffect, type ReactNode } from "react";
|
|
3
|
-
import { useTimelinePlayer
|
|
4
|
-
import type
|
|
3
|
+
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
|
|
4
|
+
import { usePlayerStore, type TimelineElement } from "../../player/store/playerStore";
|
|
5
|
+
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
|
|
5
6
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
7
|
import { useCompositionStack } from "./useCompositionStack";
|
|
7
8
|
import { setCompositionSourceMap } from "../editor/domEditingDom";
|
|
@@ -17,10 +18,20 @@ export interface NLEContextValue {
|
|
|
17
18
|
projectId: string;
|
|
18
19
|
// player (from useTimelinePlayer — single instance for the whole shell)
|
|
19
20
|
iframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
|
21
|
+
play: () => void;
|
|
22
|
+
pause: () => void;
|
|
20
23
|
togglePlay: () => void;
|
|
21
24
|
seek: (time: number, options?: { keepPlaying?: boolean }) => boolean;
|
|
22
25
|
refreshPlayer: () => void;
|
|
23
26
|
onIframeLoad: () => void;
|
|
27
|
+
// The hidden reload iframe NLEPreview renders next to the live one during a full reload.
|
|
28
|
+
previewSlots: PreviewIframeSlot[];
|
|
29
|
+
onShadowIframeLoad: (gen: number) => void;
|
|
30
|
+
onShadowReadyChange: (gen: number, ready: boolean) => void;
|
|
31
|
+
onShadowError: (gen: number, message: string) => void;
|
|
32
|
+
setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
|
|
33
|
+
resetPreviewSlots: () => void;
|
|
34
|
+
containerRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
24
35
|
// composition stack (from useCompositionStack)
|
|
25
36
|
compositionStack: CompositionLevel[];
|
|
26
37
|
updateCompositionStack: React.Dispatch<React.SetStateAction<CompositionLevel[]>>;
|
|
@@ -55,6 +66,8 @@ export interface NLEProviderProps {
|
|
|
55
66
|
onCompositionChange?: (compositionPath: string | null) => void;
|
|
56
67
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
57
68
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
69
|
+
/** A preview reload was abandoned; the previous preview is still showing. */
|
|
70
|
+
onPreviewReloadFailed?: (message: string) => void;
|
|
58
71
|
children: ReactNode;
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -66,15 +79,29 @@ export function NLEProvider({
|
|
|
66
79
|
onCompositionChange,
|
|
67
80
|
onCompIdToSrcChange,
|
|
68
81
|
onCompositionLoadingChange,
|
|
82
|
+
onPreviewReloadFailed,
|
|
69
83
|
children,
|
|
70
84
|
}: NLEProviderProps) {
|
|
85
|
+
const shadowPromotedRef = useRef<() => void>(() => {});
|
|
86
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
71
87
|
const {
|
|
72
88
|
iframeRef,
|
|
89
|
+
play,
|
|
90
|
+
pause,
|
|
73
91
|
togglePlay,
|
|
74
92
|
seek,
|
|
75
93
|
onIframeLoad: baseOnIframeLoad,
|
|
76
94
|
refreshPlayer,
|
|
77
|
-
|
|
95
|
+
previewSlots,
|
|
96
|
+
onShadowIframeLoad,
|
|
97
|
+
onShadowReadyChange,
|
|
98
|
+
onShadowError,
|
|
99
|
+
setShadowIframeNode,
|
|
100
|
+
resetPreviewSlots,
|
|
101
|
+
} = useTimelinePlayer({
|
|
102
|
+
onShadowPromoted: () => shadowPromotedRef.current(),
|
|
103
|
+
onPreviewReloadFailed,
|
|
104
|
+
});
|
|
78
105
|
|
|
79
106
|
// Reset timeline state when the project changes. Done in an effect, not during
|
|
80
107
|
// render: reset() updates the player store, and updating another store/component
|
|
@@ -107,14 +134,20 @@ export function NLEProvider({
|
|
|
107
134
|
refreshPlayer();
|
|
108
135
|
}, [refreshKey, refreshPlayer]);
|
|
109
136
|
|
|
110
|
-
|
|
111
|
-
|
|
137
|
+
// Steps that follow every load of the live iframe, including a reload promoted in place.
|
|
138
|
+
const afterLiveIframeLoad = useCallback(() => {
|
|
112
139
|
// Pre-load + register MotionPathPlugin once so adding a motion path in the
|
|
113
140
|
// studio doesn't take the async plugin-load flash path on the first soft
|
|
114
141
|
// reload (the comp may not ship the plugin until it actually uses one).
|
|
115
142
|
ensureMotionPathPluginLoaded(iframeRef.current);
|
|
116
143
|
onIframeRef?.(iframeRef.current);
|
|
117
|
-
}, [
|
|
144
|
+
}, [iframeRef, onIframeRef]);
|
|
145
|
+
shadowPromotedRef.current = afterLiveIframeLoad;
|
|
146
|
+
|
|
147
|
+
const onIframeLoad = useCallback(() => {
|
|
148
|
+
baseOnIframeLoad();
|
|
149
|
+
afterLiveIframeLoad();
|
|
150
|
+
}, [baseOnIframeLoad, afterLiveIframeLoad]);
|
|
118
151
|
|
|
119
152
|
const {
|
|
120
153
|
compositionStack,
|
|
@@ -269,10 +302,19 @@ export function NLEProvider({
|
|
|
269
302
|
const value: NLEContextValue = {
|
|
270
303
|
projectId,
|
|
271
304
|
iframeRef,
|
|
305
|
+
play,
|
|
306
|
+
pause,
|
|
307
|
+
containerRef,
|
|
272
308
|
togglePlay,
|
|
273
309
|
seek,
|
|
274
310
|
refreshPlayer,
|
|
275
311
|
onIframeLoad,
|
|
312
|
+
previewSlots,
|
|
313
|
+
onShadowIframeLoad,
|
|
314
|
+
onShadowReadyChange,
|
|
315
|
+
onShadowError,
|
|
316
|
+
setShadowIframeNode,
|
|
317
|
+
resetPreviewSlots,
|
|
276
318
|
compositionStack,
|
|
277
319
|
updateCompositionStack,
|
|
278
320
|
handleNavigateComposition,
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
import React, { act, createRef } from "react";
|
|
4
4
|
import { createRoot } from "react-dom/client";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useTimelinePlayer } from "../../player/hooks/useTimelinePlayer";
|
|
6
7
|
import { NLEPreview, getPreviewPlayerKey, resolvePreviewStageSize } from "./NLEPreview";
|
|
8
|
+
import { readPreviewComplexity } from "../../player/hooks/usePreviewFirstFrameTelemetry";
|
|
7
9
|
|
|
8
10
|
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
11
|
|
|
12
|
+
const playerMounts: string[] = [];
|
|
13
|
+
|
|
10
14
|
vi.mock("../../player", async () => {
|
|
11
15
|
const React = await import("react");
|
|
12
16
|
|
|
@@ -14,6 +18,7 @@ vi.mock("../../player", async () => {
|
|
|
14
18
|
Player: React.forwardRef(function MockPlayer(
|
|
15
19
|
props: {
|
|
16
20
|
onLoad?: () => void;
|
|
21
|
+
suppressLoadingOverlay?: boolean;
|
|
17
22
|
style?: React.CSSProperties;
|
|
18
23
|
},
|
|
19
24
|
ref: React.ForwardedRef<HTMLIFrameElement>,
|
|
@@ -21,6 +26,7 @@ vi.mock("../../player", async () => {
|
|
|
21
26
|
React.useEffect(() => {
|
|
22
27
|
props.onLoad?.();
|
|
23
28
|
}, [props]);
|
|
29
|
+
React.useState(() => playerMounts.push(props.suppressLoadingOverlay ? "shadow" : "live"));
|
|
24
30
|
|
|
25
31
|
return React.createElement("div", {
|
|
26
32
|
ref: ref as React.ForwardedRef<HTMLDivElement>,
|
|
@@ -70,7 +76,11 @@ function setRect(node: Element, rect: { width: number; height: number }) {
|
|
|
70
76
|
});
|
|
71
77
|
}
|
|
72
78
|
|
|
73
|
-
function renderPreview(
|
|
79
|
+
function renderPreview(
|
|
80
|
+
previewSlots: Array<{ gen: number; role: "live" | "shadow"; url?: string }> = [
|
|
81
|
+
{ gen: 0, role: "live" },
|
|
82
|
+
],
|
|
83
|
+
) {
|
|
74
84
|
resizeCallbacks = [];
|
|
75
85
|
const host = document.createElement("div");
|
|
76
86
|
document.body.append(host);
|
|
@@ -83,6 +93,12 @@ function renderPreview() {
|
|
|
83
93
|
projectId: "timeline-edit-playground",
|
|
84
94
|
iframeRef,
|
|
85
95
|
onIframeLoad: () => {},
|
|
96
|
+
previewSlots,
|
|
97
|
+
onShadowIframeLoad: () => {},
|
|
98
|
+
onShadowReadyChange: () => {},
|
|
99
|
+
onShadowError: () => {},
|
|
100
|
+
setShadowIframeNode: () => {},
|
|
101
|
+
resetPreviewSlots: () => {},
|
|
86
102
|
}),
|
|
87
103
|
);
|
|
88
104
|
});
|
|
@@ -163,6 +179,13 @@ describe("resolvePreviewStageSize", () => {
|
|
|
163
179
|
});
|
|
164
180
|
|
|
165
181
|
describe("NLEPreview", () => {
|
|
182
|
+
it("counts timeline clips and media clips from the painted document", () => {
|
|
183
|
+
const doc = new DOMParser().parseFromString(
|
|
184
|
+
'<div data-composition-id="main" data-duration="10"><video data-start="0" data-duration="2"></video><audio data-start="2" data-duration="3"></audio><div data-start="5" data-duration="1"></div></div>',
|
|
185
|
+
"text/html",
|
|
186
|
+
);
|
|
187
|
+
expect(readPreviewComplexity(doc)).toEqual({ clip_count: 3, media_clip_count: 2 });
|
|
188
|
+
});
|
|
166
189
|
beforeEach(() => {
|
|
167
190
|
globalThis.ResizeObserver = MockResizeObserver as typeof ResizeObserver;
|
|
168
191
|
});
|
|
@@ -227,4 +250,47 @@ describe("NLEPreview", () => {
|
|
|
227
250
|
expect(view.stage.style.transform).toContain("translate3d(30px, -24px, 0)");
|
|
228
251
|
view.cleanup();
|
|
229
252
|
});
|
|
253
|
+
|
|
254
|
+
it("clips a shadow reload so its own loading overlay cannot paint over the live frame", () => {
|
|
255
|
+
const view = renderPreview([
|
|
256
|
+
{ gen: 0, role: "live" },
|
|
257
|
+
{ gen: 1, role: "shadow", url: "/api/projects/p/preview?_t=1" },
|
|
258
|
+
]);
|
|
259
|
+
const players = [...view.stage.querySelectorAll<HTMLElement>('[data-testid="mock-player"]')];
|
|
260
|
+
expect(players).toHaveLength(2);
|
|
261
|
+
expect(players[0].style.clipPath).toBe("");
|
|
262
|
+
expect(players[1].style.clipPath).toBe("inset(100%)");
|
|
263
|
+
expect(players[1].style.visibility).toBe("hidden");
|
|
264
|
+
expect(players[1].style.pointerEvents).toBe("none");
|
|
265
|
+
view.cleanup();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("mounts the live player once when the composition switches", () => {
|
|
269
|
+
playerMounts.length = 0;
|
|
270
|
+
const Harness = ({ projectId }: { projectId: string }) => {
|
|
271
|
+
const api = useTimelinePlayer();
|
|
272
|
+
return React.createElement(NLEPreview, {
|
|
273
|
+
projectId,
|
|
274
|
+
iframeRef: api.iframeRef,
|
|
275
|
+
onIframeLoad: api.onIframeLoad,
|
|
276
|
+
previewSlots: api.previewSlots,
|
|
277
|
+
onShadowIframeLoad: api.onShadowIframeLoad,
|
|
278
|
+
onShadowReadyChange: api.onShadowReadyChange,
|
|
279
|
+
onShadowError: api.onShadowError,
|
|
280
|
+
setShadowIframeNode: api.setShadowIframeNode,
|
|
281
|
+
resetPreviewSlots: api.resetPreviewSlots,
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const host = document.createElement("div");
|
|
285
|
+
document.body.append(host);
|
|
286
|
+
const root = createRoot(host);
|
|
287
|
+
act(() => root.render(React.createElement(Harness, { projectId: "a" })));
|
|
288
|
+
expect(playerMounts).toEqual(["live"]);
|
|
289
|
+
|
|
290
|
+
act(() => root.render(React.createElement(Harness, { projectId: "b" })));
|
|
291
|
+
expect(playerMounts).toEqual(["live", "live"]);
|
|
292
|
+
|
|
293
|
+
act(() => root.unmount());
|
|
294
|
+
host.remove();
|
|
295
|
+
});
|
|
230
296
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readPreviewCompositionSize,
|
|
3
|
+
type PreviewCompositionSize,
|
|
4
|
+
} from "../../utils/previewCompositionSize";
|
|
1
5
|
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
|
2
6
|
import { Player } from "../../player";
|
|
7
|
+
import type { PreviewIframeSlot } from "../../player/hooks/useTimelineSyncCallbacks";
|
|
3
8
|
import {
|
|
4
9
|
DEFAULT_PREVIEW_ZOOM,
|
|
5
10
|
canStartPreviewPan,
|
|
@@ -13,10 +18,17 @@ import {
|
|
|
13
18
|
} from "./previewZoom";
|
|
14
19
|
import { RULER_GUTTER_PX, usePreviewGuidesStore } from "../editor/previewGuidesStore";
|
|
15
20
|
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
21
|
+
import { usePreviewFirstFrameTelemetry } from "../../player/hooks/usePreviewFirstFrameTelemetry";
|
|
16
22
|
interface NLEPreviewProps {
|
|
17
23
|
projectId: string;
|
|
18
24
|
iframeRef: RefObject<HTMLIFrameElement | null>;
|
|
19
25
|
onIframeLoad: () => void;
|
|
26
|
+
previewSlots: PreviewIframeSlot[];
|
|
27
|
+
onShadowIframeLoad: (gen: number) => void;
|
|
28
|
+
onShadowReadyChange: (gen: number, ready: boolean) => void;
|
|
29
|
+
onShadowError: (gen: number, message: string) => void;
|
|
30
|
+
setShadowIframeNode: (node: HTMLIFrameElement | null) => void;
|
|
31
|
+
resetPreviewSlots: () => void;
|
|
20
32
|
onCompositionLoadingChange?: (loading: boolean) => void;
|
|
21
33
|
portrait?: boolean;
|
|
22
34
|
directUrl?: string;
|
|
@@ -40,10 +52,15 @@ const ZOOM_HUD_TIMEOUT_MS = 1200;
|
|
|
40
52
|
const ZOOM_SETTLE_MS = 200;
|
|
41
53
|
const PREVIEW_STAGE_INSET_PX = 16;
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
// clip-path as well as visibility: the player's loading overlay sets its own
|
|
56
|
+
// visibility:visible and would otherwise paint over the live frame.
|
|
57
|
+
const SHADOW_IFRAME_STYLE: React.CSSProperties = {
|
|
58
|
+
position: "absolute",
|
|
59
|
+
inset: 0,
|
|
60
|
+
visibility: "hidden",
|
|
61
|
+
clipPath: "inset(100%)",
|
|
62
|
+
pointerEvents: "none",
|
|
63
|
+
};
|
|
47
64
|
|
|
48
65
|
function isPreviewAtFit(state: PreviewZoomState): boolean {
|
|
49
66
|
return (
|
|
@@ -64,27 +81,6 @@ function loadInitialZoom(): PreviewZoomState {
|
|
|
64
81
|
: DEFAULT_PREVIEW_ZOOM;
|
|
65
82
|
}
|
|
66
83
|
|
|
67
|
-
// fallow-ignore-next-line complexity
|
|
68
|
-
function readPreviewCompositionSize(
|
|
69
|
-
iframe: HTMLIFrameElement | null,
|
|
70
|
-
): PreviewCompositionSize | null {
|
|
71
|
-
try {
|
|
72
|
-
const doc = iframe?.contentDocument;
|
|
73
|
-
const root =
|
|
74
|
-
doc?.querySelector("[data-composition-id][data-width][data-height]") ??
|
|
75
|
-
doc?.querySelector("[data-width][data-height]");
|
|
76
|
-
if (!root) return null;
|
|
77
|
-
const width = Number.parseInt(root.getAttribute("data-width") ?? "", 10);
|
|
78
|
-
const height = Number.parseInt(root.getAttribute("data-height") ?? "", 10);
|
|
79
|
-
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
return { width, height };
|
|
83
|
-
} catch {
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
84
|
export function resolvePreviewStageSize(
|
|
89
85
|
viewportWidth: number,
|
|
90
86
|
viewportHeight: number,
|
|
@@ -122,6 +118,12 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
122
118
|
projectId,
|
|
123
119
|
iframeRef,
|
|
124
120
|
onIframeLoad,
|
|
121
|
+
previewSlots,
|
|
122
|
+
onShadowIframeLoad,
|
|
123
|
+
onShadowReadyChange,
|
|
124
|
+
onShadowError,
|
|
125
|
+
setShadowIframeNode,
|
|
126
|
+
resetPreviewSlots,
|
|
125
127
|
onCompositionLoadingChange,
|
|
126
128
|
portrait,
|
|
127
129
|
directUrl,
|
|
@@ -136,6 +138,17 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
136
138
|
useEffect(() => {
|
|
137
139
|
onStageRef?.(stageRef);
|
|
138
140
|
}, [onStageRef]);
|
|
141
|
+
|
|
142
|
+
// Composition switch: drop any in-flight shadow reload (skipped on first mount).
|
|
143
|
+
const previousActiveKeyRef = useRef(activeKey);
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
if (previousActiveKeyRef.current === activeKey) return;
|
|
146
|
+
previousActiveKeyRef.current = activeKey;
|
|
147
|
+
resetPreviewSlots();
|
|
148
|
+
}, [activeKey, resetPreviewSlots]);
|
|
149
|
+
|
|
150
|
+
const liveGenRef = useRef<number | null>(null);
|
|
151
|
+
const reportPreviewFirstFrame = usePreviewFirstFrameTelemetry(previewSlots);
|
|
139
152
|
const [compositionSize, setCompositionSize] = useState<PreviewCompositionSize | null>(null);
|
|
140
153
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
141
154
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
@@ -299,6 +312,18 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
299
312
|
}
|
|
300
313
|
}, [writeTransform]);
|
|
301
314
|
|
|
315
|
+
// A promotion does not re-fire Player.onLoad: re-sync the local iframe ref, size and zoom.
|
|
316
|
+
useEffect(() => {
|
|
317
|
+
const live = previewSlots.find((slot) => slot.role === "live");
|
|
318
|
+
if (!live || live.gen === liveGenRef.current) return;
|
|
319
|
+
const isPromotion = liveGenRef.current !== null;
|
|
320
|
+
liveGenRef.current = live.gen;
|
|
321
|
+
if (!isPromotion) return;
|
|
322
|
+
previewIframeRef.current = iframeRef.current;
|
|
323
|
+
updateCompositionSizeFromPreview();
|
|
324
|
+
applyInitialZoom();
|
|
325
|
+
}, [previewSlots, iframeRef, updateCompositionSizeFromPreview, applyInitialZoom]);
|
|
326
|
+
|
|
302
327
|
useEffect(() => {
|
|
303
328
|
const viewport = viewportRef.current;
|
|
304
329
|
if (!viewport) return;
|
|
@@ -469,25 +494,44 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
469
494
|
style={{ position: "absolute", inset: 0, zIndex: 0 }}
|
|
470
495
|
/>
|
|
471
496
|
)}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
497
|
+
{previewSlots.map((slot) =>
|
|
498
|
+
slot.role === "live" ? (
|
|
499
|
+
<Player
|
|
500
|
+
key={`${activeKey}-${slot.gen}`}
|
|
501
|
+
ref={setPreviewIframeRef}
|
|
502
|
+
projectId={directUrl ? undefined : projectId}
|
|
503
|
+
directUrl={directUrl}
|
|
504
|
+
onLoad={() => {
|
|
505
|
+
updateCompositionSizeFromPreview();
|
|
506
|
+
onIframeLoad();
|
|
507
|
+
applyInitialZoom();
|
|
508
|
+
}}
|
|
509
|
+
onCompositionLoadingChange={onCompositionLoadingChange}
|
|
510
|
+
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
|
|
511
|
+
portrait={portrait}
|
|
512
|
+
suppressLoadingOverlay={suppressLoadingOverlay}
|
|
513
|
+
style={
|
|
514
|
+
directUrl?.includes("/components/")
|
|
515
|
+
? { position: "absolute", inset: 0, zIndex: 1 }
|
|
516
|
+
: undefined
|
|
517
|
+
}
|
|
518
|
+
/>
|
|
519
|
+
) : (
|
|
520
|
+
// Loads hidden behind the live slot until promoted.
|
|
521
|
+
<Player
|
|
522
|
+
key={`${activeKey}-${slot.gen}`}
|
|
523
|
+
ref={setShadowIframeNode}
|
|
524
|
+
directUrl={slot.url}
|
|
525
|
+
onLoad={() => onShadowIframeLoad(slot.gen)}
|
|
526
|
+
onReadyToShowChange={(ready) => onShadowReadyChange(slot.gen, ready)}
|
|
527
|
+
onPainted={(details) => reportPreviewFirstFrame(slot, details)}
|
|
528
|
+
onPreviewError={(message) => onShadowError(slot.gen, message)}
|
|
529
|
+
portrait={portrait}
|
|
530
|
+
suppressLoadingOverlay
|
|
531
|
+
style={SHADOW_IFRAME_STYLE}
|
|
532
|
+
/>
|
|
533
|
+
),
|
|
534
|
+
)}
|
|
491
535
|
</div>
|
|
492
536
|
</div>
|
|
493
537
|
<div
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { useCallback
|
|
1
|
+
import { useCallback } from "react";
|
|
2
2
|
import { CaptionOverlay } from "../../captions/components/CaptionOverlay";
|
|
3
3
|
import { useCaptionStore } from "../../captions/store";
|
|
4
4
|
import { DomEditOverlay } from "../editor/DomEditOverlay";
|
|
5
5
|
import { TopologyLens } from "../editor/TopologyLens";
|
|
6
6
|
import { MotionPathOverlay } from "../editor/MotionPathOverlay";
|
|
7
7
|
import { SnapToolbar } from "../editor/SnapToolbar";
|
|
8
|
+
import { GridOverlay } from "../editor/GridOverlay";
|
|
8
9
|
import { usePreviewReadOnly } from "../editor/previewReadOnlyContext";
|
|
9
10
|
import { useCompositionDimensions } from "../../hooks/useCompositionDimensions";
|
|
10
11
|
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
useDomEditActionsContext,
|
|
13
14
|
useDomEditSelectionContext,
|
|
14
15
|
} from "../../contexts/DomEditContext";
|
|
15
|
-
import { readStudioUiPreferences } from "../../utils/studioUiPreferences";
|
|
16
16
|
import { readHfId, type DomEditSelection } from "../editor/domEditing";
|
|
17
17
|
import { buildStableSelector } from "../editor/domEditingDom";
|
|
18
18
|
import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
|
|
@@ -182,17 +182,6 @@ export function PreviewOverlays({
|
|
|
182
182
|
} = useDomEditActionsContext();
|
|
183
183
|
const mirrorZOrderToTimeline = useCanvasZOrderTimelineMirror();
|
|
184
184
|
|
|
185
|
-
// fallow-ignore-next-line complexity
|
|
186
|
-
const [snapPrefs, setSnapPrefs] = useState(() => {
|
|
187
|
-
const p = readStudioUiPreferences();
|
|
188
|
-
return {
|
|
189
|
-
snapEnabled: p.snapEnabled ?? true,
|
|
190
|
-
gridVisible: p.gridVisible ?? false,
|
|
191
|
-
gridSpacing: p.gridSpacing ?? 50,
|
|
192
|
-
snapToGrid: p.snapToGrid ?? false,
|
|
193
|
-
};
|
|
194
|
-
});
|
|
195
|
-
|
|
196
185
|
if (blockPreview) {
|
|
197
186
|
return (
|
|
198
187
|
<>
|
|
@@ -266,6 +255,7 @@ export function PreviewOverlays({
|
|
|
266
255
|
return (
|
|
267
256
|
<>
|
|
268
257
|
<TopologyLens iframeRef={previewIframeRef} activeCompositionPath={activeCompPath} />
|
|
258
|
+
<GridOverlay />
|
|
269
259
|
<DomEditOverlay
|
|
270
260
|
iframeRef={previewIframeRef}
|
|
271
261
|
activeCompositionPath={activeCompPath}
|
|
@@ -325,13 +315,11 @@ export function PreviewOverlays({
|
|
|
325
315
|
}),
|
|
326
316
|
}).catch(() => undefined);
|
|
327
317
|
}}
|
|
328
|
-
gridVisible={snapPrefs.gridVisible}
|
|
329
|
-
gridSpacing={snapPrefs.gridSpacing}
|
|
330
318
|
recordingState={recordingState}
|
|
331
319
|
onToggleRecording={onToggleRecording}
|
|
332
320
|
onMarqueeSelect={applyMarqueeSelection}
|
|
333
321
|
/>
|
|
334
|
-
<SnapToolbar
|
|
322
|
+
<SnapToolbar />
|
|
335
323
|
{!readOnly && (
|
|
336
324
|
<MotionPathOverlay
|
|
337
325
|
iframeRef={previewIframeRef}
|
|
@@ -7,6 +7,7 @@ import { usePreviewBlockDrop } from "./usePreviewBlockDrop";
|
|
|
7
7
|
import { useNLEContext } from "./NLEContext";
|
|
8
8
|
import { AssetPreviewOverlay } from "./AssetPreviewOverlay";
|
|
9
9
|
import { PreviewGuides } from "../editor/PreviewGuides";
|
|
10
|
+
import { PreviewOverlayProvider } from "../editor/PreviewOverlayProvider";
|
|
10
11
|
|
|
11
12
|
function subscribeFullscreen(cb: () => void) {
|
|
12
13
|
document.addEventListener("fullscreenchange", cb);
|
|
@@ -60,6 +61,12 @@ export function PreviewPane({
|
|
|
60
61
|
togglePlay,
|
|
61
62
|
seek,
|
|
62
63
|
onIframeLoad,
|
|
64
|
+
previewSlots,
|
|
65
|
+
onShadowIframeLoad,
|
|
66
|
+
onShadowReadyChange,
|
|
67
|
+
onShadowError,
|
|
68
|
+
setShadowIframeNode,
|
|
69
|
+
resetPreviewSlots,
|
|
63
70
|
compositionStack,
|
|
64
71
|
handleNavigateComposition,
|
|
65
72
|
setCompositionLoading,
|
|
@@ -131,6 +138,12 @@ export function PreviewPane({
|
|
|
131
138
|
projectId={projectId}
|
|
132
139
|
iframeRef={iframeRef}
|
|
133
140
|
onIframeLoad={onIframeLoad}
|
|
141
|
+
previewSlots={previewSlots}
|
|
142
|
+
onShadowIframeLoad={onShadowIframeLoad}
|
|
143
|
+
onShadowReadyChange={onShadowReadyChange}
|
|
144
|
+
onShadowError={onShadowError}
|
|
145
|
+
setShadowIframeNode={setShadowIframeNode}
|
|
146
|
+
resetPreviewSlots={resetPreviewSlots}
|
|
134
147
|
onCompositionLoadingChange={setCompositionLoading}
|
|
135
148
|
portrait={portrait}
|
|
136
149
|
directUrl={directUrl}
|
|
@@ -143,8 +156,10 @@ export function PreviewPane({
|
|
|
143
156
|
)}
|
|
144
157
|
<AssetPreviewOverlay />
|
|
145
158
|
</div>
|
|
146
|
-
<
|
|
147
|
-
|
|
159
|
+
<PreviewOverlayProvider>
|
|
160
|
+
<PreviewGuides />
|
|
161
|
+
{!isFullscreen && previewOverlay}
|
|
162
|
+
</PreviewOverlayProvider>
|
|
148
163
|
</div>
|
|
149
164
|
{/* Transport row: no own background or border — the controls sit flat on
|
|
150
165
|
the preview panel's surface (CapCut-style). */}
|
|
@@ -32,6 +32,7 @@ vi.mock("../editor/MotionPathOverlay", () => ({
|
|
|
32
32
|
MotionPathOverlay: () => <i data-testid="motion-path" />,
|
|
33
33
|
}));
|
|
34
34
|
vi.mock("../editor/SnapToolbar", () => ({ SnapToolbar: () => null }));
|
|
35
|
+
vi.mock("../editor/GridOverlay", () => ({ GridOverlay: () => null }));
|
|
35
36
|
|
|
36
37
|
let root: Root;
|
|
37
38
|
let host: HTMLDivElement;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildProjectApiPath } from "../../utils/projectRouting";
|
|
2
2
|
// Composition drill-down stack management for NLEContext/EditorShell
|
|
3
3
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
-
import { usePlayerStore } from "../../player";
|
|
4
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
5
5
|
import type { CompositionLevel } from "./CompositionBreadcrumb";
|
|
6
6
|
import { encodePreviewPath } from "../../player/components/thumbnailUtils";
|
|
7
7
|
|