@hyperframes/studio 0.6.52 → 0.6.53
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-B2mn12z0.css +1 -0
- package/dist/assets/{index-Bvy50smZ.js → index-CZNoIjSE.js} +32 -32
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/StudioFeedbackBar.tsx +208 -0
- package/src/components/StudioPreviewArea.tsx +97 -92
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +142 -0
- package/src/player/hooks/useTimelinePlayer.ts +2 -1
- package/src/telemetry/events.ts +32 -0
- package/dist/assets/index-SKRp8mGz.css +0 -1
package/dist/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
7
7
|
<title>HyperFrames Studio</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-CZNoIjSE.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B2mn12z0.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.53",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
"@codemirror/view": "6.40.0",
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"mediabunny": "^1.45.3",
|
|
34
|
-
"@hyperframes/core": "0.6.
|
|
35
|
-
"@hyperframes/player": "0.6.
|
|
34
|
+
"@hyperframes/core": "0.6.53",
|
|
35
|
+
"@hyperframes/player": "0.6.53"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@types/react": "19",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"vite": "^6.4.2",
|
|
47
47
|
"vitest": "^3.2.4",
|
|
48
48
|
"zustand": "^5.0.0",
|
|
49
|
-
"@hyperframes/producer": "0.6.
|
|
49
|
+
"@hyperframes/producer": "0.6.53"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"react": "19",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import { trackStudioFeedback } from "../telemetry/events";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_FEEDBACK_INTERVAL = 10;
|
|
5
|
+
const AUTO_DISMISS_MS = 20_000;
|
|
6
|
+
|
|
7
|
+
function isFeedbackDisabled(): boolean {
|
|
8
|
+
try {
|
|
9
|
+
return import.meta.env.VITE_HYPERFRAMES_NO_FEEDBACK === "1";
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// fallow-ignore-next-line complexity
|
|
16
|
+
function getFeedbackInterval(): number {
|
|
17
|
+
try {
|
|
18
|
+
const v = import.meta.env.VITE_HYPERFRAMES_FEEDBACK_INTERVAL as string | undefined;
|
|
19
|
+
if (v) {
|
|
20
|
+
const n = parseInt(v, 10);
|
|
21
|
+
if (Number.isFinite(n) && n > 0) return n;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
// import.meta.env unavailable
|
|
25
|
+
}
|
|
26
|
+
return DEFAULT_FEEDBACK_INTERVAL;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STORAGE_KEYS = {
|
|
30
|
+
sessionCount: "hyperframes-studio:feedbackSessionCount",
|
|
31
|
+
lastPromptedAt: "hyperframes-studio:feedbackLastPromptedAt",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
// fallow-ignore-next-line complexity
|
|
35
|
+
function shouldShowFeedback(): boolean {
|
|
36
|
+
if (isFeedbackDisabled()) return false;
|
|
37
|
+
try {
|
|
38
|
+
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
|
|
39
|
+
const lastAt = parseInt(localStorage.getItem(STORAGE_KEYS.lastPromptedAt) || "0", 10) || 0;
|
|
40
|
+
return count - lastAt >= getFeedbackInterval();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SESSION_COUNTED_KEY = "hyperframes-studio:feedbackSessionCounted";
|
|
47
|
+
|
|
48
|
+
// fallow-ignore-next-line complexity
|
|
49
|
+
function incrementSessionCount(): void {
|
|
50
|
+
try {
|
|
51
|
+
if (sessionStorage.getItem(SESSION_COUNTED_KEY)) return;
|
|
52
|
+
sessionStorage.setItem(SESSION_COUNTED_KEY, "1");
|
|
53
|
+
const count = parseInt(localStorage.getItem(STORAGE_KEYS.sessionCount) || "0", 10) || 0;
|
|
54
|
+
localStorage.setItem(STORAGE_KEYS.sessionCount, String(count + 1));
|
|
55
|
+
} catch {
|
|
56
|
+
// storage unavailable
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function markPrompted(): void {
|
|
61
|
+
try {
|
|
62
|
+
const count = localStorage.getItem(STORAGE_KEYS.sessionCount) || "0";
|
|
63
|
+
localStorage.setItem(STORAGE_KEYS.lastPromptedAt, count);
|
|
64
|
+
} catch {
|
|
65
|
+
// localStorage unavailable
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// fallow-ignore-next-line complexity
|
|
70
|
+
export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
|
|
71
|
+
const [visible, setVisible] = useState(false);
|
|
72
|
+
const [rating, setRating] = useState<number | null>(null);
|
|
73
|
+
const [comment, setComment] = useState("");
|
|
74
|
+
const [submitted, setSubmitted] = useState(false);
|
|
75
|
+
const [exiting, setExiting] = useState(false);
|
|
76
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
77
|
+
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
78
|
+
|
|
79
|
+
// On mount: increment session count, check if we should show
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
incrementSessionCount();
|
|
82
|
+
// Small delay so the bar doesn't flash on page load
|
|
83
|
+
const showTimer = setTimeout(() => {
|
|
84
|
+
if (shouldShowFeedback()) {
|
|
85
|
+
setVisible(true);
|
|
86
|
+
}
|
|
87
|
+
}, 3000);
|
|
88
|
+
return () => clearTimeout(showTimer);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Auto-dismiss timer — reset when user interacts (sets rating)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!visible || rating !== null || submitted) return;
|
|
94
|
+
dismissTimerRef.current = setTimeout(() => {
|
|
95
|
+
handleDismiss();
|
|
96
|
+
}, AUTO_DISMISS_MS);
|
|
97
|
+
return () => {
|
|
98
|
+
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current);
|
|
99
|
+
};
|
|
100
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
101
|
+
}, [visible, rating, submitted]);
|
|
102
|
+
|
|
103
|
+
// Focus text input when rating is selected
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (rating !== null && inputRef.current) {
|
|
106
|
+
inputRef.current.focus();
|
|
107
|
+
}
|
|
108
|
+
}, [rating]);
|
|
109
|
+
|
|
110
|
+
const handleDismiss = useCallback(() => {
|
|
111
|
+
setExiting(true);
|
|
112
|
+
markPrompted();
|
|
113
|
+
setTimeout(() => setVisible(false), 300);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
const handleSubmit = useCallback(() => {
|
|
117
|
+
if (rating === null) return;
|
|
118
|
+
trackStudioFeedback({
|
|
119
|
+
rating,
|
|
120
|
+
comment: comment.trim() || undefined,
|
|
121
|
+
});
|
|
122
|
+
setSubmitted(true);
|
|
123
|
+
markPrompted();
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
setExiting(true);
|
|
126
|
+
setTimeout(() => setVisible(false), 300);
|
|
127
|
+
}, 1500);
|
|
128
|
+
}, [rating, comment]);
|
|
129
|
+
|
|
130
|
+
const handleRating = useCallback((n: number) => {
|
|
131
|
+
setRating(n);
|
|
132
|
+
// Cancel auto-dismiss — user is engaged
|
|
133
|
+
if (dismissTimerRef.current) {
|
|
134
|
+
clearTimeout(dismissTimerRef.current);
|
|
135
|
+
dismissTimerRef.current = null;
|
|
136
|
+
}
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
if (!visible) return null;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={[
|
|
144
|
+
"flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
|
|
145
|
+
exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
|
|
146
|
+
].join(" ")}
|
|
147
|
+
>
|
|
148
|
+
{submitted ? (
|
|
149
|
+
<span className="text-neutral-500">Thanks for the feedback!</span>
|
|
150
|
+
) : rating !== null ? (
|
|
151
|
+
<>
|
|
152
|
+
<input
|
|
153
|
+
ref={inputRef}
|
|
154
|
+
type="text"
|
|
155
|
+
value={comment}
|
|
156
|
+
onChange={(e) => setComment(e.target.value)}
|
|
157
|
+
onKeyDown={(e) => {
|
|
158
|
+
if (e.key === "Enter") handleSubmit();
|
|
159
|
+
if (e.key === "Escape") handleDismiss();
|
|
160
|
+
}}
|
|
161
|
+
placeholder="Any details? (enter to send, esc to close)"
|
|
162
|
+
className="flex-1 bg-transparent border-none text-[11px] text-neutral-300 placeholder-neutral-600 outline-none"
|
|
163
|
+
maxLength={500}
|
|
164
|
+
/>
|
|
165
|
+
<button
|
|
166
|
+
onClick={handleSubmit}
|
|
167
|
+
className="text-[11px] text-neutral-500 hover:text-neutral-300 transition-colors flex-shrink-0"
|
|
168
|
+
>
|
|
169
|
+
send
|
|
170
|
+
</button>
|
|
171
|
+
</>
|
|
172
|
+
) : (
|
|
173
|
+
<>
|
|
174
|
+
<span className="text-neutral-500 flex-shrink-0">How's the Studio experience?</span>
|
|
175
|
+
<div className="flex items-center gap-0.5">
|
|
176
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
177
|
+
<button
|
|
178
|
+
key={n}
|
|
179
|
+
onClick={() => handleRating(n)}
|
|
180
|
+
className="w-6 h-6 rounded text-[11px] text-neutral-600 hover:text-neutral-200 hover:bg-neutral-700/50 transition-colors"
|
|
181
|
+
>
|
|
182
|
+
{n}
|
|
183
|
+
</button>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex-1" />
|
|
187
|
+
<button
|
|
188
|
+
onClick={handleDismiss}
|
|
189
|
+
className="text-neutral-700 hover:text-neutral-400 transition-colors flex-shrink-0"
|
|
190
|
+
aria-label="Dismiss"
|
|
191
|
+
>
|
|
192
|
+
<svg
|
|
193
|
+
width="12"
|
|
194
|
+
height="12"
|
|
195
|
+
viewBox="0 0 24 24"
|
|
196
|
+
fill="none"
|
|
197
|
+
stroke="currentColor"
|
|
198
|
+
strokeWidth="2"
|
|
199
|
+
strokeLinecap="round"
|
|
200
|
+
>
|
|
201
|
+
<path d="M18 6L6 18M6 6l12 12" />
|
|
202
|
+
</svg>
|
|
203
|
+
</button>
|
|
204
|
+
</>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
});
|
|
@@ -3,6 +3,7 @@ import { NLELayout } from "./nle/NLELayout";
|
|
|
3
3
|
import { CaptionOverlay } from "../captions/components/CaptionOverlay";
|
|
4
4
|
import { CaptionTimeline } from "../captions/components/CaptionTimeline";
|
|
5
5
|
import { DomEditOverlay } from "./editor/DomEditOverlay";
|
|
6
|
+
import { StudioFeedbackBar } from "./StudioFeedbackBar";
|
|
6
7
|
import type { TimelineElement } from "../player";
|
|
7
8
|
import type { BlockedTimelineEditIntent } from "../player/components/timelineEditing";
|
|
8
9
|
import {
|
|
@@ -53,6 +54,7 @@ export interface StudioPreviewAreaProps {
|
|
|
53
54
|
blockPreview?: BlockPreviewInfo | null;
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
// fallow-ignore-next-line complexity
|
|
56
58
|
export function StudioPreviewArea({
|
|
57
59
|
timelineToolbar,
|
|
58
60
|
renderClipContent,
|
|
@@ -102,100 +104,103 @@ export function StudioPreviewArea({
|
|
|
102
104
|
} = useDomEditContext();
|
|
103
105
|
|
|
104
106
|
return (
|
|
105
|
-
<div className="flex-1 relative min-w-0">
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
107
|
+
<div className="flex-1 flex flex-col relative min-w-0">
|
|
108
|
+
<div className="flex-1 min-h-0 relative">
|
|
109
|
+
<NLELayout
|
|
110
|
+
projectId={projectId}
|
|
111
|
+
refreshKey={refreshKey}
|
|
112
|
+
activeCompositionPath={activeCompPath}
|
|
113
|
+
timelineToolbar={timelineToolbar}
|
|
114
|
+
renderClipContent={renderClipContent}
|
|
115
|
+
onDeleteElement={handleTimelineElementDelete}
|
|
116
|
+
onAssetDrop={handleTimelineAssetDrop}
|
|
117
|
+
onBlockDrop={handleTimelineBlockDrop}
|
|
118
|
+
onPreviewBlockDrop={handlePreviewBlockDrop}
|
|
119
|
+
onFileDrop={handleTimelineFileDrop}
|
|
120
|
+
onMoveElement={handleTimelineElementMove}
|
|
121
|
+
onResizeElement={handleTimelineElementResize}
|
|
122
|
+
onBlockedEditAttempt={handleBlockedTimelineEdit}
|
|
123
|
+
onSelectTimelineElement={handleTimelineElementSelect}
|
|
124
|
+
onCompIdToSrcChange={setCompIdToSrc}
|
|
125
|
+
onCompositionLoadingChange={setCompositionLoading}
|
|
126
|
+
onCompositionChange={(compPath) => {
|
|
127
|
+
// Sync activeCompPath when user drills down via timeline double-click
|
|
128
|
+
// or navigates back via breadcrumb — keeps sidebar + thumbnails in sync.
|
|
129
|
+
// Guard against no-op updates to prevent circular refresh cascades
|
|
130
|
+
// between activeCompPath → compositionStack → onCompositionChange.
|
|
131
|
+
if (compPath !== activeCompPath) {
|
|
132
|
+
setActiveCompPath(compPath);
|
|
133
|
+
refreshPreviewDocumentVersion();
|
|
134
|
+
}
|
|
135
|
+
}}
|
|
136
|
+
onIframeRef={handlePreviewIframeRef}
|
|
137
|
+
previewOverlay={
|
|
138
|
+
blockPreview ? (
|
|
139
|
+
<div className="absolute inset-0 z-30 bg-black pointer-events-none">
|
|
140
|
+
{blockPreview.videoUrl ? (
|
|
141
|
+
<video
|
|
142
|
+
src={blockPreview.videoUrl}
|
|
143
|
+
autoPlay
|
|
144
|
+
muted
|
|
145
|
+
loop
|
|
146
|
+
playsInline
|
|
147
|
+
className="w-full h-full object-contain"
|
|
148
|
+
/>
|
|
149
|
+
) : blockPreview.posterUrl ? (
|
|
150
|
+
<img
|
|
151
|
+
src={blockPreview.posterUrl}
|
|
152
|
+
alt={blockPreview.title}
|
|
153
|
+
className="w-full h-full object-contain"
|
|
154
|
+
/>
|
|
155
|
+
) : null}
|
|
156
|
+
</div>
|
|
157
|
+
) : captionEditMode ? (
|
|
158
|
+
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
159
|
+
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
160
|
+
<DomEditOverlay
|
|
161
|
+
iframeRef={previewIframeRef}
|
|
162
|
+
activeCompositionPath={activeCompPath}
|
|
163
|
+
hoverSelection={
|
|
164
|
+
STUDIO_PREVIEW_SELECTION_ENABLED &&
|
|
165
|
+
!captionEditMode &&
|
|
166
|
+
!compositionLoading &&
|
|
167
|
+
!isPlaying
|
|
168
|
+
? domEditHoverSelection
|
|
169
|
+
: null
|
|
170
|
+
}
|
|
171
|
+
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
172
|
+
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
173
|
+
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
174
|
+
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
175
|
+
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
176
|
+
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
177
|
+
onSelectionChange={applyDomSelection}
|
|
178
|
+
onBlockedMove={handleBlockedDomMove}
|
|
179
|
+
onManualDragStart={handleDomManualDragStart}
|
|
180
|
+
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
181
|
+
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
182
|
+
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
183
|
+
onRotationCommit={handleDomRotationCommit}
|
|
184
|
+
/>
|
|
185
|
+
) : null
|
|
131
186
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
muted
|
|
142
|
-
loop
|
|
143
|
-
playsInline
|
|
144
|
-
className="w-full h-full object-contain"
|
|
145
|
-
/>
|
|
146
|
-
) : blockPreview.posterUrl ? (
|
|
147
|
-
<img
|
|
148
|
-
src={blockPreview.posterUrl}
|
|
149
|
-
alt={blockPreview.title}
|
|
150
|
-
className="w-full h-full object-contain"
|
|
151
|
-
/>
|
|
152
|
-
) : null}
|
|
153
|
-
</div>
|
|
154
|
-
) : captionEditMode ? (
|
|
155
|
-
<CaptionOverlay iframeRef={previewIframeRef} />
|
|
156
|
-
) : STUDIO_INSPECTOR_PANELS_ENABLED ? (
|
|
157
|
-
<DomEditOverlay
|
|
158
|
-
iframeRef={previewIframeRef}
|
|
159
|
-
activeCompositionPath={activeCompPath}
|
|
160
|
-
hoverSelection={
|
|
161
|
-
STUDIO_PREVIEW_SELECTION_ENABLED &&
|
|
162
|
-
!captionEditMode &&
|
|
163
|
-
!compositionLoading &&
|
|
164
|
-
!isPlaying
|
|
165
|
-
? domEditHoverSelection
|
|
166
|
-
: null
|
|
167
|
-
}
|
|
168
|
-
selection={shouldShowSelectedDomBounds ? domEditSelection : null}
|
|
169
|
-
groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []}
|
|
170
|
-
allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED}
|
|
171
|
-
onCanvasMouseDown={handlePreviewCanvasMouseDown}
|
|
172
|
-
onCanvasPointerMove={handlePreviewCanvasPointerMove}
|
|
173
|
-
onCanvasPointerLeave={handlePreviewCanvasPointerLeave}
|
|
174
|
-
onSelectionChange={applyDomSelection}
|
|
175
|
-
onBlockedMove={handleBlockedDomMove}
|
|
176
|
-
onManualDragStart={handleDomManualDragStart}
|
|
177
|
-
onPathOffsetCommit={handleDomPathOffsetCommit}
|
|
178
|
-
onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit}
|
|
179
|
-
onBoxSizeCommit={handleDomBoxSizeCommit}
|
|
180
|
-
onRotationCommit={handleDomRotationCommit}
|
|
181
|
-
/>
|
|
182
|
-
) : null
|
|
183
|
-
}
|
|
184
|
-
timelineFooter={
|
|
185
|
-
captionEditMode ? (
|
|
186
|
-
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
|
|
187
|
-
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
|
188
|
-
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
189
|
-
Captions
|
|
190
|
-
</span>
|
|
187
|
+
timelineFooter={
|
|
188
|
+
captionEditMode ? (
|
|
189
|
+
<div className="border-t border-neutral-800/30 flex-shrink-0" style={{ height: 60 }}>
|
|
190
|
+
<div className="flex items-center gap-1.5 px-2 py-0.5">
|
|
191
|
+
<span className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider">
|
|
192
|
+
Captions
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
<CaptionTimeline pixelsPerSecond={100} />
|
|
191
196
|
</div>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
/>
|
|
197
|
+
) : undefined
|
|
198
|
+
}
|
|
199
|
+
timelineVisible={timelineVisible}
|
|
200
|
+
onToggleTimeline={toggleTimelineVisibility}
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
<StudioFeedbackBar />
|
|
199
204
|
</div>
|
|
200
205
|
);
|
|
201
206
|
}
|
|
@@ -279,3 +279,145 @@ describe("useTimelinePlayer seek keepPlaying option (#834)", () => {
|
|
|
279
279
|
expectStorePlaybackState(root, { isPlaying: true, currentTime: 0 });
|
|
280
280
|
});
|
|
281
281
|
});
|
|
282
|
+
|
|
283
|
+
describe("useTimelinePlayer RAF loop wrap-around", () => {
|
|
284
|
+
type SeekCall = { time: number; options?: { keepPlaying?: boolean } };
|
|
285
|
+
|
|
286
|
+
function attachInstrumentedAdapter(api: ReturnType<typeof useTimelinePlayer>, duration = 30) {
|
|
287
|
+
const iframe = document.createElement("iframe");
|
|
288
|
+
let currentTime = 0;
|
|
289
|
+
let playing = false;
|
|
290
|
+
const seekCalls: SeekCall[] = [];
|
|
291
|
+
const adapter = {
|
|
292
|
+
play: vi.fn(() => {
|
|
293
|
+
playing = true;
|
|
294
|
+
}),
|
|
295
|
+
pause: vi.fn(() => {
|
|
296
|
+
playing = false;
|
|
297
|
+
}),
|
|
298
|
+
seek: vi.fn((time: number, options?: { keepPlaying?: boolean }) => {
|
|
299
|
+
currentTime = time;
|
|
300
|
+
seekCalls.push({ time, options });
|
|
301
|
+
}),
|
|
302
|
+
getTime: () => currentTime,
|
|
303
|
+
getDuration: () => duration,
|
|
304
|
+
isPlaying: () => playing,
|
|
305
|
+
setTime: (t: number) => {
|
|
306
|
+
currentTime = t;
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
Object.defineProperty(iframe, "contentWindow", {
|
|
310
|
+
value: {
|
|
311
|
+
__player: adapter,
|
|
312
|
+
postMessage: () => {},
|
|
313
|
+
scrollTo: () => {},
|
|
314
|
+
addEventListener: () => {},
|
|
315
|
+
removeEventListener: () => {},
|
|
316
|
+
},
|
|
317
|
+
configurable: true,
|
|
318
|
+
});
|
|
319
|
+
Object.defineProperty(iframe, "contentDocument", {
|
|
320
|
+
value: document.implementation.createHTMLDocument("preview"),
|
|
321
|
+
configurable: true,
|
|
322
|
+
});
|
|
323
|
+
act(() => {
|
|
324
|
+
api.iframeRef.current = iframe;
|
|
325
|
+
api.onIframeLoad();
|
|
326
|
+
});
|
|
327
|
+
return { adapter, seekCalls };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function installRafCapture(): {
|
|
331
|
+
flushOne: () => boolean;
|
|
332
|
+
restore: () => void;
|
|
333
|
+
} {
|
|
334
|
+
const callbacks: FrameRequestCallback[] = [];
|
|
335
|
+
const originalRAF = globalThis.requestAnimationFrame;
|
|
336
|
+
const originalCancel = globalThis.cancelAnimationFrame;
|
|
337
|
+
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
|
|
338
|
+
callbacks.push(cb);
|
|
339
|
+
return callbacks.length;
|
|
340
|
+
}) as typeof requestAnimationFrame;
|
|
341
|
+
globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame;
|
|
342
|
+
return {
|
|
343
|
+
flushOne: () => {
|
|
344
|
+
const next = callbacks.shift();
|
|
345
|
+
if (!next) return false;
|
|
346
|
+
next(performance.now());
|
|
347
|
+
return true;
|
|
348
|
+
},
|
|
349
|
+
restore: () => {
|
|
350
|
+
globalThis.requestAnimationFrame = originalRAF;
|
|
351
|
+
globalThis.cancelAnimationFrame = originalCancel;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
it("passes { keepPlaying: true } when forward playback wraps around loopEnd", () => {
|
|
357
|
+
const raf = installRafCapture();
|
|
358
|
+
try {
|
|
359
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
360
|
+
const { adapter, seekCalls } = attachInstrumentedAdapter(api);
|
|
361
|
+
|
|
362
|
+
act(() => {
|
|
363
|
+
usePlayerStore.getState().setInPoint(2);
|
|
364
|
+
usePlayerStore.getState().setOutPoint(5);
|
|
365
|
+
});
|
|
366
|
+
expect(usePlayerStore.getState().loopEnabled).toBe(true);
|
|
367
|
+
|
|
368
|
+
act(() => {
|
|
369
|
+
api.play();
|
|
370
|
+
});
|
|
371
|
+
adapter.seek.mockClear();
|
|
372
|
+
seekCalls.length = 0;
|
|
373
|
+
|
|
374
|
+
adapter.setTime(6); // past outPoint=5
|
|
375
|
+
act(() => {
|
|
376
|
+
raf.flushOne();
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const wrapSeek = seekCalls.find((call) => call.time === 2);
|
|
380
|
+
expect(wrapSeek).toBeDefined();
|
|
381
|
+
expect(wrapSeek?.options).toEqual({ keepPlaying: true });
|
|
382
|
+
expect(adapter.play).toHaveBeenCalled();
|
|
383
|
+
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
|
384
|
+
|
|
385
|
+
unmountWithAct(root);
|
|
386
|
+
} finally {
|
|
387
|
+
raf.restore();
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("does not seek and pauses cleanly when forward playback reaches the end without loop", () => {
|
|
392
|
+
const raf = installRafCapture();
|
|
393
|
+
try {
|
|
394
|
+
const { api, root } = renderTimelinePlayerHarness();
|
|
395
|
+
const { adapter, seekCalls } = attachInstrumentedAdapter(api);
|
|
396
|
+
|
|
397
|
+
act(() => {
|
|
398
|
+
usePlayerStore.getState().setLoopEnabled(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
act(() => {
|
|
402
|
+
api.play();
|
|
403
|
+
});
|
|
404
|
+
adapter.seek.mockClear();
|
|
405
|
+
seekCalls.length = 0;
|
|
406
|
+
adapter.play.mockClear();
|
|
407
|
+
adapter.pause.mockClear();
|
|
408
|
+
|
|
409
|
+
adapter.setTime(adapter.getDuration() + 1); // past end
|
|
410
|
+
act(() => {
|
|
411
|
+
raf.flushOne();
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
expect(seekCalls).toHaveLength(0);
|
|
415
|
+
expect(adapter.pause).toHaveBeenCalled();
|
|
416
|
+
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
|
417
|
+
|
|
418
|
+
unmountWithAct(root);
|
|
419
|
+
} finally {
|
|
420
|
+
raf.restore();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -229,7 +229,8 @@ export function useTimelinePlayer() {
|
|
|
229
229
|
const loopStart = rawLoopStart < rawLoopEnd ? rawLoopStart : 0;
|
|
230
230
|
if (time >= loopEnd) {
|
|
231
231
|
if (usePlayerStore.getState().loopEnabled && dur > 0) {
|
|
232
|
-
adapter
|
|
232
|
+
// keepPlaying skips the adapter's implicit pause; play() below is then a no-op.
|
|
233
|
+
adapter.seek(loopStart, { keepPlaying: true });
|
|
233
234
|
liveTime.notify(loopStart);
|
|
234
235
|
adapter.play();
|
|
235
236
|
setIsPlaying(true);
|
package/src/telemetry/events.ts
CHANGED
|
@@ -25,3 +25,35 @@ export function trackStudioRenderStart(props: {
|
|
|
25
25
|
composition: props.composition,
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
function getBrowserDoctorSummary(): string {
|
|
30
|
+
try {
|
|
31
|
+
const nav = navigator as Navigator & {
|
|
32
|
+
deviceMemory?: number;
|
|
33
|
+
connection?: { effectiveType?: string };
|
|
34
|
+
userAgentData?: { platform?: string };
|
|
35
|
+
};
|
|
36
|
+
const platform = nav.userAgentData?.platform ?? navigator.platform ?? "unknown";
|
|
37
|
+
const parts = [
|
|
38
|
+
`ua=${platform}`,
|
|
39
|
+
`screen=${screen.width}x${screen.height}@${devicePixelRatio}x`,
|
|
40
|
+
`lang=${navigator.language}`,
|
|
41
|
+
];
|
|
42
|
+
if (nav.deviceMemory) parts.push(`mem=${nav.deviceMemory}GB`);
|
|
43
|
+
if (nav.connection?.effectiveType) parts.push(`net=${nav.connection.effectiveType}`);
|
|
44
|
+
if (navigator.hardwareConcurrency) parts.push(`cpu=${navigator.hardwareConcurrency}cores`);
|
|
45
|
+
return parts.join(" ");
|
|
46
|
+
} catch {
|
|
47
|
+
return "";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
|
52
|
+
trackEvent("survey sent", {
|
|
53
|
+
$survey_id: "studio_experience",
|
|
54
|
+
$survey_response: props.rating,
|
|
55
|
+
...(props.comment ? { $survey_response_2: props.comment } : {}),
|
|
56
|
+
doctor_summary: getBrowserDoctorSummary(),
|
|
57
|
+
source: "studio",
|
|
58
|
+
});
|
|
59
|
+
}
|