@effectnode/media 0.1.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 (133) hide show
  1. package/bin/effectnode-media +2 -0
  2. package/dist/backend/index.d.ts +8 -0
  3. package/dist/backend/index.js +13 -0
  4. package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
  5. package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
  6. package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
  7. package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
  8. package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
  9. package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
  10. package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
  11. package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
  12. package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
  13. package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
  14. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
  15. package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
  16. package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
  17. package/dist/backend/movie-backend/agent/tools/index.js +59 -0
  18. package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
  19. package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
  20. package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
  21. package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
  22. package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
  23. package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
  24. package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
  25. package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
  26. package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
  27. package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
  28. package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
  29. package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
  30. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
  31. package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
  32. package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
  33. package/dist/backend/movie-backend/agent/tools/types.js +1 -0
  34. package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
  35. package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
  36. package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
  37. package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
  38. package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
  39. package/dist/backend/movie-backend/agent/workspace.js +124 -0
  40. package/dist/backend/movie-backend/core.d.ts +4 -0
  41. package/dist/backend/movie-backend/core.js +1167 -0
  42. package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
  43. package/dist/backend/movie-backend/generation-queue.js +766 -0
  44. package/dist/backend/movie-backend/process.d.ts +21 -0
  45. package/dist/backend/movie-backend/process.js +98 -0
  46. package/dist/backend/movie-backend/render-media.d.ts +48 -0
  47. package/dist/backend/movie-backend/render-media.js +3311 -0
  48. package/dist/backend/routes/health.d.ts +1 -0
  49. package/dist/backend/routes/health.js +9 -0
  50. package/dist/backend/routes/index.d.ts +1 -0
  51. package/dist/backend/routes/index.js +11 -0
  52. package/dist/backend/routes/media.d.ts +1 -0
  53. package/dist/backend/routes/media.js +13 -0
  54. package/dist/backend/routes/projects.d.ts +8 -0
  55. package/dist/backend/routes/projects.js +68 -0
  56. package/dist/backend/routes/voice.d.ts +1 -0
  57. package/dist/backend/routes/voice.js +39 -0
  58. package/dist/backend/routes.d.ts +1 -0
  59. package/dist/backend/routes.js +20 -0
  60. package/dist/backend/server.d.ts +1 -0
  61. package/dist/backend/server.js +23 -0
  62. package/dist/backend/workspace.d.ts +5 -0
  63. package/dist/backend/workspace.js +27 -0
  64. package/dist/backend/ws/index.d.ts +3 -0
  65. package/dist/backend/ws/index.js +27 -0
  66. package/dist/backend/ws/messages.d.ts +8 -0
  67. package/dist/backend/ws/messages.js +21 -0
  68. package/dist/backend/ws.d.ts +3 -0
  69. package/dist/backend/ws.js +29 -0
  70. package/dist/index.d.ts +2 -0
  71. package/dist/index.js +80 -0
  72. package/frontend/index.html +22 -0
  73. package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
  74. package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
  75. package/frontend/src/index.css +46 -0
  76. package/frontend/src/main.tsx +13 -0
  77. package/frontend/src/movie-app/AppRouter.tsx +14 -0
  78. package/frontend/src/movie-app/MediaStudio.tsx +9 -0
  79. package/frontend/src/movie-app/SetupPage.tsx +293 -0
  80. package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
  81. package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
  82. package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
  83. package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
  84. package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
  85. package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
  86. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
  87. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
  88. package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
  89. package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
  90. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
  91. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
  92. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
  93. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
  94. package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
  95. package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
  96. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
  97. package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
  98. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
  99. package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
  100. package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
  101. package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
  102. package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
  103. package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
  104. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
  105. package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
  106. package/frontend/src/movie-app/index.css +46 -0
  107. package/frontend/src/movie-app/index.html +16 -0
  108. package/frontend/src/movie-app/lambobo.png +0 -0
  109. package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
  110. package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
  111. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
  112. package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
  113. package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
  114. package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
  115. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
  116. package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
  117. package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
  118. package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
  119. package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
  120. package/frontend/src/movie-app/stores/characterStore.ts +109 -0
  121. package/frontend/src/movie-app/stores/chatStore.ts +459 -0
  122. package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
  123. package/frontend/src/movie-app/stores/logStore.ts +25 -0
  124. package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
  125. package/frontend/src/movie-app/stores/projectStore.ts +124 -0
  126. package/frontend/src/movie-app/stores/queueStore.ts +214 -0
  127. package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
  128. package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
  129. package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
  130. package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
  131. package/frontend/src/vite-env.d.ts +1 -0
  132. package/frontend/tsconfig.json +19 -0
  133. package/package.json +65 -0
