@hyperframes/studio 0.6.37 → 0.6.38

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.
@@ -0,0 +1,277 @@
1
+ import { useState, useCallback, useRef, useEffect, memo } from "react";
2
+ import { formatTime, frameToSeconds } from "../lib/time";
3
+ import { Tooltip } from "../../components/ui";
4
+
5
+ const SHORTCUT_SECTIONS = [
6
+ {
7
+ title: "Playback",
8
+ hints: [
9
+ { key: "Space", label: "Play / Pause" },
10
+ { key: "J", label: "Play backward" },
11
+ { key: "K", label: "Stop" },
12
+ { key: "L", label: "Play forward" },
13
+ { key: "M", label: "Toggle mute" },
14
+ { key: "⇧L", label: "Toggle loop" },
15
+ { key: "←/→", label: "Step 1 frame" },
16
+ { key: "⇧←/⇧→", label: "Step 10 frames" },
17
+ { key: "F", label: "Toggle fullscreen" },
18
+ ],
19
+ },
20
+ {
21
+ title: "Work area",
22
+ hints: [
23
+ { key: "I", label: "Set in-point" },
24
+ { key: "⇧I", label: "Clear in-point" },
25
+ { key: "O", label: "Set out-point" },
26
+ { key: "⇧O", label: "Clear out-point" },
27
+ { key: "A", label: "Jump to in-point" },
28
+ { key: "E", label: "Jump to out-point" },
29
+ ],
30
+ },
31
+ ] as const;
32
+
33
+ interface ShortcutsPanelProps {
34
+ disabled: boolean;
35
+ duration: number;
36
+ inPoint: number | null;
37
+ outPoint: number | null;
38
+ setInPoint: (v: number | null) => void;
39
+ setOutPoint: (v: number | null) => void;
40
+ onSeek: (time: number) => void;
41
+ }
42
+
43
+ export const ShortcutsPanel = memo(function ShortcutsPanel({
44
+ disabled,
45
+ duration,
46
+ inPoint,
47
+ outPoint,
48
+ setInPoint,
49
+ setOutPoint,
50
+ onSeek,
51
+ }: ShortcutsPanelProps) {
52
+ const [showShortcuts, setShowShortcuts] = useState(false);
53
+ const [jumpFrame, setJumpFrame] = useState("");
54
+ const shortcutsPanelRef = useRef<HTMLDivElement>(null);
55
+
56
+ useEffect(() => {
57
+ if (!showShortcuts) return;
58
+ const handleMouseDown = (e: MouseEvent) => {
59
+ if (shortcutsPanelRef.current && !shortcutsPanelRef.current.contains(e.target as Node)) {
60
+ setShowShortcuts(false);
61
+ }
62
+ };
63
+ document.addEventListener("mousedown", handleMouseDown);
64
+ return () => {
65
+ document.removeEventListener("mousedown", handleMouseDown);
66
+ };
67
+ }, [showShortcuts]);
68
+
69
+ const commitJumpFrame = useCallback(() => {
70
+ if (disabled) return;
71
+ const frame = Number.parseInt(jumpFrame, 10);
72
+ if (!Number.isFinite(frame) || duration <= 0) return;
73
+ onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
74
+ }, [disabled, duration, jumpFrame, onSeek]);
75
+
76
+ const handleJumpSubmit = useCallback(
77
+ (e: React.FormEvent) => {
78
+ e.preventDefault();
79
+ commitJumpFrame();
80
+ },
81
+ [commitJumpFrame],
82
+ );
83
+
84
+ const handleJumpKeyDown = useCallback(
85
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
86
+ if (e.key !== "Enter") return;
87
+ e.preventDefault();
88
+ commitJumpFrame();
89
+ },
90
+ [commitJumpFrame],
91
+ );
92
+
93
+ return (
94
+ <div ref={shortcutsPanelRef} className="relative flex-shrink-0">
95
+ <Tooltip label="Shortcuts and tools">
96
+ <button
97
+ type="button"
98
+ onClick={() => setShowShortcuts((v) => !v)}
99
+ className={`w-6 h-6 flex items-center justify-center rounded border transition-colors ${
100
+ showShortcuts
101
+ ? "border-neutral-600 text-neutral-200 bg-neutral-800"
102
+ : "border-neutral-800 text-neutral-600 hover:text-neutral-300 hover:border-neutral-600"
103
+ }`}
104
+ aria-label="Shortcuts and tools"
105
+ aria-expanded={showShortcuts}
106
+ >
107
+ <svg
108
+ width="11"
109
+ height="11"
110
+ viewBox="0 0 24 24"
111
+ fill="none"
112
+ stroke="currentColor"
113
+ strokeWidth="1.75"
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ aria-hidden="true"
117
+ >
118
+ <rect x="2" y="4" width="20" height="16" rx="2" />
119
+ <path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8" />
120
+ </svg>
121
+ </button>
122
+ </Tooltip>
123
+ {showShortcuts && (
124
+ <div
125
+ className="absolute bottom-full right-0 mb-2 z-50 rounded-lg shadow-xl min-w-[220px] overflow-y-auto"
126
+ style={{
127
+ background: "#161618",
128
+ border: "1px solid rgba(255,255,255,0.08)",
129
+ maxHeight: "min(280px, calc(100vh - 80px))",
130
+ }}
131
+ >
132
+ <div className="px-3 pt-3 pb-2.5">
133
+ <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
134
+ Jump to frame
135
+ </p>
136
+ <form onSubmit={handleJumpSubmit} className="flex items-center gap-1.5">
137
+ <input
138
+ value={jumpFrame}
139
+ onChange={(e) => setJumpFrame(e.target.value)}
140
+ disabled={disabled}
141
+ inputMode="numeric"
142
+ pattern="[0-9]*"
143
+ aria-label="Jump to frame"
144
+ placeholder="frame number"
145
+ className="h-6 flex-1 rounded border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
146
+ onKeyDown={handleJumpKeyDown}
147
+ onBlur={commitJumpFrame}
148
+ />
149
+ <Tooltip label="Jump to frame">
150
+ <button
151
+ type="submit"
152
+ disabled={disabled}
153
+ className="h-6 px-2 rounded border border-neutral-700 text-[10px] text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800 disabled:opacity-40"
154
+ >
155
+ Go
156
+ </button>
157
+ </Tooltip>
158
+ </form>
159
+ </div>
160
+ <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
161
+ <div className="px-3 pt-2.5 pb-2">
162
+ <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
163
+ Work area
164
+ </p>
165
+ <div className="flex flex-col gap-1">
166
+ <div className="flex items-center justify-between gap-2">
167
+ <div className="flex items-center gap-2">
168
+ <span
169
+ className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
170
+ style={{ background: "rgba(255,255,255,0.05)" }}
171
+ >
172
+ I
173
+ </span>
174
+ <span className="text-[10px] text-neutral-400">In-point</span>
175
+ </div>
176
+ <div className="flex items-center gap-1.5">
177
+ {inPoint !== null ? (
178
+ <>
179
+ <span className="font-mono text-[10px] text-neutral-300">
180
+ {formatTime(inPoint)}
181
+ </span>
182
+ <Tooltip label="Clear in-point">
183
+ <button
184
+ type="button"
185
+ onClick={() => setInPoint(null)}
186
+ className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
187
+ aria-label="Clear in-point"
188
+ >
189
+ <svg
190
+ width="8"
191
+ height="8"
192
+ viewBox="0 0 24 24"
193
+ fill="none"
194
+ stroke="currentColor"
195
+ strokeWidth="2.5"
196
+ >
197
+ <path d="M18 6L6 18M6 6l12 12" />
198
+ </svg>
199
+ </button>
200
+ </Tooltip>
201
+ </>
202
+ ) : (
203
+ <span className="text-[10px] text-neutral-600">—</span>
204
+ )}
205
+ </div>
206
+ </div>
207
+ <div className="flex items-center justify-between gap-2">
208
+ <div className="flex items-center gap-2">
209
+ <span
210
+ className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[20px] text-center"
211
+ style={{ background: "rgba(255,255,255,0.05)" }}
212
+ >
213
+ O
214
+ </span>
215
+ <span className="text-[10px] text-neutral-400">Out-point</span>
216
+ </div>
217
+ <div className="flex items-center gap-1.5">
218
+ {outPoint !== null ? (
219
+ <>
220
+ <span className="font-mono text-[10px] text-neutral-300">
221
+ {formatTime(outPoint)}
222
+ </span>
223
+ <Tooltip label="Clear out-point">
224
+ <button
225
+ type="button"
226
+ onClick={() => setOutPoint(null)}
227
+ className="w-4 h-4 flex items-center justify-center rounded text-neutral-500 hover:text-neutral-200 transition-colors"
228
+ aria-label="Clear out-point"
229
+ >
230
+ <svg
231
+ width="8"
232
+ height="8"
233
+ viewBox="0 0 24 24"
234
+ fill="none"
235
+ stroke="currentColor"
236
+ strokeWidth="2.5"
237
+ >
238
+ <path d="M18 6L6 18M6 6l12 12" />
239
+ </svg>
240
+ </button>
241
+ </Tooltip>
242
+ </>
243
+ ) : (
244
+ <span className="text-[10px] text-neutral-600">—</span>
245
+ )}
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)" }} />
251
+ <div className="px-3 pt-2.5 pb-3 flex flex-col gap-3">
252
+ {SHORTCUT_SECTIONS.map((section) => (
253
+ <div key={section.title}>
254
+ <p className="text-[9px] font-medium text-neutral-500 uppercase tracking-wider mb-1.5">
255
+ {section.title}
256
+ </p>
257
+ <div className="flex flex-col gap-1">
258
+ {section.hints.map((hint) => (
259
+ <div key={hint.key} className="flex items-center gap-3">
260
+ <span
261
+ className="font-mono text-[10px] rounded border border-neutral-700 px-1.5 py-0.5 text-neutral-300 min-w-[36px] text-center"
262
+ style={{ background: "rgba(255,255,255,0.05)" }}
263
+ >
264
+ {hint.key}
265
+ </span>
266
+ <span className="text-[10px] text-neutral-400">{hint.label}</span>
267
+ </div>
268
+ ))}
269
+ </div>
270
+ </div>
271
+ ))}
272
+ </div>
273
+ </div>
274
+ )}
275
+ </div>
276
+ );
277
+ });
@@ -0,0 +1,83 @@
1
+ import { useState, useRef, useEffect, memo } from "react";
2
+ import { trackStudioEvent } from "../../utils/studioTelemetry";
3
+ import { Tooltip } from "../../components/ui";
4
+
5
+ const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
6
+
7
+ interface SpeedMenuProps {
8
+ playbackRate: number;
9
+ setPlaybackRate: (rate: number) => void;
10
+ disabled: boolean;
11
+ }
12
+
13
+ export const SpeedMenu = memo(function SpeedMenu({
14
+ playbackRate,
15
+ setPlaybackRate,
16
+ disabled,
17
+ }: SpeedMenuProps) {
18
+ const [showSpeedMenu, setShowSpeedMenu] = useState(false);
19
+ const speedMenuContainerRef = useRef<HTMLDivElement>(null);
20
+
21
+ useEffect(() => {
22
+ if (!showSpeedMenu) return;
23
+ const handleMouseDown = (e: MouseEvent) => {
24
+ if (
25
+ speedMenuContainerRef.current &&
26
+ !speedMenuContainerRef.current.contains(e.target as Node)
27
+ ) {
28
+ setShowSpeedMenu(false);
29
+ }
30
+ };
31
+ document.addEventListener("mousedown", handleMouseDown);
32
+ return () => {
33
+ document.removeEventListener("mousedown", handleMouseDown);
34
+ };
35
+ }, [showSpeedMenu]);
36
+
37
+ return (
38
+ <div ref={speedMenuContainerRef} className="relative flex-shrink-0">
39
+ <Tooltip label="Playback speed">
40
+ <button
41
+ type="button"
42
+ onClick={() => setShowSpeedMenu((v) => !v)}
43
+ disabled={disabled}
44
+ className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
45
+ style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
46
+ >
47
+ {playbackRate === 1 ? "1x" : `${playbackRate}x`}
48
+ </button>
49
+ </Tooltip>
50
+ {showSpeedMenu && (
51
+ <div
52
+ className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
53
+ style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
54
+ >
55
+ {SPEED_OPTIONS.map((rate) => (
56
+ <button
57
+ key={rate}
58
+ onClick={() => {
59
+ trackStudioEvent("playback", { action: "speed_change", rate });
60
+ setPlaybackRate(rate);
61
+ setShowSpeedMenu(false);
62
+ }}
63
+ className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
64
+ style={{
65
+ color: rate === playbackRate ? "#FAFAFA" : "#71717A",
66
+ background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
67
+ }}
68
+ onMouseEnter={(e) => {
69
+ if (rate !== playbackRate)
70
+ e.currentTarget.style.background = "rgba(255,255,255,0.04)";
71
+ }}
72
+ onMouseLeave={(e) => {
73
+ if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
74
+ }}
75
+ >
76
+ {rate}x
77
+ </button>
78
+ ))}
79
+ </div>
80
+ )}
81
+ </div>
82
+ );
83
+ });
@@ -0,0 +1,168 @@
1
+ import { useCallback } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+ import { formatFrameTime, formatTime } from "../lib/time";
4
+ import { usePlayerStore, liveTime } from "../store/playerStore";
5
+
6
+ const SEEK_EDGE_SNAP_PX = 8;
7
+
8
+ export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
9
+ if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
10
+ const rawPercent = (clientX - rectLeft) / rectWidth;
11
+ const clamped = Math.max(0, Math.min(1, rawPercent));
12
+ const snapThreshold = Math.min(0.5, SEEK_EDGE_SNAP_PX / rectWidth);
13
+ if (clamped <= snapThreshold) return 0;
14
+ if (clamped >= 1 - snapThreshold) return 1;
15
+ return clamped;
16
+ }
17
+
18
+ interface SeekBarRefs {
19
+ seekBarRef: React.RefObject<HTMLDivElement | null>;
20
+ progressFillRef: React.RefObject<HTMLDivElement | null>;
21
+ progressThumbRef: React.RefObject<HTMLDivElement | null>;
22
+ sliderRef: React.RefObject<HTMLDivElement | null>;
23
+ timeDisplayRef: React.RefObject<HTMLSpanElement | null>;
24
+ isDraggingRef: React.MutableRefObject<boolean>;
25
+ durationRef: React.MutableRefObject<number>;
26
+ currentTimeRef: React.MutableRefObject<number>;
27
+ timeDisplayModeRef: React.MutableRefObject<"time" | "frame">;
28
+ }
29
+
30
+ function updateProgressUI(
31
+ fillRef: React.RefObject<HTMLDivElement | null>,
32
+ thumbRef: React.RefObject<HTMLDivElement | null>,
33
+ pct: number,
34
+ ): void {
35
+ if (fillRef.current) fillRef.current.style.width = `${pct}%`;
36
+ if (thumbRef.current) thumbRef.current.style.left = `${pct}%`;
37
+ }
38
+
39
+ export function useSeekBarDrag(
40
+ refs: SeekBarRefs,
41
+ onSeek: (time: number) => void,
42
+ disabled: boolean,
43
+ duration: number,
44
+ ) {
45
+ const seekFromClientX = useCallback(
46
+ (clientX: number) => {
47
+ if (disabled) return;
48
+ const bar = refs.seekBarRef.current;
49
+ if (!bar || duration <= 0) return;
50
+ const rect = bar.getBoundingClientRect();
51
+ const percent = resolveSeekPercent(clientX, rect.left, rect.width);
52
+ updateProgressUI(refs.progressFillRef, refs.progressThumbRef, percent * 100);
53
+ onSeek(percent * duration);
54
+ },
55
+ [disabled, duration, onSeek, refs],
56
+ );
57
+
58
+ const handlePointerDown = useCallback(
59
+ (e: React.PointerEvent<HTMLDivElement>) => {
60
+ if (e.button !== 0) return;
61
+ e.preventDefault();
62
+ e.currentTarget.focus();
63
+ refs.isDraggingRef.current = true;
64
+
65
+ const target = e.currentTarget;
66
+ const pointerId = e.pointerId;
67
+ try {
68
+ target.setPointerCapture(pointerId);
69
+ } catch {
70
+ /* fallback to window listeners */
71
+ }
72
+
73
+ seekFromClientX(e.clientX);
74
+
75
+ let seekRafId = 0;
76
+ let pendingClientX = e.clientX;
77
+ const onMove = (ev: PointerEvent) => {
78
+ if (ev.pointerId !== pointerId || !refs.isDraggingRef.current) return;
79
+ pendingClientX = ev.clientX;
80
+ const bar = refs.seekBarRef.current;
81
+ const dur = refs.durationRef.current;
82
+ if (bar && dur > 0) {
83
+ const rect = bar.getBoundingClientRect();
84
+ const pct = resolveSeekPercent(ev.clientX, rect.left, rect.width) * 100;
85
+ updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct);
86
+ }
87
+ if (!seekRafId) {
88
+ seekRafId = requestAnimationFrame(() => {
89
+ seekRafId = 0;
90
+ if (refs.isDraggingRef.current) seekFromClientX(pendingClientX);
91
+ });
92
+ }
93
+ };
94
+ const cleanup = () => {
95
+ refs.isDraggingRef.current = false;
96
+ if (seekRafId) {
97
+ cancelAnimationFrame(seekRafId);
98
+ seekRafId = 0;
99
+ }
100
+ seekFromClientX(pendingClientX);
101
+ try {
102
+ target.releasePointerCapture(pointerId);
103
+ } catch {
104
+ /* already released */
105
+ }
106
+ target.removeEventListener("pointermove", onMove);
107
+ target.removeEventListener("pointerup", onUp);
108
+ target.removeEventListener("pointercancel", onUp);
109
+ window.removeEventListener("pointerup", onUp);
110
+ window.removeEventListener("pointercancel", onUp);
111
+ document.removeEventListener("visibilitychange", onVisibilityChange);
112
+ window.removeEventListener("blur", cleanup);
113
+ };
114
+ const onUp = (ev: PointerEvent) => {
115
+ if (ev.pointerId !== pointerId) return;
116
+ cleanup();
117
+ };
118
+ const onVisibilityChange = () => {
119
+ if (document.visibilityState === "hidden") cleanup();
120
+ };
121
+
122
+ target.addEventListener("pointermove", onMove);
123
+ target.addEventListener("pointerup", onUp);
124
+ target.addEventListener("pointercancel", onUp);
125
+ window.addEventListener("pointerup", onUp);
126
+ window.addEventListener("pointercancel", onUp);
127
+ document.addEventListener("visibilitychange", onVisibilityChange);
128
+ window.addEventListener("blur", cleanup);
129
+ },
130
+ [seekFromClientX, refs],
131
+ );
132
+
133
+ useMountEffect(() => {
134
+ const updateProgress = (t: number) => {
135
+ refs.currentTimeRef.current = t;
136
+ const dur = refs.durationRef.current;
137
+ const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
138
+ updateProgressUI(refs.progressFillRef, refs.progressThumbRef, pct);
139
+ if (refs.timeDisplayRef.current) {
140
+ refs.timeDisplayRef.current.textContent =
141
+ refs.timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
142
+ }
143
+ if (refs.sliderRef.current)
144
+ refs.sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
145
+ };
146
+ const unsub = liveTime.subscribe(updateProgress);
147
+ updateProgress(usePlayerStore.getState().currentTime);
148
+
149
+ const interval = setInterval(() => {
150
+ const t = usePlayerStore.getState().currentTime;
151
+ const dur = usePlayerStore.getState().duration;
152
+ if (dur > 0 && t > 0) {
153
+ updateProgressUI(
154
+ refs.progressFillRef,
155
+ refs.progressThumbRef,
156
+ Math.min(100, (t / dur) * 100),
157
+ );
158
+ }
159
+ }, 500);
160
+
161
+ return () => {
162
+ unsub();
163
+ clearInterval(interval);
164
+ };
165
+ });
166
+
167
+ return { handlePointerDown };
168
+ }
@@ -156,6 +156,6 @@ describe("insertTimelineAssetIntoSource", () => {
156
156
  );
157
157
 
158
158
  expect(html).toContain('data-composition-id="main">');
159
- expect(html).toContain('<img id="photo_asset"');
159
+ expect(html).toContain('<img id="photo_asset" data-start="0" data-duration="3" />');
160
160
  });
161
161
  });