@hyperframes/studio 0.1.12 → 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 +77 -19
- package/src/components/renders/useRenderQueue.ts +1 -0
- package/src/components/sidebar/AssetsTab.tsx +37 -149
- package/src/components/sidebar/CompositionsTab.tsx +48 -162
- package/src/components/sidebar/LeftSidebar.tsx +79 -8
- package/src/components/ui/VideoFrameThumbnail.tsx +50 -0
- package/src/index.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +21 -95
- package/src/player/components/EditModal.tsx +5 -5
- package/src/player/components/Player.tsx +0 -1
- package/src/player/components/PlayerControls.tsx +56 -3
- package/src/player/components/Timeline.tsx +14 -18
- 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-BEwJNmPo.js +0 -92
- package/dist/assets/index-BnvciBdD.css +0 -1
- package/src/components/ui/ExpandOnHover.tsx +0 -194
- package/src/hooks/useCodeEditor.ts +0 -88
- package/src/player/components/PreviewPanel.tsx +0 -181
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { memo, useCallback, useState } from "react";
|
|
2
|
+
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
2
3
|
import type { RenderJob } from "./useRenderQueue";
|
|
3
4
|
|
|
4
5
|
interface RenderQueueItemProps {
|
|
5
6
|
job: RenderJob;
|
|
7
|
+
projectId: string;
|
|
6
8
|
onDelete: () => void;
|
|
7
9
|
}
|
|
8
10
|
|
|
@@ -19,34 +21,88 @@ function formatTimeAgo(timestamp: number): string {
|
|
|
19
21
|
return `${Math.floor(diff / 3600000)}h ago`;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/** Static frame extracted once via hidden video + canvas. */
|
|
25
|
+
|
|
22
26
|
export const RenderQueueItem = memo(function RenderQueueItem({
|
|
23
27
|
job,
|
|
28
|
+
projectId,
|
|
24
29
|
onDelete,
|
|
25
30
|
}: RenderQueueItemProps) {
|
|
26
31
|
const [hovered, setHovered] = useState(false);
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}, [
|
|
33
|
+
// Direct file URL — serves from disk, survives server restarts
|
|
34
|
+
const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
|
|
35
|
+
|
|
36
|
+
const handleOpen = useCallback(() => {
|
|
37
|
+
window.open(fileSrc, "_blank");
|
|
38
|
+
}, [fileSrc]);
|
|
39
|
+
|
|
40
|
+
const handleDownload = useCallback(
|
|
41
|
+
(e: React.MouseEvent) => {
|
|
42
|
+
e.stopPropagation();
|
|
43
|
+
const a = document.createElement("a");
|
|
44
|
+
a.href = fileSrc;
|
|
45
|
+
a.download = job.filename;
|
|
46
|
+
a.click();
|
|
47
|
+
},
|
|
48
|
+
[fileSrc, job.filename],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const viewSrc = fileSrc;
|
|
52
|
+
const isComplete = job.status === "complete";
|
|
34
53
|
|
|
35
54
|
return (
|
|
36
55
|
<div
|
|
37
56
|
onPointerEnter={() => setHovered(true)}
|
|
38
57
|
onPointerLeave={() => setHovered(false)}
|
|
39
|
-
|
|
58
|
+
onClick={isComplete ? handleOpen : undefined}
|
|
59
|
+
className={[
|
|
60
|
+
"px-3 py-2.5 border-b border-neutral-800/30 last:border-0 transition-colors duration-150",
|
|
61
|
+
isComplete ? "cursor-pointer hover:bg-neutral-800/30" : "",
|
|
62
|
+
]
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.join(" ")}
|
|
40
65
|
>
|
|
41
|
-
<div className="flex items-center gap-2">
|
|
42
|
-
{/*
|
|
43
|
-
<div className="flex-shrink-0">
|
|
66
|
+
<div className="flex items-center gap-2.5">
|
|
67
|
+
{/* Thumbnail — static frame; swaps to live video on hover */}
|
|
68
|
+
<div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
|
69
|
+
{isComplete && (
|
|
70
|
+
<>
|
|
71
|
+
{/* Live video — visible on hover */}
|
|
72
|
+
{hovered && (
|
|
73
|
+
<video
|
|
74
|
+
src={viewSrc}
|
|
75
|
+
autoPlay
|
|
76
|
+
muted
|
|
77
|
+
loop
|
|
78
|
+
playsInline
|
|
79
|
+
className="absolute inset-0 w-full h-full object-contain"
|
|
80
|
+
/>
|
|
81
|
+
)}
|
|
82
|
+
{/* Static frame — visible when not hovering */}
|
|
83
|
+
<div
|
|
84
|
+
className="absolute inset-0 transition-opacity duration-150"
|
|
85
|
+
style={{ opacity: hovered ? 0 : 1 }}
|
|
86
|
+
>
|
|
87
|
+
<VideoFrameThumbnail src={viewSrc} />
|
|
88
|
+
</div>
|
|
89
|
+
</>
|
|
90
|
+
)}
|
|
44
91
|
{job.status === "rendering" && (
|
|
45
|
-
<div className="w-
|
|
92
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
93
|
+
<div className="w-2 h-2 rounded-full bg-studio-accent animate-pulse" />
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
{job.status === "failed" && (
|
|
97
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
98
|
+
<div className="w-2 h-2 rounded-full bg-red-400" />
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
{job.status === "cancelled" && (
|
|
102
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
103
|
+
<div className="w-2 h-2 rounded-full bg-neutral-600" />
|
|
104
|
+
</div>
|
|
46
105
|
)}
|
|
47
|
-
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
|
48
|
-
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
|
49
|
-
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
|
|
50
106
|
</div>
|
|
51
107
|
|
|
52
108
|
{/* Info */}
|
|
@@ -62,16 +118,15 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
62
118
|
)}
|
|
63
119
|
</div>
|
|
64
120
|
|
|
65
|
-
{/* Progress bar + percentage */}
|
|
66
121
|
{job.status === "rendering" && (
|
|
67
122
|
<div className="mt-1">
|
|
68
123
|
<div className="flex items-center justify-between mb-0.5">
|
|
69
124
|
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
|
|
70
|
-
<span className="text-[9px] font-mono text-
|
|
125
|
+
<span className="text-[9px] font-mono text-studio-accent">{job.progress}%</span>
|
|
71
126
|
</div>
|
|
72
127
|
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
|
|
73
128
|
<div
|
|
74
|
-
className="h-full bg-
|
|
129
|
+
className="h-full bg-studio-accent rounded-full transition-all duration-300"
|
|
75
130
|
style={{ width: `${job.progress}%` }}
|
|
76
131
|
/>
|
|
77
132
|
</div>
|
|
@@ -90,7 +145,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
90
145
|
{/* Actions */}
|
|
91
146
|
{hovered && (
|
|
92
147
|
<div className="flex items-center gap-1 flex-shrink-0">
|
|
93
|
-
{
|
|
148
|
+
{isComplete && (
|
|
94
149
|
<button
|
|
95
150
|
onClick={handleDownload}
|
|
96
151
|
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
|
|
@@ -113,7 +168,10 @@ export const RenderQueueItem = memo(function RenderQueueItem({
|
|
|
113
168
|
</button>
|
|
114
169
|
)}
|
|
115
170
|
<button
|
|
116
|
-
onClick={
|
|
171
|
+
onClick={(e) => {
|
|
172
|
+
e.stopPropagation();
|
|
173
|
+
onDelete();
|
|
174
|
+
}}
|
|
117
175
|
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
|
|
118
176
|
title="Remove"
|
|
119
177
|
>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { memo, useState, useCallback, useRef } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
|
+
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
3
4
|
|
|
4
5
|
interface AssetsTabProps {
|
|
5
6
|
projectId: string;
|
|
@@ -7,11 +8,7 @@ interface AssetsTabProps {
|
|
|
7
8
|
onImport?: (files: FileList) => void;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
|
|
12
|
-
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
13
|
-
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
|
|
14
|
-
|
|
11
|
+
/** Inline thumbnail content — rendered inside the container div in AssetCard. */
|
|
15
12
|
function AssetThumbnail({
|
|
16
13
|
serveUrl,
|
|
17
14
|
name,
|
|
@@ -26,36 +23,21 @@ function AssetThumbnail({
|
|
|
26
23
|
isAudio: boolean;
|
|
27
24
|
}) {
|
|
28
25
|
return (
|
|
29
|
-
|
|
26
|
+
<>
|
|
30
27
|
{isImage && (
|
|
31
28
|
<img
|
|
32
29
|
src={serveUrl}
|
|
33
30
|
alt={name}
|
|
34
31
|
loading="lazy"
|
|
35
|
-
className="w-full h-full object-
|
|
32
|
+
className="w-full h-full object-contain"
|
|
36
33
|
onError={(e) => {
|
|
37
34
|
(e.target as HTMLImageElement).style.display = "none";
|
|
38
35
|
}}
|
|
39
36
|
/>
|
|
40
37
|
)}
|
|
41
|
-
{isVideo &&
|
|
42
|
-
<>
|
|
43
|
-
<video
|
|
44
|
-
src={serveUrl}
|
|
45
|
-
muted
|
|
46
|
-
playsInline
|
|
47
|
-
preload="metadata"
|
|
48
|
-
className="w-full h-full object-cover"
|
|
49
|
-
/>
|
|
50
|
-
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
51
|
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
|
|
52
|
-
<polygon points="6,3 20,12 6,21" />
|
|
53
|
-
</svg>
|
|
54
|
-
</div>
|
|
55
|
-
</>
|
|
56
|
-
)}
|
|
38
|
+
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
57
39
|
{isAudio && (
|
|
58
|
-
<div className="w-full h-full flex items-center justify-center
|
|
40
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
59
41
|
<svg
|
|
60
42
|
width="16"
|
|
61
43
|
height="16"
|
|
@@ -72,7 +54,7 @@ function AssetThumbnail({
|
|
|
72
54
|
</div>
|
|
73
55
|
)}
|
|
74
56
|
{!isImage && !isVideo && !isAudio && (
|
|
75
|
-
<div className="w-full h-full flex items-center justify-center
|
|
57
|
+
<div className="w-full h-full flex items-center justify-center">
|
|
76
58
|
<svg
|
|
77
59
|
width="14"
|
|
78
60
|
height="14"
|
|
@@ -91,78 +73,7 @@ function AssetThumbnail({
|
|
|
91
73
|
</svg>
|
|
92
74
|
</div>
|
|
93
75
|
)}
|
|
94
|
-
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function ExpandedAssetPreview({
|
|
99
|
-
serveUrl,
|
|
100
|
-
name,
|
|
101
|
-
asset,
|
|
102
|
-
isImage,
|
|
103
|
-
isVideo,
|
|
104
|
-
isAudio,
|
|
105
|
-
onCopy,
|
|
106
|
-
}: {
|
|
107
|
-
serveUrl: string;
|
|
108
|
-
name: string;
|
|
109
|
-
asset: string;
|
|
110
|
-
isImage: boolean;
|
|
111
|
-
isVideo: boolean;
|
|
112
|
-
isAudio: boolean;
|
|
113
|
-
onCopy: () => void;
|
|
114
|
-
}) {
|
|
115
|
-
return (
|
|
116
|
-
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
|
117
|
-
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
|
118
|
-
{isImage && (
|
|
119
|
-
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
|
120
|
-
)}
|
|
121
|
-
{isVideo && (
|
|
122
|
-
<video
|
|
123
|
-
src={serveUrl}
|
|
124
|
-
autoPlay
|
|
125
|
-
muted
|
|
126
|
-
loop
|
|
127
|
-
playsInline
|
|
128
|
-
className="max-w-full max-h-full object-contain rounded"
|
|
129
|
-
/>
|
|
130
|
-
)}
|
|
131
|
-
{isAudio && (
|
|
132
|
-
<div className="flex flex-col items-center gap-4">
|
|
133
|
-
<svg
|
|
134
|
-
width="48"
|
|
135
|
-
height="48"
|
|
136
|
-
viewBox="0 0 24 24"
|
|
137
|
-
fill="none"
|
|
138
|
-
stroke="currentColor"
|
|
139
|
-
strokeWidth="1.5"
|
|
140
|
-
className="text-purple-400"
|
|
141
|
-
>
|
|
142
|
-
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
|
143
|
-
<circle cx="6" cy="18" r="3" />
|
|
144
|
-
<circle cx="18" cy="16" r="3" />
|
|
145
|
-
</svg>
|
|
146
|
-
<audio src={serveUrl} controls autoPlay className="w-64" />
|
|
147
|
-
</div>
|
|
148
|
-
)}
|
|
149
|
-
</div>
|
|
150
|
-
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
|
151
|
-
<div>
|
|
152
|
-
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
|
153
|
-
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
|
|
154
|
-
</div>
|
|
155
|
-
<button
|
|
156
|
-
onClick={(e) => {
|
|
157
|
-
e.stopPropagation();
|
|
158
|
-
onCopy();
|
|
159
|
-
}}
|
|
160
|
-
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
|
161
|
-
>
|
|
162
|
-
Copy Path
|
|
163
|
-
</button>
|
|
164
|
-
</div>
|
|
165
|
-
</div>
|
|
76
|
+
</>
|
|
166
77
|
);
|
|
167
78
|
}
|
|
168
79
|
|
|
@@ -177,75 +88,52 @@ function AssetCard({
|
|
|
177
88
|
onCopy: (path: string) => void;
|
|
178
89
|
isCopied: boolean;
|
|
179
90
|
}) {
|
|
91
|
+
const [hovered, setHovered] = useState(false);
|
|
180
92
|
const name = asset.split("/").pop() ?? asset;
|
|
181
93
|
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
182
|
-
const isImage = IMAGE_EXT.test(asset);
|
|
183
94
|
const isVideo = VIDEO_EXT.test(asset);
|
|
184
|
-
const isAudio = AUDIO_EXT.test(asset);
|
|
185
|
-
const hasExpandablePreview = isImage || isVideo || isAudio;
|
|
186
95
|
|
|
187
|
-
|
|
96
|
+
return (
|
|
188
97
|
<div
|
|
98
|
+
onClick={() => onCopy(asset)}
|
|
99
|
+
onPointerEnter={() => setHovered(true)}
|
|
100
|
+
onPointerLeave={() => setHovered(false)}
|
|
189
101
|
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
|
190
102
|
isCopied
|
|
191
|
-
? "bg-
|
|
103
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
192
104
|
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
193
105
|
}`}
|
|
194
106
|
>
|
|
195
|
-
<
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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>
|
|
202
127
|
<div className="min-w-0 flex-1">
|
|
203
128
|
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
|
204
129
|
{isCopied ? (
|
|
205
|
-
<span className="text-[9px] text-
|
|
130
|
+
<span className="text-[9px] text-studio-accent">Copied!</span>
|
|
206
131
|
) : (
|
|
207
132
|
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
|
208
133
|
)}
|
|
209
134
|
</div>
|
|
210
135
|
</div>
|
|
211
136
|
);
|
|
212
|
-
|
|
213
|
-
if (!hasExpandablePreview) {
|
|
214
|
-
return (
|
|
215
|
-
<button
|
|
216
|
-
type="button"
|
|
217
|
-
onClick={() => onCopy(asset)}
|
|
218
|
-
title="Click to copy path"
|
|
219
|
-
className="w-full"
|
|
220
|
-
>
|
|
221
|
-
{card}
|
|
222
|
-
</button>
|
|
223
|
-
);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return (
|
|
227
|
-
<ExpandOnHover
|
|
228
|
-
expandedContent={(closeExpand) => (
|
|
229
|
-
<ExpandedAssetPreview
|
|
230
|
-
serveUrl={serveUrl}
|
|
231
|
-
name={name}
|
|
232
|
-
asset={asset}
|
|
233
|
-
isImage={isImage}
|
|
234
|
-
isVideo={isVideo}
|
|
235
|
-
isAudio={isAudio}
|
|
236
|
-
onCopy={() => {
|
|
237
|
-
closeExpand();
|
|
238
|
-
onCopy(asset);
|
|
239
|
-
}}
|
|
240
|
-
/>
|
|
241
|
-
)}
|
|
242
|
-
onClick={() => onCopy(asset)}
|
|
243
|
-
expandScale={0.45}
|
|
244
|
-
delay={500}
|
|
245
|
-
>
|
|
246
|
-
{card}
|
|
247
|
-
</ExpandOnHover>
|
|
248
|
-
);
|
|
249
137
|
}
|
|
250
138
|
|
|
251
139
|
export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
|
|
@@ -276,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
|
|
276
164
|
|
|
277
165
|
return (
|
|
278
166
|
<div
|
|
279
|
-
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]" : ""}`}
|
|
280
168
|
onDragOver={(e) => {
|
|
281
169
|
e.preventDefault();
|
|
282
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
|
-
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=
|
|
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({
|