@hyperframes/studio 0.1.10 → 0.1.11
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-Bj0pPj_X.js +92 -0
- package/dist/assets/index-BnvciBdD.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +10 -4
- package/src/App.tsx +744 -271
- package/src/components/editor/FileTree.tsx +186 -32
- package/src/components/editor/SourceEditor.tsx +3 -1
- package/src/components/nle/NLELayout.tsx +125 -23
- package/src/components/renders/RenderQueue.tsx +123 -0
- package/src/components/renders/RenderQueueItem.tsx +133 -0
- package/src/components/renders/useRenderQueue.ts +161 -0
- package/src/components/sidebar/AssetsTab.tsx +360 -0
- package/src/components/sidebar/CompositionsTab.tsx +227 -0
- package/src/components/sidebar/LeftSidebar.tsx +102 -0
- package/src/components/ui/ExpandOnHover.tsx +194 -0
- package/src/hooks/useCodeEditor.ts +1 -1
- package/src/hooks/useElementPicker.ts +5 -1
- package/src/index.ts +10 -2
- package/src/player/components/AudioWaveform.tsx +168 -0
- package/src/player/components/CompositionThumbnail.tsx +140 -0
- package/src/player/components/EditModal.tsx +165 -0
- package/src/player/components/Player.tsx +6 -5
- package/src/player/components/PlayerControls.tsx +78 -39
- package/src/player/components/Timeline.test.ts +110 -0
- package/src/player/components/Timeline.tsx +537 -260
- package/src/player/components/TimelineClip.tsx +80 -0
- package/src/player/components/VideoThumbnail.tsx +196 -0
- package/src/player/hooks/useTimelinePlayer.ts +404 -112
- package/src/player/index.ts +3 -3
- package/src/player/lib/time.test.ts +57 -0
- package/src/player/lib/time.ts +1 -0
- package/src/player/store/playerStore.test.ts +265 -0
- package/src/player/store/playerStore.ts +44 -16
- package/src/utils/htmlEditor.ts +164 -0
- package/dist/assets/index-Df6fO-S6.js +0 -78
- package/dist/assets/index-KoBceNoU.css +0 -1
- package/src/player/components/AgentActivityTrack.tsx +0 -93
- package/src/player/lib/useMountEffect.ts +0 -10
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
import { useRef, useState, useCallback, memo } from "react";
|
|
2
|
+
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
2
3
|
import { formatTime } from "../lib/time";
|
|
3
4
|
import { usePlayerStore, liveTime } from "../store/playerStore";
|
|
4
|
-
import { useMountEffect } from "../lib/useMountEffect";
|
|
5
5
|
|
|
6
6
|
const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
|
|
7
7
|
|
|
8
8
|
interface PlayerControlsProps {
|
|
9
|
-
/** @deprecated Pass via store — kept for backwards compat */
|
|
10
|
-
isPlaying?: boolean;
|
|
11
|
-
/** @deprecated Pass via store — kept for backwards compat */
|
|
12
|
-
duration?: number;
|
|
13
|
-
/** @deprecated Pass via store — kept for backwards compat */
|
|
14
|
-
timelineReady?: boolean;
|
|
15
9
|
onTogglePlay: () => void;
|
|
16
10
|
onSeek: (time: number) => void;
|
|
17
11
|
}
|
|
@@ -19,20 +13,15 @@ interface PlayerControlsProps {
|
|
|
19
13
|
export const PlayerControls = memo(function PlayerControls({
|
|
20
14
|
onTogglePlay,
|
|
21
15
|
onSeek,
|
|
22
|
-
...overrides
|
|
23
16
|
}: PlayerControlsProps) {
|
|
24
17
|
// Subscribe to only the fields we render — each selector prevents cascading re-renders
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
18
|
+
const isPlaying = usePlayerStore((s) => s.isPlaying);
|
|
19
|
+
const duration = usePlayerStore((s) => s.duration);
|
|
20
|
+
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
|
28
21
|
const playbackRate = usePlayerStore((s) => s.playbackRate);
|
|
29
22
|
const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
|
|
30
23
|
const [showSpeedMenu, setShowSpeedMenu] = useState(false);
|
|
31
24
|
|
|
32
|
-
const isPlaying = overrides.isPlaying ?? storeIsPlaying;
|
|
33
|
-
const duration = overrides.duration ?? storeDuration;
|
|
34
|
-
const timelineReady = overrides.timelineReady ?? storeTimelineReady;
|
|
35
|
-
|
|
36
25
|
const progressFillRef = useRef<HTMLDivElement>(null);
|
|
37
26
|
const progressThumbRef = useRef<HTMLDivElement>(null);
|
|
38
27
|
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
|
@@ -43,15 +32,32 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
43
32
|
const durationRef = useRef(duration);
|
|
44
33
|
durationRef.current = duration;
|
|
45
34
|
useMountEffect(() => {
|
|
46
|
-
const
|
|
35
|
+
const updateProgress = (t: number) => {
|
|
47
36
|
currentTimeRef.current = t;
|
|
48
37
|
const dur = durationRef.current;
|
|
49
|
-
const pct = dur > 0 ? (t / dur) * 100 : 0;
|
|
38
|
+
const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
|
|
50
39
|
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
51
40
|
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
52
41
|
if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
|
|
53
|
-
}
|
|
54
|
-
|
|
42
|
+
};
|
|
43
|
+
const unsub = liveTime.subscribe(updateProgress);
|
|
44
|
+
updateProgress(usePlayerStore.getState().currentTime);
|
|
45
|
+
|
|
46
|
+
// Also poll every 500ms as a fallback in case liveTime doesn't fire
|
|
47
|
+
const interval = setInterval(() => {
|
|
48
|
+
const t = usePlayerStore.getState().currentTime;
|
|
49
|
+
const dur = usePlayerStore.getState().duration;
|
|
50
|
+
if (dur > 0 && t > 0) {
|
|
51
|
+
const pct = Math.min(100, (t / dur) * 100);
|
|
52
|
+
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
53
|
+
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
54
|
+
}
|
|
55
|
+
}, 500);
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
unsub();
|
|
59
|
+
clearInterval(interval);
|
|
60
|
+
};
|
|
55
61
|
});
|
|
56
62
|
|
|
57
63
|
const seekFromClientX = useCallback(
|
|
@@ -60,6 +66,10 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
60
66
|
if (!bar || duration <= 0) return;
|
|
61
67
|
const rect = bar.getBoundingClientRect();
|
|
62
68
|
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
69
|
+
// Immediately update progress bar visuals (don't wait for liveTime round-trip)
|
|
70
|
+
const pct = percent * 100;
|
|
71
|
+
if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
|
|
72
|
+
if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
|
|
63
73
|
onSeek(percent * duration);
|
|
64
74
|
},
|
|
65
75
|
[duration, onSeek],
|
|
@@ -102,32 +112,42 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
102
112
|
);
|
|
103
113
|
|
|
104
114
|
return (
|
|
105
|
-
<div
|
|
115
|
+
<div
|
|
116
|
+
className="px-4 py-2 flex items-center gap-3"
|
|
117
|
+
style={{ borderTop: "1px solid rgba(255,255,255,0.04)" }}
|
|
118
|
+
>
|
|
119
|
+
{/* Play/Pause button */}
|
|
106
120
|
<button
|
|
107
121
|
type="button"
|
|
108
122
|
aria-label={isPlaying ? "Pause" : "Play"}
|
|
109
123
|
onClick={onTogglePlay}
|
|
110
124
|
disabled={!timelineReady}
|
|
111
|
-
className="flex-shrink-0 w-
|
|
125
|
+
className="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-colors"
|
|
126
|
+
style={{ background: "rgba(255,255,255,0.06)" }}
|
|
112
127
|
>
|
|
113
128
|
{isPlaying ? (
|
|
114
|
-
<svg width="
|
|
129
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
115
130
|
<rect x="6" y="4" width="4" height="16" rx="1" />
|
|
116
131
|
<rect x="14" y="4" width="4" height="16" rx="1" />
|
|
117
132
|
</svg>
|
|
118
133
|
) : (
|
|
119
|
-
<svg width="
|
|
120
|
-
<
|
|
134
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="#FAFAFA" aria-hidden="true">
|
|
135
|
+
<polygon points="6,3 20,12 6,21" />
|
|
121
136
|
</svg>
|
|
122
137
|
)}
|
|
123
138
|
</button>
|
|
124
139
|
|
|
125
|
-
|
|
140
|
+
{/* Time display */}
|
|
141
|
+
<span
|
|
142
|
+
className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
|
|
143
|
+
style={{ color: "#A1A1AA" }}
|
|
144
|
+
>
|
|
126
145
|
<span ref={timeDisplayRef}>{formatTime(0)}</span>
|
|
127
|
-
<span
|
|
128
|
-
<span
|
|
146
|
+
<span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
|
|
147
|
+
<span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
|
|
129
148
|
</span>
|
|
130
149
|
|
|
150
|
+
{/* Seek bar — teal progress fill */}
|
|
131
151
|
<div
|
|
132
152
|
ref={seekBarRef}
|
|
133
153
|
role="slider"
|
|
@@ -141,16 +161,24 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
141
161
|
onMouseDown={handleMouseDown}
|
|
142
162
|
onKeyDown={handleKeyDown}
|
|
143
163
|
>
|
|
144
|
-
<div
|
|
164
|
+
<div
|
|
165
|
+
className="w-full rounded-full relative"
|
|
166
|
+
style={{ background: "rgba(255,255,255,0.15)", height: "3px" }}
|
|
167
|
+
>
|
|
168
|
+
{/* Progress fill — width is controlled imperatively via ref to avoid React re-render resets */}
|
|
145
169
|
<div
|
|
146
170
|
ref={progressFillRef}
|
|
147
|
-
className="absolute
|
|
148
|
-
style={{
|
|
171
|
+
className="absolute top-0 bottom-0 left-0 z-[1] rounded-full"
|
|
172
|
+
style={{ background: "linear-gradient(90deg, var(--hf-accent, #3CE6AC), #2BBFA0)" }}
|
|
149
173
|
/>
|
|
174
|
+
{/* Playhead thumb — left is controlled imperatively via ref */}
|
|
150
175
|
<div
|
|
151
176
|
ref={progressThumbRef}
|
|
152
|
-
className="absolute top-1/2
|
|
153
|
-
style={{
|
|
177
|
+
className="absolute top-1/2 z-[2] w-3 h-3 rounded-full -translate-y-1/2 -translate-x-1/2 transition-transform group-hover:scale-125"
|
|
178
|
+
style={{
|
|
179
|
+
background: "var(--hf-accent, #3CE6AC)",
|
|
180
|
+
boxShadow: "0 0 6px rgba(60,230,172,0.4), 0 1px 4px rgba(0,0,0,0.4)",
|
|
181
|
+
}}
|
|
154
182
|
/>
|
|
155
183
|
</div>
|
|
156
184
|
</div>
|
|
@@ -160,12 +188,16 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
160
188
|
<button
|
|
161
189
|
type="button"
|
|
162
190
|
onClick={() => setShowSpeedMenu((v) => !v)}
|
|
163
|
-
className="px-
|
|
191
|
+
className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
|
|
192
|
+
style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
|
|
164
193
|
>
|
|
165
194
|
{playbackRate === 1 ? "1x" : `${playbackRate}x`}
|
|
166
195
|
</button>
|
|
167
196
|
{showSpeedMenu && (
|
|
168
|
-
<div
|
|
197
|
+
<div
|
|
198
|
+
className="absolute bottom-full right-0 mb-1.5 rounded-lg shadow-xl z-50 min-w-[56px] overflow-hidden"
|
|
199
|
+
style={{ background: "#161618", border: "1px solid rgba(255,255,255,0.08)" }}
|
|
200
|
+
>
|
|
169
201
|
{SPEED_OPTIONS.map((rate) => (
|
|
170
202
|
<button
|
|
171
203
|
key={rate}
|
|
@@ -173,11 +205,18 @@ export const PlayerControls = memo(function PlayerControls({
|
|
|
173
205
|
setPlaybackRate(rate);
|
|
174
206
|
setShowSpeedMenu(false);
|
|
175
207
|
}}
|
|
176
|
-
className=
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
208
|
+
className="block w-full px-3 py-1.5 text-[11px] text-left font-mono tabular-nums transition-colors"
|
|
209
|
+
style={{
|
|
210
|
+
color: rate === playbackRate ? "#FAFAFA" : "#71717A",
|
|
211
|
+
background: rate === playbackRate ? "rgba(255,255,255,0.06)" : "transparent",
|
|
212
|
+
}}
|
|
213
|
+
onMouseEnter={(e) => {
|
|
214
|
+
if (rate !== playbackRate)
|
|
215
|
+
e.currentTarget.style.background = "rgba(255,255,255,0.04)";
|
|
216
|
+
}}
|
|
217
|
+
onMouseLeave={(e) => {
|
|
218
|
+
if (rate !== playbackRate) e.currentTarget.style.background = "transparent";
|
|
219
|
+
}}
|
|
181
220
|
>
|
|
182
221
|
{rate}x
|
|
183
222
|
</button>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateTicks } from "./Timeline";
|
|
3
|
+
import { formatTime } from "../lib/time";
|
|
4
|
+
|
|
5
|
+
describe("generateTicks", () => {
|
|
6
|
+
it("returns empty arrays for duration <= 0", () => {
|
|
7
|
+
expect(generateTicks(0)).toEqual({ major: [], minor: [] });
|
|
8
|
+
expect(generateTicks(-5)).toEqual({ major: [], minor: [] });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("generates ticks for a short duration (3 seconds)", () => {
|
|
12
|
+
const { major } = generateTicks(3);
|
|
13
|
+
expect(major.length).toBeGreaterThan(0);
|
|
14
|
+
expect(major[0]).toBe(0);
|
|
15
|
+
expect(major).toContain(0);
|
|
16
|
+
expect(major).toContain(1);
|
|
17
|
+
expect(major).toContain(2);
|
|
18
|
+
expect(major).toContain(3);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("generates ticks for a medium duration (10 seconds)", () => {
|
|
22
|
+
const { major, minor } = generateTicks(10);
|
|
23
|
+
expect(major).toContain(0);
|
|
24
|
+
expect(major).toContain(2);
|
|
25
|
+
expect(major).toContain(4);
|
|
26
|
+
expect(major).toContain(6);
|
|
27
|
+
expect(major).toContain(8);
|
|
28
|
+
expect(major).toContain(10);
|
|
29
|
+
expect(minor).toContain(1);
|
|
30
|
+
expect(minor).toContain(3);
|
|
31
|
+
expect(minor).toContain(5);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("generates ticks for a long duration (120 seconds)", () => {
|
|
35
|
+
const { major, minor } = generateTicks(120);
|
|
36
|
+
expect(major).toContain(0);
|
|
37
|
+
expect(major).toContain(30);
|
|
38
|
+
expect(major).toContain(60);
|
|
39
|
+
expect(major).toContain(90);
|
|
40
|
+
expect(major).toContain(120);
|
|
41
|
+
expect(minor).toContain(15);
|
|
42
|
+
expect(minor).toContain(45);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("generates ticks for a very long duration (500 seconds)", () => {
|
|
46
|
+
const { major } = generateTicks(500);
|
|
47
|
+
expect(major).toContain(0);
|
|
48
|
+
expect(major).toContain(60);
|
|
49
|
+
expect(major).toContain(120);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("major and minor ticks do not overlap", () => {
|
|
53
|
+
const { major, minor } = generateTicks(30);
|
|
54
|
+
for (const t of minor) {
|
|
55
|
+
expect(major).not.toContain(t);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("all tick values are non-negative", () => {
|
|
60
|
+
const { major, minor } = generateTicks(60);
|
|
61
|
+
for (const t of [...major, ...minor]) {
|
|
62
|
+
expect(t).toBeGreaterThanOrEqual(0);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("major ticks always start at 0", () => {
|
|
67
|
+
for (const d of [1, 5, 10, 30, 60, 120, 300]) {
|
|
68
|
+
const { major } = generateTicks(d);
|
|
69
|
+
expect(major[0]).toBe(0);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("formatTime", () => {
|
|
75
|
+
it("formats 0 seconds as 0:00", () => {
|
|
76
|
+
expect(formatTime(0)).toBe("0:00");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("formats seconds below a minute", () => {
|
|
80
|
+
expect(formatTime(5)).toBe("0:05");
|
|
81
|
+
expect(formatTime(30)).toBe("0:30");
|
|
82
|
+
expect(formatTime(59)).toBe("0:59");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("formats exactly one minute", () => {
|
|
86
|
+
expect(formatTime(60)).toBe("1:00");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("formats minutes and seconds", () => {
|
|
90
|
+
expect(formatTime(90)).toBe("1:30");
|
|
91
|
+
expect(formatTime(125)).toBe("2:05");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("floors fractional seconds", () => {
|
|
95
|
+
expect(formatTime(5.7)).toBe("0:05");
|
|
96
|
+
expect(formatTime(59.9)).toBe("0:59");
|
|
97
|
+
expect(formatTime(90.5)).toBe("1:30");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles large values", () => {
|
|
101
|
+
expect(formatTime(600)).toBe("10:00");
|
|
102
|
+
expect(formatTime(3661)).toBe("61:01");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("zero-pads seconds to two digits", () => {
|
|
106
|
+
expect(formatTime(1)).toBe("0:01");
|
|
107
|
+
expect(formatTime(9)).toBe("0:09");
|
|
108
|
+
expect(formatTime(61)).toBe("1:01");
|
|
109
|
+
});
|
|
110
|
+
});
|