@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
@@ -0,0 +1,80 @@
1
+ // TimelineClip — Visual clip component for the NLE timeline.
2
+
3
+ import { memo, type ReactNode } from "react";
4
+ import type { TimelineElement } from "../store/playerStore";
5
+
6
+ interface TimelineClipProps {
7
+ el: TimelineElement;
8
+ pps: number;
9
+ trackH: number;
10
+ clipY: number;
11
+ isSelected: boolean;
12
+ isHovered: boolean;
13
+ hasCustomContent: boolean;
14
+ style: { clip: string; label: string };
15
+ isComposition: boolean;
16
+ onHoverStart: () => void;
17
+ onHoverEnd: () => void;
18
+ onClick: (e: React.MouseEvent) => void;
19
+ onDoubleClick: (e: React.MouseEvent) => void;
20
+ children?: ReactNode;
21
+ }
22
+
23
+ export const TimelineClip = memo(function TimelineClip({
24
+ el,
25
+ pps,
26
+ clipY,
27
+ isSelected,
28
+ isHovered,
29
+ hasCustomContent,
30
+ style,
31
+ isComposition,
32
+ onHoverStart,
33
+ onHoverEnd,
34
+ onClick,
35
+ onDoubleClick,
36
+ children,
37
+ }: TimelineClipProps) {
38
+ const leftPx = el.start * pps;
39
+ const widthPx = Math.max(el.duration * pps, 4);
40
+
41
+ return (
42
+ <div
43
+ data-clip="true"
44
+ className={hasCustomContent ? "absolute" : "absolute flex items-center"}
45
+ style={{
46
+ left: leftPx,
47
+ width: widthPx,
48
+ top: clipY,
49
+ bottom: clipY,
50
+ borderRadius: 5,
51
+ backgroundColor: hasCustomContent ? (isComposition ? "#111" : style.clip) : style.clip,
52
+ backgroundImage:
53
+ isComposition && !hasCustomContent
54
+ ? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
55
+ : undefined,
56
+ border: isSelected
57
+ ? `2px solid rgba(255,255,255,0.9)`
58
+ : `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
59
+ boxShadow: isSelected
60
+ ? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
61
+ : isHovered
62
+ ? "0 1px 4px rgba(0,0,0,0.3)"
63
+ : "none",
64
+ transition: "border-color 120ms, box-shadow 120ms",
65
+ zIndex: isSelected ? 10 : isHovered ? 5 : 1,
66
+ }}
67
+ title={
68
+ isComposition
69
+ ? `${el.compositionSrc} \u2022 Double-click to open`
70
+ : `${el.id || el.tag} \u2022 ${el.start.toFixed(1)}s \u2013 ${(el.start + el.duration).toFixed(1)}s`
71
+ }
72
+ onPointerEnter={onHoverStart}
73
+ onPointerLeave={onHoverEnd}
74
+ onClick={onClick}
75
+ onDoubleClick={onDoubleClick}
76
+ >
77
+ {children}
78
+ </div>
79
+ );
80
+ });
@@ -0,0 +1,196 @@
1
+ import { memo, useRef, useState, useCallback, useEffect } from "react";
2
+ import { useMountEffect } from "../../hooks/useMountEffect";
3
+
4
+ interface VideoThumbnailProps {
5
+ videoSrc: string;
6
+ label: string;
7
+ labelColor: string;
8
+ duration?: number;
9
+ }
10
+
11
+ const CLIP_HEIGHT = 66;
12
+ const MAX_UNIQUE_FRAMES: number = 6;
13
+
14
+ /**
15
+ * Renders a film-strip of video frames extracted client-side via a hidden
16
+ * <video> + <canvas>. Each frame is a fixed-width tile; frames repeat to
17
+ * fill the clip width — matching ClipThumbnail's visual pattern.
18
+ */
19
+ export const VideoThumbnail = memo(function VideoThumbnail({
20
+ videoSrc,
21
+ label,
22
+ labelColor,
23
+ duration = 5,
24
+ }: VideoThumbnailProps) {
25
+ const [containerWidth, setContainerWidth] = useState(0);
26
+ const [visible, setVisible] = useState(false);
27
+ const [frames, setFrames] = useState<string[]>([]);
28
+ const [aspect, setAspect] = useState(16 / 9);
29
+ const ioRef = useRef<IntersectionObserver | null>(null);
30
+ const roRef = useRef<ResizeObserver | null>(null);
31
+ const extractingRef = useRef(false);
32
+
33
+ const setContainerRef = useCallback((el: HTMLDivElement | null) => {
34
+ ioRef.current?.disconnect();
35
+ roRef.current?.disconnect();
36
+ if (!el) return;
37
+
38
+ const measured = el.parentElement?.clientWidth || el.clientWidth;
39
+ setContainerWidth(measured);
40
+
41
+ ioRef.current = new IntersectionObserver(
42
+ ([entry]) => {
43
+ if (entry.isIntersecting) {
44
+ setVisible(true);
45
+ ioRef.current?.disconnect();
46
+ }
47
+ },
48
+ { rootMargin: "200px" },
49
+ );
50
+ ioRef.current.observe(el);
51
+
52
+ const target = el.parentElement || el;
53
+ roRef.current = new ResizeObserver(([entry]) => {
54
+ setContainerWidth(entry.contentRect.width);
55
+ });
56
+ roRef.current.observe(target);
57
+ }, []);
58
+
59
+ useMountEffect(() => () => {
60
+ ioRef.current?.disconnect();
61
+ roRef.current?.disconnect();
62
+ });
63
+
64
+ // Extract frames progressively — each frame appears as soon as it's ready.
65
+ // Note: useEffect with deps is acceptable — syncs with external video element API,
66
+ // requires cleanup (cancel extraction, revoke URLs) when inputs change.
67
+ // eslint-disable-next-line no-restricted-syntax
68
+ useEffect(() => {
69
+ if (!visible || extractingRef.current) return;
70
+ extractingRef.current = true;
71
+
72
+ const video = document.createElement("video");
73
+ video.crossOrigin = "anonymous";
74
+ video.muted = true;
75
+ video.preload = "auto";
76
+
77
+ const canvas = document.createElement("canvas");
78
+ const ctx = canvas.getContext("2d");
79
+ if (!ctx) {
80
+ extractingRef.current = false;
81
+ return;
82
+ }
83
+
84
+ const timestamps: number[] = [];
85
+ const minSeek = Math.min(0.4, duration * 0.05);
86
+ for (let i = 0; i < MAX_UNIQUE_FRAMES; i++) {
87
+ const raw =
88
+ MAX_UNIQUE_FRAMES === 1 ? duration * 0.15 : (i / (MAX_UNIQUE_FRAMES - 1)) * duration;
89
+ timestamps.push(Math.max(raw, minSeek));
90
+ }
91
+
92
+ let idx = 0;
93
+ let cancelled = false;
94
+
95
+ const extractNext = () => {
96
+ if (cancelled || idx >= timestamps.length) {
97
+ if (!cancelled) {
98
+ video.src = "";
99
+ video.load();
100
+ }
101
+ return;
102
+ }
103
+ video.currentTime = timestamps[idx];
104
+ };
105
+
106
+ video.addEventListener("loadedmetadata", () => {
107
+ if (video.videoWidth > 0 && video.videoHeight > 0) {
108
+ setAspect(video.videoWidth / video.videoHeight);
109
+ const h = CLIP_HEIGHT * 2;
110
+ const w = Math.round(h * (video.videoWidth / video.videoHeight));
111
+ canvas.width = w;
112
+ canvas.height = h;
113
+ }
114
+ extractNext();
115
+ });
116
+
117
+ video.addEventListener("seeked", () => {
118
+ if (cancelled) return;
119
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
120
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.6);
121
+ // Stream each frame immediately
122
+ setFrames((prev) => [...prev, dataUrl]);
123
+ idx++;
124
+ extractNext();
125
+ });
126
+
127
+ video.addEventListener("error", () => {
128
+ /* keep whatever frames we have */
129
+ });
130
+
131
+ video.src = videoSrc;
132
+ video.load();
133
+
134
+ return () => {
135
+ cancelled = true;
136
+ extractingRef.current = false;
137
+ setFrames([]);
138
+ video.src = "";
139
+ video.load();
140
+ };
141
+ }, [visible, videoSrc, duration]);
142
+
143
+ const frameW = Math.round(CLIP_HEIGHT * aspect);
144
+ const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
145
+
146
+ return (
147
+ <div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
148
+ {visible && frames.length > 0 && (
149
+ <div className="absolute inset-0 flex">
150
+ {Array.from({ length: frameCount }).map((_, i) => {
151
+ const src = frames[i % frames.length];
152
+ return (
153
+ <div
154
+ key={i}
155
+ className="flex-shrink-0 h-full relative overflow-hidden bg-neutral-900"
156
+ style={{ width: frameW }}
157
+ >
158
+ <img
159
+ src={src}
160
+ alt=""
161
+ draggable={false}
162
+ className="absolute inset-0 w-full h-full object-cover"
163
+ />
164
+ </div>
165
+ );
166
+ })}
167
+ </div>
168
+ )}
169
+
170
+ {visible && frames.length === 0 && (
171
+ <div
172
+ className="absolute inset-0 animate-pulse"
173
+ style={{
174
+ background:
175
+ "linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
176
+ }}
177
+ />
178
+ )}
179
+
180
+ <div
181
+ className="absolute bottom-0 left-0 right-0 z-10 px-1.5 pb-0.5 pt-3"
182
+ style={{
183
+ background:
184
+ "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%)",
185
+ }}
186
+ >
187
+ <span
188
+ className="text-[9px] font-semibold truncate block leading-tight"
189
+ style={{ color: labelColor, textShadow: "0 1px 2px rgba(0,0,0,0.9)" }}
190
+ >
191
+ {label}
192
+ </span>
193
+ </div>
194
+ </div>
195
+ );
196
+ });