@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.
Files changed (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. 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 storeIsPlaying = usePlayerStore((s) => s.isPlaying);
26
- const storeDuration = usePlayerStore((s) => s.duration);
27
- const storeTimelineReady = usePlayerStore((s) => s.timelineReady);
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 unsub = liveTime.subscribe((t) => {
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
- return unsub;
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 className="px-3 py-2 flex items-center gap-3">
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-7 h-7 flex items-center justify-center rounded-md text-neutral-300 hover:text-white hover:bg-neutral-800 disabled:opacity-40 disabled:pointer-events-none transition-colors"
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="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
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="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
120
- <path d="M8 5v14l11-7z" />
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
- <span className="text-neutral-500 font-mono text-xs tabular-nums flex-shrink-0 min-w-[80px]">
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 className="text-neutral-700 mx-0.5">/</span>
128
- <span className="text-neutral-600">{formatTime(duration)}</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 className="w-full h-[3px] bg-neutral-800 rounded-full relative">
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 inset-y-0 left-0 bg-white/80 rounded-full"
148
- style={{ width: 0 }}
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 w-2 h-2 bg-white rounded-full -translate-y-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
153
- style={{ left: 0 }}
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-1.5 py-0.5 rounded text-[11px] font-mono tabular-nums text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
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 className="absolute bottom-full right-0 mb-1 py-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl z-50 min-w-[60px]">
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={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
177
- rate === playbackRate
178
- ? "text-white bg-neutral-800"
179
- : "text-neutral-400 hover:text-white hover:bg-neutral-800"
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
+ });