@hyperframes/studio 0.1.13 → 0.1.14
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-CLmYRLY-.css +1 -0
- package/dist/assets/index-CRvFpc0E.js +84 -0
- package/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/App.tsx +139 -657
- package/src/components/LintModal.tsx +149 -0
- package/src/components/MediaPreview.tsx +79 -0
- package/src/components/editor/FileTree.tsx +50 -40
- package/src/components/editor/PropertyPanel.tsx +3 -3
- package/src/components/nle/NLELayout.tsx +59 -43
- package/src/components/renders/RenderQueue.tsx +19 -16
- package/src/components/renders/RenderQueueItem.tsx +13 -8
- package/src/components/sidebar/AssetsTab.tsx +34 -144
- package/src/components/sidebar/CompositionsTab.tsx +47 -161
- package/src/components/sidebar/LeftSidebar.tsx +79 -8
- package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
- package/src/index.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +20 -94
- package/src/player/components/EditModal.tsx +5 -5
- package/src/player/components/PlayerControls.tsx +56 -3
- package/src/player/components/Timeline.tsx +13 -17
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/index.ts +0 -1
- package/src/player/store/playerStore.ts +3 -28
- package/src/utils/mediaTypes.ts +9 -0
- package/dist/assets/index-2uBPlHR_.css +0 -1
- package/dist/assets/index-uQ8cgxb3.js +0 -92
- package/src/components/ui/ExpandOnHover.tsx +0 -194
- package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
- package/src/hooks/useCodeEditor.ts +0 -88
- package/src/player/components/PreviewPanel.tsx +0 -181
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
|
-
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
|
3
|
-
import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
|
|
4
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
|
+
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
5
4
|
|
|
6
5
|
interface AssetsTabProps {
|
|
7
6
|
projectId: string;
|
|
@@ -9,11 +8,7 @@ interface AssetsTabProps {
|
|
|
9
8
|
onImport?: (files: FileList) => void;
|
|
10
9
|
}
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
|
|
14
|
-
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
15
|
-
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
|
|
16
|
-
|
|
11
|
+
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
|
|
17
12
|
function AssetThumbnail({
|
|
18
13
|
serveUrl,
|
|
19
14
|
name,
|
|
@@ -28,7 +23,7 @@ function AssetThumbnail({
|
|
|
28
23
|
isAudio: boolean;
|
|
29
24
|
}) {
|
|
30
25
|
return (
|
|
31
|
-
|
|
26
|
+
<>
|
|
32
27
|
{isImage && (
|
|
33
28
|
<img
|
|
34
29
|
src={serveUrl}
|
|
@@ -42,7 +37,7 @@ function AssetThumbnail({
|
|
|
42
37
|
)}
|
|
43
38
|
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
44
39
|
{isAudio && (
|
|
45
|
-
<div className="w-full h-full flex items-center justify-center
|
|
40
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
46
41
|
<svg
|
|
47
42
|
width="16"
|
|
48
43
|
height="16"
|
|
@@ -59,7 +54,7 @@ function AssetThumbnail({
|
|
|
59
54
|
</div>
|
|
60
55
|
)}
|
|
61
56
|
{!isImage && !isVideo && !isAudio && (
|
|
62
|
-
<div className="w-full h-full flex items-center justify-center
|
|
57
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
63
58
|
<svg
|
|
64
59
|
width="14"
|
|
65
60
|
height="14"
|
|
@@ -78,89 +73,7 @@ function AssetThumbnail({
|
|
|
78
73
|
</svg>
|
|
79
74
|
</div>
|
|
80
75
|
)}
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function ExpandedAssetPreview({
|
|
86
|
-
serveUrl,
|
|
87
|
-
name,
|
|
88
|
-
asset,
|
|
89
|
-
isImage,
|
|
90
|
-
isVideo,
|
|
91
|
-
isAudio,
|
|
92
|
-
onCopy,
|
|
93
|
-
}: {
|
|
94
|
-
serveUrl: string;
|
|
95
|
-
name: string;
|
|
96
|
-
asset: string;
|
|
97
|
-
isImage: boolean;
|
|
98
|
-
isVideo: boolean;
|
|
99
|
-
isAudio: boolean;
|
|
100
|
-
onCopy: () => void;
|
|
101
|
-
}) {
|
|
102
|
-
if (isVideo) {
|
|
103
|
-
return (
|
|
104
|
-
<ExpandedVideoPreview
|
|
105
|
-
src={serveUrl}
|
|
106
|
-
name={name}
|
|
107
|
-
subtitle={asset}
|
|
108
|
-
action={
|
|
109
|
-
<button
|
|
110
|
-
onClick={(e) => {
|
|
111
|
-
e.stopPropagation();
|
|
112
|
-
onCopy();
|
|
113
|
-
}}
|
|
114
|
-
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
|
|
115
|
-
>
|
|
116
|
-
Copy Path
|
|
117
|
-
</button>
|
|
118
|
-
}
|
|
119
|
-
/>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return (
|
|
124
|
-
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
|
125
|
-
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
|
126
|
-
{isImage && (
|
|
127
|
-
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
|
128
|
-
)}
|
|
129
|
-
{isAudio && (
|
|
130
|
-
<div className="flex flex-col items-center gap-4">
|
|
131
|
-
<svg
|
|
132
|
-
width="48"
|
|
133
|
-
height="48"
|
|
134
|
-
viewBox="0 0 24 24"
|
|
135
|
-
fill="none"
|
|
136
|
-
stroke="currentColor"
|
|
137
|
-
strokeWidth="1.5"
|
|
138
|
-
className="text-purple-400"
|
|
139
|
-
>
|
|
140
|
-
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
|
141
|
-
<circle cx="6" cy="18" r="3" />
|
|
142
|
-
<circle cx="18" cy="16" r="3" />
|
|
143
|
-
</svg>
|
|
144
|
-
<audio src={serveUrl} controls autoPlay className="w-64" />
|
|
145
|
-
</div>
|
|
146
|
-
)}
|
|
147
|
-
</div>
|
|
148
|
-
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
|
149
|
-
<div>
|
|
150
|
-
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
|
151
|
-
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
|
|
152
|
-
</div>
|
|
153
|
-
<button
|
|
154
|
-
onClick={(e) => {
|
|
155
|
-
e.stopPropagation();
|
|
156
|
-
onCopy();
|
|
157
|
-
}}
|
|
158
|
-
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
|
159
|
-
>
|
|
160
|
-
Copy Path
|
|
161
|
-
</button>
|
|
162
|
-
</div>
|
|
163
|
-
</div>
|
|
76
|
+
</>
|
|
164
77
|
);
|
|
165
78
|
}
|
|
166
79
|
|
|
@@ -175,75 +88,52 @@ function AssetCard({
|
|
|
175
88
|
onCopy: (path: string) => void;
|
|
176
89
|
isCopied: boolean;
|
|
177
90
|
}) {
|
|
91
|
+
const [hovered, setHovered] = useState(false);
|
|
178
92
|
const name = asset.split("/").pop() ?? asset;
|
|
179
93
|
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
180
|
-
const isImage = IMAGE_EXT.test(asset);
|
|
181
94
|
const isVideo = VIDEO_EXT.test(asset);
|
|
182
|
-
const isAudio = AUDIO_EXT.test(asset);
|
|
183
|
-
const hasExpandablePreview = isImage || isVideo || isAudio;
|
|
184
95
|
|
|
185
|
-
|
|
96
|
+
return (
|
|
186
97
|
<div
|
|
98
|
+
onClick={() => onCopy(asset)}
|
|
99
|
+
onPointerEnter={() => setHovered(true)}
|
|
100
|
+
onPointerLeave={() => setHovered(false)}
|
|
187
101
|
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
|
188
102
|
isCopied
|
|
189
|
-
? "bg-
|
|
103
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
190
104
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
191
105
|
}`}
|
|
192
106
|
>
|
|
193
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
107
|
+
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
108
|
+
<AssetThumbnail
|
|
109
|
+
serveUrl={serveUrl}
|
|
110
|
+
name={name}
|
|
111
|
+
isImage={IMAGE_EXT.test(asset)}
|
|
112
|
+
isVideo={isVideo}
|
|
113
|
+
isAudio={AUDIO_EXT.test(asset)}
|
|
114
|
+
/>
|
|
115
|
+
{/* Inline video autoplay on hover — same pattern as renders */}
|
|
116
|
+
{isVideo && hovered && (
|
|
117
|
+
<video
|
|
118
|
+
src={serveUrl}
|
|
119
|
+
autoPlay
|
|
120
|
+
muted
|
|
121
|
+
loop
|
|
122
|
+
playsInline
|
|
123
|
+
className="absolute inset-0 w-full h-full object-contain"
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
200
127
|
<div className="min-w-0 flex-1">
|
|
201
128
|
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
|
202
129
|
{isCopied ? (
|
|
203
|
-
<span className="text-[9px] text-
|
|
130
|
+
<span className="text-[9px] text-studio-accent">Copied!</span>
|
|
204
131
|
) : (
|
|
205
132
|
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
|
206
133
|
)}
|
|
207
134
|
</div>
|
|
208
135
|
</div>
|
|
209
136
|
);
|
|
210
|
-
|
|
211
|
-
if (!hasExpandablePreview) {
|
|
212
|
-
return (
|
|
213
|
-
<button
|
|
214
|
-
type="button"
|
|
215
|
-
onClick={() => onCopy(asset)}
|
|
216
|
-
title="Click to copy path"
|
|
217
|
-
className="w-full"
|
|
218
|
-
>
|
|
219
|
-
{card}
|
|
220
|
-
</button>
|
|
221
|
-
);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return (
|
|
225
|
-
<ExpandOnHover
|
|
226
|
-
expandedContent={(closeExpand) => (
|
|
227
|
-
<ExpandedAssetPreview
|
|
228
|
-
serveUrl={serveUrl}
|
|
229
|
-
name={name}
|
|
230
|
-
asset={asset}
|
|
231
|
-
isImage={isImage}
|
|
232
|
-
isVideo={isVideo}
|
|
233
|
-
isAudio={isAudio}
|
|
234
|
-
onCopy={() => {
|
|
235
|
-
closeExpand();
|
|
236
|
-
onCopy(asset);
|
|
237
|
-
}}
|
|
238
|
-
/>
|
|
239
|
-
)}
|
|
240
|
-
onClick={() => onCopy(asset)}
|
|
241
|
-
expandScale={0.45}
|
|
242
|
-
delay={500}
|
|
243
|
-
>
|
|
244
|
-
{card}
|
|
245
|
-
</ExpandOnHover>
|
|
246
|
-
);
|
|
247
137
|
}
|
|
248
138
|
|
|
249
139
|
export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
|
|
@@ -274,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
|
|
274
164
|
|
|
275
165
|
return (
|
|
276
166
|
<div
|
|
277
|
-
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-
|
|
167
|
+
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-studio-accent/[0.05]" : ""}`}
|
|
278
168
|
onDragOver={(e) => {
|
|
279
169
|
e.preventDefault();
|
|
280
170
|
setDragOver(true);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { memo, useRef, useState
|
|
2
|
-
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
|
1
|
+
import { memo, useRef, useState } from "react";
|
|
3
2
|
|
|
4
3
|
interface CompositionsTabProps {
|
|
5
4
|
projectId: string;
|
|
@@ -8,132 +7,6 @@ interface CompositionsTabProps {
|
|
|
8
7
|
onSelect: (comp: string) => void;
|
|
9
8
|
}
|
|
10
9
|
|
|
11
|
-
function ExpandedCompPreview({
|
|
12
|
-
previewUrl,
|
|
13
|
-
name,
|
|
14
|
-
comp,
|
|
15
|
-
onSelect,
|
|
16
|
-
}: {
|
|
17
|
-
previewUrl: string;
|
|
18
|
-
name: string;
|
|
19
|
-
comp: string;
|
|
20
|
-
onSelect: () => void;
|
|
21
|
-
}) {
|
|
22
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
-
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
24
|
-
const [dims, setDims] = useState({ w: 1920, h: 1080 });
|
|
25
|
-
const [scale, setScale] = useState(1);
|
|
26
|
-
|
|
27
|
-
const updateScale = useCallback(() => {
|
|
28
|
-
const el = containerRef.current;
|
|
29
|
-
if (!el) return;
|
|
30
|
-
const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
|
|
31
|
-
setScale(s);
|
|
32
|
-
}, [dims]);
|
|
33
|
-
|
|
34
|
-
// eslint-disable-next-line no-restricted-syntax
|
|
35
|
-
useEffect(() => {
|
|
36
|
-
updateScale();
|
|
37
|
-
const el = containerRef.current;
|
|
38
|
-
if (!el) return;
|
|
39
|
-
const ro = new ResizeObserver(updateScale);
|
|
40
|
-
ro.observe(el);
|
|
41
|
-
return () => ro.disconnect();
|
|
42
|
-
}, [updateScale]);
|
|
43
|
-
|
|
44
|
-
const handleLoad = useCallback(() => {
|
|
45
|
-
const iframe = iframeRef.current;
|
|
46
|
-
if (!iframe) return;
|
|
47
|
-
// Detect dimensions from composition
|
|
48
|
-
try {
|
|
49
|
-
const doc = iframe.contentDocument;
|
|
50
|
-
if (doc) {
|
|
51
|
-
const root = doc.querySelector("[data-composition-id]");
|
|
52
|
-
if (root) {
|
|
53
|
-
const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
|
|
54
|
-
const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
|
|
55
|
-
if (w > 0 && h > 0) setDims({ w, h });
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
} catch {
|
|
59
|
-
/* cross-origin */
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
let attempts = 0;
|
|
63
|
-
const interval = setInterval(() => {
|
|
64
|
-
try {
|
|
65
|
-
const win = iframe.contentWindow as Window & {
|
|
66
|
-
__player?: { play: () => void; seek: (t: number) => void };
|
|
67
|
-
__timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
|
|
68
|
-
};
|
|
69
|
-
if (win?.__player) {
|
|
70
|
-
win.__player.seek(0.5);
|
|
71
|
-
win.__player.play();
|
|
72
|
-
clearInterval(interval);
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
if (win?.__timelines) {
|
|
76
|
-
const keys = Object.keys(win.__timelines);
|
|
77
|
-
const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
|
|
78
|
-
if (tl) {
|
|
79
|
-
tl.seek(0.5);
|
|
80
|
-
tl.play();
|
|
81
|
-
clearInterval(interval);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
} catch {
|
|
85
|
-
/* cross-origin */
|
|
86
|
-
}
|
|
87
|
-
if (++attempts > 15) clearInterval(interval);
|
|
88
|
-
}, 200);
|
|
89
|
-
}, []);
|
|
90
|
-
|
|
91
|
-
const offsetX = containerRef.current
|
|
92
|
-
? (containerRef.current.clientWidth - dims.w * scale) / 2
|
|
93
|
-
: 0;
|
|
94
|
-
const offsetY = containerRef.current
|
|
95
|
-
? (containerRef.current.clientHeight - dims.h * scale) / 2
|
|
96
|
-
: 0;
|
|
97
|
-
|
|
98
|
-
return (
|
|
99
|
-
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
|
100
|
-
<div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
|
|
101
|
-
<iframe
|
|
102
|
-
ref={iframeRef}
|
|
103
|
-
src={previewUrl}
|
|
104
|
-
sandbox="allow-scripts allow-same-origin"
|
|
105
|
-
onLoad={handleLoad}
|
|
106
|
-
className="absolute border-none"
|
|
107
|
-
style={{
|
|
108
|
-
left: Math.max(0, offsetX),
|
|
109
|
-
top: Math.max(0, offsetY),
|
|
110
|
-
width: dims.w,
|
|
111
|
-
height: dims.h,
|
|
112
|
-
transformOrigin: "0 0",
|
|
113
|
-
transform: `scale(${scale})`,
|
|
114
|
-
}}
|
|
115
|
-
tabIndex={-1}
|
|
116
|
-
/>
|
|
117
|
-
</div>
|
|
118
|
-
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
|
119
|
-
<div>
|
|
120
|
-
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
|
121
|
-
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
|
|
122
|
-
</div>
|
|
123
|
-
<button
|
|
124
|
-
onClick={(e) => {
|
|
125
|
-
e.stopPropagation();
|
|
126
|
-
onSelect();
|
|
127
|
-
}}
|
|
128
|
-
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
|
129
|
-
>
|
|
130
|
-
Open
|
|
131
|
-
</button>
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
10
|
function CompCard({
|
|
138
11
|
projectId,
|
|
139
12
|
comp,
|
|
@@ -145,28 +18,62 @@ function CompCard({
|
|
|
145
18
|
isActive: boolean;
|
|
146
19
|
onSelect: () => void;
|
|
147
20
|
}) {
|
|
21
|
+
const [hovered, setHovered] = useState(false);
|
|
22
|
+
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
23
|
+
const handleEnter = () => {
|
|
24
|
+
hoverTimer.current = setTimeout(() => setHovered(true), 300);
|
|
25
|
+
};
|
|
26
|
+
const handleLeave = () => {
|
|
27
|
+
if (hoverTimer.current) {
|
|
28
|
+
clearTimeout(hoverTimer.current);
|
|
29
|
+
hoverTimer.current = null;
|
|
30
|
+
}
|
|
31
|
+
setHovered(false);
|
|
32
|
+
};
|
|
148
33
|
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
|
149
34
|
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
|
|
150
35
|
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
|
151
36
|
|
|
152
|
-
|
|
37
|
+
return (
|
|
153
38
|
<div
|
|
39
|
+
onClick={onSelect}
|
|
40
|
+
onPointerEnter={handleEnter}
|
|
41
|
+
onPointerLeave={handleLeave}
|
|
154
42
|
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
|
155
43
|
isActive
|
|
156
|
-
? "bg-
|
|
44
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
157
45
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
158
46
|
}`}
|
|
159
47
|
>
|
|
160
|
-
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0">
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
48
|
+
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
49
|
+
{/* Live iframe preview on hover */}
|
|
50
|
+
{hovered && (
|
|
51
|
+
<iframe
|
|
52
|
+
src={previewUrl}
|
|
53
|
+
sandbox="allow-scripts allow-same-origin"
|
|
54
|
+
className="absolute inset-0 w-[1920px] h-[1080px] border-none pointer-events-none"
|
|
55
|
+
style={{
|
|
56
|
+
transformOrigin: "0 0",
|
|
57
|
+
transform: `scale(${80 / 1920})`,
|
|
58
|
+
}}
|
|
59
|
+
tabIndex={-1}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
62
|
+
{/* Static thumbnail — hidden while hovering */}
|
|
63
|
+
<div
|
|
64
|
+
className="absolute inset-0 transition-opacity duration-150"
|
|
65
|
+
style={{ opacity: hovered ? 0 : 1 }}
|
|
66
|
+
>
|
|
67
|
+
<img
|
|
68
|
+
src={thumbnailUrl}
|
|
69
|
+
alt={name}
|
|
70
|
+
loading="lazy"
|
|
71
|
+
className="w-full h-full object-contain"
|
|
72
|
+
onError={(e) => {
|
|
73
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
74
|
+
}}
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
170
77
|
</div>
|
|
171
78
|
<div className="min-w-0 flex-1">
|
|
172
79
|
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
|
@@ -174,27 +81,6 @@ function CompCard({
|
|
|
174
81
|
</div>
|
|
175
82
|
</div>
|
|
176
83
|
);
|
|
177
|
-
|
|
178
|
-
return (
|
|
179
|
-
<ExpandOnHover
|
|
180
|
-
expandedContent={(closeExpand) => (
|
|
181
|
-
<ExpandedCompPreview
|
|
182
|
-
previewUrl={previewUrl}
|
|
183
|
-
name={name}
|
|
184
|
-
comp={comp}
|
|
185
|
-
onSelect={() => {
|
|
186
|
-
closeExpand();
|
|
187
|
-
onSelect();
|
|
188
|
-
}}
|
|
189
|
-
/>
|
|
190
|
-
)}
|
|
191
|
-
onClick={onSelect}
|
|
192
|
-
expandScale={0.5}
|
|
193
|
-
delay={500}
|
|
194
|
-
>
|
|
195
|
-
{card}
|
|
196
|
-
</ExpandOnHover>
|
|
197
|
-
);
|
|
198
84
|
}
|
|
199
85
|
|
|
200
86
|
export const CompositionsTab = memo(function CompositionsTab({
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import { memo, useState, useCallback } from "react";
|
|
1
|
+
import { memo, useState, useCallback, type ReactNode } from "react";
|
|
2
2
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
3
3
|
import { CompositionsTab } from "./CompositionsTab";
|
|
4
4
|
import { AssetsTab } from "./AssetsTab";
|
|
5
|
+
import { FileTree } from "../editor/FileTree";
|
|
5
6
|
|
|
6
|
-
type SidebarTab = "compositions" | "assets";
|
|
7
|
+
type SidebarTab = "compositions" | "assets" | "code";
|
|
7
8
|
|
|
8
9
|
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
|
9
10
|
|
|
10
11
|
function getPersistedTab(): SidebarTab {
|
|
11
12
|
const stored = localStorage.getItem(STORAGE_KEY);
|
|
12
|
-
|
|
13
|
+
if (stored === "assets") return "assets";
|
|
14
|
+
if (stored === "code") return "code";
|
|
15
|
+
return "compositions";
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
interface LeftSidebarProps {
|
|
@@ -20,6 +23,12 @@ interface LeftSidebarProps {
|
|
|
20
23
|
activeComposition: string | null;
|
|
21
24
|
onSelectComposition: (comp: string) => void;
|
|
22
25
|
onImportFiles?: (files: FileList) => void;
|
|
26
|
+
fileTree?: string[];
|
|
27
|
+
editingFile?: { path: string; content: string | null } | null;
|
|
28
|
+
onSelectFile?: (path: string) => void;
|
|
29
|
+
codeChildren?: ReactNode;
|
|
30
|
+
onLint?: () => void;
|
|
31
|
+
linting?: boolean;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export const LeftSidebar = memo(function LeftSidebar({
|
|
@@ -30,6 +39,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
30
39
|
activeComposition,
|
|
31
40
|
onSelectComposition,
|
|
32
41
|
onImportFiles,
|
|
42
|
+
fileTree: fileProp,
|
|
43
|
+
editingFile,
|
|
44
|
+
onSelectFile,
|
|
45
|
+
codeChildren,
|
|
46
|
+
onLint,
|
|
47
|
+
linting,
|
|
33
48
|
}: LeftSidebarProps) {
|
|
34
49
|
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
|
35
50
|
|
|
@@ -60,14 +75,25 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
60
75
|
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
|
61
76
|
style={{ width }}
|
|
62
77
|
>
|
|
63
|
-
{/* Tabs */}
|
|
78
|
+
{/* Tabs — Code first */}
|
|
64
79
|
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={() => selectTab("code")}
|
|
83
|
+
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
84
|
+
tab === "code"
|
|
85
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
86
|
+
: "text-neutral-500 hover:text-neutral-400"
|
|
87
|
+
}`}
|
|
88
|
+
>
|
|
89
|
+
Code
|
|
90
|
+
</button>
|
|
65
91
|
<button
|
|
66
92
|
type="button"
|
|
67
93
|
onClick={() => selectTab("compositions")}
|
|
68
94
|
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
69
95
|
tab === "compositions"
|
|
70
|
-
? "text-neutral-200 border-b-2 border-
|
|
96
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
71
97
|
: "text-neutral-500 hover:text-neutral-400"
|
|
72
98
|
}`}
|
|
73
99
|
>
|
|
@@ -78,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
78
104
|
onClick={() => selectTab("assets")}
|
|
79
105
|
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
|
80
106
|
tab === "assets"
|
|
81
|
-
? "text-neutral-200 border-b-2 border-
|
|
107
|
+
? "text-neutral-200 border-b-2 border-studio-accent"
|
|
82
108
|
: "text-neutral-500 hover:text-neutral-400"
|
|
83
109
|
}`}
|
|
84
110
|
>
|
|
@@ -87,16 +113,61 @@ export const LeftSidebar = memo(function LeftSidebar({
|
|
|
87
113
|
</div>
|
|
88
114
|
|
|
89
115
|
{/* Tab content */}
|
|
90
|
-
{tab === "compositions"
|
|
116
|
+
{tab === "compositions" && (
|
|
91
117
|
<CompositionsTab
|
|
92
118
|
projectId={projectId}
|
|
93
119
|
compositions={compositions}
|
|
94
120
|
activeComposition={activeComposition}
|
|
95
121
|
onSelect={onSelectComposition}
|
|
96
122
|
/>
|
|
97
|
-
)
|
|
123
|
+
)}
|
|
124
|
+
{tab === "assets" && (
|
|
98
125
|
<AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
|
|
99
126
|
)}
|
|
127
|
+
{tab === "code" && (
|
|
128
|
+
<div className="flex flex-1 min-h-0">
|
|
129
|
+
{(fileProp?.length ?? 0) > 0 && (
|
|
130
|
+
<div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
|
|
131
|
+
<FileTree
|
|
132
|
+
files={fileProp ?? []}
|
|
133
|
+
activeFile={editingFile?.path ?? null}
|
|
134
|
+
onSelectFile={onSelectFile ?? (() => {})}
|
|
135
|
+
/>
|
|
136
|
+
</div>
|
|
137
|
+
)}
|
|
138
|
+
<div className="flex-1 overflow-hidden min-w-0">
|
|
139
|
+
{codeChildren ?? (
|
|
140
|
+
<div className="flex items-center justify-center h-full text-neutral-600 text-sm">
|
|
141
|
+
Select a file to edit
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{/* Lint button pinned at the bottom */}
|
|
149
|
+
{onLint && (
|
|
150
|
+
<div className="border-t border-neutral-800 p-2 flex-shrink-0">
|
|
151
|
+
<button
|
|
152
|
+
onClick={onLint}
|
|
153
|
+
disabled={linting}
|
|
154
|
+
className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
|
|
155
|
+
>
|
|
156
|
+
<svg
|
|
157
|
+
width="12"
|
|
158
|
+
height="12"
|
|
159
|
+
viewBox="0 0 24 24"
|
|
160
|
+
fill="none"
|
|
161
|
+
stroke="currentColor"
|
|
162
|
+
strokeWidth="2"
|
|
163
|
+
>
|
|
164
|
+
<path d="M9 11l3 3L22 4" />
|
|
165
|
+
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
|
166
|
+
</svg>
|
|
167
|
+
{linting ? "Linting…" : "Lint"}
|
|
168
|
+
</button>
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
100
171
|
</div>
|
|
101
172
|
);
|
|
102
173
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Extracts a representative JPEG frame from a video URL using a hidden
|
|
@@ -7,12 +7,8 @@ import { useState, useEffect, useRef } from "react";
|
|
|
7
7
|
*/
|
|
8
8
|
export function VideoFrameThumbnail({ src }: { src: string }) {
|
|
9
9
|
const [frame, setFrame] = useState<string | null>(null);
|
|
10
|
-
const didExtract = useRef(false);
|
|
11
10
|
|
|
12
11
|
useEffect(() => {
|
|
13
|
-
if (didExtract.current) return;
|
|
14
|
-
didExtract.current = true;
|
|
15
|
-
|
|
16
12
|
const video = document.createElement("video");
|
|
17
13
|
video.crossOrigin = "anonymous";
|
|
18
14
|
video.muted = true;
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,6 @@ export {
|
|
|
9
9
|
Player,
|
|
10
10
|
PlayerControls,
|
|
11
11
|
Timeline,
|
|
12
|
-
PreviewPanel,
|
|
13
12
|
VideoThumbnail,
|
|
14
13
|
CompositionThumbnail,
|
|
15
14
|
useTimelinePlayer,
|
|
@@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree";
|
|
|
28
27
|
export { StudioApp } from "./App";
|
|
29
28
|
|
|
30
29
|
// Hooks
|
|
31
|
-
export { useCodeEditor } from "./hooks/useCodeEditor";
|
|
32
|
-
export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
|
|
33
30
|
export { useElementPicker } from "./hooks/useElementPicker";
|
|
34
31
|
export type { PickedElement } from "./hooks/useElementPicker";
|
|
35
32
|
|