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