@effectnode/media 0.4.0 → 0.5.0
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/backend/movie-backend/generation-queue.js +99 -43
- package/frontend/index.html +8 -1
- package/frontend/public/lambobo.png +0 -0
- package/frontend/src/movie-app/MediaStudio.tsx +6 -2
- package/frontend/src/movie-app/SetupPage.tsx +98 -63
- package/frontend/src/movie-app/components/Aurora.tsx +13 -0
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +5 -8
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +297 -189
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +345 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +40 -34
- package/frontend/src/movie-app/components/ProjectManager.tsx +80 -52
- package/frontend/src/movie-app/index.css +260 -19
- package/frontend/src/movie-app/stores/aiModelStore.ts +163 -0
- package/frontend/src/movie-app/stores/generationStore.ts +2 -2
- package/frontend/src/movie-app/stores/movieStudioStore.ts +45 -0
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +0 -881
|
@@ -1,881 +0,0 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
useReferencesToVideoStore,
|
|
4
|
-
type ReferenceKind,
|
|
5
|
-
} from "../../stores/referencesToVideoStore";
|
|
6
|
-
import { useGenerationStore } from "../../stores/generationStore";
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
projectId: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
// ========== Video thumbnail with hover-to-play + preview ==========
|
|
13
|
-
|
|
14
|
-
const ExpandIcon = (
|
|
15
|
-
<svg
|
|
16
|
-
width="14"
|
|
17
|
-
height="14"
|
|
18
|
-
viewBox="0 0 24 24"
|
|
19
|
-
fill="none"
|
|
20
|
-
stroke="currentColor"
|
|
21
|
-
strokeWidth="2"
|
|
22
|
-
strokeLinecap="round"
|
|
23
|
-
strokeLinejoin="round"
|
|
24
|
-
>
|
|
25
|
-
<polyline points="15 3 21 3 21 9" />
|
|
26
|
-
<polyline points="9 21 3 21 3 15" />
|
|
27
|
-
<line x1="21" y1="3" x2="14" y2="10" />
|
|
28
|
-
<line x1="3" y1="21" x2="10" y2="14" />
|
|
29
|
-
</svg>
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
function VideoThumb({
|
|
33
|
-
src,
|
|
34
|
-
onSelect,
|
|
35
|
-
onPreview,
|
|
36
|
-
}: {
|
|
37
|
-
src: string;
|
|
38
|
-
onSelect: () => void;
|
|
39
|
-
onPreview: () => void;
|
|
40
|
-
}) {
|
|
41
|
-
const videoRef = useRef<HTMLVideoElement>(null);
|
|
42
|
-
|
|
43
|
-
const handleMouseEnter = () => {
|
|
44
|
-
const v = videoRef.current;
|
|
45
|
-
if (!v) return;
|
|
46
|
-
v.currentTime = 0;
|
|
47
|
-
v.muted = false;
|
|
48
|
-
v.play().catch(() => {});
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
const handleMouseLeave = () => {
|
|
52
|
-
const v = videoRef.current;
|
|
53
|
-
if (!v) return;
|
|
54
|
-
v.pause();
|
|
55
|
-
v.muted = true;
|
|
56
|
-
v.currentTime = 0;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const handlePreview = (e: React.MouseEvent) => {
|
|
60
|
-
e.stopPropagation();
|
|
61
|
-
onPreview();
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
return (
|
|
65
|
-
<div
|
|
66
|
-
className="relative"
|
|
67
|
-
onMouseEnter={handleMouseEnter}
|
|
68
|
-
onMouseLeave={handleMouseLeave}
|
|
69
|
-
>
|
|
70
|
-
<video
|
|
71
|
-
ref={videoRef}
|
|
72
|
-
src={src}
|
|
73
|
-
muted
|
|
74
|
-
preload="metadata"
|
|
75
|
-
playsInline
|
|
76
|
-
loop
|
|
77
|
-
onClick={onSelect}
|
|
78
|
-
className="aspect-video object-cover w-full bg-black cursor-pointer"
|
|
79
|
-
/>
|
|
80
|
-
<button
|
|
81
|
-
onClick={handlePreview}
|
|
82
|
-
className="absolute top-1.5 right-1.5 flex items-center justify-center w-7 h-7 rounded-full bg-black/60 text-white hover:bg-black/80 transition-colors"
|
|
83
|
-
title="Preview"
|
|
84
|
-
>
|
|
85
|
-
{ExpandIcon}
|
|
86
|
-
</button>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export default function ReferencesToVideoTab({ projectId }: Props) {
|
|
92
|
-
const store = useReferencesToVideoStore();
|
|
93
|
-
const genStore = useGenerationStore();
|
|
94
|
-
|
|
95
|
-
const [activeSlot, setActiveSlot] = useState(0);
|
|
96
|
-
const [previewVideo, setPreviewVideo] = useState<{
|
|
97
|
-
url: string;
|
|
98
|
-
filename: string;
|
|
99
|
-
} | null>(null);
|
|
100
|
-
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
101
|
-
const videoInputRef = useRef<HTMLInputElement>(null);
|
|
102
|
-
const audioInputRef = useRef<HTMLInputElement>(null);
|
|
103
|
-
const logRef = useRef<HTMLDivElement>(null);
|
|
104
|
-
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
store.hydrate(projectId);
|
|
107
|
-
store.checkStatus();
|
|
108
|
-
genStore.fetchProjectImages(projectId);
|
|
109
|
-
genStore.fetchProjectVideos(projectId);
|
|
110
|
-
genStore.fetchProjectAudios(projectId);
|
|
111
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
-
}, [projectId]);
|
|
113
|
-
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
if (logRef.current) {
|
|
116
|
-
logRef.current.scrollTop = logRef.current.scrollHeight;
|
|
117
|
-
}
|
|
118
|
-
}, [store.genLogs]);
|
|
119
|
-
|
|
120
|
-
// Close the video preview modal on Escape.
|
|
121
|
-
useEffect(() => {
|
|
122
|
-
if (!previewVideo) return;
|
|
123
|
-
const onKey = (e: KeyboardEvent) => {
|
|
124
|
-
if (e.key === "Escape") setPreviewVideo(null);
|
|
125
|
-
};
|
|
126
|
-
window.addEventListener("keydown", onKey);
|
|
127
|
-
return () => window.removeEventListener("keydown", onKey);
|
|
128
|
-
}, [previewVideo]);
|
|
129
|
-
|
|
130
|
-
const activeRef = store.refs[activeSlot] ?? null;
|
|
131
|
-
|
|
132
|
-
const findImage = (filename: string | null) =>
|
|
133
|
-
genStore.projectImages.find((img) => img.filename === filename) || null;
|
|
134
|
-
|
|
135
|
-
const findVideo = (filename: string | null) =>
|
|
136
|
-
genStore.projectVideos.find((v) => v.filename === filename) || null;
|
|
137
|
-
|
|
138
|
-
const findAudio = (filename: string | null) =>
|
|
139
|
-
genStore.projectAudios.find((a) => a.filename === filename) || null;
|
|
140
|
-
|
|
141
|
-
const kindLabel = (kind: ReferenceKind) =>
|
|
142
|
-
kind === "image" ? "Image" : kind === "video" ? "Video" : "Audio";
|
|
143
|
-
|
|
144
|
-
const labelFor = (index: number) => {
|
|
145
|
-
const ref = store.refs[index];
|
|
146
|
-
const count = store.refs
|
|
147
|
-
.slice(0, index + 1)
|
|
148
|
-
.filter((r) => r.kind === ref.kind).length;
|
|
149
|
-
return `${kindLabel(ref.kind)} ${count}`;
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
153
|
-
const file = e.target.files?.[0];
|
|
154
|
-
if (!file) return;
|
|
155
|
-
|
|
156
|
-
const reader = new FileReader();
|
|
157
|
-
reader.onload = async () => {
|
|
158
|
-
const base64 = reader.result as string;
|
|
159
|
-
const path = await genStore.uploadImage(projectId, base64, file.name);
|
|
160
|
-
if (path) {
|
|
161
|
-
genStore.fetchProjectImages(projectId);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
reader.readAsDataURL(file);
|
|
165
|
-
e.target.value = "";
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
169
|
-
const file = e.target.files?.[0];
|
|
170
|
-
if (!file) return;
|
|
171
|
-
|
|
172
|
-
const reader = new FileReader();
|
|
173
|
-
reader.onload = async () => {
|
|
174
|
-
const base64 = reader.result as string;
|
|
175
|
-
const filename = await store.uploadVideo(base64, file.name, projectId);
|
|
176
|
-
if (filename) {
|
|
177
|
-
genStore.fetchProjectVideos(projectId);
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
reader.readAsDataURL(file);
|
|
181
|
-
e.target.value = "";
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const handleAudioUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
185
|
-
const file = e.target.files?.[0];
|
|
186
|
-
if (!file) return;
|
|
187
|
-
|
|
188
|
-
const reader = new FileReader();
|
|
189
|
-
reader.onload = async () => {
|
|
190
|
-
const base64 = reader.result as string;
|
|
191
|
-
const filename = await store.uploadAudio(base64, file.name, projectId);
|
|
192
|
-
if (filename) {
|
|
193
|
-
genStore.fetchProjectAudios(projectId);
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
reader.readAsDataURL(file);
|
|
197
|
-
e.target.value = "";
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
// ========== SVG Icons ==========
|
|
201
|
-
|
|
202
|
-
const BookIcon = (
|
|
203
|
-
<svg
|
|
204
|
-
width="18"
|
|
205
|
-
height="18"
|
|
206
|
-
viewBox="0 0 24 24"
|
|
207
|
-
fill="none"
|
|
208
|
-
stroke="currentColor"
|
|
209
|
-
strokeWidth="2"
|
|
210
|
-
strokeLinecap="round"
|
|
211
|
-
strokeLinejoin="round"
|
|
212
|
-
className="text-tiffany-600"
|
|
213
|
-
>
|
|
214
|
-
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
|
215
|
-
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
|
216
|
-
</svg>
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
const DownloadIcon = (
|
|
220
|
-
<svg
|
|
221
|
-
width="16"
|
|
222
|
-
height="16"
|
|
223
|
-
viewBox="0 0 24 24"
|
|
224
|
-
fill="none"
|
|
225
|
-
stroke="currentColor"
|
|
226
|
-
strokeWidth="2"
|
|
227
|
-
strokeLinecap="round"
|
|
228
|
-
strokeLinejoin="round"
|
|
229
|
-
>
|
|
230
|
-
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
231
|
-
<polyline points="7 10 12 15 17 10" />
|
|
232
|
-
<line x1="12" y1="15" x2="12" y2="3" />
|
|
233
|
-
</svg>
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
const CheckIcon = (
|
|
237
|
-
<svg
|
|
238
|
-
width="16"
|
|
239
|
-
height="16"
|
|
240
|
-
viewBox="0 0 24 24"
|
|
241
|
-
fill="none"
|
|
242
|
-
stroke="currentColor"
|
|
243
|
-
strokeWidth="2"
|
|
244
|
-
strokeLinecap="round"
|
|
245
|
-
strokeLinejoin="round"
|
|
246
|
-
>
|
|
247
|
-
<polyline points="20 6 9 17 4 12" />
|
|
248
|
-
</svg>
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
const SpinnerIcon = (
|
|
252
|
-
<svg
|
|
253
|
-
className="animate-spin"
|
|
254
|
-
width="16"
|
|
255
|
-
height="16"
|
|
256
|
-
viewBox="0 0 24 24"
|
|
257
|
-
fill="none"
|
|
258
|
-
stroke="currentColor"
|
|
259
|
-
strokeWidth="2"
|
|
260
|
-
>
|
|
261
|
-
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
262
|
-
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
263
|
-
</svg>
|
|
264
|
-
);
|
|
265
|
-
|
|
266
|
-
const ImageIcon = (
|
|
267
|
-
<svg
|
|
268
|
-
width="16"
|
|
269
|
-
height="16"
|
|
270
|
-
viewBox="0 0 24 24"
|
|
271
|
-
fill="none"
|
|
272
|
-
stroke="currentColor"
|
|
273
|
-
strokeWidth="2"
|
|
274
|
-
strokeLinecap="round"
|
|
275
|
-
strokeLinejoin="round"
|
|
276
|
-
>
|
|
277
|
-
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
|
|
278
|
-
<circle cx="8.5" cy="8.5" r="1.5" />
|
|
279
|
-
<polyline points="21 15 16 10 5 21" />
|
|
280
|
-
</svg>
|
|
281
|
-
);
|
|
282
|
-
|
|
283
|
-
const VideoIcon = (
|
|
284
|
-
<svg
|
|
285
|
-
width="16"
|
|
286
|
-
height="16"
|
|
287
|
-
viewBox="0 0 24 24"
|
|
288
|
-
fill="none"
|
|
289
|
-
stroke="currentColor"
|
|
290
|
-
strokeWidth="2"
|
|
291
|
-
strokeLinecap="round"
|
|
292
|
-
strokeLinejoin="round"
|
|
293
|
-
>
|
|
294
|
-
<polygon points="23 7 16 12 23 17 23 7" />
|
|
295
|
-
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
|
296
|
-
</svg>
|
|
297
|
-
);
|
|
298
|
-
|
|
299
|
-
const AudioIcon = (
|
|
300
|
-
<svg
|
|
301
|
-
width="16"
|
|
302
|
-
height="16"
|
|
303
|
-
viewBox="0 0 24 24"
|
|
304
|
-
fill="none"
|
|
305
|
-
stroke="currentColor"
|
|
306
|
-
strokeWidth="2"
|
|
307
|
-
strokeLinecap="round"
|
|
308
|
-
strokeLinejoin="round"
|
|
309
|
-
>
|
|
310
|
-
<path d="M9 18V5l12-2v13" />
|
|
311
|
-
<circle cx="6" cy="18" r="3" />
|
|
312
|
-
<circle cx="18" cy="16" r="3" />
|
|
313
|
-
</svg>
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
const PlusIcon = (
|
|
317
|
-
<svg
|
|
318
|
-
width="14"
|
|
319
|
-
height="14"
|
|
320
|
-
viewBox="0 0 24 24"
|
|
321
|
-
fill="none"
|
|
322
|
-
stroke="currentColor"
|
|
323
|
-
strokeWidth="2"
|
|
324
|
-
strokeLinecap="round"
|
|
325
|
-
strokeLinejoin="round"
|
|
326
|
-
>
|
|
327
|
-
<line x1="12" y1="5" x2="12" y2="19" />
|
|
328
|
-
<line x1="5" y1="12" x2="19" y2="12" />
|
|
329
|
-
</svg>
|
|
330
|
-
);
|
|
331
|
-
|
|
332
|
-
const CloseIcon = (
|
|
333
|
-
<svg
|
|
334
|
-
width="12"
|
|
335
|
-
height="12"
|
|
336
|
-
viewBox="0 0 24 24"
|
|
337
|
-
fill="none"
|
|
338
|
-
stroke="currentColor"
|
|
339
|
-
strokeWidth="2"
|
|
340
|
-
strokeLinecap="round"
|
|
341
|
-
strokeLinejoin="round"
|
|
342
|
-
>
|
|
343
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
344
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
345
|
-
</svg>
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const SparkleIcon = (
|
|
349
|
-
<svg
|
|
350
|
-
width="16"
|
|
351
|
-
height="16"
|
|
352
|
-
viewBox="0 0 24 24"
|
|
353
|
-
fill="none"
|
|
354
|
-
stroke="currentColor"
|
|
355
|
-
strokeWidth="2"
|
|
356
|
-
>
|
|
357
|
-
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
|
358
|
-
</svg>
|
|
359
|
-
);
|
|
360
|
-
|
|
361
|
-
const renderRefCard = (index: number) => {
|
|
362
|
-
const ref = store.refs[index];
|
|
363
|
-
const isActive = activeSlot === index;
|
|
364
|
-
const img = ref.kind === "image" ? findImage(ref.filename) : null;
|
|
365
|
-
const video = ref.kind === "video" ? findVideo(ref.filename) : null;
|
|
366
|
-
const audio = ref.kind === "audio" ? findAudio(ref.filename) : null;
|
|
367
|
-
|
|
368
|
-
return (
|
|
369
|
-
<div
|
|
370
|
-
key={index}
|
|
371
|
-
className={`relative flex flex-col items-center gap-1.5 p-2 rounded-2xl border-2 transition-all text-left w-32 ${
|
|
372
|
-
isActive
|
|
373
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
374
|
-
: "border-ink-200 hover:border-ink-300"
|
|
375
|
-
}`}
|
|
376
|
-
>
|
|
377
|
-
<div
|
|
378
|
-
onClick={() => setActiveSlot(index)}
|
|
379
|
-
className="flex flex-col items-center gap-1.5 w-full cursor-pointer"
|
|
380
|
-
>
|
|
381
|
-
<span className="text-[10px] font-semibold text-ink-600 uppercase tracking-wider">
|
|
382
|
-
{labelFor(index)}
|
|
383
|
-
</span>
|
|
384
|
-
{ref.kind === "image" ? (
|
|
385
|
-
img ? (
|
|
386
|
-
<img
|
|
387
|
-
src={img.url}
|
|
388
|
-
alt={img.filename}
|
|
389
|
-
className="w-24 h-24 object-cover rounded-xl"
|
|
390
|
-
/>
|
|
391
|
-
) : (
|
|
392
|
-
<span className="w-24 h-24 rounded-xl bg-ink-50 border border-dashed border-ink-200 flex items-center justify-center text-tiffany-600">
|
|
393
|
-
{ImageIcon}
|
|
394
|
-
</span>
|
|
395
|
-
)
|
|
396
|
-
) : ref.kind === "video" ? (
|
|
397
|
-
video ? (
|
|
398
|
-
<div className="w-24 rounded-xl overflow-hidden">
|
|
399
|
-
<VideoThumb
|
|
400
|
-
src={video.url}
|
|
401
|
-
onSelect={() => setActiveSlot(index)}
|
|
402
|
-
onPreview={() =>
|
|
403
|
-
setPreviewVideo({
|
|
404
|
-
url: video.url,
|
|
405
|
-
filename: video.filename,
|
|
406
|
-
})
|
|
407
|
-
}
|
|
408
|
-
/>
|
|
409
|
-
</div>
|
|
410
|
-
) : (
|
|
411
|
-
<span className="w-24 h-24 rounded-xl bg-ink-50 border border-dashed border-ink-200 flex items-center justify-center text-tiffany-600">
|
|
412
|
-
{VideoIcon}
|
|
413
|
-
</span>
|
|
414
|
-
)
|
|
415
|
-
) : (
|
|
416
|
-
<span
|
|
417
|
-
className={`w-24 h-24 rounded-xl border flex items-center justify-center ${
|
|
418
|
-
audio
|
|
419
|
-
? "bg-ink-100 border-ink-200 text-ink-600"
|
|
420
|
-
: "bg-ink-50 border-dashed border-ink-200 text-tiffany-600"
|
|
421
|
-
}`}
|
|
422
|
-
>
|
|
423
|
-
{AudioIcon}
|
|
424
|
-
</span>
|
|
425
|
-
)}
|
|
426
|
-
<span className="text-[10px] text-ink-600 truncate max-w-[100px]">
|
|
427
|
-
{ref.filename || "Not selected"}
|
|
428
|
-
</span>
|
|
429
|
-
</div>
|
|
430
|
-
<button
|
|
431
|
-
onClick={() => {
|
|
432
|
-
store.removeRef(index);
|
|
433
|
-
setActiveSlot((s) => Math.min(s, store.refs.length - 2));
|
|
434
|
-
}}
|
|
435
|
-
disabled={store.generating}
|
|
436
|
-
className="absolute top-1 right-1 flex items-center justify-center w-5 h-5 rounded-full bg-ink-100 text-ink-600 hover:bg-red-100 hover:text-red-600 transition-colors disabled:opacity-50"
|
|
437
|
-
title="Remove reference"
|
|
438
|
-
>
|
|
439
|
-
{CloseIcon}
|
|
440
|
-
</button>
|
|
441
|
-
</div>
|
|
442
|
-
);
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
return (
|
|
446
|
-
<div className="flex flex-col gap-7">
|
|
447
|
-
<div className="flex items-center gap-2">
|
|
448
|
-
{BookIcon}
|
|
449
|
-
<h2 className="text-base font-semibold text-ink-900">
|
|
450
|
-
References to Video
|
|
451
|
-
</h2>
|
|
452
|
-
</div>
|
|
453
|
-
|
|
454
|
-
{/* Model download */}
|
|
455
|
-
<div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3 bg-ink-100/60">
|
|
456
|
-
<div className="flex flex-col gap-1">
|
|
457
|
-
<span className="text-sm font-semibold text-ink-800">
|
|
458
|
-
AI Model
|
|
459
|
-
</span>
|
|
460
|
-
<span className="text-xs text-ink-600">
|
|
461
|
-
appautomaton/minimax-h3-base-8bit-mlx
|
|
462
|
-
</span>
|
|
463
|
-
</div>
|
|
464
|
-
|
|
465
|
-
<button
|
|
466
|
-
onClick={() => store.downloadModel()}
|
|
467
|
-
disabled={store.downloading || store.downloaded}
|
|
468
|
-
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-2xl bg-tiffany-500 hover:bg-tiffany-600 disabled:bg-ink-100 disabled:text-tiffany-600 text-ink-950 transition-colors"
|
|
469
|
-
>
|
|
470
|
-
{store.downloading
|
|
471
|
-
? SpinnerIcon
|
|
472
|
-
: store.downloaded
|
|
473
|
-
? CheckIcon
|
|
474
|
-
: DownloadIcon}
|
|
475
|
-
{store.downloading
|
|
476
|
-
? "Downloading..."
|
|
477
|
-
: store.downloaded
|
|
478
|
-
? "Model Downloaded"
|
|
479
|
-
: "Download Model"}
|
|
480
|
-
</button>
|
|
481
|
-
|
|
482
|
-
{store.error && <p className="text-xs text-red-600">{store.error}</p>}
|
|
483
|
-
|
|
484
|
-
{store.logs.length > 0 && (
|
|
485
|
-
<div className="p-2 bg-ink-50 border border-ink-200 rounded-xl max-h-40 overflow-y-auto">
|
|
486
|
-
<pre className="text-[10px] text-ink-600 font-mono whitespace-pre-wrap">
|
|
487
|
-
{store.logs.join("")}
|
|
488
|
-
</pre>
|
|
489
|
-
</div>
|
|
490
|
-
)}
|
|
491
|
-
</div>
|
|
492
|
-
|
|
493
|
-
{/* Prompt */}
|
|
494
|
-
<div>
|
|
495
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
496
|
-
Prompt
|
|
497
|
-
</label>
|
|
498
|
-
<textarea
|
|
499
|
-
value={store.prompt}
|
|
500
|
-
onChange={(e) => store.setPrompt(e.target.value)}
|
|
501
|
-
placeholder="Describe the scene using [image1], [video1] placeholders..."
|
|
502
|
-
rows={3}
|
|
503
|
-
disabled={store.generating}
|
|
504
|
-
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
|
|
505
|
-
/>
|
|
506
|
-
<p className="text-xs text-ink-600/50 mt-1.5">
|
|
507
|
-
Use{" "}
|
|
508
|
-
<code className="text-[11px] bg-ink-100 px-1 rounded">
|
|
509
|
-
[image1]
|
|
510
|
-
</code>
|
|
511
|
-
,{" "}
|
|
512
|
-
<code className="text-[11px] bg-ink-100 px-1 rounded">
|
|
513
|
-
[image2]
|
|
514
|
-
</code>
|
|
515
|
-
,{" "}
|
|
516
|
-
<code className="text-[11px] bg-ink-100 px-1 rounded">
|
|
517
|
-
[video1]
|
|
518
|
-
</code>{" "}
|
|
519
|
-
… in order to reference the media below.
|
|
520
|
-
</p>
|
|
521
|
-
</div>
|
|
522
|
-
|
|
523
|
-
{/* References */}
|
|
524
|
-
<div>
|
|
525
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
526
|
-
References
|
|
527
|
-
</label>
|
|
528
|
-
|
|
529
|
-
<div className="flex flex-wrap gap-2 mb-3">
|
|
530
|
-
{store.refs.map((_, index) => renderRefCard(index))}
|
|
531
|
-
</div>
|
|
532
|
-
|
|
533
|
-
<div className="flex flex-wrap gap-2 mb-3">
|
|
534
|
-
<button
|
|
535
|
-
onClick={() => {
|
|
536
|
-
store.addRef("image");
|
|
537
|
-
setActiveSlot(store.refs.length);
|
|
538
|
-
}}
|
|
539
|
-
disabled={store.generating}
|
|
540
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
|
|
541
|
-
>
|
|
542
|
-
{PlusIcon}
|
|
543
|
-
Add Image
|
|
544
|
-
</button>
|
|
545
|
-
<button
|
|
546
|
-
onClick={() => {
|
|
547
|
-
store.addRef("video");
|
|
548
|
-
setActiveSlot(store.refs.length);
|
|
549
|
-
}}
|
|
550
|
-
disabled={store.generating}
|
|
551
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
|
|
552
|
-
>
|
|
553
|
-
{PlusIcon}
|
|
554
|
-
Add Video
|
|
555
|
-
</button>
|
|
556
|
-
<button
|
|
557
|
-
onClick={() => {
|
|
558
|
-
store.addRef("audio");
|
|
559
|
-
setActiveSlot(store.refs.length);
|
|
560
|
-
}}
|
|
561
|
-
disabled={store.generating}
|
|
562
|
-
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border transition-all bg-white border-ink-200 text-ink-600 hover:border-ink-300 disabled:opacity-50"
|
|
563
|
-
>
|
|
564
|
-
{PlusIcon}
|
|
565
|
-
Add Audio
|
|
566
|
-
</button>
|
|
567
|
-
</div>
|
|
568
|
-
|
|
569
|
-
{activeRef ? (
|
|
570
|
-
activeRef.kind === "image" ? (
|
|
571
|
-
<>
|
|
572
|
-
<div className="flex items-center gap-2 mb-2">
|
|
573
|
-
<input
|
|
574
|
-
ref={imageInputRef}
|
|
575
|
-
type="file"
|
|
576
|
-
accept="image/*"
|
|
577
|
-
onChange={handleImageUpload}
|
|
578
|
-
disabled={store.generating}
|
|
579
|
-
className="flex-1 text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
|
|
580
|
-
/>
|
|
581
|
-
</div>
|
|
582
|
-
{genStore.projectImagesLoading ? (
|
|
583
|
-
<p className="text-xs text-ink-500 italic py-4 text-center">
|
|
584
|
-
Loading images...
|
|
585
|
-
</p>
|
|
586
|
-
) : genStore.projectImages.length === 0 ? (
|
|
587
|
-
<p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
588
|
-
No images yet. Upload one above.
|
|
589
|
-
</p>
|
|
590
|
-
) : (
|
|
591
|
-
<div className="grid grid-cols-4 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-2">
|
|
592
|
-
{genStore.projectImages.map((img) => {
|
|
593
|
-
const isSelected = img.filename === activeRef.filename;
|
|
594
|
-
return (
|
|
595
|
-
<button
|
|
596
|
-
key={`${img.source}-${img.filename}`}
|
|
597
|
-
onClick={() =>
|
|
598
|
-
store.setRefFilename(activeSlot, img.filename)
|
|
599
|
-
}
|
|
600
|
-
disabled={store.generating}
|
|
601
|
-
className={`relative rounded-xl border-2 transition-all ${
|
|
602
|
-
isSelected
|
|
603
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
604
|
-
: "border-ink-200 hover:border-ink-300"
|
|
605
|
-
} disabled:opacity-50`}
|
|
606
|
-
>
|
|
607
|
-
<img
|
|
608
|
-
src={img.url}
|
|
609
|
-
alt={img.filename}
|
|
610
|
-
className="aspect-square object-cover object-center"
|
|
611
|
-
/>
|
|
612
|
-
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 text-[10px] text-ink-700 truncate text-center">
|
|
613
|
-
{img.filename}
|
|
614
|
-
</span>
|
|
615
|
-
</button>
|
|
616
|
-
);
|
|
617
|
-
})}
|
|
618
|
-
</div>
|
|
619
|
-
)}
|
|
620
|
-
</>
|
|
621
|
-
) : activeRef.kind === "video" ? (
|
|
622
|
-
<>
|
|
623
|
-
<div className="flex items-center gap-2 mb-2">
|
|
624
|
-
<input
|
|
625
|
-
ref={videoInputRef}
|
|
626
|
-
type="file"
|
|
627
|
-
accept="video/*"
|
|
628
|
-
onChange={handleVideoUpload}
|
|
629
|
-
disabled={store.generating}
|
|
630
|
-
className="flex-1 text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
|
|
631
|
-
/>
|
|
632
|
-
</div>
|
|
633
|
-
{genStore.projectVideosLoading ? (
|
|
634
|
-
<p className="text-xs text-ink-500 italic py-4 text-center">
|
|
635
|
-
Loading videos...
|
|
636
|
-
</p>
|
|
637
|
-
) : genStore.projectVideos.length === 0 ? (
|
|
638
|
-
<p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
639
|
-
No videos yet. Upload one above.
|
|
640
|
-
</p>
|
|
641
|
-
) : (
|
|
642
|
-
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 gap-2">
|
|
643
|
-
{genStore.projectVideos.map((video) => {
|
|
644
|
-
const isSelected = video.filename === activeRef.filename;
|
|
645
|
-
return (
|
|
646
|
-
<div
|
|
647
|
-
key={video.filename}
|
|
648
|
-
className={`relative rounded-xl border-2 overflow-hidden transition-all ${
|
|
649
|
-
isSelected
|
|
650
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
651
|
-
: "border-ink-200 hover:border-ink-300"
|
|
652
|
-
}`}
|
|
653
|
-
>
|
|
654
|
-
<VideoThumb
|
|
655
|
-
src={video.url}
|
|
656
|
-
onSelect={() => {
|
|
657
|
-
if (!store.generating) {
|
|
658
|
-
store.setRefFilename(activeSlot, video.filename);
|
|
659
|
-
}
|
|
660
|
-
}}
|
|
661
|
-
onPreview={() =>
|
|
662
|
-
setPreviewVideo({
|
|
663
|
-
url: video.url,
|
|
664
|
-
filename: video.filename,
|
|
665
|
-
})
|
|
666
|
-
}
|
|
667
|
-
/>
|
|
668
|
-
<span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1.5 py-0.5 text-[10px] text-ink-700 truncate text-center pointer-events-none">
|
|
669
|
-
{video.filename}
|
|
670
|
-
</span>
|
|
671
|
-
</div>
|
|
672
|
-
);
|
|
673
|
-
})}
|
|
674
|
-
</div>
|
|
675
|
-
)}
|
|
676
|
-
</>
|
|
677
|
-
) : (
|
|
678
|
-
<>
|
|
679
|
-
<div className="flex items-center gap-2 mb-2">
|
|
680
|
-
<input
|
|
681
|
-
ref={audioInputRef}
|
|
682
|
-
type="file"
|
|
683
|
-
accept="audio/*"
|
|
684
|
-
onChange={handleAudioUpload}
|
|
685
|
-
disabled={store.generating}
|
|
686
|
-
className="flex-1 text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
|
|
687
|
-
/>
|
|
688
|
-
</div>
|
|
689
|
-
{genStore.projectAudiosLoading ? (
|
|
690
|
-
<p className="text-xs text-ink-500 italic py-4 text-center">
|
|
691
|
-
Loading audio...
|
|
692
|
-
</p>
|
|
693
|
-
) : genStore.projectAudios.length === 0 ? (
|
|
694
|
-
<p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
695
|
-
No audio yet. Upload one above.
|
|
696
|
-
</p>
|
|
697
|
-
) : (
|
|
698
|
-
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
|
699
|
-
{genStore.projectAudios.map((audio) => {
|
|
700
|
-
const isSelected = audio.filename === activeRef.filename;
|
|
701
|
-
return (
|
|
702
|
-
<button
|
|
703
|
-
key={audio.filename}
|
|
704
|
-
onClick={() =>
|
|
705
|
-
store.setRefFilename(activeSlot, audio.filename)
|
|
706
|
-
}
|
|
707
|
-
disabled={store.generating}
|
|
708
|
-
className={`relative flex items-center gap-2 rounded-xl border-2 px-3 py-2 transition-all text-left ${
|
|
709
|
-
isSelected
|
|
710
|
-
? "border-tiffany-500 ring-2 ring-tiffany-500/40"
|
|
711
|
-
: "border-ink-200 hover:border-ink-300"
|
|
712
|
-
} disabled:opacity-50`}
|
|
713
|
-
>
|
|
714
|
-
<span className="text-tiffany-600 shrink-0">
|
|
715
|
-
{AudioIcon}
|
|
716
|
-
</span>
|
|
717
|
-
<span className="flex-1 text-[11px] text-ink-700 truncate">
|
|
718
|
-
{audio.filename}
|
|
719
|
-
</span>
|
|
720
|
-
</button>
|
|
721
|
-
);
|
|
722
|
-
})}
|
|
723
|
-
</div>
|
|
724
|
-
)}
|
|
725
|
-
</>
|
|
726
|
-
)
|
|
727
|
-
) : (
|
|
728
|
-
<p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
|
|
729
|
-
Add a reference above to get started.
|
|
730
|
-
</p>
|
|
731
|
-
)}
|
|
732
|
-
</div>
|
|
733
|
-
|
|
734
|
-
{/* Parameters */}
|
|
735
|
-
<div>
|
|
736
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
737
|
-
Parameters
|
|
738
|
-
</label>
|
|
739
|
-
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
|
740
|
-
{(
|
|
741
|
-
[
|
|
742
|
-
{ label: "Steps", value: store.steps, set: store.setSteps },
|
|
743
|
-
{ label: "Width", value: store.width, set: store.setWidth },
|
|
744
|
-
{ label: "Height", value: store.height, set: store.setHeight },
|
|
745
|
-
{ label: "Seconds", value: store.seconds, set: store.setSeconds },
|
|
746
|
-
{ label: "Seed", value: store.seed, set: store.setSeed },
|
|
747
|
-
] as const
|
|
748
|
-
).map((p) => (
|
|
749
|
-
<div key={p.label} className="flex flex-col gap-1">
|
|
750
|
-
<label className="text-xs font-medium text-ink-700">
|
|
751
|
-
{p.label}
|
|
752
|
-
</label>
|
|
753
|
-
<input
|
|
754
|
-
type="number"
|
|
755
|
-
value={p.value}
|
|
756
|
-
onChange={(e) => p.set(Number(e.target.value))}
|
|
757
|
-
disabled={store.generating}
|
|
758
|
-
step={0.5}
|
|
759
|
-
className="px-3 py-2 bg-ink-50 border border-ink-200 rounded-xl text-ink-900 text-sm focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all disabled:opacity-50"
|
|
760
|
-
/>
|
|
761
|
-
</div>
|
|
762
|
-
))}
|
|
763
|
-
</div>
|
|
764
|
-
</div>
|
|
765
|
-
|
|
766
|
-
{/* Generate / Stop */}
|
|
767
|
-
{store.generating ? (
|
|
768
|
-
<div className="flex items-center gap-3">
|
|
769
|
-
<div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
|
|
770
|
-
<svg
|
|
771
|
-
className="animate-spin text-tiffany-600"
|
|
772
|
-
width="16"
|
|
773
|
-
height="16"
|
|
774
|
-
viewBox="0 0 24 24"
|
|
775
|
-
fill="none"
|
|
776
|
-
stroke="currentColor"
|
|
777
|
-
strokeWidth="2"
|
|
778
|
-
>
|
|
779
|
-
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
|
|
780
|
-
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
|
|
781
|
-
</svg>
|
|
782
|
-
<span className="text-sm font-medium text-ink-700">
|
|
783
|
-
Generating...
|
|
784
|
-
</span>
|
|
785
|
-
</div>
|
|
786
|
-
<button
|
|
787
|
-
onClick={() => store.cancelGenerate()}
|
|
788
|
-
className="flex items-center justify-center gap-1.5 px-5 py-3 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
|
|
789
|
-
>
|
|
790
|
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
|
791
|
-
<rect x="4" y="4" width="16" height="16" rx="2" />
|
|
792
|
-
</svg>
|
|
793
|
-
Stop
|
|
794
|
-
</button>
|
|
795
|
-
</div>
|
|
796
|
-
) : (
|
|
797
|
-
<button
|
|
798
|
-
onClick={() => store.generate(projectId)}
|
|
799
|
-
disabled={
|
|
800
|
-
!store.prompt.trim() ||
|
|
801
|
-
store.refs.every((r) => !r.filename) ||
|
|
802
|
-
!store.downloaded
|
|
803
|
-
}
|
|
804
|
-
className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm hover:shadow-md disabled:shadow-none"
|
|
805
|
-
>
|
|
806
|
-
{SparkleIcon}
|
|
807
|
-
Generate Video
|
|
808
|
-
</button>
|
|
809
|
-
)}
|
|
810
|
-
{!store.downloaded && (
|
|
811
|
-
<p className="text-xs text-tiffany-600 -mt-2">
|
|
812
|
-
Download the AI model above before generating.
|
|
813
|
-
</p>
|
|
814
|
-
)}
|
|
815
|
-
|
|
816
|
-
{/* Error */}
|
|
817
|
-
{store.genError && (
|
|
818
|
-
<div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
|
|
819
|
-
{store.genError}
|
|
820
|
-
</div>
|
|
821
|
-
)}
|
|
822
|
-
|
|
823
|
-
{/* Logs */}
|
|
824
|
-
{store.genLogs.length > 0 && (
|
|
825
|
-
<div
|
|
826
|
-
ref={logRef}
|
|
827
|
-
className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
|
|
828
|
-
>
|
|
829
|
-
<p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
830
|
-
Logs
|
|
831
|
-
</p>
|
|
832
|
-
<pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
|
|
833
|
-
{store.genLogs.join("")}
|
|
834
|
-
</pre>
|
|
835
|
-
</div>
|
|
836
|
-
)}
|
|
837
|
-
|
|
838
|
-
{/* Result */}
|
|
839
|
-
{store.result && (
|
|
840
|
-
<div>
|
|
841
|
-
<label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
|
|
842
|
-
Generated Video
|
|
843
|
-
</label>
|
|
844
|
-
<div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black w-full max-w-[500px]">
|
|
845
|
-
<video src={store.result} controls className="w-full h-auto" />
|
|
846
|
-
</div>
|
|
847
|
-
</div>
|
|
848
|
-
)}
|
|
849
|
-
|
|
850
|
-
{/* ===== Video Preview Modal ===== */}
|
|
851
|
-
{previewVideo && (
|
|
852
|
-
<div
|
|
853
|
-
className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
|
|
854
|
-
onClick={() => setPreviewVideo(null)}
|
|
855
|
-
>
|
|
856
|
-
<div
|
|
857
|
-
className="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center gap-3"
|
|
858
|
-
onClick={(e) => e.stopPropagation()}
|
|
859
|
-
>
|
|
860
|
-
<video
|
|
861
|
-
src={previewVideo.url}
|
|
862
|
-
controls
|
|
863
|
-
autoPlay
|
|
864
|
-
className="max-w-full max-h-[80vh] rounded-2xl shadow-2xl bg-black"
|
|
865
|
-
/>
|
|
866
|
-
<span className="text-xs text-white/70 truncate max-w-full">
|
|
867
|
-
{previewVideo.filename}
|
|
868
|
-
</span>
|
|
869
|
-
<button
|
|
870
|
-
onClick={() => setPreviewVideo(null)}
|
|
871
|
-
className="absolute -top-3 -right-3 flex items-center justify-center w-9 h-9 bg-white text-ink-700 rounded-full shadow-lg hover:bg-ink-200 transition-colors"
|
|
872
|
-
title="Close (Esc)"
|
|
873
|
-
>
|
|
874
|
-
{CloseIcon}
|
|
875
|
-
</button>
|
|
876
|
-
</div>
|
|
877
|
-
</div>
|
|
878
|
-
)}
|
|
879
|
-
</div>
|
|
880
|
-
);
|
|
881
|
-
}
|