@effectnode/media 0.9.0 → 0.10.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 (31) hide show
  1. package/dist/backend/movie-backend/agent/prompt/script.md +111 -1
  2. package/dist/backend/movie-backend/core.js +33 -0
  3. package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
  4. package/dist/backend/movie-backend/generation-queue.js +75 -8
  5. package/dist/backend/movie-backend/render-media.d.ts +79 -2
  6. package/dist/backend/movie-backend/render-media.js +722 -418
  7. package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
  8. package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
  9. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +47 -0
  10. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
  11. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
  12. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
  13. package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
  14. package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
  15. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +130 -125
  16. package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
  17. package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
  18. package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
  19. package/frontend/src/movie-app/stores/generationStore.ts +156 -255
  20. package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
  21. package/frontend/src/movie-app/stores/queueStore.ts +47 -1
  22. package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
  23. package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
  24. package/package.json +1 -1
  25. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
  26. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
  27. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
  28. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
  29. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
  30. package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
  31. package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
@@ -0,0 +1,406 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useQueueStore } from "../../stores/queueStore";
3
+ import { useProjectStore } from "../../stores/projectStore";
4
+ import { useAudioToVideoStore } from "../../stores/audioToVideoStore";
5
+ import TaskQueuePanel from "./TaskQueuePanel";
6
+ import TerminalLogPanel from "./TerminalLogPanel";
7
+
8
+ interface Props {
9
+ projectId: string;
10
+ }
11
+
12
+ export default function AudioToVideoTab({ projectId }: Props) {
13
+ const queue = useQueueStore();
14
+ const { openFolder } = useProjectStore();
15
+ const a2v = useAudioToVideoStore();
16
+
17
+ const imageInputRef = useRef<HTMLInputElement>(null);
18
+ const audioInputRef = useRef<HTMLInputElement>(null);
19
+
20
+ useEffect(() => {
21
+ a2v.fetchImages(projectId);
22
+ a2v.fetchAudios(projectId);
23
+ // eslint-disable-next-line react-hooks/exhaustive-deps
24
+ }, [projectId]);
25
+
26
+ // Stream the generation queue so audio-to-video tasks surface here.
27
+ useEffect(() => {
28
+ queue.startStreaming(projectId);
29
+ return () => queue.stopStreaming();
30
+ // eslint-disable-next-line react-hooks/exhaustive-deps
31
+ }, [projectId]);
32
+
33
+ // Reconcile audio-to-video state with the latest queue task state.
34
+ useEffect(() => {
35
+ for (const task of queue.tasks) a2v.applyQueueTask(task);
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [queue.tasks, projectId]);
38
+
39
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
40
+ const file = e.target.files?.[0];
41
+ e.target.value = "";
42
+ if (!file) return;
43
+ const reader = new FileReader();
44
+ reader.onload = () => {
45
+ a2v.uploadImage(projectId, reader.result as string, file.name);
46
+ };
47
+ reader.readAsDataURL(file);
48
+ };
49
+
50
+ const handleAudioUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
51
+ const file = e.target.files?.[0];
52
+ e.target.value = "";
53
+ if (!file) return;
54
+ const reader = new FileReader();
55
+ reader.onload = () => {
56
+ a2v.uploadAudio(projectId, reader.result as string, file.name);
57
+ };
58
+ reader.readAsDataURL(file);
59
+ };
60
+
61
+ const busy =
62
+ a2v.generating || a2v.uploadingImage || a2v.uploadingAudio;
63
+
64
+ // ========== SVG Icons ==========
65
+
66
+ const VideoIcon = (
67
+ <svg
68
+ width="18"
69
+ height="18"
70
+ viewBox="0 0 24 24"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth="2"
74
+ >
75
+ <polygon points="23 7 16 12 23 17 23 7" />
76
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
77
+ </svg>
78
+ );
79
+
80
+ const UploadIcon = (
81
+ <svg
82
+ width="14"
83
+ height="14"
84
+ viewBox="0 0 24 24"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ strokeWidth="2"
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ >
91
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
92
+ <polyline points="17 8 12 3 7 8" />
93
+ <line x1="12" y1="3" x2="12" y2="15" />
94
+ </svg>
95
+ );
96
+
97
+ const SparkleIcon = (
98
+ <svg
99
+ width="16"
100
+ height="16"
101
+ viewBox="0 0 24 24"
102
+ fill="none"
103
+ stroke="currentColor"
104
+ strokeWidth="2"
105
+ >
106
+ <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" />
107
+ </svg>
108
+ );
109
+
110
+ const SpinnerIcon = (
111
+ <svg
112
+ className="animate-spin text-tiffany-600"
113
+ width="16"
114
+ height="16"
115
+ viewBox="0 0 24 24"
116
+ fill="none"
117
+ stroke="currentColor"
118
+ strokeWidth="2"
119
+ >
120
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
121
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
122
+ </svg>
123
+ );
124
+
125
+ const FolderIcon = (
126
+ <svg
127
+ width="16"
128
+ height="16"
129
+ viewBox="0 0 24 24"
130
+ fill="none"
131
+ stroke="currentColor"
132
+ strokeWidth="2"
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ >
136
+ <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" />
137
+ </svg>
138
+ );
139
+
140
+ return (
141
+ <div className="flex flex-col gap-7">
142
+ <div className="flex items-center gap-2">
143
+ <span className="text-tiffany-600">{VideoIcon}</span>
144
+ <h2 className="text-base font-semibold text-ink-900">Audio to Video</h2>
145
+ </div>
146
+
147
+ <TaskQueuePanel projectId={projectId} />
148
+ <TerminalLogPanel />
149
+
150
+ {/* ===== Image ===== */}
151
+ <div>
152
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
153
+ Image
154
+ </label>
155
+ <div className="flex items-center gap-2">
156
+ <button
157
+ onClick={() => imageInputRef.current?.click()}
158
+ disabled={a2v.uploadingImage || a2v.generating}
159
+ className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 disabled:opacity-50 transition-colors"
160
+ >
161
+ {UploadIcon}
162
+ Upload Image
163
+ </button>
164
+ <input
165
+ ref={imageInputRef}
166
+ type="file"
167
+ accept="image/*"
168
+ onChange={handleImageUpload}
169
+ className="hidden"
170
+ />
171
+ {a2v.image && (
172
+ <span className="truncate text-xs text-ink-600">
173
+ {a2v.image.filename}
174
+ </span>
175
+ )}
176
+ </div>
177
+
178
+ {a2v.imagesLoading ? (
179
+ <p className="text-xs text-ink-500 italic py-3 text-center">
180
+ Loading images…
181
+ </p>
182
+ ) : a2v.images.length === 0 ? null : (
183
+ <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-2 p-1 mt-2">
184
+ {a2v.images.map((img) => {
185
+ const isSelected = a2v.image?.filename === img.filename;
186
+ return (
187
+ <button
188
+ key={img.filename}
189
+ onClick={() => a2v.setImage(img)}
190
+ disabled={a2v.generating}
191
+ className={`relative rounded-xl border-2 overflow-hidden transition-all ${
192
+ isSelected
193
+ ? "border-tiffany-500 ring-2 ring-tiffany-500/40"
194
+ : "border-ink-200 hover:border-ink-300"
195
+ } disabled:opacity-50`}
196
+ >
197
+ <img
198
+ src={img.url}
199
+ alt={img.filename}
200
+ className="aspect-square object-cover object-center w-full"
201
+ />
202
+ <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">
203
+ {img.filename}
204
+ </span>
205
+ </button>
206
+ );
207
+ })}
208
+ </div>
209
+ )}
210
+ </div>
211
+
212
+ {/* ===== Audio ===== */}
213
+ <div>
214
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
215
+ Audio (MP3 / WAV)
216
+ </label>
217
+ <div className="flex items-center gap-2">
218
+ <button
219
+ onClick={() => audioInputRef.current?.click()}
220
+ disabled={a2v.uploadingAudio || a2v.generating}
221
+ className="flex items-center gap-1.5 px-4 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 disabled:opacity-50 transition-colors"
222
+ >
223
+ {UploadIcon}
224
+ Upload Audio
225
+ </button>
226
+ <input
227
+ ref={audioInputRef}
228
+ type="file"
229
+ accept="audio/*"
230
+ onChange={handleAudioUpload}
231
+ className="hidden"
232
+ />
233
+ {a2v.audio && (
234
+ <span className="truncate text-xs text-ink-600">
235
+ {a2v.audio.filename}
236
+ </span>
237
+ )}
238
+ </div>
239
+
240
+ {a2v.audiosLoading ? (
241
+ <p className="text-xs text-ink-500 italic py-3 text-center">
242
+ Loading audios…
243
+ </p>
244
+ ) : a2v.audios.length === 0 ? null : (
245
+ <ul className="mt-2 divide-y divide-ink-200 border border-ink-200 rounded-2xl overflow-hidden max-h-48 overflow-y-auto">
246
+ {a2v.audios.map((a) => {
247
+ const isSelected = a2v.audio?.filename === a.filename;
248
+ return (
249
+ <li key={a.filename}>
250
+ <button
251
+ onClick={() => a2v.setAudio(a)}
252
+ disabled={a2v.generating}
253
+ className={`w-full flex items-center gap-2 px-3 py-2 text-left text-xs transition-colors disabled:opacity-50 ${
254
+ isSelected
255
+ ? "bg-ink-100 text-ink-800"
256
+ : "text-ink-600 hover:bg-ink-100"
257
+ }`}
258
+ >
259
+ <span className="truncate">{a.filename}</span>
260
+ </button>
261
+ </li>
262
+ );
263
+ })}
264
+ </ul>
265
+ )}
266
+ </div>
267
+
268
+ {/* ===== Steps ===== */}
269
+ <div>
270
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
271
+ Stage-1 Steps
272
+ </label>
273
+ <div className="flex flex-wrap items-center gap-2">
274
+ <button
275
+ onClick={() => a2v.setSteps(15)}
276
+ disabled={a2v.generating}
277
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
278
+ a2v.steps === 15
279
+ ? "bg-ink-100 border-ink-300 text-ink-800"
280
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
281
+ } disabled:opacity-50`}
282
+ >
283
+ 15 (default)
284
+ </button>
285
+ <button
286
+ onClick={() => a2v.setSteps(30)}
287
+ disabled={a2v.generating}
288
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
289
+ a2v.steps === 30
290
+ ? "bg-ink-100 border-ink-300 text-ink-800"
291
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
292
+ } disabled:opacity-50`}
293
+ >
294
+ 30 (high quality)
295
+ </button>
296
+ <input
297
+ type="number"
298
+ min={1}
299
+ step={1}
300
+ value={a2v.steps}
301
+ onChange={(e) => a2v.setSteps(Number(e.target.value))}
302
+ disabled={a2v.generating}
303
+ className="w-24 px-3 py-1.5 text-xs bg-ink-50 border border-ink-200 rounded-xl text-ink-800 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/25 disabled:opacity-50"
304
+ />
305
+ </div>
306
+ </div>
307
+
308
+ {/* ===== Duration ===== */}
309
+ <div>
310
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
311
+ Duration (seconds)
312
+ </label>
313
+ <input
314
+ type="number"
315
+ min={1}
316
+ step={1}
317
+ value={a2v.duration}
318
+ onChange={(e) => a2v.setDuration(Number(e.target.value))}
319
+ disabled={a2v.generating}
320
+ className="w-32 px-4 py-2.5 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 disabled:opacity-50"
321
+ />
322
+ </div>
323
+
324
+ {/* ===== Frames ===== */}
325
+ <div>
326
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
327
+ Frames
328
+ </label>
329
+ <input
330
+ type="number"
331
+ min={1}
332
+ step={1}
333
+ value={a2v.frames}
334
+ onChange={(e) => a2v.setFrames(Number(e.target.value))}
335
+ disabled={a2v.generating}
336
+ className="w-32 px-4 py-2.5 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 disabled:opacity-50"
337
+ />
338
+ <p className="text-xs text-ink-600/50 mt-1.5">
339
+ Auto-updated from duration: 1 second = 24 frames + 1 (24n+1)
340
+ </p>
341
+ </div>
342
+
343
+ {/* ===== Prompt ===== */}
344
+ <div>
345
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
346
+ Scene Prompt
347
+ </label>
348
+ <textarea
349
+ value={a2v.prompt}
350
+ onChange={(e) => a2v.setPrompt(e.target.value)}
351
+ placeholder="Describe the scene, e.g. scene at restaurant"
352
+ rows={3}
353
+ disabled={a2v.generating}
354
+ 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"
355
+ />
356
+ </div>
357
+
358
+ {/* ===== Generate ===== */}
359
+ {a2v.generating ? (
360
+ <div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
361
+ {SpinnerIcon}
362
+ <span className="text-sm font-medium text-ink-700">
363
+ Generating video…
364
+ </span>
365
+ </div>
366
+ ) : (
367
+ <button
368
+ onClick={() => a2v.generate(projectId)}
369
+ disabled={busy || !a2v.image || !a2v.audio}
370
+ 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"
371
+ >
372
+ {SparkleIcon}
373
+ Generate Video
374
+ </button>
375
+ )}
376
+
377
+ {/* ===== Open output folder ===== */}
378
+ <button
379
+ onClick={() => openFolder(projectId, "output")}
380
+ 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"
381
+ >
382
+ {FolderIcon}
383
+ Open Output Folder
384
+ </button>
385
+
386
+ {/* ===== Error ===== */}
387
+ {a2v.error && (
388
+ <div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
389
+ {a2v.error}
390
+ </div>
391
+ )}
392
+
393
+ {/* ===== Result ===== */}
394
+ {a2v.result && (
395
+ <div>
396
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
397
+ Generated Video
398
+ </label>
399
+ <div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black">
400
+ <video src={a2v.result} controls className="w-full h-auto" />
401
+ </div>
402
+ </div>
403
+ )}
404
+ </div>
405
+ );
406
+ }
@@ -415,6 +415,53 @@ export default function FastImageEditTab({ projectId }: Props) {
415
415
  />
416
416
  </div>
417
417
 
418
+ {/* ===== Steps ===== */}
419
+ <div>
420
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
421
+ Steps
422
+ </label>
423
+ <input
424
+ type="number"
425
+ min={1}
426
+ step={1}
427
+ value={store.fastImageEdit.steps}
428
+ onChange={(e) => store.setFastImageEditSteps(Number(e.target.value))}
429
+ disabled={store.fastImageEdit.generating}
430
+ className="w-32 px-4 py-2.5 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 disabled:opacity-50"
431
+ />
432
+ </div>
433
+
434
+ {/* ===== Upscale Result (optional) ===== */}
435
+ <div>
436
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
437
+ Upscale Result (optional)
438
+ </label>
439
+ <div className="flex flex-wrap gap-2">
440
+ {(
441
+ [
442
+ { value: "none", label: "None" },
443
+ { value: "1x", label: "1x" },
444
+ { value: "1500", label: "1500px" },
445
+ { value: "2000", label: "2000px" },
446
+ { value: "2500", label: "2500px" },
447
+ ] as const
448
+ ).map((o) => (
449
+ <button
450
+ key={o.value}
451
+ onClick={() => store.setFastImageEditUpscale(o.value)}
452
+ disabled={store.fastImageEdit.generating}
453
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
454
+ store.fastImageEdit.upscale === o.value
455
+ ? "bg-ink-100 border-ink-300 text-ink-800"
456
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
457
+ } disabled:opacity-50`}
458
+ >
459
+ {o.label}
460
+ </button>
461
+ ))}
462
+ </div>
463
+ </div>
464
+
418
465
  {/* ===== Generate ===== */}
419
466
  {store.fastImageEdit.generating ? (
420
467
  <div className="flex items-center gap-2 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
@@ -1,19 +1,43 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useGenerationStore } from "../../stores/generationStore";
3
3
  import { useProjectStore } from "../../stores/projectStore";
4
+ import { useQueueStore } from "../../stores/queueStore";
5
+ import { useAiModelStore } from "../../stores/aiModelStore";
6
+ import TaskQueuePanel from "./TaskQueuePanel";
7
+ import TerminalLogPanel from "./TerminalLogPanel";
4
8
 
5
9
  interface Props {
6
10
  projectId: string;
7
11
  }
8
12
 
13
+ const VIDEO_MODEL_OPTIONS = [
14
+ {
15
+ value: "dgrauet/ltx-2.3-mlx",
16
+ quality: "High Quality",
17
+ aiId: "ltx-base",
18
+ },
19
+ {
20
+ value: "dgrauet/ltx-2.3-mlx-q8",
21
+ quality: "Standard Quality",
22
+ aiId: "ltx",
23
+ },
24
+ ] as const;
25
+
9
26
  export default function GenerateVideoTab({ projectId }: Props) {
10
27
  const store = useGenerationStore();
11
28
  const { openFolder } = useProjectStore();
29
+ const queue = useQueueStore();
30
+ const ai = useAiModelStore();
12
31
 
13
32
  const fileInputRef = useRef<HTMLInputElement>(null);
14
33
  const csvInputRef = useRef<HTMLInputElement>(null);
15
34
  const videoLogRef = useRef<HTMLPreElement | any>(null);
16
35
 
36
+ useEffect(() => {
37
+ ai.checkStatus();
38
+ // eslint-disable-next-line react-hooks/exhaustive-deps
39
+ }, []);
40
+
17
41
  useEffect(() => {
18
42
  if (videoLogRef.current) {
19
43
  videoLogRef.current.scrollTop = 100000;
@@ -33,6 +57,19 @@ export default function GenerateVideoTab({ projectId }: Props) {
33
57
  };
34
58
  }, []);
35
59
 
60
+ // Stream the generation queue so scene video tasks surface here.
61
+ useEffect(() => {
62
+ queue.startStreaming(projectId);
63
+ return () => queue.stopStreaming();
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [projectId]);
66
+
67
+ // Reconcile scene video state with the latest queue task state.
68
+ useEffect(() => {
69
+ for (const task of queue.tasks) store.applyVideoQueueTask(task);
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [queue.tasks, projectId]);
72
+
36
73
  const handleGenerateVideo = async () => {
37
74
  await store.generateVideo(projectId);
38
75
  document.body.scrollTop = 99999999999;
@@ -182,6 +219,21 @@ export default function GenerateVideoTab({ projectId }: Props) {
182
219
  </svg>
183
220
  );
184
221
 
222
+ const SpinnerIcon = (
223
+ <svg
224
+ className="animate-spin"
225
+ width="14"
226
+ height="14"
227
+ viewBox="0 0 24 24"
228
+ fill="none"
229
+ stroke="currentColor"
230
+ strokeWidth="2"
231
+ >
232
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
233
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
234
+ </svg>
235
+ );
236
+
185
237
  return (
186
238
  <div className="flex flex-col gap-7">
187
239
  <div className="flex items-center gap-2">
@@ -191,6 +243,9 @@ export default function GenerateVideoTab({ projectId }: Props) {
191
243
  </h2>
192
244
  </div>
193
245
 
246
+ <TaskQueuePanel projectId={projectId} />
247
+ <TerminalLogPanel />
248
+
194
249
  {/* CSV upload for batch generation */}
195
250
  <div>
196
251
  <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
@@ -573,6 +628,105 @@ export default function GenerateVideoTab({ projectId }: Props) {
573
628
  </div>
574
629
  </div>
575
630
 
631
+ {/* Model */}
632
+ <div>
633
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
634
+ Model
635
+ </label>
636
+ <div className="flex flex-col gap-2">
637
+ {VIDEO_MODEL_OPTIONS.map((m) => {
638
+ const downloaded =
639
+ m.aiId === "ltx" ? ai.ltxDownloaded : ai.ltxBaseDownloaded;
640
+ const selected = store.video.model === m.value;
641
+ return (
642
+ <div
643
+ key={m.value}
644
+ className={`flex items-center gap-3 rounded-2xl border p-3 transition-all ${
645
+ selected
646
+ ? "border-tiffany-500 ring-2 ring-tiffany-500/40 bg-white"
647
+ : "border-ink-200 bg-white"
648
+ }`}
649
+ >
650
+ <button
651
+ onClick={() => store.setVideoModel(m.value)}
652
+ disabled={store.video.generating || store.batchRunning}
653
+ className="flex-1 text-left disabled:opacity-50"
654
+ >
655
+ <div className="text-xs font-medium text-ink-800">
656
+ {m.value}
657
+ </div>
658
+ <div className="text-[11px] text-ink-500 mt-0.5">
659
+ {m.quality}
660
+ </div>
661
+ </button>
662
+ {downloaded === null ? (
663
+ <span className="whitespace-nowrap text-[11px] text-ink-500">
664
+ Checking…
665
+ </span>
666
+ ) : downloaded ? (
667
+ <span className="whitespace-nowrap text-[11px] font-medium text-emerald-600">
668
+ ✓ downloaded
669
+ </span>
670
+ ) : (
671
+ <span className="whitespace-nowrap text-[11px] font-medium text-amber-600">
672
+ not downloaded
673
+ </span>
674
+ )}
675
+ <button
676
+ onClick={() => ai.downloadModel(m.aiId)}
677
+ disabled={ai.downloading !== null}
678
+ className="flex items-center gap-1.5 whitespace-nowrap rounded-xl bg-tiffany-500 px-3 py-1.5 text-xs font-medium text-ink-950 transition-colors hover:bg-tiffany-600 disabled:opacity-50"
679
+ >
680
+ {ai.downloading === m.aiId ? SpinnerIcon : DownloadIcon}
681
+ {ai.downloading === m.aiId ? "Downloading…" : "Download"}
682
+ </button>
683
+ </div>
684
+ );
685
+ })}
686
+ </div>
687
+ </div>
688
+
689
+ {/* Stage steps */}
690
+ <div>
691
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
692
+ Steps
693
+ </label>
694
+ <div className="grid grid-cols-2 gap-3">
695
+ <div>
696
+ <label className="block text-[11px] font-medium text-ink-600 mb-1">
697
+ Stage 1 Steps
698
+ </label>
699
+ <input
700
+ type="number"
701
+ min={1}
702
+ value={store.video.stage1Steps}
703
+ onChange={(e) =>
704
+ store.setVideoStage1Steps(Number(e.target.value))
705
+ }
706
+ disabled={store.video.generating || store.batchRunning}
707
+ className="w-full px-4 py-2 bg-ink-50 border border-ink-200 rounded-2xl 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"
708
+ />
709
+ </div>
710
+ <div>
711
+ <label className="block text-[11px] font-medium text-ink-600 mb-1">
712
+ Stage 2 Steps
713
+ </label>
714
+ <input
715
+ type="number"
716
+ min={1}
717
+ value={store.video.stage2Steps}
718
+ onChange={(e) =>
719
+ store.setVideoStage2Steps(Number(e.target.value))
720
+ }
721
+ disabled={store.video.generating || store.batchRunning}
722
+ className="w-full px-4 py-2 bg-ink-50 border border-ink-200 rounded-2xl 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"
723
+ />
724
+ </div>
725
+ </div>
726
+ <pre className="text-[11px] text-ink-500/70 mt-2 font-mono whitespace-pre-wrap">{`--stage1-steps Stage 1 steps (default: 30 standard, 15 HQ)
727
+ --stage2-steps Stage 2 steps (default: 3)`}</pre>
728
+ </div>
729
+
576
730
  <button
577
731
  onClick={handleOpenVideoFolder}
578
732
  className="flex items-center justify-center gap-2 w-full px-4 py-2.5 mb-3 bg-ink-50 hover:bg-ink-200 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors"
@@ -651,7 +805,7 @@ export default function GenerateVideoTab({ projectId }: Props) {
651
805
  </div>
652
806
  <button
653
807
  onClick={() => {
654
- store.cancelBatch();
808
+ queue.cancelActive(projectId);
655
809
  store.cancelGenerate();
656
810
  }}
657
811
  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"