@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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// LocalStorage-backed config for studio telemetry.
|
|
3
|
+
// Anonymous ID + opt-out flag are stored per-browser-profile.
|
|
4
|
+
// Users opt out via DevTools:
|
|
5
|
+
// localStorage.setItem('hyperframes-studio:telemetryDisabled','1')
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const ANON_ID_KEY = "hyperframes-studio:anonymousId";
|
|
9
|
+
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
|
10
|
+
const NOTICE_KEY = "hyperframes-studio:telemetryNoticeShown";
|
|
11
|
+
|
|
12
|
+
function safeLocalStorage(): Storage | null {
|
|
13
|
+
try {
|
|
14
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newAnonymousId(): string {
|
|
21
|
+
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
|
22
|
+
return `anon-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getAnonymousId(): string {
|
|
26
|
+
const ls = safeLocalStorage();
|
|
27
|
+
if (!ls) return "anonymous";
|
|
28
|
+
const existing = ls.getItem(ANON_ID_KEY);
|
|
29
|
+
if (existing) return existing;
|
|
30
|
+
const id = newAnonymousId();
|
|
31
|
+
try {
|
|
32
|
+
ls.setItem(ANON_ID_KEY, id);
|
|
33
|
+
} catch {
|
|
34
|
+
/* private browsing / quota — return the in-memory ID for this session */
|
|
35
|
+
}
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isOptedOut(): boolean {
|
|
40
|
+
return safeLocalStorage()?.getItem(OPT_OUT_KEY) === "1";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hasShownNotice(): boolean {
|
|
44
|
+
return safeLocalStorage()?.getItem(NOTICE_KEY) === "1";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function markNoticeShown(): void {
|
|
48
|
+
try {
|
|
49
|
+
safeLocalStorage()?.setItem(NOTICE_KEY, "1");
|
|
50
|
+
} catch {
|
|
51
|
+
/* ignore */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Session-scoped (cleared when the tab closes) so HMR remounts and
|
|
56
|
+
// route-level remounts within one tab don't refire `studio_session_start`.
|
|
57
|
+
// Uses sessionStorage directly because the dedupe is per-tab, not per-browser.
|
|
58
|
+
const SESSION_FIRED_KEY = "hyperframes-studio:sessionStartFired";
|
|
59
|
+
|
|
60
|
+
function safeSessionStorage(): Storage | null {
|
|
61
|
+
try {
|
|
62
|
+
return typeof sessionStorage === "undefined" ? null : sessionStorage;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hasFiredSessionStart(): boolean {
|
|
69
|
+
return safeSessionStorage()?.getItem(SESSION_FIRED_KEY) === "1";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function markSessionStartFired(): void {
|
|
73
|
+
try {
|
|
74
|
+
safeSessionStorage()?.setItem(SESSION_FIRED_KEY, "1");
|
|
75
|
+
} catch {
|
|
76
|
+
/* ignore */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock client.trackEvent so we can assert event names and payloads without
|
|
4
|
+
// firing network requests or relying on memoized shouldTrack() state.
|
|
5
|
+
const trackEvent = vi.fn();
|
|
6
|
+
vi.mock("./client", () => ({
|
|
7
|
+
trackEvent: (...args: unknown[]) => trackEvent(...args),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const { trackStudioSessionStart, trackStudioRenderStart } = await import("./events");
|
|
11
|
+
|
|
12
|
+
describe("studio telemetry events", () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
trackEvent.mockClear();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("trackStudioSessionStart emits 'studio_session_start' with has_project", () => {
|
|
18
|
+
trackStudioSessionStart({ has_project: true });
|
|
19
|
+
expect(trackEvent).toHaveBeenCalledOnce();
|
|
20
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("trackStudioSessionStart preserves false for has_project (scratch open)", () => {
|
|
24
|
+
trackStudioSessionStart({ has_project: false });
|
|
25
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_session_start", { has_project: false });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("trackStudioRenderStart emits 'studio_render_start' with all render opts", () => {
|
|
29
|
+
trackStudioRenderStart({
|
|
30
|
+
fps: 30,
|
|
31
|
+
quality: "standard",
|
|
32
|
+
format: "mp4",
|
|
33
|
+
resolution: "landscape",
|
|
34
|
+
composition: "intro.html",
|
|
35
|
+
});
|
|
36
|
+
expect(trackEvent).toHaveBeenCalledOnce();
|
|
37
|
+
expect(trackEvent).toHaveBeenCalledWith("studio_render_start", {
|
|
38
|
+
fps: 30,
|
|
39
|
+
quality: "standard",
|
|
40
|
+
format: "mp4",
|
|
41
|
+
resolution: "landscape",
|
|
42
|
+
composition: "intro.html",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("trackStudioRenderStart leaves optional fields undefined when omitted", () => {
|
|
47
|
+
trackStudioRenderStart({ fps: 60, quality: "high", format: "webm" });
|
|
48
|
+
const payload = trackEvent.mock.calls[0][1];
|
|
49
|
+
expect(payload).toEqual({
|
|
50
|
+
fps: 60,
|
|
51
|
+
quality: "high",
|
|
52
|
+
format: "webm",
|
|
53
|
+
resolution: undefined,
|
|
54
|
+
composition: undefined,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { trackEvent } from "./client";
|
|
2
|
+
|
|
3
|
+
// Studio frontend events. The corresponding `render_complete` / `render_error`
|
|
4
|
+
// events are emitted server-side by `packages/cli/src/server/studioServer.ts`
|
|
5
|
+
// with `source: "studio"` — keeping rich perf data on a single unified event.
|
|
6
|
+
|
|
7
|
+
export function trackStudioSessionStart(props: { has_project: boolean }): void {
|
|
8
|
+
trackEvent("studio_session_start", {
|
|
9
|
+
has_project: props.has_project,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function trackStudioRenderStart(props: {
|
|
14
|
+
fps: number;
|
|
15
|
+
quality: string;
|
|
16
|
+
format: string;
|
|
17
|
+
resolution?: string;
|
|
18
|
+
composition?: string;
|
|
19
|
+
}): void {
|
|
20
|
+
trackEvent("studio_render_start", {
|
|
21
|
+
fps: props.fps,
|
|
22
|
+
quality: props.quality,
|
|
23
|
+
format: props.format,
|
|
24
|
+
resolution: props.resolution,
|
|
25
|
+
composition: props.composition,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Browser metadata attached to every studio telemetry event.
|
|
3
|
+
// Mirrors `packages/cli/src/telemetry/system.ts` but uses browser APIs.
|
|
4
|
+
// No PII — only environment characteristics useful for product analytics.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface BrowserSystemMeta {
|
|
8
|
+
user_agent: string;
|
|
9
|
+
language: string;
|
|
10
|
+
screen_width: number;
|
|
11
|
+
screen_height: number;
|
|
12
|
+
device_pixel_ratio: number;
|
|
13
|
+
timezone_offset_minutes: number;
|
|
14
|
+
is_mobile: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EMPTY_META: BrowserSystemMeta = {
|
|
18
|
+
user_agent: "",
|
|
19
|
+
language: "",
|
|
20
|
+
screen_width: 0,
|
|
21
|
+
screen_height: 0,
|
|
22
|
+
device_pixel_ratio: 0,
|
|
23
|
+
timezone_offset_minutes: 0,
|
|
24
|
+
is_mobile: false,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let cached: BrowserSystemMeta | null = null;
|
|
28
|
+
|
|
29
|
+
export function getBrowserSystemMeta(): BrowserSystemMeta {
|
|
30
|
+
if (cached) return cached;
|
|
31
|
+
// SSR / no-DOM: return zeroed meta. Cheap to detect once at module load.
|
|
32
|
+
if (typeof navigator === "undefined" || typeof window === "undefined") {
|
|
33
|
+
cached = EMPTY_META;
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
const ua = navigator.userAgent;
|
|
37
|
+
const screen = window.screen;
|
|
38
|
+
cached = {
|
|
39
|
+
user_agent: ua,
|
|
40
|
+
language: navigator.language,
|
|
41
|
+
screen_width: screen.width,
|
|
42
|
+
screen_height: screen.height,
|
|
43
|
+
device_pixel_ratio: window.devicePixelRatio,
|
|
44
|
+
timezone_offset_minutes: new Date().getTimezoneOffset(),
|
|
45
|
+
is_mobile: /Android|iPhone|iPad/i.test(ua),
|
|
46
|
+
};
|
|
47
|
+
return cached;
|
|
48
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// PostHog public ingest key — write-only, safe to ship in the client bundle
|
|
2
|
+
const POSTHOG_API_KEY = "phc_zjjbX0PnWxERXrMHhkEJWj9A9BhGVLRReICgsfTMmpx";
|
|
3
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
4
|
+
const FLUSH_INTERVAL_MS = 30_000;
|
|
5
|
+
const FLUSH_TIMEOUT_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
interface EventProperties {
|
|
8
|
+
[key: string]: string | number | boolean | null | undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface QueuedEvent {
|
|
12
|
+
event: string;
|
|
13
|
+
properties: EventProperties;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let queue: QueuedEvent[] = [];
|
|
18
|
+
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
19
|
+
let distinctId: string | null = null;
|
|
20
|
+
|
|
21
|
+
function getDistinctId(): string {
|
|
22
|
+
if (distinctId) return distinctId;
|
|
23
|
+
try {
|
|
24
|
+
const stored = localStorage.getItem("hf-studio-anon-id");
|
|
25
|
+
if (stored) {
|
|
26
|
+
distinctId = stored;
|
|
27
|
+
return stored;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// localStorage may be unavailable
|
|
31
|
+
}
|
|
32
|
+
distinctId = crypto.randomUUID();
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem("hf-studio-anon-id", distinctId);
|
|
35
|
+
} catch {
|
|
36
|
+
// best-effort persistence
|
|
37
|
+
}
|
|
38
|
+
return distinctId;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isEnabled(): boolean {
|
|
42
|
+
try {
|
|
43
|
+
return localStorage.getItem("hf-studio-telemetry-opt-out") !== "1";
|
|
44
|
+
} catch {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getSessionProperties(): EventProperties {
|
|
50
|
+
return {
|
|
51
|
+
studio_version: typeof __STUDIO_VERSION__ !== "undefined" ? __STUDIO_VERSION__ : "dev",
|
|
52
|
+
screen_width: window.screen?.width,
|
|
53
|
+
screen_height: window.screen?.height,
|
|
54
|
+
viewport_width: window.innerWidth,
|
|
55
|
+
viewport_height: window.innerHeight,
|
|
56
|
+
user_agent: navigator.userAgent,
|
|
57
|
+
url_hash: location.hash.replace(/#project\//, ""),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare const __STUDIO_VERSION__: string;
|
|
62
|
+
|
|
63
|
+
export function trackStudioEvent(event: string, properties: EventProperties = {}): void {
|
|
64
|
+
if (!isEnabled()) return;
|
|
65
|
+
|
|
66
|
+
queue.push({
|
|
67
|
+
event: `studio:${event}`,
|
|
68
|
+
properties: { ...getSessionProperties(), ...properties },
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!flushTimer) {
|
|
73
|
+
flushTimer = setInterval(flushEvents, FLUSH_INTERVAL_MS);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function flushEvents(): Promise<void> {
|
|
78
|
+
if (queue.length === 0) return;
|
|
79
|
+
|
|
80
|
+
const batch = queue.map((e) => ({
|
|
81
|
+
event: e.event,
|
|
82
|
+
properties: { ...e.properties, $ip: null },
|
|
83
|
+
distinct_id: getDistinctId(),
|
|
84
|
+
timestamp: e.timestamp,
|
|
85
|
+
}));
|
|
86
|
+
queue = [];
|
|
87
|
+
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timeout = setTimeout(() => controller.abort(), FLUSH_TIMEOUT_MS);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
await fetch(`${POSTHOG_HOST}/batch/`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ api_key: POSTHOG_API_KEY, batch }),
|
|
96
|
+
signal: controller.signal,
|
|
97
|
+
});
|
|
98
|
+
} catch {
|
|
99
|
+
// Telemetry must never break the studio
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timeout);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (typeof window !== "undefined") {
|
|
106
|
+
window.addEventListener("visibilitychange", () => {
|
|
107
|
+
if (document.visibilityState === "hidden") {
|
|
108
|
+
if (flushTimer) {
|
|
109
|
+
clearInterval(flushTimer);
|
|
110
|
+
flushTimer = null;
|
|
111
|
+
}
|
|
112
|
+
if (queue.length === 0) return;
|
|
113
|
+
const batch = queue.map((e) => ({
|
|
114
|
+
event: e.event,
|
|
115
|
+
properties: { ...e.properties, $ip: null },
|
|
116
|
+
distinct_id: getDistinctId(),
|
|
117
|
+
timestamp: e.timestamp,
|
|
118
|
+
}));
|
|
119
|
+
queue = [];
|
|
120
|
+
const body = JSON.stringify({ api_key: POSTHOG_API_KEY, batch });
|
|
121
|
+
try {
|
|
122
|
+
navigator.sendBeacon(`${POSTHOG_HOST}/batch/`, body);
|
|
123
|
+
} catch {
|
|
124
|
+
// best-effort
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|