@@ -0,0 +1,927 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import {
3
+ useBatchImageToVideoStore,
4
+ type BatchI2VRowStatus,
5
+ } from "../../stores/batchImageToVideoStore";
6
+ import { useProjectStore } from "../../stores/projectStore";
7
+
8
+ interface Props {
9
+ projectId: string;
10
+ }
11
+
12
+ // ========== SVG Icons ==========
13
+
14
+ const TableIcon = (
15
+ <svg
16
+ width="18"
17
+ height="18"
18
+ viewBox="0 0 24 24"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ strokeWidth="2"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ className="text-tiffany-600"
25
+ >
26
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
27
+ <line x1="3" y1="9" x2="21" y2="9" />
28
+ <line x1="3" y1="15" x2="21" y2="15" />
29
+ <line x1="9" y1="3" x2="9" y2="21" />
30
+ </svg>
31
+ );
32
+
33
+ const PlusIcon = (
34
+ <svg
35
+ width="14"
36
+ height="14"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ strokeWidth="2"
41
+ strokeLinecap="round"
42
+ strokeLinejoin="round"
43
+ >
44
+ <line x1="12" y1="5" x2="12" y2="19" />
45
+ <line x1="5" y1="12" x2="19" y2="12" />
46
+ </svg>
47
+ );
48
+
49
+ const UploadIcon = (
50
+ <svg
51
+ width="14"
52
+ height="14"
53
+ viewBox="0 0 24 24"
54
+ fill="none"
55
+ stroke="currentColor"
56
+ strokeWidth="2"
57
+ strokeLinecap="round"
58
+ strokeLinejoin="round"
59
+ >
60
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
61
+ <polyline points="17 8 12 3 7 8" />
62
+ <line x1="12" y1="3" x2="12" y2="15" />
63
+ </svg>
64
+ );
65
+
66
+ const ClipboardIcon = (
67
+ <svg
68
+ width="14"
69
+ height="14"
70
+ viewBox="0 0 24 24"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth="2"
74
+ strokeLinecap="round"
75
+ strokeLinejoin="round"
76
+ >
77
+ <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
78
+ <rect x="8" y="2" width="8" height="4" rx="1" ry="1" />
79
+ </svg>
80
+ );
81
+
82
+ const FileIcon = (
83
+ <svg
84
+ width="14"
85
+ height="14"
86
+ viewBox="0 0 24 24"
87
+ fill="none"
88
+ stroke="currentColor"
89
+ strokeWidth="2"
90
+ strokeLinecap="round"
91
+ strokeLinejoin="round"
92
+ >
93
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
94
+ <polyline points="14 2 14 8 20 8" />
95
+ </svg>
96
+ );
97
+
98
+ const ImageIcon = (
99
+ <svg
100
+ width="16"
101
+ height="16"
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ strokeWidth="2"
106
+ strokeLinecap="round"
107
+ strokeLinejoin="round"
108
+ >
109
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
110
+ <circle cx="8.5" cy="8.5" r="1.5" />
111
+ <polyline points="21 15 16 10 5 21" />
112
+ </svg>
113
+ );
114
+
115
+ const FilmIcon = (
116
+ <svg
117
+ width="16"
118
+ height="16"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="2"
123
+ strokeLinecap="round"
124
+ strokeLinejoin="round"
125
+ >
126
+ <rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18" />
127
+ <line x1="7" y1="2" x2="7" y2="22" />
128
+ <line x1="17" y1="2" x2="17" y2="22" />
129
+ <line x1="2" y1="12" x2="22" y2="12" />
130
+ <line x1="2" y1="7" x2="7" y2="7" />
131
+ <line x1="2" y1="17" x2="7" y2="17" />
132
+ <line x1="17" y1="17" x2="22" y2="17" />
133
+ <line x1="17" y1="7" x2="22" y2="7" />
134
+ </svg>
135
+ );
136
+
137
+ const ExpandIcon = (
138
+ <svg
139
+ width="14"
140
+ height="14"
141
+ viewBox="0 0 24 24"
142
+ fill="none"
143
+ stroke="currentColor"
144
+ strokeWidth="2"
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ >
148
+ <polyline points="15 3 21 3 21 9" />
149
+ <polyline points="9 21 3 21 3 15" />
150
+ <line x1="21" y1="3" x2="14" y2="10" />
151
+ <line x1="3" y1="21" x2="10" y2="14" />
152
+ </svg>
153
+ );
154
+
155
+ const FolderIcon = (
156
+ <svg
157
+ width="16"
158
+ height="16"
159
+ viewBox="0 0 24 24"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ strokeWidth="2"
163
+ strokeLinecap="round"
164
+ strokeLinejoin="round"
165
+ >
166
+ <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" />
167
+ </svg>
168
+ );
169
+
170
+ const CloseIcon = (
171
+ <svg
172
+ width="12"
173
+ height="12"
174
+ viewBox="0 0 24 24"
175
+ fill="none"
176
+ stroke="currentColor"
177
+ strokeWidth="2"
178
+ strokeLinecap="round"
179
+ strokeLinejoin="round"
180
+ >
181
+ <line x1="18" y1="6" x2="6" y2="18" />
182
+ <line x1="6" y1="6" x2="18" y2="18" />
183
+ </svg>
184
+ );
185
+
186
+ const SparkleIcon = (
187
+ <svg
188
+ width="16"
189
+ height="16"
190
+ viewBox="0 0 24 24"
191
+ fill="none"
192
+ stroke="currentColor"
193
+ strokeWidth="2"
194
+ >
195
+ <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" />
196
+ </svg>
197
+ );
198
+
199
+ const CheckIcon = (
200
+ <svg
201
+ width="14"
202
+ height="14"
203
+ viewBox="0 0 24 24"
204
+ fill="none"
205
+ stroke="currentColor"
206
+ strokeWidth="2"
207
+ strokeLinecap="round"
208
+ strokeLinejoin="round"
209
+ >
210
+ <polyline points="20 6 9 17 4 12" />
211
+ </svg>
212
+ );
213
+
214
+ const RefreshCwIcon = (
215
+ <svg
216
+ width="14"
217
+ height="14"
218
+ viewBox="0 0 24 24"
219
+ fill="none"
220
+ stroke="currentColor"
221
+ strokeWidth="2"
222
+ strokeLinecap="round"
223
+ strokeLinejoin="round"
224
+ >
225
+ <polyline points="23 4 23 10 17 10" />
226
+ <polyline points="1 20 1 14 7 14" />
227
+ <path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
228
+ </svg>
229
+ );
230
+
231
+ const SpinnerIcon = (
232
+ <svg
233
+ className="animate-spin"
234
+ width="14"
235
+ height="14"
236
+ viewBox="0 0 24 24"
237
+ fill="none"
238
+ stroke="currentColor"
239
+ strokeWidth="2"
240
+ >
241
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
242
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
243
+ </svg>
244
+ );
245
+
246
+ // ========== Status badge ==========
247
+
248
+ function StatusBadge({ status }: { status: BatchI2VRowStatus }) {
249
+ switch (status) {
250
+ case "generating":
251
+ return (
252
+ <span className="inline-flex items-center gap-1.5 text-xs font-medium text-ink-600">
253
+ {SpinnerIcon}
254
+ Generating
255
+ </span>
256
+ );
257
+ case "done":
258
+ return (
259
+ <span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-600">
260
+ {CheckIcon}
261
+ Done
262
+ </span>
263
+ );
264
+ case "error":
265
+ return <span className="text-xs font-medium text-red-600">Error</span>;
266
+ default:
267
+ return <span className="text-xs font-medium text-ink-500">—</span>;
268
+ }
269
+ }
270
+
271
+ // ========== Duration cell (text input) ==========
272
+
273
+ function DurationCell({
274
+ value,
275
+ shared,
276
+ disabled,
277
+ onCommit,
278
+ }: {
279
+ value: number | null;
280
+ shared: number;
281
+ disabled: boolean;
282
+ onCommit: (v: number | null) => void;
283
+ }) {
284
+ const [text, setText] = useState(value == null ? "" : String(value));
285
+
286
+ // Sync local text when the row's value changes externally (e.g. CSV re-parse).
287
+ useEffect(() => {
288
+ setText(value == null ? "" : String(value));
289
+ }, [value]);
290
+
291
+ const commit = () => {
292
+ const t = text.trim();
293
+ if (t === "") {
294
+ onCommit(null);
295
+ return;
296
+ }
297
+ const n = Number(t);
298
+ if (Number.isFinite(n) && n > 0) {
299
+ onCommit(n);
300
+ } else {
301
+ setText(value == null ? "" : String(value));
302
+ }
303
+ };
304
+
305
+ return (
306
+ <input
307
+ type="text"
308
+ value={text}
309
+ onChange={(e) => setText(e.target.value)}
310
+ onBlur={commit}
311
+ onKeyDown={(e) => {
312
+ if (e.key === "Enter") (e.currentTarget as HTMLInputElement).blur();
313
+ }}
314
+ placeholder={`Default (${shared}s)`}
315
+ disabled={disabled}
316
+ title="Override video duration in seconds (empty = shared default)"
317
+ className="w-full px-2 py-1.5 bg-white 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 disabled:opacity-50"
318
+ />
319
+ );
320
+ }
321
+
322
+ // ========== Component ==========
323
+
324
+ export default function BatchImageToVideoTab({ projectId }: Props) {
325
+ const store = useBatchImageToVideoStore();
326
+ const { openFolder } = useProjectStore();
327
+
328
+ const logRef = useRef<HTMLDivElement | null>(null);
329
+ const csvInputRef = useRef<HTMLInputElement>(null);
330
+ const [csvFilename, setCsvFilename] = useState<string | null>(null);
331
+ const [csvText, setCsvText] = useState("");
332
+ const [preview, setPreview] = useState<{
333
+ url: string;
334
+ filename: string;
335
+ type: "image" | "video";
336
+ } | null>(null);
337
+
338
+ useEffect(() => {
339
+ store.hydrate(projectId);
340
+ // eslint-disable-next-line react-hooks/exhaustive-deps
341
+ }, [projectId]);
342
+
343
+ useEffect(() => {
344
+ if (logRef.current) {
345
+ logRef.current.scrollTop = logRef.current.scrollHeight;
346
+ }
347
+ }, [store.logs]);
348
+
349
+ // Close the preview modal on Escape.
350
+ useEffect(() => {
351
+ if (!preview) return;
352
+ const onKey = (e: KeyboardEvent) => {
353
+ if (e.key === "Escape") setPreview(null);
354
+ };
355
+ window.addEventListener("keydown", onKey);
356
+ return () => window.removeEventListener("keydown", onKey);
357
+ }, [preview]);
358
+
359
+ const handleCsvSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
360
+ const file = e.target.files?.[0];
361
+ if (!file) return;
362
+
363
+ const reader = new FileReader();
364
+ reader.onload = () => {
365
+ const base64 = reader.result as string;
366
+ store.uploadCsv(base64, file.name);
367
+ setCsvFilename(file.name);
368
+ };
369
+ reader.readAsDataURL(file);
370
+ e.target.value = "";
371
+ };
372
+
373
+ const handlePasteCsv = () => {
374
+ if (!csvText.trim()) return;
375
+ store.parseCsvText(csvText);
376
+ setCsvFilename(null);
377
+ setCsvText("");
378
+ };
379
+
380
+ const imageCount = store.rows.filter((r) => r.t2iPrompt.trim()).length;
381
+ const videoCount = store.rows.filter(
382
+ (r) => r.imagePath && r.i2vPrompt.trim(),
383
+ ).length;
384
+ const bothCount = store.rows.filter(
385
+ (r) => r.t2iPrompt.trim() && r.i2vPrompt.trim(),
386
+ ).length;
387
+
388
+ return (
389
+ <div className="flex flex-col gap-7">
390
+ <div className="flex items-center gap-2">
391
+ {TableIcon}
392
+ <h2 className="text-base font-semibold text-ink-900">
393
+ Batch Image to Video
394
+ </h2>
395
+ </div>
396
+
397
+ {/* CSV upload */}
398
+ <div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3">
399
+ <div className="flex items-center gap-2">
400
+ {FileIcon}
401
+ <span className="text-sm font-semibold text-ink-900">
402
+ Import Scenes (CSV)
403
+ </span>
404
+ </div>
405
+ <div className="flex flex-wrap items-center gap-2">
406
+ <input
407
+ ref={csvInputRef}
408
+ type="file"
409
+ accept=".csv"
410
+ onChange={handleCsvSelect}
411
+ disabled={store.running}
412
+ className="hidden"
413
+ />
414
+ <button
415
+ onClick={() => csvInputRef.current?.click()}
416
+ disabled={store.running}
417
+ 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 disabled:opacity-50"
418
+ >
419
+ {UploadIcon}
420
+ Upload CSV
421
+ </button>
422
+ {csvFilename && (
423
+ <span className="text-[11px] text-ink-600 truncate max-w-[240px]">
424
+ {csvFilename}
425
+ </span>
426
+ )}
427
+ </div>
428
+
429
+ <div className="flex items-start gap-2">
430
+ <textarea
431
+ value={csvText}
432
+ onChange={(e) => setCsvText(e.target.value)}
433
+ placeholder={`Paste CSV here, e.g.\nt2i,i2v,duration\nA student sleeping in class...,Slow pan across the classroom...,5`}
434
+ rows={4}
435
+ disabled={store.running}
436
+ className="flex-1 px-2.5 py-1.5 bg-white 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-y disabled:opacity-50 font-mono"
437
+ />
438
+ <button
439
+ onClick={handlePasteCsv}
440
+ disabled={store.running || !csvText.trim()}
441
+ className="shrink-0 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 disabled:opacity-50"
442
+ >
443
+ {ClipboardIcon}
444
+ Parse CSV
445
+ </button>
446
+ </div>
447
+
448
+ <p className="text-xs text-tiffany-600 italic">
449
+ CSV must contain columns <code className="text-[11px] bg-ink-100 px-1 rounded">t2i</code>{" "}
450
+ (text-to-image prompt) and{" "}
451
+ <code className="text-[11px] bg-ink-100 px-1 rounded">i2v</code>{" "}
452
+ (image-to-video prompt). An optional{" "}
453
+ <code className="text-[11px] bg-ink-100 px-1 rounded">duration</code>{" "}
454
+ column (seconds) overrides the shared duration for that row.
455
+ </p>
456
+ </div>
457
+
458
+ {/* Shared settings */}
459
+ <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
460
+ {/* Duration */}
461
+ <div>
462
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
463
+ Duration (seconds)
464
+ </label>
465
+ <div className="flex flex-wrap gap-1.5">
466
+ {[0.5, 3, 5, 7.5, 10, 15, 20].map((d) => (
467
+ <button
468
+ key={d}
469
+ onClick={() => store.setDuration(d)}
470
+ disabled={store.running}
471
+ className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
472
+ store.duration === d
473
+ ? "bg-ink-100 border-ink-300 text-ink-800"
474
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
475
+ } disabled:opacity-50`}
476
+ >
477
+ {d}s
478
+ </button>
479
+ ))}
480
+ </div>
481
+ </div>
482
+
483
+ {/* Aspect ratio */}
484
+ <div>
485
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
486
+ Aspect Ratio
487
+ </label>
488
+ <div className="flex flex-wrap gap-1.5">
489
+ {(["1:1", "16:9", "9:16", "4:3", "3:4"] as const).map((ratio) => (
490
+ <button
491
+ key={ratio}
492
+ onClick={() => store.setAspectRatio(ratio)}
493
+ disabled={store.running}
494
+ className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
495
+ store.aspectRatio === ratio
496
+ ? "bg-ink-100 border-ink-300 text-ink-800"
497
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
498
+ } disabled:opacity-50`}
499
+ >
500
+ {ratio}
501
+ </button>
502
+ ))}
503
+ </div>
504
+ </div>
505
+
506
+ {/* Resolution */}
507
+ <div>
508
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
509
+ Resolution
510
+ </label>
511
+ <div className="flex flex-wrap gap-1.5">
512
+ {(["320p", "480p", "576p", "640p", "720p", "1080p"] as const).map(
513
+ (res: any) => (
514
+ <button
515
+ key={res}
516
+ onClick={() => store.setResolution(res)}
517
+ disabled={store.running}
518
+ className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
519
+ store.resolution === res
520
+ ? "bg-ink-100 border-ink-300 text-ink-800"
521
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
522
+ } disabled:opacity-50`}
523
+ >
524
+ {res}
525
+ </button>
526
+ ),
527
+ )}
528
+ </div>
529
+ </div>
530
+
531
+ {/* Video mode */}
532
+ <div>
533
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
534
+ Mode
535
+ </label>
536
+ <div className="flex flex-wrap gap-1.5">
537
+ {(
538
+ [
539
+ { label: "Low", value: "distilled" },
540
+ { label: "Standard", value: "one-stage" },
541
+ { label: "High", value: "two-stage" },
542
+ ] as const
543
+ ).map((q) => (
544
+ <button
545
+ key={q.value}
546
+ onClick={() => store.setMode(q.value)}
547
+ disabled={store.running}
548
+ className={`px-3 py-1 text-xs font-medium rounded-xl border transition-all ${
549
+ store.mode === q.value
550
+ ? "bg-ink-100 border-ink-300 text-ink-800"
551
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
552
+ } disabled:opacity-50`}
553
+ >
554
+ {q.label}
555
+ </button>
556
+ ))}
557
+ </div>
558
+ </div>
559
+ </div>
560
+
561
+ {/* Table */}
562
+ <div>
563
+ <div className="flex items-center justify-between mb-2">
564
+ <label className="text-xs font-semibold text-ink-700 uppercase tracking-wider">
565
+ Scenes
566
+ </label>
567
+ <div className="flex items-center gap-1.5">
568
+ <button
569
+ onClick={store.clear}
570
+ disabled={store.running}
571
+ title="Clear all rows and settings"
572
+ 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"
573
+ >
574
+ {CloseIcon}
575
+ Clear
576
+ </button>
577
+ <button
578
+ onClick={store.addRow}
579
+ disabled={store.running}
580
+ 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"
581
+ >
582
+ {PlusIcon}
583
+ Add Row
584
+ </button>
585
+ </div>
586
+ </div>
587
+
588
+ <div className="border border-ink-200 rounded-2xl overflow-hidden">
589
+ <div className="overflow-auto max-h-[480px]">
590
+ <table className="w-full text-xs">
591
+ <thead>
592
+ <tr className="bg-ink-50 border-b border-ink-200">
593
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-10">
594
+ #
595
+ </th>
596
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 min-w-[220px]">
597
+ Text-to-Image Prompt
598
+ </th>
599
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 min-w-[220px]">
600
+ Image-to-Video Prompt
601
+ </th>
602
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-28">
603
+ Duration
604
+ </th>
605
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-32">
606
+ Image
607
+ </th>
608
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-40">
609
+ Video
610
+ </th>
611
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-32">
612
+ Status
613
+ </th>
614
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-36">
615
+ Actions
616
+ </th>
617
+ </tr>
618
+ </thead>
619
+ <tbody>
620
+ {store.rows.map((row, idx) => (
621
+ <tr
622
+ key={row.id}
623
+ className="border-b border-ink-200 last:border-b-0 align-top transition-colors hover:bg-ink-100/40"
624
+ >
625
+ <td className="px-3 py-2 text-ink-600/60 font-medium tabular-nums">
626
+ {idx + 1}
627
+ </td>
628
+
629
+ {/* Text-to-image prompt */}
630
+ <td className="px-3 py-2">
631
+ <textarea
632
+ value={row.t2iPrompt}
633
+ onChange={(e) =>
634
+ store.updateT2IPrompt(row.id, e.target.value)
635
+ }
636
+ placeholder="Describe the starting image..."
637
+ rows={3}
638
+ disabled={store.running}
639
+ 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"
640
+ />
641
+ </td>
642
+
643
+ {/* Image-to-video prompt */}
644
+ <td className="px-3 py-2">
645
+ <textarea
646
+ value={row.i2vPrompt}
647
+ onChange={(e) =>
648
+ store.updateI2VPrompt(row.id, e.target.value)
649
+ }
650
+ placeholder="Describe the motion..."
651
+ rows={3}
652
+ disabled={store.running}
653
+ 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"
654
+ />
655
+ </td>
656
+
657
+ {/* Duration override */}
658
+ <td className="px-3 py-2">
659
+ <DurationCell
660
+ value={row.duration}
661
+ shared={store.duration}
662
+ disabled={store.running}
663
+ onCommit={(v) => store.updateRowDuration(row.id, v)}
664
+ />
665
+ </td>
666
+
667
+ {/* Generated image */}
668
+ <td className="px-3 py-2">
669
+ {row.imageUrl ? (
670
+ <img
671
+ src={row.imageUrl}
672
+ alt={row.imageFilename || "Generated image"}
673
+ onClick={() =>
674
+ setPreview({
675
+ url: row.imageUrl!,
676
+ filename: row.imageFilename || "Image",
677
+ type: "image",
678
+ })
679
+ }
680
+ className="w-20 h-20 object-cover rounded-xl border border-ink-200 cursor-zoom-in hover:opacity-80 transition-opacity"
681
+ />
682
+ ) : (
683
+ <span className="text-[10px] text-ink-500 italic">
684
+
685
+ </span>
686
+ )}
687
+ </td>
688
+
689
+ {/* Generated video */}
690
+ <td className="px-3 py-2">
691
+ {row.videoResult ? (
692
+ <div className="relative inline-block">
693
+ <video
694
+ src={row.videoResult}
695
+ controls
696
+ className="w-full max-w-[160px] h-auto rounded-xl bg-black"
697
+ />
698
+ <button
699
+ onClick={() =>
700
+ setPreview({
701
+ url: row.videoResult!,
702
+ filename: "Video",
703
+ type: "video",
704
+ })
705
+ }
706
+ className="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded-full bg-black/60 text-white hover:bg-black/80 transition-colors"
707
+ title="Preview"
708
+ >
709
+ {ExpandIcon}
710
+ </button>
711
+ </div>
712
+ ) : (
713
+ <span className="text-[10px] text-ink-500 italic">
714
+
715
+ </span>
716
+ )}
717
+ </td>
718
+
719
+ {/* Status */}
720
+ <td className="px-3 py-2">
721
+ <div className="flex flex-col gap-1">
722
+ <div className="flex items-center gap-1">
723
+ <span className="text-[10px] text-tiffany-600 w-10">
724
+ Img:
725
+ </span>
726
+ <StatusBadge status={row.imageStatus} />
727
+ </div>
728
+ <div className="flex items-center gap-1">
729
+ <span className="text-[10px] text-tiffany-600 w-10">
730
+ Vid:
731
+ </span>
732
+ <StatusBadge status={row.videoStatus} />
733
+ </div>
734
+ </div>
735
+ {row.error && (
736
+ <p className="text-[10px] text-red-600 mt-1 break-words max-w-[120px]">
737
+ {row.error}
738
+ </p>
739
+ )}
740
+ </td>
741
+
742
+ {/* Actions */}
743
+ <td className="px-3 py-2">
744
+ <div className="flex flex-wrap gap-1">
745
+ <button
746
+ onClick={() =>
747
+ store.regenerateImage(projectId, row.id)
748
+ }
749
+ disabled={store.running || !row.t2iPrompt.trim()}
750
+ title="Regenerate image"
751
+ className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 hover:text-ink-800 hover:border-ink-300 transition-colors disabled:opacity-40"
752
+ >
753
+ {ImageIcon}
754
+ </button>
755
+ <button
756
+ onClick={() =>
757
+ store.regenerateVideo(projectId, row.id)
758
+ }
759
+ disabled={
760
+ store.running ||
761
+ !row.imagePath ||
762
+ !row.i2vPrompt.trim()
763
+ }
764
+ title="Regenerate video"
765
+ className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 hover:text-ink-800 hover:border-ink-300 transition-colors disabled:opacity-40"
766
+ >
767
+ {FilmIcon}
768
+ </button>
769
+ <button
770
+ onClick={() => store.regenerateBoth(projectId, row.id)}
771
+ disabled={
772
+ store.running ||
773
+ !row.t2iPrompt.trim() ||
774
+ !row.i2vPrompt.trim()
775
+ }
776
+ title="Regenerate image + video"
777
+ className="flex items-center justify-center w-7 h-7 rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 hover:text-ink-800 hover:border-ink-300 transition-colors disabled:opacity-40"
778
+ >
779
+ {RefreshCwIcon}
780
+ </button>
781
+ <button
782
+ onClick={() => store.removeRow(row.id)}
783
+ disabled={store.running}
784
+ title="Remove row"
785
+ 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"
786
+ >
787
+ {CloseIcon}
788
+ </button>
789
+ </div>
790
+ </td>
791
+ </tr>
792
+ ))}
793
+ </tbody>
794
+ </table>
795
+ </div>
796
+ </div>
797
+
798
+ {store.rows.length === 0 && (
799
+ <p className="text-xs text-ink-500 italic py-6 text-center border border-dashed border-ink-200 rounded-2xl mt-2">
800
+ No scenes yet. Click "Add Row" or upload a CSV.
801
+ </p>
802
+ )}
803
+ </div>
804
+
805
+ {/* Open output folder */}
806
+ <button
807
+ onClick={() => openFolder(projectId, "output")}
808
+ 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"
809
+ >
810
+ {FolderIcon}
811
+ Open Output Folder
812
+ </button>
813
+
814
+ {/* Progress */}
815
+ {store.progress && (
816
+ <div className="flex items-center gap-3 p-4 bg-ink-50 border border-ink-200 rounded-2xl">
817
+ <span className="text-xs font-medium text-ink-700">
818
+ Batch Progress
819
+ </span>
820
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
821
+ <div
822
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
823
+ style={{
824
+ width: `${(store.progress.current / store.progress.total) * 100}%`,
825
+ }}
826
+ />
827
+ </div>
828
+ <span className="text-xs font-semibold text-ink-700 tabular-nums">
829
+ {store.progress.current}/{store.progress.total}
830
+ </span>
831
+ </div>
832
+ )}
833
+
834
+ {/* Logs */}
835
+ {store.logs.length > 0 && (
836
+ <div
837
+ ref={logRef}
838
+ className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
839
+ >
840
+ <p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
841
+ Progress Logs
842
+ </p>
843
+ <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
844
+ {store.logs.join("\n")}
845
+ </pre>
846
+ </div>
847
+ )}
848
+
849
+ {/* Generate buttons */}
850
+ {store.running ? (
851
+ <button
852
+ onClick={store.cancel}
853
+ 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"
854
+ >
855
+ Cancel Batch
856
+ </button>
857
+ ) : (
858
+ <div className="flex flex-col gap-2">
859
+ <button
860
+ onClick={() => store.generateAll(projectId)}
861
+ disabled={bothCount === 0}
862
+ 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-glow hover:shadow-glow disabled:shadow-none"
863
+ >
864
+ {SparkleIcon}
865
+ Generate All Images &amp; Videos ({bothCount})
866
+ </button>
867
+ <div className="grid grid-cols-2 gap-2">
868
+ <button
869
+ onClick={() => store.generateAllImages(projectId)}
870
+ disabled={imageCount === 0}
871
+ className="flex items-center justify-center gap-2 px-4 py-2.5 bg-white hover:bg-ink-100 disabled:bg-ink-50 disabled:text-ink-500 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors disabled:border-ink-200"
872
+ >
873
+ {ImageIcon}
874
+ Generate All Images ({imageCount})
875
+ </button>
876
+ <button
877
+ onClick={() => store.generateAllVideos(projectId)}
878
+ disabled={videoCount === 0}
879
+ className="flex items-center justify-center gap-2 px-4 py-2.5 bg-white hover:bg-ink-100 disabled:bg-ink-50 disabled:text-ink-500 text-ink-700 text-sm font-medium rounded-2xl border border-ink-200 transition-colors disabled:border-ink-200"
880
+ >
881
+ {FilmIcon}
882
+ Generate All Videos ({videoCount})
883
+ </button>
884
+ </div>
885
+ </div>
886
+ )}
887
+
888
+ {/* ===== Preview Modal ===== */}
889
+ {preview && (
890
+ <div
891
+ className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
892
+ onClick={() => setPreview(null)}
893
+ >
894
+ <div
895
+ className="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center gap-3"
896
+ onClick={(e) => e.stopPropagation()}
897
+ >
898
+ {preview.type === "image" ? (
899
+ <img
900
+ src={preview.url}
901
+ alt={preview.filename}
902
+ className="max-w-full max-h-[80vh] object-contain rounded-2xl shadow-2xl"
903
+ />
904
+ ) : (
905
+ <video
906
+ src={preview.url}
907
+ controls
908
+ autoPlay
909
+ className="max-w-full max-h-[80vh] rounded-2xl shadow-2xl bg-black"
910
+ />
911
+ )}
912
+ <span className="text-xs text-white/70 truncate max-w-full">
913
+ {preview.filename}
914
+ </span>
915
+ <button
916
+ onClick={() => setPreview(null)}
917
+ 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"
918
+ title="Close (Esc)"
919
+ >
920
+ {CloseIcon}
921
+ </button>
922
+ </div>
923
+ </div>
924
+ )}
925
+ </div>
926
+ );
927
+ }