@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.
Files changed (32) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +77 -19
  13. package/src/components/renders/useRenderQueue.ts +1 -0
  14. package/src/components/sidebar/AssetsTab.tsx +37 -149
  15. package/src/components/sidebar/CompositionsTab.tsx +48 -162
  16. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  17. package/src/components/ui/VideoFrameThumbnail.tsx +50 -0
  18. package/src/index.ts +0 -3
  19. package/src/player/components/CompositionThumbnail.tsx +21 -95
  20. package/src/player/components/EditModal.tsx +5 -5
  21. package/src/player/components/Player.tsx +0 -1
  22. package/src/player/components/PlayerControls.tsx +56 -3
  23. package/src/player/components/Timeline.tsx +14 -18
  24. package/src/player/components/TimelineClip.tsx +0 -1
  25. package/src/player/index.ts +0 -1
  26. package/src/player/store/playerStore.ts +3 -28
  27. package/src/utils/mediaTypes.ts +9 -0
  28. package/dist/assets/index-BEwJNmPo.js +0 -92
  29. package/dist/assets/index-BnvciBdD.css +0 -1
  30. package/src/components/ui/ExpandOnHover.tsx +0 -194
  31. package/src/hooks/useCodeEditor.ts +0 -88
  32. 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
- const handleDownload = useCallback(() => {
29
- const a = document.createElement("a");
30
- a.href = `/api/render/${job.id}/download`;
31
- a.download = job.filename;
32
- a.click();
33
- }, [job.id, job.filename]);
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
- className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
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
- {/* Status indicator */}
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-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
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-[#3CE6AC]">{job.progress}%</span>
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-[#3CE6AC] rounded-full transition-all duration-300"
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
- {job.status === "complete" && (
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={onDelete}
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
  >
@@ -128,6 +128,7 @@ export function useRenderQueue(projectId: string | null) {
128
128
  ? "failed"
129
129
  : j.status,
130
130
  durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
131
+ error: data.error ?? j.error,
131
132
  }
132
133
  : j,
133
134
  ),
@@ -1,5 +1,6 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
- import { ExpandOnHover } from "../ui/ExpandOnHover";
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
- const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
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
- <div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
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-cover"
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 bg-neutral-900">
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 bg-neutral-900">
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
- </div>
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
- const card = (
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-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
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
- <AssetThumbnail
196
- serveUrl={serveUrl}
197
- name={name}
198
- isImage={isImage}
199
- isVideo={isVideo}
200
- isAudio={isAudio}
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-[#3CE6AC]">Copied!</span>
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-blue-950/20" : ""}`}
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, useCallback, useEffect } from "react";
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=0.5`;
34
+ const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
150
35
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
151
36
 
152
- const card = (
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-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
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
- <img
162
- src={thumbnailUrl}
163
- alt={name}
164
- loading="lazy"
165
- className="w-full h-full object-cover"
166
- onError={(e) => {
167
- (e.target as HTMLImageElement).style.display = "none";
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({