@hyperframes/studio 0.6.29 → 0.6.31
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/index-BWBj8I6Q.css +1 -0
- package/dist/assets/index-DSLrl2tB.js +531 -0
- package/dist/assets/index-Do0kAMcy.js +115 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +13 -0
- package/src/components/StudioErrorBoundary.tsx +69 -0
- package/src/components/StudioHeader.tsx +15 -3
- package/src/components/editor/PropertyPanel.tsx +4 -1
- package/src/components/nle/CompositionBreadcrumb.tsx +12 -2
- package/src/components/nle/NLELayout.tsx +41 -6
- package/src/components/renders/RenderQueue.tsx +2 -0
- package/src/components/renders/useRenderQueue.ts +9 -0
- package/src/components/sidebar/LeftSidebar.tsx +2 -0
- package/src/contexts/FileManagerContext.tsx +3 -3
- package/src/hooks/useAppHotkeys.ts +18 -0
- package/src/hooks/useDomEditCommits.ts +52 -24
- package/src/hooks/useFileManager.ts +15 -13
- package/src/hooks/usePanelLayout.ts +11 -1
- package/src/hooks/useRenderClipContent.test.ts +50 -0
- package/src/hooks/useRenderClipContent.ts +23 -4
- package/src/hooks/useServerConnection.ts +11 -1
- package/src/main.tsx +36 -1
- package/src/player/components/CompositionThumbnail.tsx +10 -44
- package/src/player/components/PlayerControls.tsx +75 -3
- package/src/player/components/TimelineCanvas.tsx +9 -23
- package/src/player/components/TimelineClip.tsx +63 -67
- package/src/player/components/timelineTheme.ts +18 -48
- package/src/player/hooks/usePlaybackKeyboard.test.ts +55 -0
- package/src/player/hooks/usePlaybackKeyboard.ts +15 -0
- package/src/player/lib/mediaProbe.ts +20 -5
- package/src/styles/studio.css +9 -0
- package/src/telemetry/client.test.ts +100 -0
- package/src/telemetry/client.ts +145 -0
- package/src/telemetry/config.ts +78 -0
- package/src/telemetry/events.test.ts +57 -0
- package/src/telemetry/events.ts +27 -0
- package/src/telemetry/system.ts +48 -0
- package/src/utils/studioTelemetry.ts +128 -0
- package/dist/assets/index-C-kAqQVb.js +0 -362
- package/dist/assets/index-DVpLGNHi.css +0 -1
|
@@ -51,14 +51,14 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
51
51
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
52
52
|
|
|
53
53
|
const borderColor = isSelected
|
|
54
|
-
?
|
|
54
|
+
? trackStyle.accent + "60"
|
|
55
55
|
: isHovered
|
|
56
56
|
? theme.clipBorderHover
|
|
57
57
|
: theme.clipBorder;
|
|
58
58
|
const boxShadow = isDragging
|
|
59
59
|
? theme.clipShadowDragging
|
|
60
60
|
: isSelected
|
|
61
|
-
?
|
|
61
|
+
? `0 0 0 1px ${trackStyle.accent}40`
|
|
62
62
|
: isHovered
|
|
63
63
|
? theme.clipShadowHover
|
|
64
64
|
: theme.clipShadow;
|
|
@@ -77,20 +77,14 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
77
77
|
top: clipY,
|
|
78
78
|
bottom: clipY,
|
|
79
79
|
borderRadius: theme.clipRadius,
|
|
80
|
-
background:
|
|
81
|
-
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
|
82
|
-
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
|
83
|
-
backgroundImage:
|
|
84
|
-
isComposition && !hasCustomContent
|
|
85
|
-
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
|
86
|
-
: undefined,
|
|
80
|
+
background: trackStyle.clip,
|
|
87
81
|
border: `1px solid ${borderColor}`,
|
|
88
82
|
boxShadow,
|
|
89
|
-
transition:
|
|
90
|
-
"border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
|
|
83
|
+
transition: "border-color 100ms, box-shadow 100ms",
|
|
91
84
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
|
92
85
|
cursor: capabilities.canMove ? "grab" : "default",
|
|
93
86
|
transform: isDragging ? "translateY(-1px)" : undefined,
|
|
87
|
+
opacity: isDragging ? 0.92 : 1,
|
|
94
88
|
}}
|
|
95
89
|
title={
|
|
96
90
|
isComposition
|
|
@@ -103,78 +97,80 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
103
97
|
onClick={onClick}
|
|
104
98
|
onDoubleClick={onDoubleClick}
|
|
105
99
|
>
|
|
100
|
+
{/* Left accent stripe */}
|
|
106
101
|
<div
|
|
107
102
|
aria-hidden="true"
|
|
108
|
-
role="presentation"
|
|
109
|
-
onPointerDown={(e) => onResizeStart?.("start", e)}
|
|
110
103
|
style={{
|
|
111
104
|
position: "absolute",
|
|
112
105
|
left: 0,
|
|
113
106
|
top: 0,
|
|
114
107
|
bottom: 0,
|
|
115
|
-
width:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
background:
|
|
122
|
-
showHandles && capabilities.canTrimStart
|
|
123
|
-
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
|
124
|
-
: "transparent",
|
|
108
|
+
width: 3,
|
|
109
|
+
background: trackStyle.accent,
|
|
110
|
+
opacity: isSelected ? 0.7 : 0.3,
|
|
111
|
+
borderRadius: `${theme.clipRadius} 0 0 ${theme.clipRadius}`,
|
|
112
|
+
zIndex: 2,
|
|
113
|
+
pointerEvents: "none",
|
|
125
114
|
}}
|
|
126
|
-
|
|
115
|
+
/>
|
|
116
|
+
{/* Left trim handle */}
|
|
117
|
+
{showHandles && capabilities.canTrimStart && (
|
|
127
118
|
<div
|
|
119
|
+
aria-hidden="true"
|
|
120
|
+
onPointerDown={(e) => onResizeStart?.("start", e)}
|
|
128
121
|
style={{
|
|
129
122
|
position: "absolute",
|
|
130
|
-
left:
|
|
131
|
-
top:
|
|
132
|
-
bottom:
|
|
133
|
-
width:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
|
137
|
-
opacity: handleOpacity,
|
|
138
|
-
pointerEvents: "none",
|
|
123
|
+
left: 0,
|
|
124
|
+
top: 0,
|
|
125
|
+
bottom: 0,
|
|
126
|
+
width: 14,
|
|
127
|
+
cursor: "col-resize",
|
|
128
|
+
zIndex: 4,
|
|
139
129
|
}}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
background:
|
|
158
|
-
showHandles && capabilities.canTrimEnd
|
|
159
|
-
? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
|
160
|
-
: "transparent",
|
|
161
|
-
}}
|
|
162
|
-
>
|
|
130
|
+
>
|
|
131
|
+
<div
|
|
132
|
+
style={{
|
|
133
|
+
position: "absolute",
|
|
134
|
+
left: 4,
|
|
135
|
+
top: 6,
|
|
136
|
+
bottom: 6,
|
|
137
|
+
width: 2,
|
|
138
|
+
borderRadius: 1,
|
|
139
|
+
background: trackStyle.accent,
|
|
140
|
+
opacity: handleOpacity * 0.6,
|
|
141
|
+
}}
|
|
142
|
+
/>
|
|
143
|
+
</div>
|
|
144
|
+
)}
|
|
145
|
+
{/* Right trim handle */}
|
|
146
|
+
{showHandles && capabilities.canTrimEnd && (
|
|
163
147
|
<div
|
|
148
|
+
aria-hidden="true"
|
|
149
|
+
onPointerDown={(e) => onResizeStart?.("end", e)}
|
|
164
150
|
style={{
|
|
165
151
|
position: "absolute",
|
|
166
|
-
right:
|
|
167
|
-
top:
|
|
168
|
-
bottom:
|
|
169
|
-
width:
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
|
173
|
-
opacity: handleOpacity,
|
|
174
|
-
pointerEvents: "none",
|
|
152
|
+
right: 0,
|
|
153
|
+
top: 0,
|
|
154
|
+
bottom: 0,
|
|
155
|
+
width: 14,
|
|
156
|
+
cursor: "col-resize",
|
|
157
|
+
zIndex: 4,
|
|
175
158
|
}}
|
|
176
|
-
|
|
177
|
-
|
|
159
|
+
>
|
|
160
|
+
<div
|
|
161
|
+
style={{
|
|
162
|
+
position: "absolute",
|
|
163
|
+
right: 4,
|
|
164
|
+
top: 6,
|
|
165
|
+
bottom: 6,
|
|
166
|
+
width: 2,
|
|
167
|
+
borderRadius: 1,
|
|
168
|
+
background: trackStyle.accent,
|
|
169
|
+
opacity: handleOpacity * 0.6,
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
178
174
|
{children}
|
|
179
175
|
</div>
|
|
180
176
|
);
|
|
@@ -35,33 +35,13 @@ export interface TimelineTheme {
|
|
|
35
35
|
clipRadius: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
return {
|
|
44
|
-
clip: TIMELINE_TEAL,
|
|
45
|
-
accent: TIMELINE_TEAL,
|
|
46
|
-
label: TIMELINE_TEAL_LABEL,
|
|
47
|
-
iconBackground: TIMELINE_TEAL_ICON_BACKGROUND,
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
|
52
|
-
video: createTrackStyle(),
|
|
53
|
-
audio: createTrackStyle(),
|
|
54
|
-
img: createTrackStyle(),
|
|
55
|
-
div: createTrackStyle(),
|
|
56
|
-
span: createTrackStyle(),
|
|
57
|
-
p: createTrackStyle(),
|
|
58
|
-
h1: createTrackStyle(),
|
|
59
|
-
section: createTrackStyle(),
|
|
60
|
-
sfx: createTrackStyle(),
|
|
38
|
+
const TRACK_STYLE: TimelineTrackStyle = {
|
|
39
|
+
clip: "#1c2028",
|
|
40
|
+
accent: "#3CE6AC",
|
|
41
|
+
label: "#dde1e8",
|
|
42
|
+
iconBackground: "rgba(255,255,255,0.06)",
|
|
61
43
|
};
|
|
62
44
|
|
|
63
|
-
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
|
64
|
-
|
|
65
45
|
export const defaultTimelineTheme: TimelineTheme = {
|
|
66
46
|
shellBackground: "#0A0A0B",
|
|
67
47
|
shellBorder: "rgba(255,255,255,0.05)",
|
|
@@ -75,33 +55,23 @@ export const defaultTimelineTheme: TimelineTheme = {
|
|
|
75
55
|
tickText: "rgba(131,145,168,0.92)",
|
|
76
56
|
tickMajor: "rgba(255,255,255,0.13)",
|
|
77
57
|
tickMinor: "rgba(255,255,255,0.08)",
|
|
78
|
-
clipBackground: "
|
|
79
|
-
clipBackgroundActive: "
|
|
80
|
-
clipBorder: "rgba(255,255,255,0.
|
|
81
|
-
clipBorderHover: "rgba(255,255,255,0.
|
|
82
|
-
clipBorderActive: "rgba(255,255,255,0.
|
|
83
|
-
clipShadow: "
|
|
84
|
-
clipShadowHover: "
|
|
85
|
-
clipShadowActive:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"inset 0 1px 0 rgba(255,255,255,0.04), 0 18px 36px rgba(0,0,0,0.34), 0 8px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.04)",
|
|
89
|
-
handleColor: "rgba(255,255,255,0.11)",
|
|
58
|
+
clipBackground: "#141922",
|
|
59
|
+
clipBackgroundActive: "#181e28",
|
|
60
|
+
clipBorder: "rgba(255,255,255,0.10)",
|
|
61
|
+
clipBorderHover: "rgba(255,255,255,0.18)",
|
|
62
|
+
clipBorderActive: "rgba(255,255,255,0.24)",
|
|
63
|
+
clipShadow: "none",
|
|
64
|
+
clipShadowHover: "0 2px 8px rgba(0,0,0,0.2)",
|
|
65
|
+
clipShadowActive: "0 2px 8px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.04)",
|
|
66
|
+
clipShadowDragging: "0 8px 24px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.06)",
|
|
67
|
+
handleColor: "rgba(255,255,255,0.2)",
|
|
90
68
|
panelResizeSeam: "rgba(255,255,255,0.12)",
|
|
91
69
|
panelResizeActive: "rgba(255,255,255,0.24)",
|
|
92
|
-
clipRadius: "
|
|
70
|
+
clipRadius: "6px",
|
|
93
71
|
};
|
|
94
72
|
|
|
95
|
-
export function getTimelineTrackStyle(
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
normalized.startsWith("h") &&
|
|
99
|
-
normalized.length === 2 &&
|
|
100
|
-
"123456".includes(normalized[1] ?? "")
|
|
101
|
-
) {
|
|
102
|
-
return TRACK_STYLES.h1;
|
|
103
|
-
}
|
|
104
|
-
return TRACK_STYLES[normalized] ?? DEFAULT_TRACK_STYLE;
|
|
73
|
+
export function getTimelineTrackStyle(_tag: string): TimelineTrackStyle {
|
|
74
|
+
return TRACK_STYLE;
|
|
105
75
|
}
|
|
106
76
|
|
|
107
77
|
export function getClipHandleOpacity({
|
|
@@ -172,3 +172,58 @@ describe("usePlaybackKeyboard — keyboard layout independence (#834)", () => {
|
|
|
172
172
|
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
describe("usePlaybackKeyboard — mute & loop shortcuts (#905)", () => {
|
|
177
|
+
it("M toggles audioMuted", () => {
|
|
178
|
+
const { dispatch } = setupHook();
|
|
179
|
+
expect(usePlayerStore.getState().audioMuted).toBe(false);
|
|
180
|
+
|
|
181
|
+
act(() => {
|
|
182
|
+
dispatch(keydown({ code: "KeyM", key: "m" }));
|
|
183
|
+
});
|
|
184
|
+
expect(usePlayerStore.getState().audioMuted).toBe(true);
|
|
185
|
+
|
|
186
|
+
act(() => {
|
|
187
|
+
dispatch(keydown({ code: "KeyM", key: "m" }));
|
|
188
|
+
});
|
|
189
|
+
expect(usePlayerStore.getState().audioMuted).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("M does NOT toggle audioMuted above 1x playback (matches button gating)", () => {
|
|
193
|
+
const { dispatch } = setupHook();
|
|
194
|
+
usePlayerStore.setState({ playbackRate: 2, audioMuted: false });
|
|
195
|
+
|
|
196
|
+
act(() => {
|
|
197
|
+
dispatch(keydown({ code: "KeyM", key: "m" }));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(usePlayerStore.getState().audioMuted).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("Shift+L toggles loopEnabled without starting forward shuttle", () => {
|
|
204
|
+
const { dispatch, spies } = setupHook();
|
|
205
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
206
|
+
|
|
207
|
+
act(() => {
|
|
208
|
+
dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true }));
|
|
209
|
+
});
|
|
210
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
211
|
+
expect(spies.play).not.toHaveBeenCalled();
|
|
212
|
+
|
|
213
|
+
act(() => {
|
|
214
|
+
dispatch(keydown({ code: "KeyL", key: "L", shiftKey: true }));
|
|
215
|
+
});
|
|
216
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("Plain L still starts forward shuttle (regression guard)", () => {
|
|
220
|
+
const { dispatch, spies } = setupHook();
|
|
221
|
+
|
|
222
|
+
act(() => {
|
|
223
|
+
dispatch(keydown({ code: "KeyL", key: "l" }));
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(spies.play).toHaveBeenCalledTimes(1);
|
|
227
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -108,6 +108,21 @@ export function usePlaybackKeyboard({
|
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
110
110
|
if (e.repeat) return;
|
|
111
|
+
if (key === "m") {
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
const state = usePlayerStore.getState();
|
|
114
|
+
// Audio is force-muted above 1x playback — match the mute button's gating.
|
|
115
|
+
if (state.playbackRate <= 1) {
|
|
116
|
+
state.setAudioMuted(!state.audioMuted);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (key === "l" && e.shiftKey) {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
const state = usePlayerStore.getState();
|
|
123
|
+
state.setLoopEnabled(!state.loopEnabled);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
111
126
|
if (key === "k") {
|
|
112
127
|
e.preventDefault();
|
|
113
128
|
pause();
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
|
|
2
|
-
|
|
3
1
|
export interface MediaProbeResult {
|
|
4
2
|
duration: number;
|
|
5
3
|
width?: number;
|
|
@@ -11,6 +9,20 @@ export interface MediaProbeResult {
|
|
|
11
9
|
const cache = new Map<string, MediaProbeResult>();
|
|
12
10
|
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
|
13
11
|
|
|
12
|
+
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
|
|
13
|
+
|
|
14
|
+
async function loadMediabunny() {
|
|
15
|
+
if (mediabunnyModule === false) return null;
|
|
16
|
+
if (mediabunnyModule) return mediabunnyModule;
|
|
17
|
+
try {
|
|
18
|
+
mediabunnyModule = await import("mediabunny");
|
|
19
|
+
return mediabunnyModule;
|
|
20
|
+
} catch {
|
|
21
|
+
mediabunnyModule = false;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
14
26
|
function normalizeUrl(url: string): string {
|
|
15
27
|
try {
|
|
16
28
|
return new URL(url, window.location.href).href;
|
|
@@ -20,9 +32,12 @@ function normalizeUrl(url: string): string {
|
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
35
|
+
const mb = await loadMediabunny();
|
|
36
|
+
if (!mb) return null;
|
|
37
|
+
|
|
38
|
+
const input = new mb.Input({
|
|
39
|
+
source: new mb.UrlSource(url),
|
|
40
|
+
formats: mb.ALL_FORMATS,
|
|
26
41
|
});
|
|
27
42
|
try {
|
|
28
43
|
const duration = await input.getDurationFromMetadata();
|
package/src/styles/studio.css
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
// `shouldTrack()` reads `POSTHOG_API_KEY` from module-level const that's
|
|
6
|
+
// evaluated at module load time, so changing `import.meta.env` after import
|
|
7
|
+
// has no effect on the key. Each test resets module cache and re-imports.
|
|
8
|
+
|
|
9
|
+
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
|
10
|
+
|
|
11
|
+
function setKey(value: string | undefined): void {
|
|
12
|
+
if (value === undefined) {
|
|
13
|
+
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY;
|
|
14
|
+
} else {
|
|
15
|
+
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_POSTHOG_KEY = value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function setNoTelemetry(value: string | undefined): void {
|
|
20
|
+
if (value === undefined) {
|
|
21
|
+
delete (import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY;
|
|
22
|
+
} else {
|
|
23
|
+
(import.meta.env as Record<string, unknown>).VITE_HYPERFRAMES_NO_TELEMETRY = value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setDev(value: boolean): void {
|
|
28
|
+
(import.meta.env as { DEV: boolean }).DEV = value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function loadShouldTrack(): Promise<() => boolean> {
|
|
32
|
+
vi.resetModules();
|
|
33
|
+
const mod = await import("./client");
|
|
34
|
+
return mod.shouldTrack;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("studio client shouldTrack", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
setDev(false);
|
|
40
|
+
setKey("phc_test_key");
|
|
41
|
+
setNoTelemetry(undefined);
|
|
42
|
+
localStorage.clear();
|
|
43
|
+
vi.unstubAllGlobals();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns true when key is configured, not in dev mode, and no opt-outs", async () => {
|
|
47
|
+
const shouldTrack = await loadShouldTrack();
|
|
48
|
+
expect(shouldTrack()).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns false when API key does not start with phc_", async () => {
|
|
52
|
+
setKey("not_a_real_key");
|
|
53
|
+
const shouldTrack = await loadShouldTrack();
|
|
54
|
+
expect(shouldTrack()).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns false when API key is empty string", async () => {
|
|
58
|
+
setKey("");
|
|
59
|
+
const shouldTrack = await loadShouldTrack();
|
|
60
|
+
expect(shouldTrack()).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns false when user has opted out via localStorage", async () => {
|
|
64
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
65
|
+
const shouldTrack = await loadShouldTrack();
|
|
66
|
+
expect(shouldTrack()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns false when navigator.doNotTrack is '1'", async () => {
|
|
70
|
+
vi.stubGlobal("navigator", { ...navigator, doNotTrack: "1" });
|
|
71
|
+
const shouldTrack = await loadShouldTrack();
|
|
72
|
+
expect(shouldTrack()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY=1 at build time", async () => {
|
|
76
|
+
setNoTelemetry("1");
|
|
77
|
+
const shouldTrack = await loadShouldTrack();
|
|
78
|
+
expect(shouldTrack()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
|
|
82
|
+
setNoTelemetry("true");
|
|
83
|
+
const shouldTrack = await loadShouldTrack();
|
|
84
|
+
expect(shouldTrack()).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("returns false in vite dev mode", async () => {
|
|
88
|
+
setDev(true);
|
|
89
|
+
const shouldTrack = await loadShouldTrack();
|
|
90
|
+
expect(shouldTrack()).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("memoizes its decision after the first call", async () => {
|
|
94
|
+
const shouldTrack = await loadShouldTrack();
|
|
95
|
+
const first = shouldTrack();
|
|
96
|
+
// Flip an underlying input — memoized return must not change.
|
|
97
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
98
|
+
expect(shouldTrack()).toBe(first);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Lightweight PostHog client for the studio browser bundle.
|
|
3
|
+
// Mirrors `packages/cli/src/telemetry/client.ts` but uses fetch/sendBeacon.
|
|
4
|
+
// All calls are fire-and-forget; telemetry must never break the studio UI.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
import { getAnonymousId, hasShownNotice, isOptedOut, markNoticeShown } from "./config";
|
|
8
|
+
import { getBrowserSystemMeta } from "./system";
|
|
9
|
+
|
|
10
|
+
// HeyGen's PostHog project key — write-only, safe to embed in client code.
|
|
11
|
+
// OSS builds can override via `VITE_HYPERFRAMES_POSTHOG_KEY` at build time,
|
|
12
|
+
// or set it to an empty string to disable telemetry entirely.
|
|
13
|
+
const POSTHOG_API_KEY =
|
|
14
|
+
(import.meta.env.VITE_HYPERFRAMES_POSTHOG_KEY as string | undefined) ??
|
|
15
|
+
"phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
|
16
|
+
const POSTHOG_HOST =
|
|
17
|
+
(import.meta.env.VITE_HYPERFRAMES_POSTHOG_HOST as string | undefined) ??
|
|
18
|
+
"https://us.i.posthog.com";
|
|
19
|
+
const FLUSH_INTERVAL_MS = 1_000;
|
|
20
|
+
|
|
21
|
+
type EventProperties = Record<string, string | number | boolean | undefined>;
|
|
22
|
+
|
|
23
|
+
interface QueuedEvent {
|
|
24
|
+
event: string;
|
|
25
|
+
properties: EventProperties;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let eventQueue: QueuedEvent[] = [];
|
|
30
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
let telemetryEnabled: boolean | null = null;
|
|
32
|
+
|
|
33
|
+
function isDoNotTrackOn(): boolean {
|
|
34
|
+
return typeof navigator !== "undefined" && navigator.doNotTrack === "1";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isApiKeyConfigured(): boolean {
|
|
38
|
+
return POSTHOG_API_KEY.startsWith("phc_");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
|
|
42
|
+
// opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
|
|
43
|
+
// bundle the same way. Vite injects it at build time. Accepts "1" or "true".
|
|
44
|
+
function isBuildTimeOptOut(): boolean {
|
|
45
|
+
const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
|
|
46
|
+
return v === "1" || v === "true";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `import.meta.env.DEV` is true under `vite dev` / `vite preview`. Auto-suppress
|
|
50
|
+
// so developers running `hyperframes preview` don't pollute production telemetry.
|
|
51
|
+
function isViteDevMode(): boolean {
|
|
52
|
+
return import.meta.env.DEV === true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function shouldTrack(): boolean {
|
|
56
|
+
if (telemetryEnabled !== null) return telemetryEnabled;
|
|
57
|
+
telemetryEnabled =
|
|
58
|
+
isApiKeyConfigured() &&
|
|
59
|
+
!isBuildTimeOptOut() &&
|
|
60
|
+
!isViteDevMode() &&
|
|
61
|
+
!isOptedOut() &&
|
|
62
|
+
!isDoNotTrackOn();
|
|
63
|
+
return telemetryEnabled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function trackEvent(event: string, properties: EventProperties = {}): void {
|
|
67
|
+
if (!shouldTrack()) return;
|
|
68
|
+
|
|
69
|
+
const sys = getBrowserSystemMeta();
|
|
70
|
+
eventQueue.push({
|
|
71
|
+
event,
|
|
72
|
+
properties: { ...properties, ...sys },
|
|
73
|
+
timestamp: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (flushTimer === null) {
|
|
77
|
+
flushTimer = setTimeout(() => {
|
|
78
|
+
flushTimer = null;
|
|
79
|
+
flush();
|
|
80
|
+
}, FLUSH_INTERVAL_MS);
|
|
81
|
+
}
|
|
82
|
+
showNoticeOnce();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fire-and-forget: the queue is cleared before `send()` resolves, so a network
|
|
86
|
+
// failure drops the batch rather than retrying. Matches the CLI client's
|
|
87
|
+
// design. Do NOT add retry logic here — a retry without cross-batch dedup
|
|
88
|
+
// would risk double-counting events on transient PostHog 5xx responses.
|
|
89
|
+
function flush(): void {
|
|
90
|
+
if (eventQueue.length === 0) return;
|
|
91
|
+
const distinctId = getAnonymousId();
|
|
92
|
+
const batch = eventQueue.map((e) => ({
|
|
93
|
+
event: e.event,
|
|
94
|
+
// $ip: null tells PostHog to not record the request IP.
|
|
95
|
+
properties: { ...e.properties, $ip: null },
|
|
96
|
+
distinct_id: distinctId,
|
|
97
|
+
timestamp: e.timestamp,
|
|
98
|
+
}));
|
|
99
|
+
eventQueue = [];
|
|
100
|
+
send(`${POSTHOG_HOST}/batch/`, JSON.stringify({ api_key: POSTHOG_API_KEY, batch }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function send(url: string, payload: string): void {
|
|
104
|
+
// Prefer fetch with keepalive (survives page navigation). sendBeacon is a
|
|
105
|
+
// fallback for older runtimes where fetch isn't available.
|
|
106
|
+
try {
|
|
107
|
+
void fetch(url, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: payload,
|
|
111
|
+
keepalive: true,
|
|
112
|
+
}).catch(() => {
|
|
113
|
+
/* silent */
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
} catch {
|
|
117
|
+
/* fall through */
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
navigator.sendBeacon(url, new Blob([payload], { type: "application/json" }));
|
|
121
|
+
} catch {
|
|
122
|
+
/* silent */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function showNoticeOnce(): void {
|
|
127
|
+
if (hasShownNotice()) return;
|
|
128
|
+
markNoticeShown();
|
|
129
|
+
// eslint-disable-next-line no-console
|
|
130
|
+
console.info(
|
|
131
|
+
"%c[HyperFrames]%c Anonymous studio usage analytics enabled. " +
|
|
132
|
+
"Disable: localStorage.setItem('hyperframes-studio:telemetryDisabled','1') (then reload).",
|
|
133
|
+
"color:#7c3aed;font-weight:bold",
|
|
134
|
+
"color:inherit",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Flush queued events when the tab is being hidden or closed so tail events
|
|
139
|
+
// (e.g. a render_start fired moments before the user navigates away) aren't lost.
|
|
140
|
+
if (typeof window !== "undefined") {
|
|
141
|
+
window.addEventListener("pagehide", () => flush(), { capture: true });
|
|
142
|
+
window.addEventListener("visibilitychange", () => {
|
|
143
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") flush();
|
|
144
|
+
});
|
|
145
|
+
}
|