@effectnode/media 0.9.0 → 0.11.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 (32) hide show
  1. package/dist/backend/movie-backend/agent/prompt/ltx.txt +20 -0
  2. package/dist/backend/movie-backend/agent/prompt/script.md +111 -1
  3. package/dist/backend/movie-backend/core.js +41 -0
  4. package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
  5. package/dist/backend/movie-backend/generation-queue.js +75 -8
  6. package/dist/backend/movie-backend/render-media.d.ts +79 -2
  7. package/dist/backend/movie-backend/render-media.js +694 -418
  8. package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
  9. package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
  10. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +47 -0
  11. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
  12. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
  13. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
  14. package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +343 -0
  15. package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
  16. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +130 -125
  17. package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +205 -0
  18. package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
  19. package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
  20. package/frontend/src/movie-app/stores/generationStore.ts +156 -255
  21. package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
  22. package/frontend/src/movie-app/stores/queueStore.ts +47 -1
  23. package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
  24. package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
  25. package/package.json +1 -1
  26. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
  27. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
  28. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
  29. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
  30. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
  31. package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
  32. package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
@@ -1,913 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import {
3
- useBatchVoiceStore,
4
- type BatchVoiceRowStatus,
5
- } from "../../stores/batchVoiceStore";
6
- import { useGenerationStore } from "../../stores/generationStore";
7
- import { useProjectStore } from "../../stores/projectStore";
8
-
9
- interface Props {
10
- projectId: string;
11
- }
12
-
13
- // ========== SVG Icons ==========
14
-
15
- const TableIcon = (
16
- <svg
17
- width="18"
18
- height="18"
19
- viewBox="0 0 24 24"
20
- fill="none"
21
- stroke="currentColor"
22
- strokeWidth="2"
23
- strokeLinecap="round"
24
- strokeLinejoin="round"
25
- className="text-tiffany-600"
26
- >
27
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
28
- <line x1="3" y1="9" x2="21" y2="9" />
29
- <line x1="3" y1="15" x2="21" y2="15" />
30
- <line x1="9" y1="3" x2="9" y2="21" />
31
- </svg>
32
- );
33
-
34
- const PlusIcon = (
35
- <svg
36
- width="14"
37
- height="14"
38
- viewBox="0 0 24 24"
39
- fill="none"
40
- stroke="currentColor"
41
- strokeWidth="2"
42
- strokeLinecap="round"
43
- strokeLinejoin="round"
44
- >
45
- <line x1="12" y1="5" x2="12" y2="19" />
46
- <line x1="5" y1="12" x2="19" y2="12" />
47
- </svg>
48
- );
49
-
50
- const UploadIcon = (
51
- <svg
52
- width="14"
53
- height="14"
54
- viewBox="0 0 24 24"
55
- fill="none"
56
- stroke="currentColor"
57
- strokeWidth="2"
58
- strokeLinecap="round"
59
- strokeLinejoin="round"
60
- >
61
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
62
- <polyline points="17 8 12 3 7 8" />
63
- <line x1="12" y1="3" x2="12" y2="15" />
64
- </svg>
65
- );
66
-
67
- const ImageIcon = (
68
- <svg
69
- width="14"
70
- height="14"
71
- viewBox="0 0 24 24"
72
- fill="none"
73
- stroke="currentColor"
74
- strokeWidth="2"
75
- strokeLinecap="round"
76
- strokeLinejoin="round"
77
- >
78
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
79
- <circle cx="8.5" cy="8.5" r="1.5" />
80
- <polyline points="21 15 16 10 5 21" />
81
- </svg>
82
- );
83
-
84
- const FolderIcon = (
85
- <svg
86
- width="16"
87
- height="16"
88
- viewBox="0 0 24 24"
89
- fill="none"
90
- stroke="currentColor"
91
- strokeWidth="2"
92
- strokeLinecap="round"
93
- strokeLinejoin="round"
94
- >
95
- <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
96
- </svg>
97
- );
98
-
99
- const MicIcon = (
100
- <svg
101
- width="16"
102
- height="16"
103
- viewBox="0 0 24 24"
104
- fill="none"
105
- stroke="currentColor"
106
- strokeWidth="2"
107
- strokeLinecap="round"
108
- strokeLinejoin="round"
109
- >
110
- <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
111
- <path d="M19 10v2a7 7 0 0 1-14 0v-2" />
112
- <line x1="12" y1="19" x2="12" y2="23" />
113
- </svg>
114
- );
115
-
116
- const CloseIcon = (
117
- <svg
118
- width="12"
119
- height="12"
120
- viewBox="0 0 24 24"
121
- fill="none"
122
- stroke="currentColor"
123
- strokeWidth="2"
124
- strokeLinecap="round"
125
- strokeLinejoin="round"
126
- >
127
- <line x1="18" y1="6" x2="6" y2="18" />
128
- <line x1="6" y1="6" x2="18" y2="18" />
129
- </svg>
130
- );
131
-
132
- const SparkleIcon = (
133
- <svg
134
- width="16"
135
- height="16"
136
- viewBox="0 0 24 24"
137
- fill="none"
138
- stroke="currentColor"
139
- strokeWidth="2"
140
- >
141
- <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" />
142
- </svg>
143
- );
144
-
145
- const CheckIcon = (
146
- <svg
147
- width="14"
148
- height="14"
149
- viewBox="0 0 24 24"
150
- fill="none"
151
- stroke="currentColor"
152
- strokeWidth="2"
153
- strokeLinecap="round"
154
- strokeLinejoin="round"
155
- >
156
- <polyline points="20 6 9 17 4 12" />
157
- </svg>
158
- );
159
-
160
- const SpinnerIcon = (
161
- <svg
162
- className="animate-spin"
163
- width="14"
164
- height="14"
165
- viewBox="0 0 24 24"
166
- fill="none"
167
- stroke="currentColor"
168
- strokeWidth="2"
169
- >
170
- <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
171
- <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
172
- </svg>
173
- );
174
-
175
- const FilmIcon = (
176
- <svg
177
- width="16"
178
- height="16"
179
- viewBox="0 0 24 24"
180
- fill="none"
181
- stroke="currentColor"
182
- strokeWidth="2"
183
- strokeLinecap="round"
184
- strokeLinejoin="round"
185
- >
186
- <rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
187
- <line x1="7" y1="2" x2="7" y2="22" />
188
- <line x1="17" y1="2" x2="17" y2="22" />
189
- <line x1="2" y1="12" x2="22" y2="12" />
190
- <line x1="2" y1="7" x2="7" y2="7" />
191
- <line x1="2" y1="17" x2="7" y2="17" />
192
- <line x1="17" y1="17" x2="22" y2="17" />
193
- <line x1="17" y1="7" x2="22" y2="7" />
194
- </svg>
195
- );
196
-
197
- // ========== Status badge ==========
198
-
199
- function StatusBadge({ status }: { status: BatchVoiceRowStatus }) {
200
- switch (status) {
201
- case "uploading":
202
- case "tts":
203
- case "video":
204
- case "muxing":
205
- return (
206
- <span className="inline-flex items-center gap-1.5 text-xs font-medium text-ink-600">
207
- {SpinnerIcon}
208
- {status === "uploading"
209
- ? "Uploading"
210
- : status === "tts"
211
- ? "Voice"
212
- : status === "video"
213
- ? "Video"
214
- : "Muxing"}
215
- </span>
216
- );
217
- case "done":
218
- return (
219
- <span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600">
220
- {CheckIcon}
221
- Done
222
- </span>
223
- );
224
- case "error":
225
- return <span className="text-xs font-medium text-red-600">Error</span>;
226
- default:
227
- return <span className="text-xs font-medium text-ink-500">—</span>;
228
- }
229
- }
230
-
231
- // ========== Component ==========
232
-
233
- export default function BatchVoiceVideoTab({ projectId }: Props) {
234
- const store = useBatchVoiceStore();
235
- const genStore = useGenerationStore();
236
- const { openFolder } = useProjectStore();
237
-
238
- const logRef = useRef<HTMLDivElement | null>(null);
239
- const stitchLogRef = useRef<HTMLDivElement | null>(null);
240
- const [pickRowId, setPickRowId] = useState<string | null>(null);
241
-
242
- // Restore persisted UI state (rows + settings) for the active project,
243
- // re-loading whenever the project changes.
244
- useEffect(() => {
245
- store.hydrate(projectId);
246
- // eslint-disable-next-line react-hooks/exhaustive-deps
247
- }, [projectId]);
248
-
249
- useEffect(() => {
250
- if (logRef.current) {
251
- logRef.current.scrollTop = logRef.current.scrollHeight;
252
- }
253
- }, [store.logs]);
254
-
255
- useEffect(() => {
256
- if (stitchLogRef.current) {
257
- stitchLogRef.current.scrollTop = stitchLogRef.current.scrollHeight;
258
- }
259
- }, [store.stitchLogs]);
260
-
261
- // Close the picker modal on Escape.
262
- useEffect(() => {
263
- if (!pickRowId) return;
264
- const onKey = (e: KeyboardEvent) => {
265
- if (e.key === "Escape") setPickRowId(null);
266
- };
267
- window.addEventListener("keydown", onKey);
268
- return () => window.removeEventListener("keydown", onKey);
269
- }, [pickRowId]);
270
-
271
- const openPicker = (rowId: string) => {
272
- genStore.fetchProjectImages(projectId);
273
- setPickRowId(rowId);
274
- };
275
-
276
- const handleImageSelect = async (
277
- e: React.ChangeEvent<HTMLInputElement>,
278
- rowId: string,
279
- ) => {
280
- const file = e.target.files?.[0];
281
- if (!file) return;
282
-
283
- const reader = new FileReader();
284
- reader.onload = async () => {
285
- const base64 = reader.result as string;
286
- const path = await store.uploadRowImage(
287
- rowId,
288
- base64,
289
- file.name,
290
- projectId,
291
- );
292
- if (path) {
293
- genStore.fetchProjectImages(projectId);
294
- }
295
- };
296
- reader.readAsDataURL(file);
297
- e.target.value = "";
298
- };
299
-
300
- const handleVoiceSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
301
- const file = e.target.files?.[0];
302
- if (!file) return;
303
-
304
- const reader = new FileReader();
305
- reader.onload = async () => {
306
- const base64 = reader.result as string;
307
- await store.uploadVoiceRef(base64, file.name, projectId);
308
- };
309
- reader.readAsDataURL(file);
310
- e.target.value = "";
311
- };
312
-
313
- const readyCount = store.rows.filter(
314
- (r) =>
315
- r.prompt.trim() &&
316
- r.imagePath &&
317
- (!r.script.trim() || store.voiceRefPath),
318
- ).length;
319
-
320
- const resultCount = store.rows.filter((r) => r.result).length;
321
-
322
- return (
323
- <div className="flex flex-col gap-7">
324
- <div className="flex items-center gap-2">
325
- {TableIcon}
326
- <h2 className="text-base font-semibold text-ink-900">
327
- Batch Videos with Voice Over
328
- </h2>
329
- </div>
330
-
331
- {/* Voice settings */}
332
- <div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-6">
333
- <div className="flex items-center gap-2">
334
- {MicIcon}
335
- <span className="text-sm font-semibold text-ink-900">
336
- Voice Reference
337
- </span>
338
- </div>
339
-
340
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
341
- {/* Voice reference upload */}
342
- <div>
343
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
344
- Reference Voice
345
- </label>
346
- <div className="flex flex-col items-start gap-2">
347
- <label className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-ink-200 bg-white text-ink-600 text-xs font-medium cursor-pointer hover:border-ink-300 hover:bg-ink-100 transition-colors">
348
- {UploadIcon}
349
- {store.voiceRefFilename ? "Replace" : "Upload"}
350
- <input
351
- type="file"
352
- accept="audio/*"
353
- onChange={handleVoiceSelect}
354
- disabled={store.running}
355
- className="hidden"
356
- />
357
- </label>
358
- {store.voiceRefFilename && (
359
- <span className="text-[11px] text-ink-600 truncate max-w-[240px]">
360
- {store.voiceRefFilename}
361
- </span>
362
- )}
363
- {store.voiceRefUrl && (
364
- <audio
365
- controls
366
- src={store.voiceRefUrl}
367
- className="w-full max-w-[320px] h-9"
368
- />
369
- )}
370
- {!store.voiceRefPath && (
371
- <p className="text-xs text-tiffany-600 italic">
372
- Upload a reference voice (.mp3/.wav) to clone for every row.
373
- </p>
374
- )}
375
- </div>
376
- </div>
377
-
378
- {/* Voice quality */}
379
- <div>
380
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
381
- Voice Quality
382
- </label>
383
- <div className="flex flex-wrap gap-1.5">
384
- {(
385
- [
386
- { label: "High · Qwen3 1.7B", value: "high" },
387
- { label: "Low · Qwen3 0.6B", value: "low" },
388
- ] as const
389
- ).map((q) => (
390
- <button
391
- key={q.value}
392
- onClick={() => store.setQuality(q.value)}
393
- disabled={store.running}
394
- className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
395
- store.quality === q.value
396
- ? "bg-ink-100 border-ink-300 text-ink-800"
397
- : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
398
- } disabled:opacity-50`}
399
- >
400
- {q.label}
401
- </button>
402
- ))}
403
- </div>
404
- </div>
405
- </div>
406
- </div>
407
-
408
- {/* Shared video settings */}
409
- <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
410
- {/* Duration */}
411
- <div>
412
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
413
- Duration (seconds)
414
- </label>
415
- <div className="flex flex-wrap gap-1.5">
416
- {[0.5, 3, 5, 7.5, 10, 15, 20].map((d) => (
417
- <button
418
- key={d}
419
- onClick={() => store.setDuration(d)}
420
- disabled={store.running}
421
- className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
422
- store.duration === d
423
- ? "bg-ink-100 border-ink-300 text-ink-800"
424
- : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
425
- } disabled:opacity-50`}
426
- >
427
- {d}s
428
- </button>
429
- ))}
430
- </div>
431
- </div>
432
-
433
- {/* Aspect ratio */}
434
- <div>
435
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
436
- Aspect Ratio
437
- </label>
438
- <div className="flex flex-wrap gap-1.5">
439
- {(["1:1", "16:9", "9:16", "4:3", "3:4"] as const).map((ratio) => (
440
- <button
441
- key={ratio}
442
- onClick={() => store.setAspectRatio(ratio)}
443
- disabled={store.running}
444
- className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
445
- store.aspectRatio === ratio
446
- ? "bg-ink-100 border-ink-300 text-ink-800"
447
- : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
448
- } disabled:opacity-50`}
449
- >
450
- {ratio}
451
- </button>
452
- ))}
453
- </div>
454
- </div>
455
-
456
- {/* Resolution */}
457
- <div>
458
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
459
- Resolution
460
- </label>
461
- <div className="flex flex-wrap gap-1.5">
462
- {(["320p", "480p", "576p", "640p", "720p", "1080p"] as const).map(
463
- (res: any) => (
464
- <button
465
- key={res}
466
- onClick={() => store.setResolution(res)}
467
- disabled={store.running}
468
- className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
469
- store.resolution === res
470
- ? "bg-ink-100 border-ink-300 text-ink-800"
471
- : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
472
- } disabled:opacity-50`}
473
- >
474
- {res}
475
- </button>
476
- ),
477
- )}
478
- </div>
479
- </div>
480
-
481
- {/* Video mode */}
482
- <div>
483
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
484
- Mode
485
- </label>
486
- <div className="flex flex-wrap gap-1.5">
487
- {(
488
- [
489
- { label: "Low", value: "distilled" },
490
- { label: "Standard", value: "one-stage" },
491
- { label: "High", value: "two-stage" },
492
- ] as const
493
- ).map((q) => (
494
- <button
495
- key={q.value}
496
- onClick={() => store.setMode(q.value)}
497
- disabled={store.running}
498
- className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
499
- store.mode === q.value
500
- ? "bg-ink-100 border-ink-300 text-ink-800"
501
- : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
502
- } disabled:opacity-50`}
503
- >
504
- {q.label}
505
- </button>
506
- ))}
507
- </div>
508
- </div>
509
- </div>
510
-
511
- {/* Table */}
512
- <div>
513
- <div className="flex items-center justify-between mb-2">
514
- <label className="text-xs font-semibold text-ink-700 uppercase tracking-wider">
515
- Batch Rows
516
- </label>
517
- <div className="flex items-center gap-1.5">
518
- <button
519
- onClick={store.clear}
520
- disabled={store.running}
521
- title="Clear all rows and settings"
522
- 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-red-600 hover:border-red-200 hover:bg-red-50 disabled:opacity-50"
523
- >
524
- {CloseIcon}
525
- Clear
526
- </button>
527
- <button
528
- onClick={store.addRow}
529
- disabled={store.running}
530
- 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"
531
- >
532
- {PlusIcon}
533
- Add Row
534
- </button>
535
- </div>
536
- </div>
537
-
538
- <div className="border border-ink-200 rounded-2xl overflow-hidden">
539
- <div className="overflow-auto max-h-[480px]">
540
- <table className="w-full text-xs">
541
- <thead>
542
- <tr className="bg-ink-50 border-b border-ink-200">
543
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-10">
544
- #
545
- </th>
546
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-36">
547
- Starting Image
548
- </th>
549
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 min-w-[220px]">
550
- Video Prompt
551
- </th>
552
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 min-w-[220px]">
553
- Script (Voice · optional)
554
- </th>
555
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-28">
556
- Status
557
- </th>
558
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-40">
559
- Result
560
- </th>
561
- <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-24">
562
- Actions
563
- </th>
564
- </tr>
565
- </thead>
566
- <tbody>
567
- {store.rows.map((row, idx) => (
568
- <tr
569
- key={row.id}
570
- className="border-b border-ink-200 last:border-b-0 align-top transition-colors hover:bg-ink-100/40"
571
- >
572
- <td className="px-3 py-2 text-ink-600/60 font-medium tabular-nums">
573
- {idx + 1}
574
- </td>
575
-
576
- {/* Starting image */}
577
- <td className="px-3 py-2">
578
- <div className="flex flex-col items-start gap-1.5">
579
- <div className="flex items-center gap-1">
580
- <label className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-ink-200 bg-white text-ink-600 text-[11px] font-medium cursor-pointer hover:border-ink-300 hover:bg-ink-100 transition-colors">
581
- {UploadIcon}
582
- {row.imageFilename ? "Replace" : "Upload"}
583
- <input
584
- type="file"
585
- accept="image/*"
586
- onChange={(e) => handleImageSelect(e, row.id)}
587
- disabled={store.running}
588
- className="hidden"
589
- />
590
- </label>
591
- <button
592
- onClick={() => openPicker(row.id)}
593
- disabled={store.running}
594
- title="Pick from project images"
595
- className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl border border-ink-200 bg-white text-ink-600 text-[11px] font-medium hover:border-ink-300 hover:bg-ink-100 transition-colors disabled:opacity-50"
596
- >
597
- {ImageIcon}
598
- Pick
599
- </button>
600
- </div>
601
- {row.imageUrl ? (
602
- <img
603
- src={row.imageUrl}
604
- alt={row.imageFilename || "Starting image"}
605
- className="w-20 h-20 object-cover rounded-xl border border-ink-200"
606
- />
607
- ) : (
608
- <span className="text-[10px] text-ink-500 italic">
609
- No image
610
- </span>
611
- )}
612
- {row.imageFilename && (
613
- <span className="text-[10px] text-ink-600 truncate max-w-[120px]">
614
- {row.imageFilename}
615
- </span>
616
- )}
617
- </div>
618
- </td>
619
-
620
- {/* Video prompt */}
621
- <td className="px-3 py-2">
622
- <textarea
623
- value={row.prompt}
624
- onChange={(e) =>
625
- store.updatePrompt(row.id, e.target.value)
626
- }
627
- placeholder="Describe the video scene..."
628
- rows={3}
629
- disabled={store.running}
630
- className="w-full px-2.5 py-1.5 bg-transparent border border-ink-200 rounded-xl text-ink-800 text-xs placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-1 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
631
- />
632
- </td>
633
-
634
- {/* Script (voice) */}
635
- <td className="px-3 py-2">
636
- <textarea
637
- value={row.script}
638
- onChange={(e) =>
639
- store.updateScript(row.id, e.target.value)
640
- }
641
- placeholder="Optional — text spoken in the cloned voice (leave empty for a silent video)..."
642
- rows={3}
643
- disabled={store.running}
644
- className="w-full px-2.5 py-1.5 bg-transparent border border-ink-200 rounded-xl text-ink-800 text-xs placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-1 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
645
- />
646
- </td>
647
-
648
- {/* Status */}
649
- <td className="px-3 py-2">
650
- <StatusBadge status={row.status} />
651
- {row.status === "error" && row.error && (
652
- <p className="text-[10px] text-red-600 mt-1 break-words max-w-[120px]">
653
- {row.error}
654
- </p>
655
- )}
656
- </td>
657
-
658
- {/* Result */}
659
- <td className="px-3 py-2">
660
- {row.result ? (
661
- <video
662
- src={row.result}
663
- controls
664
- className="w-full max-w-[160px] h-auto rounded-xl bg-black"
665
- />
666
- ) : (
667
- <span className="text-[10px] text-ink-500 italic">
668
-
669
- </span>
670
- )}
671
- </td>
672
-
673
- {/* Actions */}
674
- <td className="px-3 py-2">
675
- <div className="flex items-center gap-1">
676
- <button
677
- onClick={() => store.generateRow(projectId, row.id)}
678
- disabled={
679
- store.running ||
680
- !row.prompt.trim() ||
681
- !row.imagePath ||
682
- (!!row.script.trim() && !store.voiceRefPath)
683
- }
684
- title="Generate this row"
685
- className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-200 hover:border-ink-300 transition-colors disabled:opacity-40"
686
- >
687
- {SparkleIcon}
688
- </button>
689
- <button
690
- onClick={() => store.removeRow(row.id)}
691
- disabled={store.running}
692
- title="Remove row"
693
- className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 transition-colors disabled:opacity-40"
694
- >
695
- {CloseIcon}
696
- </button>
697
- </div>
698
- </td>
699
- </tr>
700
- ))}
701
- </tbody>
702
- </table>
703
- </div>
704
- </div>
705
-
706
- {store.rows.length === 0 && (
707
- <p className="text-xs text-ink-500 italic py-6 text-center border border-dashed border-ink-200 rounded-2xl mt-2">
708
- No rows yet. Click "Add Row" to get started.
709
- </p>
710
- )}
711
- </div>
712
-
713
- {/* Open output folder */}
714
- <button
715
- onClick={() => openFolder(projectId, "output")}
716
- className="flex items-center justify-center gap-2 w-full px-4 py-2.5 bg-ink-50 hover:bg-ink-200 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors"
717
- >
718
- {FolderIcon}
719
- Open Output Folder
720
- </button>
721
-
722
- {/* Progress */}
723
- {store.progress && (
724
- <div className="flex items-center gap-3 p-4 bg-ink-50 border border-ink-200 rounded-2xl">
725
- <span className="text-xs font-medium text-ink-700">
726
- Batch Progress
727
- </span>
728
- <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
729
- <div
730
- className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
731
- style={{
732
- width: `${(store.progress.current / store.progress.total) * 100}%`,
733
- }}
734
- />
735
- </div>
736
- <span className="text-xs font-semibold text-ink-700 tabular-nums">
737
- {store.progress.current}/{store.progress.total}
738
- </span>
739
- </div>
740
- )}
741
-
742
- {/* Logs */}
743
- {store.logs.length > 0 && (
744
- <div
745
- ref={logRef}
746
- className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
747
- >
748
- <p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
749
- Progress Logs
750
- </p>
751
- <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
752
- {store.logs.join("\n")}
753
- </pre>
754
- </div>
755
- )}
756
-
757
- {/* Generate all / cancel */}
758
- {store.running ? (
759
- <button
760
- onClick={store.cancel}
761
- className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-red-50 hover:bg-red-100 text-red-600 text-sm font-semibold rounded-2xl border border-red-200 transition-colors"
762
- >
763
- Cancel Batch
764
- </button>
765
- ) : (
766
- <button
767
- onClick={() => store.generateAll(projectId)}
768
- disabled={readyCount === 0}
769
- 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"
770
- >
771
- {SparkleIcon}
772
- Generate All ({readyCount} videos)
773
- </button>
774
- )}
775
-
776
- {readyCount === 0 && !store.running && (
777
- <p className="text-xs text-tiffany-600 -mt-2">
778
- Each row needs a starting image and a video prompt. The script is
779
- optional — leave it empty for a silent video. Rows with a script also
780
- need the shared reference voice.
781
- </p>
782
- )}
783
-
784
- {/* ===== Stitch Videos ===== */}
785
- <div className="pt-3 border-t border-ink-200">
786
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
787
- Stitch Videos
788
- </label>
789
-
790
- {store.stitching ? (
791
- <div className="flex items-center gap-2 w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
792
- {SpinnerIcon}
793
- <span className="text-sm font-medium text-ink-700">
794
- Stitching...
795
- </span>
796
- </div>
797
- ) : (
798
- <button
799
- onClick={() => store.stitchVideos()}
800
- disabled={resultCount < 2}
801
- 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"
802
- >
803
- {FilmIcon}
804
- Stitch All Videos ({resultCount})
805
- </button>
806
- )}
807
-
808
- {store.stitchError && (
809
- <div className="mt-2 p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
810
- {store.stitchError}
811
- </div>
812
- )}
813
-
814
- {store.stitchLogs.length > 0 && (
815
- <div
816
- ref={stitchLogRef}
817
- className="mt-2 p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
818
- >
819
- <p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
820
- Stitch Logs
821
- </p>
822
- <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
823
- {store.stitchLogs.join("\n")}
824
- </pre>
825
- </div>
826
- )}
827
-
828
- {store.stitchResult && (
829
- <div className="mt-3">
830
- <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
831
- Stitched Video
832
- </label>
833
- <div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black w-full max-w-[500px]">
834
- <video
835
- src={store.stitchResult}
836
- controls
837
- className="w-full h-auto"
838
- />
839
- </div>
840
- <a
841
- href={store.stitchResult}
842
- download="stitched.mp4"
843
- className="inline-flex items-center gap-1.5 mt-2 text-xs font-medium text-ink-600 hover:text-ink-900 transition-colors"
844
- >
845
- {UploadIcon}
846
- Download stitched.mp4
847
- </a>
848
- </div>
849
- )}
850
- </div>
851
-
852
- {/* ===== Project Images Picker Modal ===== */}
853
- {pickRowId && (
854
- <div
855
- className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
856
- onClick={() => setPickRowId(null)}
857
- >
858
- <div
859
- className="w-full max-w-3xl max-h-[80vh] flex flex-col bg-white rounded-3xl border border-ink-200 shadow-2xl overflow-hidden"
860
- onClick={(e) => e.stopPropagation()}
861
- >
862
- <div className="flex items-center justify-between px-5 py-3 border-b border-ink-200">
863
- <span className="text-sm font-semibold text-ink-900">
864
- Pick Starting Image
865
- </span>
866
- <button
867
- onClick={() => setPickRowId(null)}
868
- className="flex items-center justify-center w-7 h-7 rounded-xl text-ink-600 hover:bg-ink-200 transition-colors"
869
- title="Close (Esc)"
870
- >
871
- {CloseIcon}
872
- </button>
873
- </div>
874
-
875
- <div className="p-6 overflow-y-auto">
876
- {genStore.projectImagesLoading ? (
877
- <p className="text-xs text-ink-500 italic py-8 text-center">
878
- Loading images...
879
- </p>
880
- ) : genStore.projectImages.length === 0 ? (
881
- <p className="text-xs text-ink-500 italic py-8 text-center border border-dashed border-ink-200 rounded-2xl">
882
- No images yet. Upload one first.
883
- </p>
884
- ) : (
885
- <div className="grid grid-cols-3 lg:grid-cols-5 gap-2">
886
- {genStore.projectImages.map((img) => (
887
- <button
888
- key={`${img.source}-${img.filename}`}
889
- onClick={() => {
890
- store.setRowImage(pickRowId, img);
891
- setPickRowId(null);
892
- }}
893
- className="relative rounded-xl border-2 border-ink-200 hover:border-ink-300 transition-all overflow-hidden"
894
- >
895
- <img
896
- src={img.url}
897
- alt={img.filename}
898
- className="aspect-square object-cover object-center w-full"
899
- />
900
- <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">
901
- {img.filename}
902
- </span>
903
- </button>
904
- ))}
905
- </div>
906
- )}
907
- </div>
908
- </div>
909
- </div>
910
- )}
911
- </div>
912
- );
913
- }