@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,722 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useGenerationStore } from "../../stores/generationStore";
3
+ import { useProjectStore } from "../../stores/projectStore";
4
+
5
+ interface Props {
6
+ projectId: string;
7
+ }
8
+
9
+ export default function GenerateVideoTab({ projectId }: Props) {
10
+ const store = useGenerationStore();
11
+ const { openFolder } = useProjectStore();
12
+
13
+ const fileInputRef = useRef<HTMLInputElement>(null);
14
+ const csvInputRef = useRef<HTMLInputElement>(null);
15
+ const videoLogRef = useRef<HTMLPreElement | any>(null);
16
+
17
+ useEffect(() => {
18
+ if (videoLogRef.current) {
19
+ videoLogRef.current.scrollTop = 100000;
20
+ }
21
+ }, [store.video.logs]);
22
+
23
+ useEffect(() => {
24
+ let ttt = setInterval(() => {
25
+ let dom = document.querySelector("#video-logs");
26
+ if (dom) {
27
+ dom.scrollTop = 99999999;
28
+ }
29
+ });
30
+
31
+ return () => {
32
+ clearInterval(ttt);
33
+ };
34
+ }, []);
35
+
36
+ const handleGenerateVideo = async () => {
37
+ await store.generateVideo(projectId);
38
+ document.body.scrollTop = 99999999999;
39
+ };
40
+
41
+ const handleBatchGenerate = async () => {
42
+ await store.generateBatchVideos(projectId);
43
+ };
44
+
45
+ const handleOpenVideoFolder = async () => {
46
+ await openFolder(projectId, "output");
47
+ };
48
+
49
+ const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
50
+ const file = e.target.files?.[0];
51
+ if (!file) return;
52
+
53
+ // Read file as base64 data URL
54
+ const reader = new FileReader();
55
+ reader.onload = async () => {
56
+ const base64 = reader.result as string;
57
+ const uploadedPath = await store.uploadImage(
58
+ projectId,
59
+ base64,
60
+ file.name,
61
+ );
62
+ if (uploadedPath) {
63
+ // Refresh the project images list so the new upload appears
64
+ store.fetchProjectImages(projectId);
65
+ }
66
+ };
67
+ reader.readAsDataURL(file);
68
+
69
+ // Reset so the same file can be re-selected
70
+ e.target.value = "";
71
+ };
72
+
73
+ const handleCsvSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
74
+ const file = e.target.files?.[0];
75
+ if (!file) return;
76
+
77
+ const reader = new FileReader();
78
+ reader.onload = () => {
79
+ const base64 = reader.result as string;
80
+ store.uploadCsv(base64, file.name);
81
+ };
82
+ reader.readAsDataURL(file);
83
+
84
+ e.target.value = "";
85
+ };
86
+
87
+ // ========== SVG Icons ==========
88
+
89
+ const VideoIcon = (
90
+ <svg
91
+ width="18"
92
+ height="18"
93
+ viewBox="0 0 24 24"
94
+ fill="none"
95
+ stroke="currentColor"
96
+ strokeWidth="2"
97
+ >
98
+ <polygon points="23 7 16 12 23 17 23 7" />
99
+ <rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
100
+ </svg>
101
+ );
102
+
103
+ const SparkleIcon = (
104
+ <svg
105
+ width="16"
106
+ height="16"
107
+ viewBox="0 0 24 24"
108
+ fill="none"
109
+ stroke="currentColor"
110
+ strokeWidth="2"
111
+ >
112
+ <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" />
113
+ </svg>
114
+ );
115
+
116
+ const FolderIcon = (
117
+ <svg
118
+ width="16"
119
+ height="16"
120
+ viewBox="0 0 24 24"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ strokeWidth="2"
124
+ strokeLinecap="round"
125
+ strokeLinejoin="round"
126
+ >
127
+ <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" />
128
+ </svg>
129
+ );
130
+
131
+ const CsvIcon = (
132
+ <svg
133
+ width="16"
134
+ height="16"
135
+ viewBox="0 0 24 24"
136
+ fill="none"
137
+ stroke="currentColor"
138
+ strokeWidth="2"
139
+ strokeLinecap="round"
140
+ strokeLinejoin="round"
141
+ >
142
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
143
+ <polyline points="14 2 14 8 20 8" />
144
+ <line x1="8" y1="13" x2="16" y2="13" />
145
+ <line x1="8" y1="17" x2="16" y2="17" />
146
+ <line x1="8" y1="9" x2="10" y2="9" />
147
+ </svg>
148
+ );
149
+
150
+ const TableIcon = (
151
+ <svg
152
+ width="14"
153
+ height="14"
154
+ viewBox="0 0 24 24"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ strokeWidth="2"
158
+ strokeLinecap="round"
159
+ strokeLinejoin="round"
160
+ >
161
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
162
+ <line x1="3" y1="9" x2="21" y2="9" />
163
+ <line x1="3" y1="15" x2="21" y2="15" />
164
+ <line x1="9" y1="3" x2="9" y2="21" />
165
+ </svg>
166
+ );
167
+
168
+ const DownloadIcon = (
169
+ <svg
170
+ width="14"
171
+ height="14"
172
+ viewBox="0 0 24 24"
173
+ fill="none"
174
+ stroke="currentColor"
175
+ strokeWidth="2"
176
+ strokeLinecap="round"
177
+ strokeLinejoin="round"
178
+ >
179
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
180
+ <polyline points="7 10 12 15 17 10" />
181
+ <line x1="12" y1="15" x2="12" y2="3" />
182
+ </svg>
183
+ );
184
+
185
+ return (
186
+ <div className="flex flex-col gap-7">
187
+ <div className="flex items-center gap-2">
188
+ <span className="text-tiffany-600">{VideoIcon}</span>
189
+ <h2 className="text-base font-semibold text-ink-900">
190
+ Scene Video Generation
191
+ </h2>
192
+ </div>
193
+
194
+ {/* CSV upload for batch generation */}
195
+ <div>
196
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
197
+ CSV Batch Data
198
+ </label>
199
+ <div className="flex items-center gap-2">
200
+ <input
201
+ ref={csvInputRef}
202
+ type="file"
203
+ accept=".csv"
204
+ onChange={handleCsvSelect}
205
+ disabled={store.batchRunning}
206
+ className="flex-1 text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors disabled:opacity-50"
207
+ />
208
+ {store.csvFilename && (
209
+ <button
210
+ onClick={() => store.clearCsvData()}
211
+ disabled={store.batchRunning}
212
+ className="px-3 py-2 text-xs font-medium text-red-600 hover:bg-red-50 rounded-xl transition-colors disabled:opacity-50"
213
+ >
214
+ Clear
215
+ </button>
216
+ )}
217
+ </div>
218
+ <a
219
+ href={(() => {
220
+ const csv = `id,book,chapter,verses,script
221
+ 1,Ephesians,4,32,"Be kind to one another, tenderhearted, forgiving one another, as God in Christ forgave you."
222
+ 2,Colossians,3,13,Bear with each other and forgive one another if any of you has a grievance against someone. Forgive as the Lord forgave you.
223
+ 3,Matthew,6,14,"For if you forgive other people when they sin against you, your heavenly Father will also forgive you."
224
+ 4,Luke,6,37,"Do not judge, and you will not be judged. Do not condemn, and you will not be condemned. Forgive, and you will be forgiven."
225
+ 5,Mark,11,25,"And whenever you stand praying, forgive, if you have anything against anyone, so that your Father also who is in heaven may forgive you."
226
+ 6,1 John,1,9,"If we confess our sins, he is faithful and just to forgive us our sins and to cleanse us from all unrighteousness."
227
+ 7,Psalm,103,12,"As far as the east is from the west, so far has he removed our transgressions from us."
228
+ 8,Proverbs,17,9,"Whoever would foster love covers over an offense, but whoever repeats the matter separates close friends."
229
+ 9,Psalm,86,5,"For you, O Lord, are good and forgiving, abounding in steadfast love to all who call upon you."
230
+ 10,Isaiah,43,25,"I, even I, am he who blots out your transgressions, for my own sake, and remembers your sins no more."`;
231
+ return `data:text/csv;charset=utf-8,${encodeURIComponent(csv.trim())}`;
232
+ })()}
233
+ download="bible.csv"
234
+ className="inline-flex items-center gap-1.5 mt-2 text-xs font-medium text-ink-600 hover:text-ink-900 transition-colors"
235
+ >
236
+ {DownloadIcon}
237
+ Download example CSV file
238
+ </a>
239
+ {/* */}
240
+ {store.csvFilename && (
241
+ <div className="mt-2 border border-ink-200 rounded-2xl overflow-hidden">
242
+ {/* CSV header */}
243
+ <div className="flex items-center justify-between px-4 py-2.5 bg-ink-50 border-b border-ink-200">
244
+ <div className="flex items-center gap-2 text-xs text-ink-700">
245
+ {CsvIcon}
246
+ <span className="font-medium">{store.csvFilename}</span>
247
+ <span className="text-ink-600/60">
248
+ ({store.csvRows.length} rows, {store.csvColumns.length}{" "}
249
+ columns)
250
+ </span>
251
+ <span className="text-ink-600/60">
252
+ — {store.csvSelectedIndices.size} selected
253
+ </span>
254
+ </div>
255
+ <div className="flex items-center gap-1.5">
256
+ <button
257
+ onClick={() => store.selectAllCsvRows()}
258
+ disabled={store.batchRunning}
259
+ className="px-2 py-1 text-[10px] font-medium text-ink-600 hover:bg-ink-200 rounded transition-colors disabled:opacity-50"
260
+ >
261
+ Select All
262
+ </button>
263
+ <button
264
+ onClick={() => store.deselectAllCsvRows()}
265
+ disabled={store.batchRunning}
266
+ className="px-2 py-1 text-[10px] font-medium text-ink-600 hover:bg-ink-200 rounded transition-colors disabled:opacity-50"
267
+ >
268
+ Deselect All
269
+ </button>
270
+ </div>
271
+ </div>
272
+
273
+ {/* Table container with horizontal scroll */}
274
+ <div className="overflow-auto max-h-80">
275
+ <table className="w-full text-xs">
276
+ {/* Column headers */}
277
+ <thead>
278
+ <tr className="bg-ink-100/70 border-b border-ink-200">
279
+ <th className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 w-10">
280
+ <span className="sr-only">Select</span>
281
+ </th>
282
+ {store.csvColumns.map((col) => (
283
+ <th
284
+ key={col}
285
+ className="sticky top-0 bg-ink-50 px-3 py-2 text-left font-semibold text-ink-700 whitespace-nowrap"
286
+ >
287
+ {col}
288
+ <span className="ml-1 text-[10px] font-normal text-ink-500">{`{{${col}}}`}</span>
289
+ </th>
290
+ ))}
291
+ </tr>
292
+ </thead>
293
+ <tbody>
294
+ {store.csvRows.map((row, rowIdx) => {
295
+ const isSelected = store.csvSelectedIndices.has(rowIdx);
296
+ return (
297
+ <tr
298
+ key={rowIdx}
299
+ className={`border-b border-ink-200 transition-colors ${
300
+ isSelected
301
+ ? "bg-white"
302
+ : "bg-ink-100/50 opacity-60"
303
+ }`}
304
+ >
305
+ <td className="px-3 py-1.5">
306
+ <input
307
+ type="checkbox"
308
+ checked={isSelected}
309
+ onChange={() => store.toggleCsvRow(rowIdx)}
310
+ disabled={store.batchRunning}
311
+ className="w-3.5 h-3.5 rounded border-ink-300 text-ink-600 focus:ring-tiffany-500 cursor-pointer disabled:opacity-50"
312
+ />
313
+ </td>
314
+ {store.csvColumns.map((col) => (
315
+ <td key={col} className="px-3 py-1">
316
+ <input
317
+ type="text"
318
+ value={row[col] ?? ""}
319
+ onChange={(e) =>
320
+ store.updateCsvCell(rowIdx, col, e.target.value)
321
+ }
322
+ disabled={store.batchRunning}
323
+ className="w-full min-w-[120px] px-2 py-1 bg-transparent border border-transparent hover:border-ink-400 focus:border-tiffany-500 focus:outline-none focus:ring-1 focus:ring-tiffany-500/30 rounded text-ink-800 transition-colors disabled:opacity-50"
324
+ />
325
+ </td>
326
+ ))}
327
+ </tr>
328
+ );
329
+ })}
330
+ </tbody>
331
+ </table>
332
+ </div>
333
+ </div>
334
+ )}
335
+ {!store.csvFilename && (
336
+ <p className="text-xs text-ink-600/50 mt-1.5">
337
+ Upload a CSV with a{" "}
338
+ <code className="text-[11px] bg-ink-100 px-1 rounded">
339
+ name
340
+ </code>{" "}
341
+ column to batch-generate many videos. Use{" "}
342
+ <code className="text-[11px] bg-ink-100 px-1 rounded">{`{{column}}`}</code>{" "}
343
+ in your prompt as a template.
344
+ </p>
345
+ )}
346
+ </div>
347
+ {/* */}
348
+ {/* Image upload */}
349
+ <div>
350
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
351
+ Input Image
352
+ </label>
353
+ <input
354
+ ref={fileInputRef}
355
+ type="file"
356
+ accept="image/*"
357
+ onChange={handleFileSelect}
358
+ className="w-full text-sm text-ink-700 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-medium file:bg-ink-100 file:text-ink-700 hover:file:bg-ink-200 file:cursor-pointer file:transition-colors"
359
+ />
360
+ <p className="text-xs text-ink-600/50 mt-1.5">
361
+ Upload an image to animate.
362
+ </p>
363
+
364
+ <a
365
+ href={`/lambobo.png.zip`}
366
+ download="lambobo.zip"
367
+ className="inline-flex items-center gap-1.5 mt-2 text-xs font-medium text-ink-600 hover:text-ink-900 transition-colors"
368
+ >
369
+ {DownloadIcon}
370
+ Download example image file
371
+ </a>
372
+ <br />
373
+
374
+ {store.uploading && (
375
+ <p className="text-xs text-ink-600 mt-1">Uploading...</p>
376
+ )}
377
+ {store.uploadError && (
378
+ <p className="text-xs text-red-600 mt-1">{store.uploadError}</p>
379
+ )}
380
+ {store.uploadedImageUrl && (
381
+ <div className="mt-3 rounded-2xl overflow-hidden border border-ink-200 bg-ink-100 inline-block">
382
+ <img
383
+ src={store.uploadedImageUrl}
384
+ alt={store.uploadedImageFilename || "Uploaded image"}
385
+ className="w-full h-32 object-cover"
386
+ />
387
+ {store.uploadedImageFilename && (
388
+ <p className="px-3 py-1.5 text-xs text-ink-600 truncate">
389
+ {store.uploadedImageFilename}
390
+ </p>
391
+ )}
392
+ </div>
393
+ )}
394
+ </div>
395
+
396
+ {/* Project images picker */}
397
+ <div>
398
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
399
+ Project Images
400
+ </label>
401
+ {store.projectImagesLoading ? (
402
+ <p className="text-xs text-ink-500 italic py-4 text-center">
403
+ Loading images...
404
+ </p>
405
+ ) : store.projectImages.length === 0 ? (
406
+ <p className="text-xs text-ink-500 italic py-4 text-center border border-dashed border-ink-200 rounded-2xl">
407
+ No images yet. Upload one above.
408
+ </p>
409
+ ) : (
410
+ <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8 3xl:grid-cols-10 gap-2 p-1">
411
+ {store.projectImages.map((img) => {
412
+ const isSelected =
413
+ store.selectedImage?.filename === img.filename &&
414
+ store.selectedImage?.source === img.source;
415
+
416
+ const fullUrl = img.url.startsWith("http")
417
+ ? img.url
418
+ : `http://localhost:${(window as any).PORT}${img.url}`;
419
+
420
+ return (
421
+ <button
422
+ key={`${img.source}-${img.filename}`}
423
+ onClick={() => store.selectImage(img)}
424
+ disabled={store.video.generating}
425
+ className={`relative rounded-xl border-2 transition-all ${
426
+ isSelected
427
+ ? "border-tiffany-500 ring-2 ring-tiffany-500/40"
428
+ : "border-ink-200 hover:border-ink-300"
429
+ } disabled:opacity-50`}
430
+ >
431
+ <img
432
+ src={`${fullUrl}`}
433
+ alt={img.filename}
434
+ className="aspect-square object-cover object-center"
435
+ />
436
+ <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">
437
+ {img.source === "generated" ? "✦ " : "↑ "}
438
+ {img.filename}
439
+ </span>
440
+ </button>
441
+ );
442
+ })}
443
+ </div>
444
+ )}
445
+ {store.selectedImage && (
446
+ <p className="text-xs text-ink-600/60 mt-1.5">
447
+ Selected:{" "}
448
+ <span className="font-medium text-ink-700">
449
+ {store.selectedImage.filename}
450
+ </span>
451
+ </p>
452
+ )}
453
+ </div>
454
+
455
+ {/* Prompt */}
456
+ <div>
457
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
458
+ Scene Prompt
459
+ {store.csvColumns.length > 0 && (
460
+ <span className="ml-2 text-[10px] font-normal text-tiffany-600">
461
+ — template with {`{{column}}`} placeholders
462
+ </span>
463
+ )}
464
+ </label>
465
+ <textarea
466
+ value={store.video.prompt}
467
+ onChange={(e) => store.setVideoPrompt(e.target.value)}
468
+ placeholder={
469
+ store.csvColumns.length > 0
470
+ ? `Use {{name}} and other column placeholders, e.g. a {{name}} says: "Hi John Wayne, how are you? my name is {{name}}."`
471
+ : "Describe the video scene, e.g. a gentle ocean wave rolling onto a sandy beach at golden hour..."
472
+ }
473
+ rows={4}
474
+ disabled={store.video.generating || store.batchRunning}
475
+ 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"
476
+ />
477
+ </div>
478
+
479
+ {/* Duration selector */}
480
+ <div>
481
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
482
+ Duration (seconds)
483
+ </label>
484
+ <div className="flex flex-wrap gap-2">
485
+ {[0.5, 3, 5, 7.5, 10, 12.5, 15, 17.5, 20].map((d) => (
486
+ <button
487
+ key={d}
488
+ onClick={() => store.setVideoDuration(d)}
489
+ disabled={store.video.generating}
490
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
491
+ store.video.duration === d
492
+ ? "bg-ink-100 border-ink-300 text-ink-800"
493
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
494
+ } disabled:opacity-50`}
495
+ >
496
+ {d}s
497
+ </button>
498
+ ))}
499
+ </div>
500
+ </div>
501
+
502
+ {/* Aspect Ratio selector */}
503
+ <div>
504
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
505
+ Aspect Ratio
506
+ </label>
507
+ <div className="flex flex-wrap gap-2">
508
+ {(["1:1", "16:9", "9:16", "4:3", "3:4"] as const).map((ratio) => (
509
+ <button
510
+ key={ratio}
511
+ onClick={() => store.setVideoAspectRatio(ratio)}
512
+ disabled={store.video.generating}
513
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
514
+ store.video.aspectRatio === ratio
515
+ ? "bg-ink-100 border-ink-300 text-ink-800"
516
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
517
+ } disabled:opacity-50`}
518
+ >
519
+ {ratio}
520
+ </button>
521
+ ))}
522
+ </div>
523
+ </div>
524
+
525
+ {/* Resolution selector */}
526
+ <div>
527
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
528
+ Resolution
529
+ </label>
530
+ <div className="flex flex-wrap gap-2">
531
+ {(["320p", "480p", "576p", "640p", "720p", "1080p"] as const).map(
532
+ (res: any) => (
533
+ <button
534
+ key={res}
535
+ onClick={() => store.setVideoResolution(res)}
536
+ disabled={store.video.generating}
537
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
538
+ store.video.resolution === res
539
+ ? "bg-ink-100 border-ink-300 text-ink-800"
540
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
541
+ } disabled:opacity-50`}
542
+ >
543
+ {res}
544
+ </button>
545
+ ),
546
+ )}
547
+ </div>
548
+ </div>
549
+
550
+ {/* Quality selector */}
551
+ <div>
552
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
553
+ Quality
554
+ </label>
555
+ <div className="flex flex-wrap gap-2">
556
+ {(
557
+ [
558
+ { label: "Low (distilled)", value: "distilled" },
559
+ { label: "Standard (one-stage)", value: "one-stage" },
560
+ { label: "High (two-stage)", value: "two-stage" },
561
+ ] as const
562
+ ).map((q) => (
563
+ <button
564
+ key={q.value}
565
+ onClick={() => store.setVideoMode(q.value)}
566
+ disabled={store.video.generating}
567
+ className={`px-4 py-1.5 text-xs font-medium rounded-xl border transition-all ${
568
+ store.video.mode === q.value
569
+ ? "bg-ink-100 border-ink-300 text-ink-800"
570
+ : "bg-white border-ink-200 text-ink-600 hover:border-ink-300"
571
+ } disabled:opacity-50`}
572
+ >
573
+ {q.label}
574
+ </button>
575
+ ))}
576
+ </div>
577
+ </div>
578
+
579
+ <button
580
+ onClick={handleOpenVideoFolder}
581
+ 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"
582
+ >
583
+ {FolderIcon}
584
+ Open Output Folder
585
+ </button>
586
+
587
+ {/* Batch generate button */}
588
+ {store.csvRows.length > 0 && (
589
+ <div className="space-y-2">
590
+ {store.batchProgress && (
591
+ <div className="flex items-center gap-3 p-4 bg-ink-50 border border-ink-200 rounded-2xl">
592
+ <span className="text-xs font-medium text-ink-700">
593
+ {store.batchRunning ? "Batch Progress" : "Batch Complete"}
594
+ </span>
595
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
596
+ <div
597
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
598
+ style={{
599
+ width: `${(store.batchProgress.current / store.batchProgress.total) * 100}%`,
600
+ }}
601
+ />
602
+ </div>
603
+ <span className="text-xs font-semibold text-ink-700 tabular-nums">
604
+ {store.batchProgress.current}/{store.batchProgress.total}
605
+ </span>
606
+ </div>
607
+ )}
608
+ {store.batchRunning ? (
609
+ <button
610
+ onClick={() => {
611
+ store.cancelBatch();
612
+ store.cancelGenerate();
613
+ }}
614
+ 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"
615
+ >
616
+ Cancel Batch
617
+ </button>
618
+ ) : (
619
+ <button
620
+ onClick={handleBatchGenerate}
621
+ disabled={
622
+ store.video.generating ||
623
+ !store.video.prompt.trim() ||
624
+ store.uploading
625
+ }
626
+ 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"
627
+ >
628
+ {TableIcon}
629
+ Generate Batch ({store.csvSelectedIndices.size} videos)
630
+ </button>
631
+ )}
632
+ </div>
633
+ )}
634
+
635
+ {/* Generate / Stop button (video) */}
636
+ {store.video.generating ? (
637
+ <div className="flex items-center gap-3">
638
+ <div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
639
+ <svg
640
+ className="animate-spin text-tiffany-600"
641
+ width="16"
642
+ height="16"
643
+ viewBox="0 0 24 24"
644
+ fill="none"
645
+ stroke="currentColor"
646
+ strokeWidth="2"
647
+ >
648
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
649
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
650
+ </svg>
651
+ <span className="text-sm font-medium text-ink-700">
652
+ Generating...
653
+ </span>
654
+ </div>
655
+ <button
656
+ onClick={() => {
657
+ store.cancelBatch();
658
+ store.cancelGenerate();
659
+ }}
660
+ 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"
661
+ >
662
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
663
+ <rect x="4" y="4" width="16" height="16" rx="2" />
664
+ </svg>
665
+ Stop
666
+ </button>
667
+ </div>
668
+ ) : (
669
+ <button
670
+ onClick={handleGenerateVideo}
671
+ disabled={
672
+ store.batchRunning || !store.video.prompt.trim() || store.uploading
673
+ }
674
+ 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"
675
+ >
676
+ {SparkleIcon}
677
+ {`Generate 1 Video (No CSV / Template support)`}
678
+ </button>
679
+ )}
680
+
681
+ {/* Error */}
682
+ {store.video.error && (
683
+ <div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
684
+ {store.video.error}
685
+ </div>
686
+ )}
687
+
688
+ {/* Logs */}
689
+ {store.video.logs.length > 0 && (
690
+ <div
691
+ ref={videoLogRef}
692
+ id="video-logs"
693
+ className="p-5 bg-ink-50 border border-ink-200 rounded-2xl max-h-40 overflow-y-auto"
694
+ >
695
+ <p className="text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
696
+ Logs
697
+ </p>
698
+ <pre className="text-xs text-ink-600 font-mono whitespace-pre-wrap">
699
+ {store.video.logs.join("\n")}
700
+ </pre>
701
+ </div>
702
+ )}
703
+
704
+ {/* Result */}
705
+ {store.video.result && (
706
+ <div>
707
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
708
+ Generated Video
709
+ </label>
710
+
711
+ <div className="relative rounded-2xl overflow-hidden border border-ink-200 shadow-card bg-black w-[500px]">
712
+ <video
713
+ src={store.video.result}
714
+ controls
715
+ className="w-full h-auto"
716
+ />
717
+ </div>
718
+ </div>
719
+ )}
720
+ </div>
721
+ );
722
+ }