@hyperframes/studio 0.7.71 → 0.7.73
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-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
- package/dist/assets/index-B37rWXo4.css +1 -0
- package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
- package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
- package/dist/assets/index-DXcLI2wu.js +428 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.html +2 -2
- package/dist/index.js +7762 -4261
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/StudioLeftSidebar.tsx +2 -3
- package/src/components/StudioRightPanel.tsx +10 -4
- package/src/components/editor/AnimationCard.test.tsx +100 -0
- package/src/components/editor/AnimationCard.tsx +5 -2
- package/src/components/editor/EaseCurveSection.test.tsx +348 -0
- package/src/components/editor/EaseCurveSection.tsx +444 -237
- package/src/components/editor/EaseParamFields.test.tsx +188 -0
- package/src/components/editor/EaseParamFields.tsx +246 -0
- package/src/components/editor/PropertyPanel.test.tsx +9 -7
- package/src/components/editor/PropertyPanelFlat.tsx +75 -88
- package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
- package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
- package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
- package/src/components/editor/easeCurveSvg.tsx +65 -0
- package/src/components/editor/easePresetLibrary.test.ts +114 -0
- package/src/components/editor/easePresetLibrary.ts +49 -0
- package/src/components/editor/gsapAnimationConstants.ts +15 -13
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
- package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
- package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
- package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
- package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
- package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
- package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
- package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
- package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
- package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
- package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
- package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
- package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
- package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
- package/src/components/editor/propertyPanelTypes.ts +13 -0
- package/src/components/editor/useColorGradingController.test.ts +331 -10
- package/src/components/editor/useColorGradingController.ts +57 -90
- package/src/components/editor/useColorGradingPreviews.ts +350 -0
- package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
- package/src/components/editor/useColorGradingScopes.ts +62 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/contexts/PanelLayoutContext.tsx +3 -6
- package/src/hooks/useBlockCatalog.ts +31 -13
- package/src/hooks/useBlockHandlers.ts +22 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
- package/src/hooks/useGsapScriptCommits.ts +14 -1
- package/src/hooks/usePanelLayout.test.ts +68 -1
- package/src/hooks/usePanelLayout.ts +72 -34
- package/src/icons/SystemIcons.tsx +4 -0
- package/src/player/components/PlayerControls.tsx +68 -229
- package/src/player/components/ShortcutsPanel.tsx +2 -4
- package/src/player/components/SpeedMenu.tsx +1 -2
- package/src/telemetry/events.test.ts +9 -0
- package/src/telemetry/events.ts +5 -0
- package/src/utils/blockInstaller.test.ts +67 -0
- package/src/utils/blockInstaller.ts +162 -109
- package/src/utils/studioUiPreferences.test.ts +5 -0
- package/src/utils/studioUiPreferences.ts +8 -0
- package/dist/assets/index-CSCh2Vrp.js +0 -428
- package/dist/assets/index-Ceyz8Qt2.css +0 -1
- package/src/player/components/PlayerControls.test.ts +0 -20
- package/src/player/components/useSeekBarDrag.ts +0 -169
|
@@ -13,30 +13,76 @@ export interface InitialPanelLayoutState {
|
|
|
13
13
|
rightPanelTab?: RightPanelTab | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
type PanelSide = "left" | "right";
|
|
17
|
+
|
|
16
18
|
function getInitialRightInspectorPanes(tab?: RightPanelTab | null): RightInspectorPanes {
|
|
17
19
|
if (tab === "layers") return { layers: true, design: false };
|
|
18
20
|
return { layers: false, design: true };
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
function getInitialPanelWidths(): { left: number; right: number } {
|
|
24
|
+
const viewportWidth = typeof window === "undefined" ? 1496 : window.innerWidth;
|
|
25
|
+
const preferences = readStudioUiPreferences();
|
|
26
|
+
const leftDefault = Math.max(240, Math.min(384, Math.round(viewportWidth * 0.257)));
|
|
27
|
+
const rightDefault = Math.max(320, Math.min(424, Math.round(viewportWidth * 0.284)));
|
|
28
|
+
return {
|
|
29
|
+
left: Math.max(
|
|
30
|
+
160,
|
|
31
|
+
Math.min(Math.floor(viewportWidth * 0.5), preferences.leftWidth ?? leftDefault),
|
|
32
|
+
),
|
|
33
|
+
right: Math.max(160, Math.min(600, preferences.rightWidth ?? rightDefault)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampPanelWidth(side: PanelSide, width: number): number {
|
|
38
|
+
const max = side === "left" ? Math.floor(window.innerWidth * 0.5) : 600;
|
|
39
|
+
return Math.max(160, Math.min(max, width));
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
22
|
-
const [
|
|
23
|
-
const [
|
|
43
|
+
const [initialPanelWidths] = useState(getInitialPanelWidths);
|
|
44
|
+
const [leftWidth, setLeftWidth] = useState(initialPanelWidths.left);
|
|
45
|
+
const [rightWidth, setRightWidth] = useState(initialPanelWidths.right);
|
|
46
|
+
const panelWidthsRef = useRef(initialPanelWidths);
|
|
24
47
|
const [leftCollapsed, setLeftCollapsed] = useState(
|
|
25
48
|
() => readStudioUiPreferences().leftCollapsed ?? false,
|
|
26
49
|
);
|
|
27
|
-
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ??
|
|
50
|
+
const [rightCollapsed, setRightCollapsed] = useState(initialState?.rightCollapsed ?? false);
|
|
28
51
|
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>(
|
|
29
|
-
initialState?.rightPanelTab ?? "
|
|
52
|
+
initialState?.rightPanelTab ?? "design",
|
|
30
53
|
);
|
|
31
54
|
const [rightInspectorPanes, setRightInspectorPanes] = useState<RightInspectorPanes>(() =>
|
|
32
55
|
getInitialRightInspectorPanes(initialState?.rightPanelTab),
|
|
33
56
|
);
|
|
34
57
|
const panelDragRef = useRef<{
|
|
35
|
-
side:
|
|
58
|
+
side: PanelSide;
|
|
36
59
|
startX: number;
|
|
37
60
|
startW: number;
|
|
38
61
|
} | null>(null);
|
|
39
62
|
|
|
63
|
+
const updatePanelWidth = useCallback((side: PanelSide, width: number) => {
|
|
64
|
+
const next = clampPanelWidth(side, width);
|
|
65
|
+
panelWidthsRef.current[side] = next;
|
|
66
|
+
if (side === "left") setLeftWidth(next);
|
|
67
|
+
else setRightWidth(next);
|
|
68
|
+
return next;
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const commitPanelWidth = useCallback(
|
|
72
|
+
(side: PanelSide, width: number) => {
|
|
73
|
+
const next = updatePanelWidth(side, Math.round(width));
|
|
74
|
+
writeStudioUiPreferences(side === "left" ? { leftWidth: next } : { rightWidth: next });
|
|
75
|
+
},
|
|
76
|
+
[updatePanelWidth],
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const adjustPanelWidth = useCallback(
|
|
80
|
+
(side: PanelSide, delta: number) => {
|
|
81
|
+
commitPanelWidth(side, panelWidthsRef.current[side] + delta);
|
|
82
|
+
},
|
|
83
|
+
[commitPanelWidth],
|
|
84
|
+
);
|
|
85
|
+
|
|
40
86
|
const toggleLeftSidebar = useCallback(() => {
|
|
41
87
|
setLeftCollapsed((collapsed) => {
|
|
42
88
|
writeStudioUiPreferences({ leftCollapsed: !collapsed });
|
|
@@ -45,38 +91,31 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
45
91
|
});
|
|
46
92
|
}, []);
|
|
47
93
|
|
|
48
|
-
const handlePanelResizeStart = useCallback(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
const handlePanelResizeStart = useCallback((side: PanelSide, e: React.PointerEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
97
|
+
panelDragRef.current = {
|
|
98
|
+
side,
|
|
99
|
+
startX: e.clientX,
|
|
100
|
+
startW: panelWidthsRef.current[side],
|
|
101
|
+
};
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const handlePanelResizeMove = useCallback(
|
|
105
|
+
(e: React.PointerEvent) => {
|
|
106
|
+
const drag = panelDragRef.current;
|
|
107
|
+
if (!drag) return;
|
|
108
|
+
const delta = e.clientX - drag.startX;
|
|
109
|
+
updatePanelWidth(drag.side, drag.startW + (drag.side === "left" ? delta : -delta));
|
|
57
110
|
},
|
|
58
|
-
[
|
|
111
|
+
[updatePanelWidth],
|
|
59
112
|
);
|
|
60
113
|
|
|
61
|
-
const handlePanelResizeMove = useCallback((e: React.PointerEvent) => {
|
|
62
|
-
const drag = panelDragRef.current;
|
|
63
|
-
if (!drag) return;
|
|
64
|
-
const delta = e.clientX - drag.startX;
|
|
65
|
-
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
|
66
|
-
const newW = Math.max(
|
|
67
|
-
160,
|
|
68
|
-
Math.min(
|
|
69
|
-
drag.side === "left" ? maxLeft : 600,
|
|
70
|
-
drag.startW + (drag.side === "left" ? delta : -delta),
|
|
71
|
-
),
|
|
72
|
-
);
|
|
73
|
-
if (drag.side === "left") setLeftWidth(newW);
|
|
74
|
-
else setRightWidth(newW);
|
|
75
|
-
}, []);
|
|
76
|
-
|
|
77
114
|
const handlePanelResizeEnd = useCallback(() => {
|
|
115
|
+
const side = panelDragRef.current?.side;
|
|
116
|
+
if (side) commitPanelWidth(side, panelWidthsRef.current[side]);
|
|
78
117
|
panelDragRef.current = null;
|
|
79
|
-
}, []);
|
|
118
|
+
}, [commitPanelWidth]);
|
|
80
119
|
|
|
81
120
|
const trackedSetRightPanelTab = useCallback(
|
|
82
121
|
(tab: RightPanelTab) => {
|
|
@@ -120,9 +159,8 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
|
|
120
159
|
|
|
121
160
|
return {
|
|
122
161
|
leftWidth,
|
|
123
|
-
setLeftWidth,
|
|
124
162
|
rightWidth,
|
|
125
|
-
|
|
163
|
+
adjustPanelWidth,
|
|
126
164
|
leftCollapsed,
|
|
127
165
|
setLeftCollapsed,
|
|
128
166
|
rightCollapsed,
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
Gear,
|
|
23
23
|
Scissors as PhScissors,
|
|
24
24
|
Link as PhLink,
|
|
25
|
+
Eyedropper as PhEyedropper,
|
|
26
|
+
Trash as PhTrash,
|
|
25
27
|
} from "@phosphor-icons/react";
|
|
26
28
|
import type { Icon as PhosphorIcon, IconProps as PhosphorIconProps } from "@phosphor-icons/react";
|
|
27
29
|
|
|
@@ -71,3 +73,5 @@ export const RotateCw = makeIcon(ArrowClockwise);
|
|
|
71
73
|
export const Settings = makeIcon(Gear);
|
|
72
74
|
export const Scissors = makeIcon(PhScissors);
|
|
73
75
|
export const Link = makeIcon(PhLink);
|
|
76
|
+
export const Eyedropper = makeIcon(PhEyedropper);
|
|
77
|
+
export const Trash = makeIcon(PhTrash);
|
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
import { useRef,
|
|
1
|
+
import { useRef, useEffect, memo } from "react";
|
|
2
2
|
import gsap from "gsap";
|
|
3
3
|
import { MorphSVGPlugin } from "gsap/MorphSVGPlugin";
|
|
4
|
-
import { formatFrameTime, formatTime
|
|
5
|
-
import { usePlayerStore } from "../store/playerStore";
|
|
4
|
+
import { formatFrameTime, formatTime } from "../lib/time";
|
|
5
|
+
import { liveTime, usePlayerStore } from "../store/playerStore";
|
|
6
6
|
import { trackStudioEvent } from "../../utils/studioTelemetry";
|
|
7
7
|
import { Tooltip } from "../../components/ui";
|
|
8
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
8
9
|
import { ShortcutsPanel } from "./ShortcutsPanel";
|
|
9
10
|
import { SpeedMenu } from "./SpeedMenu";
|
|
10
|
-
import { useSeekBarDrag, resolveSeekPercent } from "./useSeekBarDrag";
|
|
11
|
-
|
|
12
|
-
export { resolveSeekPercent };
|
|
13
11
|
|
|
14
12
|
/* ── Icon sub-components ─────────────────────────────────────────── */
|
|
15
13
|
|
|
@@ -78,10 +76,8 @@ const MuteButton = memo(function MuteButton({
|
|
|
78
76
|
disabled={controlsDisabled}
|
|
79
77
|
aria-label={label}
|
|
80
78
|
aria-pressed={audioMuted}
|
|
81
|
-
className={`h-7 w-7 flex-shrink-0
|
|
82
|
-
audioMuted
|
|
83
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
84
|
-
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
79
|
+
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors disabled:pointer-events-none disabled:opacity-30 ${
|
|
80
|
+
audioMuted ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
85
81
|
}`}
|
|
86
82
|
>
|
|
87
83
|
<svg
|
|
@@ -131,10 +127,8 @@ const LoopButton = memo(function LoopButton({
|
|
|
131
127
|
setLoopEnabled(!loopEnabled);
|
|
132
128
|
}}
|
|
133
129
|
disabled={disabled}
|
|
134
|
-
className={`h-7 w-7
|
|
135
|
-
loopEnabled
|
|
136
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
137
|
-
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
130
|
+
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-30 ${
|
|
131
|
+
loopEnabled ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
138
132
|
}`}
|
|
139
133
|
aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
|
|
140
134
|
aria-pressed={loopEnabled}
|
|
@@ -175,10 +169,8 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
175
169
|
trackStudioEvent("playback", { action: "fullscreen_toggle", active: !isFullscreen });
|
|
176
170
|
onToggleFullscreen();
|
|
177
171
|
}}
|
|
178
|
-
className={`h-7 w-7 flex-shrink-0
|
|
179
|
-
isFullscreen
|
|
180
|
-
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
|
181
|
-
: "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
|
|
172
|
+
className={`flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md transition-colors ${
|
|
173
|
+
isFullscreen ? "text-studio-accent" : "text-neutral-500 hover:text-neutral-200"
|
|
182
174
|
}`}
|
|
183
175
|
aria-label={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
|
184
176
|
>
|
|
@@ -214,118 +206,6 @@ const FullscreenButton = memo(function FullscreenButton({
|
|
|
214
206
|
);
|
|
215
207
|
});
|
|
216
208
|
|
|
217
|
-
/* ── Seek bar sub-component ──────────────────────────────────────── */
|
|
218
|
-
|
|
219
|
-
function SeekBarMarker({ position, duration }: { position: number; duration: number }) {
|
|
220
|
-
if (duration <= 0) return null;
|
|
221
|
-
return (
|
|
222
|
-
<div
|
|
223
|
-
className="absolute z-[3] pointer-events-none"
|
|
224
|
-
style={{
|
|
225
|
-
left: `${Math.min(100, (position / duration) * 100)}%`,
|
|
226
|
-
top: "50%",
|
|
227
|
-
transform: "translate(-50%, -50%)",
|
|
228
|
-
width: "2px",
|
|
229
|
-
height: "10px",
|
|
230
|
-
background: "#3CE6AC",
|
|
231
|
-
borderRadius: "1px",
|
|
232
|
-
}}
|
|
233
|
-
/>
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
function WorkAreaOverlay({
|
|
238
|
-
inPoint,
|
|
239
|
-
outPoint,
|
|
240
|
-
duration,
|
|
241
|
-
}: {
|
|
242
|
-
inPoint: number | null;
|
|
243
|
-
outPoint: number | null;
|
|
244
|
-
duration: number;
|
|
245
|
-
}) {
|
|
246
|
-
if ((inPoint === null && outPoint === null) || duration <= 0) return null;
|
|
247
|
-
return (
|
|
248
|
-
<>
|
|
249
|
-
<div
|
|
250
|
-
className="absolute top-0 bottom-0 pointer-events-none"
|
|
251
|
-
style={{
|
|
252
|
-
left: `${inPoint !== null ? Math.min(100, (inPoint / duration) * 100) : 0}%`,
|
|
253
|
-
right: `${outPoint !== null ? 100 - Math.min(100, (outPoint / duration) * 100) : 0}%`,
|
|
254
|
-
background: "rgba(60,230,172,0.15)",
|
|
255
|
-
}}
|
|
256
|
-
/>
|
|
257
|
-
{inPoint !== null && <SeekBarMarker position={inPoint} duration={duration} />}
|
|
258
|
-
{outPoint !== null && <SeekBarMarker position={outPoint} duration={duration} />}
|
|
259
|
-
</>
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const SeekBar = memo(function SeekBar({
|
|
264
|
-
disabled,
|
|
265
|
-
duration,
|
|
266
|
-
inPoint,
|
|
267
|
-
outPoint,
|
|
268
|
-
progressFillRef,
|
|
269
|
-
progressThumbRef,
|
|
270
|
-
seekBarRef,
|
|
271
|
-
sliderRef,
|
|
272
|
-
onPointerDown,
|
|
273
|
-
onKeyDown,
|
|
274
|
-
}: {
|
|
275
|
-
disabled: boolean;
|
|
276
|
-
duration: number;
|
|
277
|
-
inPoint: number | null;
|
|
278
|
-
outPoint: number | null;
|
|
279
|
-
progressFillRef: React.RefObject<HTMLDivElement | null>;
|
|
280
|
-
progressThumbRef: React.RefObject<HTMLDivElement | null>;
|
|
281
|
-
seekBarRef: React.RefObject<HTMLDivElement | null>;
|
|
282
|
-
sliderRef: React.RefObject<HTMLDivElement | null>;
|
|
283
|
-
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
|
|
284
|
-
onKeyDown: (e: React.KeyboardEvent) => void;
|
|
285
|
-
}) {
|
|
286
|
-
return (
|
|
287
|
-
<div
|
|
288
|
-
ref={(el) => {
|
|
289
|
-
(seekBarRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
290
|
-
(sliderRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
291
|
-
}}
|
|
292
|
-
role="slider"
|
|
293
|
-
tabIndex={disabled ? -1 : 0}
|
|
294
|
-
aria-label="Seek"
|
|
295
|
-
aria-disabled={disabled || undefined}
|
|
296
|
-
aria-valuemin={0}
|
|
297
|
-
aria-valuemax={Math.round(duration)}
|
|
298
|
-
aria-valuenow={0}
|
|
299
|
-
className={`min-w-[96px] flex-1 h-6 flex items-center group outline-none focus-visible:ring-1 focus-visible:ring-white/30 focus-visible:rounded ${
|
|
300
|
-
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
|
301
|
-
}`}
|
|
302
|
-
style={{ touchAction: "none" }}
|
|
303
|
-
onPointerDown={onPointerDown}
|
|
304
|
-
onKeyDown={onKeyDown}
|
|
305
|
-
>
|
|
306
|
-
<div
|
|
307
|
-
className="w-full rounded-full relative"
|
|
308
|
-
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
|
309
|
-
>
|
|
310
|
-
<WorkAreaOverlay inPoint={inPoint} outPoint={outPoint} duration={duration} />
|
|
311
|
-
<div
|
|
312
|
-
ref={progressFillRef}
|
|
313
|
-
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
|
314
|
-
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
|
315
|
-
/>
|
|
316
|
-
<div
|
|
317
|
-
ref={progressThumbRef}
|
|
318
|
-
className="absolute top-1/2 z-[4] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
|
|
319
|
-
style={{
|
|
320
|
-
background: "var(--hf-accent, #3CE6AC)",
|
|
321
|
-
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
|
322
|
-
}}
|
|
323
|
-
/>
|
|
324
|
-
</div>
|
|
325
|
-
</div>
|
|
326
|
-
);
|
|
327
|
-
});
|
|
328
|
-
|
|
329
209
|
/* ── Main component ──────────────────────────────────────────────── */
|
|
330
210
|
|
|
331
211
|
interface PlayerControlsProps {
|
|
@@ -359,12 +239,7 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
359
239
|
const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
|
|
360
240
|
const setTimeDisplayMode = usePlayerStore.getState().setTimeDisplayMode;
|
|
361
241
|
|
|
362
|
-
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
363
|
-
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
364
242
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
365
|
-
const seekBarRef = useRef<HTMLDivElement>(null);
|
|
366
|
-
const sliderRef = useRef<HTMLDivElement>(null);
|
|
367
|
-
const isDraggingRef = useRef(false);
|
|
368
243
|
const currentTimeRef = useRef(0);
|
|
369
244
|
const timeDisplayModeRef = useRef(timeDisplayMode);
|
|
370
245
|
timeDisplayModeRef.current = timeDisplayMode;
|
|
@@ -380,64 +255,29 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
380
255
|
timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
|
|
381
256
|
}, [duration, timeDisplayMode]);
|
|
382
257
|
|
|
383
|
-
|
|
384
|
-
{
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
duration,
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
const handleKeyDown = useCallback(
|
|
401
|
-
(e: React.KeyboardEvent) => {
|
|
402
|
-
if (disabled || !timelineReady || duration <= 0) return;
|
|
403
|
-
const step = e.shiftKey ? 10 : 1;
|
|
404
|
-
if (e.key === "ArrowLeft") {
|
|
405
|
-
e.preventDefault();
|
|
406
|
-
onSeek(stepFrameTime(currentTimeRef.current, -step));
|
|
407
|
-
} else if (e.key === "ArrowRight") {
|
|
408
|
-
e.preventDefault();
|
|
409
|
-
onSeek(Math.min(duration, stepFrameTime(currentTimeRef.current, step)));
|
|
410
|
-
}
|
|
411
|
-
},
|
|
412
|
-
[disabled, timelineReady, duration, onSeek],
|
|
413
|
-
);
|
|
258
|
+
useMountEffect(() => {
|
|
259
|
+
const updateTime = (time: number) => {
|
|
260
|
+
currentTimeRef.current = time;
|
|
261
|
+
if (!timeDisplayRef.current) return;
|
|
262
|
+
const currentDuration = durationRef.current;
|
|
263
|
+
timeDisplayRef.current.textContent =
|
|
264
|
+
timeDisplayModeRef.current === "frame"
|
|
265
|
+
? formatFrameTime(time, currentDuration)
|
|
266
|
+
: formatTime(time);
|
|
267
|
+
};
|
|
268
|
+
const unsubscribe = liveTime.subscribe(updateTime);
|
|
269
|
+
updateTime(usePlayerStore.getState().currentTime);
|
|
270
|
+
return unsubscribe;
|
|
271
|
+
});
|
|
414
272
|
|
|
415
273
|
return (
|
|
416
274
|
<div
|
|
417
|
-
|
|
418
|
-
// panel's surface — buttons carry their own chrome.
|
|
419
|
-
className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
|
|
275
|
+
className="grid h-10 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center px-3"
|
|
420
276
|
aria-disabled={disabled || undefined}
|
|
421
277
|
style={{
|
|
422
|
-
paddingBottom: "
|
|
278
|
+
paddingBottom: "env(safe-area-inset-bottom)",
|
|
423
279
|
}}
|
|
424
280
|
>
|
|
425
|
-
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
|
426
|
-
<button
|
|
427
|
-
type="button"
|
|
428
|
-
aria-label={isPlaying ? "Pause" : "Play"}
|
|
429
|
-
onClick={() => {
|
|
430
|
-
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
|
431
|
-
onTogglePlay();
|
|
432
|
-
}}
|
|
433
|
-
disabled={controlsDisabled}
|
|
434
|
-
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
435
|
-
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
436
|
-
>
|
|
437
|
-
<PlayPauseMorphIcon playing={isPlaying} />
|
|
438
|
-
</button>
|
|
439
|
-
</Tooltip>
|
|
440
|
-
|
|
441
281
|
<Tooltip
|
|
442
282
|
label={timeDisplayMode === "time" ? "Switch to frame display" : "Switch to time display"}
|
|
443
283
|
>
|
|
@@ -445,59 +285,58 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
445
285
|
type="button"
|
|
446
286
|
onClick={() => setTimeDisplayMode(timeDisplayMode === "time" ? "frame" : "time")}
|
|
447
287
|
disabled={disabled}
|
|
448
|
-
className="font-mono text-[11px] tabular-nums
|
|
449
|
-
style={{ color: "#A1A1AA", cursor: "pointer" }}
|
|
288
|
+
className="min-w-0 justify-self-start whitespace-nowrap font-mono text-[11px] tabular-nums text-neutral-400 transition-colors hover:text-neutral-200 disabled:pointer-events-none"
|
|
450
289
|
>
|
|
451
290
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
452
291
|
{timeDisplayMode === "time" ? (
|
|
453
292
|
<>
|
|
454
|
-
<span
|
|
455
|
-
<span
|
|
293
|
+
<span className="mx-0.5 text-neutral-700">/</span>
|
|
294
|
+
<span className="text-neutral-600">{formatTime(duration)}</span>
|
|
456
295
|
</>
|
|
457
296
|
) : null}
|
|
458
297
|
</button>
|
|
459
298
|
</Tooltip>
|
|
460
299
|
|
|
461
|
-
<
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
audioMuted={audioMuted}
|
|
476
|
-
controlsDisabled={controlsDisabled}
|
|
477
|
-
setAudioMuted={setAudioMuted}
|
|
478
|
-
/>
|
|
479
|
-
|
|
480
|
-
<SpeedMenu
|
|
481
|
-
playbackRate={playbackRate}
|
|
482
|
-
setPlaybackRate={setPlaybackRate}
|
|
483
|
-
disabled={disabled}
|
|
484
|
-
/>
|
|
485
|
-
|
|
486
|
-
<LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
|
|
487
|
-
|
|
488
|
-
{onToggleFullscreen && (
|
|
489
|
-
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
|
490
|
-
)}
|
|
300
|
+
<Tooltip label={isPlaying ? "Pause" : "Play"}>
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
aria-label={isPlaying ? "Pause" : "Play"}
|
|
304
|
+
onClick={() => {
|
|
305
|
+
trackStudioEvent("playback", { action: isPlaying ? "pause" : "play" });
|
|
306
|
+
onTogglePlay();
|
|
307
|
+
}}
|
|
308
|
+
disabled={controlsDisabled}
|
|
309
|
+
className="flex h-8 w-8 items-center justify-center justify-self-center rounded-md text-neutral-100 transition-colors hover:text-white disabled:pointer-events-none disabled:opacity-30"
|
|
310
|
+
>
|
|
311
|
+
<PlayPauseMorphIcon playing={isPlaying} />
|
|
312
|
+
</button>
|
|
313
|
+
</Tooltip>
|
|
491
314
|
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
315
|
+
<div className="flex min-w-0 items-center justify-self-end">
|
|
316
|
+
<MuteButton
|
|
317
|
+
audioMuted={audioMuted}
|
|
318
|
+
controlsDisabled={controlsDisabled}
|
|
319
|
+
setAudioMuted={setAudioMuted}
|
|
320
|
+
/>
|
|
321
|
+
<SpeedMenu
|
|
322
|
+
playbackRate={playbackRate}
|
|
323
|
+
setPlaybackRate={setPlaybackRate}
|
|
324
|
+
disabled={disabled}
|
|
325
|
+
/>
|
|
326
|
+
<LoopButton loopEnabled={loopEnabled} disabled={disabled} setLoopEnabled={setLoopEnabled} />
|
|
327
|
+
{onToggleFullscreen && (
|
|
328
|
+
<FullscreenButton isFullscreen={isFullscreen} onToggleFullscreen={onToggleFullscreen} />
|
|
329
|
+
)}
|
|
330
|
+
<ShortcutsPanel
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
duration={duration}
|
|
333
|
+
inPoint={inPoint}
|
|
334
|
+
outPoint={outPoint}
|
|
335
|
+
setInPoint={setInPoint}
|
|
336
|
+
setOutPoint={setOutPoint}
|
|
337
|
+
onSeek={onSeek}
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
501
340
|
</div>
|
|
502
341
|
);
|
|
503
342
|
});
|
|
@@ -153,10 +153,8 @@ export const ShortcutsPanel = memo(function ShortcutsPanel({
|
|
|
153
153
|
<button
|
|
154
154
|
type="button"
|
|
155
155
|
onClick={() => setShowShortcuts((v) => !v)}
|
|
156
|
-
className={`
|
|
157
|
-
showShortcuts
|
|
158
|
-
? "border-neutral-600 text-neutral-200 bg-neutral-800"
|
|
159
|
-
: "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
|
|
156
|
+
className={`flex h-7 w-7 items-center justify-center rounded-md transition-colors ${
|
|
157
|
+
showShortcuts ? "text-neutral-200" : "text-neutral-600 hover:text-neutral-300"
|
|
160
158
|
}`}
|
|
161
159
|
aria-label="Shortcuts and tools"
|
|
162
160
|
aria-expanded={showShortcuts}
|
|
@@ -41,8 +41,7 @@ export const SpeedMenu = memo(function SpeedMenu({
|
|
|
41
41
|
type="button"
|
|
42
42
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
43
43
|
disabled={disabled}
|
|
44
|
-
className="
|
|
45
|
-
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
44
|
+
className="h-7 w-8 rounded-md font-mono text-[10px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-200 disabled:opacity-30"
|
|
46
45
|
>
|
|
47
46
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
48
47
|
</button>
|
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
trackStudioRenderStart,
|
|
13
13
|
trackStudioRazorSplit,
|
|
14
14
|
trackStudioExpandedClipEdit,
|
|
15
|
+
trackStudioSegmentEaseEdit,
|
|
15
16
|
trackStudioFeedback,
|
|
16
17
|
} = await import("./events");
|
|
17
18
|
|
|
@@ -71,6 +72,14 @@ describe("studio telemetry events", () => {
|
|
|
71
72
|
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
|
72
73
|
});
|
|
73
74
|
|
|
75
|
+
it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => {
|
|
76
|
+
trackStudioSegmentEaseEdit({ ease: "power2.out" });
|
|
77
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", {
|
|
78
|
+
action: "commit",
|
|
79
|
+
ease: "power2.out",
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
74
83
|
it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
|
|
75
84
|
trackStudioFeedback({ rating });
|
|
76
85
|
|
package/src/telemetry/events.ts
CHANGED
|
@@ -63,6 +63,11 @@ export function trackStudioExpandedClipEdit(props: {
|
|
|
63
63
|
trackEvent("studio_expanded_clip_edit", { action: props.action });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Adoption signal for committing an edit to a segment's ease.
|
|
67
|
+
export function trackStudioSegmentEaseEdit(props: { ease: string }): void {
|
|
68
|
+
trackEvent("studio_segment_ease_edit", { action: "commit", ease: props.ease });
|
|
69
|
+
}
|
|
70
|
+
|
|
66
71
|
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
|
67
72
|
trackEvent("survey sent", {
|
|
68
73
|
$survey_id: "studio_experience",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { addBlockToProject } from "./blockInstaller";
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe("addBlockToProject", () => {
|
|
11
|
+
it("uses an explicit selected-media duration and track for a Registry overlay block", async () => {
|
|
12
|
+
const componentPath = "compositions/camcorder-hud.html";
|
|
13
|
+
const targetPath = "compositions/scene.html";
|
|
14
|
+
const files = new Map([
|
|
15
|
+
[
|
|
16
|
+
componentPath,
|
|
17
|
+
`<body><div data-composition-id="camcorder-hud"><div class="hud"></div></div></body>`,
|
|
18
|
+
],
|
|
19
|
+
[
|
|
20
|
+
targetPath,
|
|
21
|
+
`<main data-composition-id="scene" data-duration="10" data-width="1920" data-height="1080"></main>`,
|
|
22
|
+
],
|
|
23
|
+
]);
|
|
24
|
+
vi.stubGlobal(
|
|
25
|
+
"fetch",
|
|
26
|
+
vi.fn().mockResolvedValue({
|
|
27
|
+
ok: true,
|
|
28
|
+
json: async () => ({
|
|
29
|
+
written: [componentPath],
|
|
30
|
+
block: {
|
|
31
|
+
name: "camcorder-hud",
|
|
32
|
+
title: "Camcorder HUD",
|
|
33
|
+
description: "HUD",
|
|
34
|
+
type: "hyperframes:block",
|
|
35
|
+
files: [],
|
|
36
|
+
dimensions: { width: 1920, height: 1080 },
|
|
37
|
+
duration: 4,
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
const writeProjectFile = vi.fn(async (path: string, content: string) => {
|
|
43
|
+
files.set(path, content);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const result = await addBlockToProject({
|
|
47
|
+
projectId: "project",
|
|
48
|
+
blockName: "camcorder-hud",
|
|
49
|
+
activeCompPath: targetPath,
|
|
50
|
+
placement: { start: 2.5, duration: 4.25, track: 3 },
|
|
51
|
+
timelineElements: [],
|
|
52
|
+
readProjectFile: async (path) => files.get(path) ?? "",
|
|
53
|
+
writeProjectFile,
|
|
54
|
+
recordEdit: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
refreshFileTree: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
reloadPreview: vi.fn(),
|
|
57
|
+
showToast: vi.fn(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result?.block.name).toBe("camcorder-hud");
|
|
61
|
+
const source = files.get(targetPath);
|
|
62
|
+
expect(source).toContain('data-composition-src="compositions/camcorder-hud.html"');
|
|
63
|
+
expect(source).toContain('data-start="2.5"');
|
|
64
|
+
expect(source).toContain('data-duration="4.25"');
|
|
65
|
+
expect(source).toContain('data-track-index="3"');
|
|
66
|
+
});
|
|
67
|
+
});
|