@hyperframes/studio 0.1.10 → 0.1.12
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-BEwJNmPo.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 +137 -0
- package/src/components/renders/useRenderQueue.ts +193 -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
|
@@ -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
|
+
});
|