@effectnode/media 0.3.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.
Files changed (48) hide show
  1. package/dist/backend/movie-backend/agent/agent-backend.js +4 -4
  2. package/dist/backend/movie-backend/agent/prompt/story-writer.txt +137 -0
  3. package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +1 -1
  4. package/dist/backend/movie-backend/agent/tools/edit-image.js +1 -1
  5. package/dist/backend/movie-backend/agent/tools/get-time.d.ts +1 -1
  6. package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +1 -1
  7. package/dist/backend/movie-backend/agent/tools/grep-files.js +1 -1
  8. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +1 -1
  9. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +1 -1
  10. package/dist/backend/movie-backend/agent/tools/index.d.ts +2 -2
  11. package/dist/backend/movie-backend/agent/tools/index.js +13 -13
  12. package/dist/backend/movie-backend/agent/tools/list-files.d.ts +1 -1
  13. package/dist/backend/movie-backend/agent/tools/list-files.js +1 -1
  14. package/dist/backend/movie-backend/agent/tools/read-file.d.ts +1 -1
  15. package/dist/backend/movie-backend/agent/tools/read-file.js +1 -1
  16. package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +1 -1
  17. package/dist/backend/movie-backend/agent/tools/remove-file.js +1 -1
  18. package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +1 -1
  19. package/dist/backend/movie-backend/agent/tools/rename-file.js +1 -1
  20. package/dist/backend/movie-backend/agent/tools/show-image.d.ts +1 -1
  21. package/dist/backend/movie-backend/agent/tools/show-image.js +1 -1
  22. package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +1 -1
  23. package/dist/backend/movie-backend/agent/tools/stitch-videos.js +1 -1
  24. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +1 -1
  25. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +1 -1
  26. package/dist/backend/movie-backend/agent/tools/update-file.d.ts +1 -1
  27. package/dist/backend/movie-backend/agent/tools/update-file.js +1 -1
  28. package/dist/backend/movie-backend/agent/tools/write-file.d.ts +1 -1
  29. package/dist/backend/movie-backend/agent/tools/write-file.js +1 -1
  30. package/dist/backend/movie-backend/core.js +4 -4
  31. package/dist/backend/movie-backend/generation-queue.js +102 -46
  32. package/dist/backend/movie-backend/render-media.js +1 -1
  33. package/frontend/index.html +8 -1
  34. package/frontend/public/lambobo.png +0 -0
  35. package/frontend/src/movie-app/MediaStudio.tsx +6 -2
  36. package/frontend/src/movie-app/SetupPage.tsx +98 -63
  37. package/frontend/src/movie-app/components/Aurora.tsx +13 -0
  38. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +5 -8
  39. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +297 -189
  40. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +345 -0
  41. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +40 -34
  42. package/frontend/src/movie-app/components/ProjectManager.tsx +80 -52
  43. package/frontend/src/movie-app/index.css +260 -19
  44. package/frontend/src/movie-app/stores/aiModelStore.ts +163 -0
  45. package/frontend/src/movie-app/stores/generationStore.ts +2 -2
  46. package/frontend/src/movie-app/stores/movieStudioStore.ts +45 -0
  47. package/package.json +2 -2
  48. 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
- }