@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,736 @@
1
+ import { create } from "zustand";
2
+ import Papa from "papaparse";
3
+ import type {
4
+ AspectRatio,
5
+ Resolution,
6
+ VideoMode,
7
+ } from "./generationStore";
8
+ import {
9
+ clearBatchImageToVideoState,
10
+ loadBatchImageToVideoState,
11
+ saveBatchImageToVideoState,
12
+ type PersistedBatchImageToVideoState,
13
+ } from "../lib/batchImageToVideoStorage";
14
+
15
+ const API_BASE = `http://localhost:${(window as any).PORT}`;
16
+
17
+ export type BatchI2VRowStatus = "idle" | "generating" | "done" | "error";
18
+
19
+ export interface BatchI2VRow {
20
+ id: string;
21
+ // Text-to-image prompt (drives the starting image)
22
+ t2iPrompt: string;
23
+ // Image-to-video prompt (drives the motion on top of the generated image)
24
+ i2vPrompt: string;
25
+ // Optional per-row video duration override (seconds); null = use shared.
26
+ duration: number | null;
27
+ imagePath: string | null;
28
+ imageUrl: string | null;
29
+ imageFilename: string | null;
30
+ imageStatus: BatchI2VRowStatus;
31
+ videoResult: string | null;
32
+ videoStatus: BatchI2VRowStatus;
33
+ error: string | null;
34
+ }
35
+
36
+ interface BatchImageToVideoStore {
37
+ rows: BatchI2VRow[];
38
+
39
+ // Shared settings (image + video share the same aspect ratio / resolution)
40
+ aspectRatio: AspectRatio;
41
+ resolution: Resolution;
42
+ duration: number;
43
+ mode: VideoMode;
44
+
45
+ running: boolean;
46
+ progress: { current: number; total: number } | null;
47
+ cancelRequested: boolean;
48
+ logs: string[];
49
+
50
+ // Persistence
51
+ projectId: string | null;
52
+ hydrated: boolean;
53
+ hydrate: (projectId: string) => Promise<void>;
54
+ clear: () => void;
55
+
56
+ addRow: () => void;
57
+ removeRow: (id: string) => void;
58
+ updateT2IPrompt: (id: string, v: string) => void;
59
+ updateI2VPrompt: (id: string, v: string) => void;
60
+ updateRowDuration: (id: string, duration: number | null) => void;
61
+ uploadCsv: (base64: string, filename: string) => void;
62
+ parseCsvText: (text: string) => void;
63
+
64
+ setAspectRatio: (v: AspectRatio) => void;
65
+ setResolution: (v: Resolution) => void;
66
+ setDuration: (v: number) => void;
67
+ setMode: (v: VideoMode) => void;
68
+
69
+ generateAllImages: (projectId: string) => Promise<void>;
70
+ generateAllVideos: (projectId: string) => Promise<void>;
71
+ generateAll: (projectId: string) => Promise<void>;
72
+ regenerateImage: (projectId: string, rowId: string) => Promise<void>;
73
+ regenerateVideo: (projectId: string, rowId: string) => Promise<void>;
74
+ regenerateBoth: (projectId: string, rowId: string) => Promise<void>;
75
+ cancel: () => void;
76
+
77
+ reset: () => void;
78
+ }
79
+
80
+ let batchAbortController: AbortController | null = null;
81
+
82
+ function getDimensions(
83
+ aspect: AspectRatio,
84
+ resolution: Resolution,
85
+ ): { width: number; height: number } {
86
+ const size = parseInt(resolution);
87
+ switch (aspect) {
88
+ case "1:1":
89
+ return { width: size, height: size };
90
+ case "16:9":
91
+ return { width: Math.round((size * 16) / 9), height: size };
92
+ case "9:16":
93
+ return { width: size, height: Math.round((size * 16) / 9) };
94
+ case "4:3":
95
+ return { width: Math.round((size * 4) / 3), height: size };
96
+ case "3:4":
97
+ return { width: size, height: Math.round((size * 4) / 3) };
98
+ }
99
+ }
100
+
101
+ // The image-to-video backend only accepts a bare filename, so normalise any
102
+ // path / file URL / api-files URL down to just the basename.
103
+ function normalizeImagePath(path: string): string {
104
+ let p = path;
105
+ if (p.includes("/api/files?path=")) {
106
+ try {
107
+ const url = new URL(p);
108
+ p = url.searchParams.get("path") || p;
109
+ } catch {
110
+ // not a valid URL, use as-is
111
+ }
112
+ }
113
+ if (p.startsWith("file://")) {
114
+ p = p.slice(7);
115
+ }
116
+ return p.split("/").pop() || p;
117
+ }
118
+
119
+ async function readSSEStream(
120
+ response: Response,
121
+ onEvent: (event: string, data: any) => void,
122
+ ): Promise<void> {
123
+ const reader = response.body?.getReader();
124
+ if (!reader) return;
125
+
126
+ const decoder = new TextDecoder();
127
+ let buffer = "";
128
+
129
+ try {
130
+ while (true) {
131
+ const { done, value } = await reader.read();
132
+ if (done) break;
133
+
134
+ buffer += decoder.decode(value, { stream: true });
135
+ const lines = buffer.split("\n");
136
+ buffer = lines.pop() || "";
137
+
138
+ let eventType = "message";
139
+ for (const line of lines) {
140
+ if (line.startsWith("event: ")) {
141
+ eventType = line.slice(7).trim();
142
+ } else if (line.startsWith("data: ")) {
143
+ try {
144
+ onEvent(eventType, JSON.parse(line.slice(6)));
145
+ } catch {
146
+ // skip malformed lines
147
+ }
148
+ eventType = "message";
149
+ }
150
+ }
151
+ }
152
+ } finally {
153
+ reader.releaseLock();
154
+ }
155
+ }
156
+
157
+ function playBeep() {
158
+ try {
159
+ const ctx = new (
160
+ window.AudioContext || (window as any).webkitAudioContext
161
+ )();
162
+ const osc = ctx.createOscillator();
163
+ const gain = ctx.createGain();
164
+ osc.connect(gain);
165
+ gain.connect(ctx.destination);
166
+ osc.type = "sine";
167
+ osc.frequency.setValueAtTime(880, ctx.currentTime);
168
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
169
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
170
+ osc.start(ctx.currentTime);
171
+ osc.stop(ctx.currentTime + 0.4);
172
+ } catch {
173
+ // silently ignore if audio not available
174
+ }
175
+ }
176
+
177
+ function makeRow(): BatchI2VRow {
178
+ return {
179
+ id: `row-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
180
+ t2iPrompt: "",
181
+ i2vPrompt: "",
182
+ duration: null,
183
+ imagePath: null,
184
+ imageUrl: null,
185
+ imageFilename: null,
186
+ imageStatus: "idle",
187
+ videoResult: null,
188
+ videoStatus: "idle",
189
+ error: null,
190
+ };
191
+ }
192
+
193
+ /** Parse raw CSV text (t2i / i2v columns) into batch rows. */
194
+ function parseCsvRows(text: string): BatchI2VRow[] {
195
+ const parsed = Papa.parse<Record<string, string>>(text, {
196
+ header: true,
197
+ skipEmptyLines: true,
198
+ });
199
+ return parsed.data
200
+ .filter((r) => (r.t2i || "").trim() || (r.i2v || "").trim())
201
+ .map((r) => {
202
+ const rawDuration = String(r.duration ?? "").trim().replace(/s$/i, "");
203
+ const parsedDuration = rawDuration ? Number(rawDuration) : NaN;
204
+ return {
205
+ id: `row-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
206
+ t2iPrompt: (r.t2i || "").trim(),
207
+ i2vPrompt: (r.i2v || "").trim(),
208
+ duration:
209
+ Number.isFinite(parsedDuration) && parsedDuration > 0
210
+ ? parsedDuration
211
+ : null,
212
+ imagePath: null,
213
+ imageUrl: null,
214
+ imageFilename: null,
215
+ imageStatus: "idle",
216
+ videoResult: null,
217
+ videoStatus: "idle",
218
+ error: null,
219
+ };
220
+ });
221
+ }
222
+
223
+ function toPersistedState(
224
+ s: Pick<
225
+ BatchImageToVideoStore,
226
+ "rows" | "aspectRatio" | "resolution" | "duration" | "mode"
227
+ >,
228
+ ): PersistedBatchImageToVideoState {
229
+ return {
230
+ rows: s.rows.map((r) => ({
231
+ id: r.id,
232
+ t2iPrompt: r.t2iPrompt,
233
+ i2vPrompt: r.i2vPrompt,
234
+ duration: r.duration ?? null,
235
+ })),
236
+ aspectRatio: s.aspectRatio,
237
+ resolution: s.resolution,
238
+ duration: s.duration,
239
+ mode: s.mode,
240
+ };
241
+ }
242
+
243
+ function persistBatchState() {
244
+ const { projectId } = useBatchImageToVideoStore.getState();
245
+ if (!projectId) return;
246
+ void saveBatchImageToVideoState(
247
+ projectId,
248
+ toPersistedState(useBatchImageToVideoStore.getState()),
249
+ );
250
+ }
251
+
252
+ // ========== Generation helpers (module-level, using store getState/setState) ==========
253
+
254
+ async function runImageGeneration(
255
+ projectId: string,
256
+ rowId: string,
257
+ settings: { aspect: AspectRatio; width: number; height: number },
258
+ signal: AbortSignal,
259
+ onLog: (text: string) => void,
260
+ ): Promise<string | null> {
261
+ const row = useBatchImageToVideoStore
262
+ .getState()
263
+ .rows.find((r) => r.id === rowId);
264
+ if (!row) return null;
265
+
266
+ useBatchImageToVideoStore.setState((s) => ({
267
+ rows: s.rows.map((r) =>
268
+ r.id === rowId ? { ...r, imageStatus: "generating", error: null } : r,
269
+ ),
270
+ }));
271
+
272
+ const res = await fetch(`${API_BASE}/api/render/text-to-image`, {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({
276
+ prompt: row.t2iPrompt.trim(),
277
+ projectId,
278
+ aspect: settings.aspect,
279
+ width: settings.width,
280
+ height: settings.height,
281
+ device: "mps",
282
+ }),
283
+ signal,
284
+ });
285
+
286
+ if (!res.ok) {
287
+ const err = await res.text();
288
+ useBatchImageToVideoStore.setState((s) => ({
289
+ rows: s.rows.map((r) =>
290
+ r.id === rowId ? { ...r, imageStatus: "error", error: err } : r,
291
+ ),
292
+ }));
293
+ return null;
294
+ }
295
+
296
+ let imagePath: string | null = null;
297
+ await readSSEStream(res, (event, data) => {
298
+ switch (event) {
299
+ case "log":
300
+ onLog(data.text as string);
301
+ break;
302
+ case "complete":
303
+ imagePath = data.path as string;
304
+ useBatchImageToVideoStore.setState((s) => ({
305
+ rows: s.rows.map((r) =>
306
+ r.id === rowId
307
+ ? {
308
+ ...r,
309
+ imageStatus: "done",
310
+ imagePath: data.path,
311
+ imageUrl: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
312
+ imageFilename: data.filename,
313
+ }
314
+ : r,
315
+ ),
316
+ }));
317
+ break;
318
+ case "error":
319
+ useBatchImageToVideoStore.setState((s) => ({
320
+ rows: s.rows.map((r) =>
321
+ r.id === rowId
322
+ ? {
323
+ ...r,
324
+ imageStatus: "error",
325
+ error: data.error || "Image generation failed",
326
+ }
327
+ : r,
328
+ ),
329
+ }));
330
+ break;
331
+ }
332
+ });
333
+
334
+ return imagePath;
335
+ }
336
+
337
+ async function runVideoGeneration(
338
+ projectId: string,
339
+ rowId: string,
340
+ settings: {
341
+ width: number;
342
+ height: number;
343
+ duration: number;
344
+ mode: VideoMode;
345
+ },
346
+ signal: AbortSignal,
347
+ onLog: (text: string) => void,
348
+ ): Promise<void> {
349
+ const row = useBatchImageToVideoStore
350
+ .getState()
351
+ .rows.find((r) => r.id === rowId);
352
+ if (!row || !row.imagePath) return;
353
+
354
+ useBatchImageToVideoStore.setState((s) => ({
355
+ rows: s.rows.map((r) =>
356
+ r.id === rowId ? { ...r, videoStatus: "generating", error: null } : r,
357
+ ),
358
+ }));
359
+
360
+ const res = await fetch(`${API_BASE}/api/render/image-to-video`, {
361
+ method: "POST",
362
+ headers: { "Content-Type": "application/json" },
363
+ body: JSON.stringify({
364
+ prompt: row.i2vPrompt.trim(),
365
+ imagePath: normalizeImagePath(row.imagePath),
366
+ projectId,
367
+ width: settings.width,
368
+ height: settings.height,
369
+ frames: settings.duration * 24 + 1,
370
+ frameRate: 24,
371
+ mode: settings.mode,
372
+ }),
373
+ signal,
374
+ });
375
+
376
+ if (!res.ok) {
377
+ const err = await res.text();
378
+ useBatchImageToVideoStore.setState((s) => ({
379
+ rows: s.rows.map((r) =>
380
+ r.id === rowId ? { ...r, videoStatus: "error", error: err } : r,
381
+ ),
382
+ }));
383
+ return;
384
+ }
385
+
386
+ await readSSEStream(res, (event, data) => {
387
+ switch (event) {
388
+ case "log":
389
+ onLog(data.text as string);
390
+ break;
391
+ case "complete":
392
+ useBatchImageToVideoStore.setState((s) => ({
393
+ rows: s.rows.map((r) =>
394
+ r.id === rowId
395
+ ? {
396
+ ...r,
397
+ videoStatus: "done",
398
+ videoResult: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
399
+ }
400
+ : r,
401
+ ),
402
+ }));
403
+ break;
404
+ case "error":
405
+ useBatchImageToVideoStore.setState((s) => ({
406
+ rows: s.rows.map((r) =>
407
+ r.id === rowId
408
+ ? {
409
+ ...r,
410
+ videoStatus: "error",
411
+ error: data.error || "Video generation failed",
412
+ }
413
+ : r,
414
+ ),
415
+ }));
416
+ break;
417
+ }
418
+ });
419
+ }
420
+
421
+ type PipelineMode = "images" | "videos" | "both";
422
+
423
+ async function runPipeline(
424
+ projectId: string,
425
+ ids: string[],
426
+ pipelineMode: PipelineMode,
427
+ ) {
428
+ const state = useBatchImageToVideoStore.getState();
429
+ if (state.running) return;
430
+ if (ids.length === 0) return;
431
+
432
+ const { aspectRatio, resolution, duration, mode } = state;
433
+ const { width, height } = getDimensions(aspectRatio, resolution);
434
+
435
+ batchAbortController = new AbortController();
436
+ const signal = batchAbortController.signal;
437
+
438
+ useBatchImageToVideoStore.setState({
439
+ running: true,
440
+ progress: { current: 0, total: ids.length },
441
+ cancelRequested: false,
442
+ logs: [],
443
+ });
444
+
445
+ let cancelled = false;
446
+
447
+ const appendLog = (text: string, idx: number) => {
448
+ useBatchImageToVideoStore.setState((s) => ({
449
+ logs: [...s.logs, `[${idx + 1}/${ids.length}] ${text}`],
450
+ }));
451
+ };
452
+
453
+ for (let i = 0; i < ids.length; i++) {
454
+ if (useBatchImageToVideoStore.getState().cancelRequested) {
455
+ cancelled = true;
456
+ break;
457
+ }
458
+
459
+ const row = useBatchImageToVideoStore
460
+ .getState()
461
+ .rows.find((r) => r.id === ids[i]);
462
+ if (!row) continue;
463
+
464
+ const onLog = (text: string) => appendLog(text, i);
465
+
466
+ try {
467
+ if (pipelineMode === "images" || pipelineMode === "both") {
468
+ const imagePath = await runImageGeneration(
469
+ projectId,
470
+ row.id,
471
+ { aspect: aspectRatio, width, height },
472
+ signal,
473
+ onLog,
474
+ );
475
+ if (imagePath) {
476
+ appendLog(`Image ready: ${row.t2iPrompt.trim().slice(0, 60)}`, i);
477
+ } else if (pipelineMode === "both") {
478
+ // Image failed — skip the video stage for this row.
479
+ useBatchImageToVideoStore.setState(() => ({
480
+ progress: { current: i + 1, total: ids.length },
481
+ }));
482
+ continue;
483
+ }
484
+ }
485
+
486
+ if (pipelineMode === "videos" || pipelineMode === "both") {
487
+ await runVideoGeneration(
488
+ projectId,
489
+ row.id,
490
+ { width, height, duration: row.duration ?? duration, mode },
491
+ signal,
492
+ onLog,
493
+ );
494
+ }
495
+ } catch (e: any) {
496
+ if (e?.name === "AbortError") {
497
+ cancelled = true;
498
+ break;
499
+ }
500
+ useBatchImageToVideoStore.setState((s) => ({
501
+ rows: s.rows.map((r) =>
502
+ r.id === row.id ? { ...r, error: String(e) } : r,
503
+ ),
504
+ }));
505
+ }
506
+
507
+ useBatchImageToVideoStore.setState(() => ({
508
+ progress: { current: i + 1, total: ids.length },
509
+ }));
510
+ }
511
+
512
+ batchAbortController = null;
513
+ useBatchImageToVideoStore.setState({
514
+ running: false,
515
+ progress: null,
516
+ cancelRequested: false,
517
+ });
518
+
519
+ if (!cancelled) {
520
+ playBeep();
521
+ }
522
+ }
523
+
524
+ export const useBatchImageToVideoStore = create<BatchImageToVideoStore>(
525
+ (set, get) => ({
526
+ rows: [makeRow()],
527
+
528
+ aspectRatio: "1:1",
529
+ resolution: "480p",
530
+ duration: 5,
531
+ mode: "distilled",
532
+
533
+ running: false,
534
+ progress: null,
535
+ cancelRequested: false,
536
+ logs: [],
537
+
538
+ projectId: null,
539
+ hydrated: false,
540
+
541
+ hydrate: async (projectId) => {
542
+ if (get().hydrated && get().projectId === projectId) return;
543
+
544
+ const previous = get().projectId;
545
+ if (previous !== null && previous !== projectId) {
546
+ get().reset();
547
+ }
548
+ set({ hydrated: true, projectId });
549
+
550
+ const stored = await loadBatchImageToVideoState(projectId);
551
+ if (!stored) return;
552
+
553
+ set((s) => {
554
+ const rows: BatchI2VRow[] = (stored.rows ?? []).map((r) => ({
555
+ id: r.id,
556
+ t2iPrompt: r.t2iPrompt,
557
+ i2vPrompt: r.i2vPrompt,
558
+ duration: r.duration ?? null,
559
+ imagePath: null,
560
+ imageUrl: null,
561
+ imageFilename: null,
562
+ imageStatus: "idle",
563
+ videoResult: null,
564
+ videoStatus: "idle",
565
+ error: null,
566
+ }));
567
+
568
+ return {
569
+ rows: rows.length > 0 ? rows : [makeRow()],
570
+ aspectRatio: stored.aspectRatio ?? s.aspectRatio,
571
+ resolution: stored.resolution ?? s.resolution,
572
+ duration: stored.duration ?? s.duration,
573
+ mode: stored.mode ?? s.mode,
574
+ };
575
+ });
576
+ },
577
+
578
+ clear: () => {
579
+ const { projectId } = get();
580
+ get().reset();
581
+ if (projectId) void clearBatchImageToVideoState(projectId);
582
+ },
583
+
584
+ addRow: () => {
585
+ set((s) => ({ rows: [...s.rows, makeRow()] }));
586
+ persistBatchState();
587
+ },
588
+
589
+ removeRow: (id) => {
590
+ set((s) => ({ rows: s.rows.filter((r) => r.id !== id) }));
591
+ persistBatchState();
592
+ },
593
+
594
+ updateT2IPrompt: (id, t2iPrompt) => {
595
+ set((s) => ({
596
+ rows: s.rows.map((r) => (r.id === id ? { ...r, t2iPrompt } : r)),
597
+ }));
598
+ persistBatchState();
599
+ },
600
+
601
+ updateI2VPrompt: (id, i2vPrompt) => {
602
+ set((s) => ({
603
+ rows: s.rows.map((r) => (r.id === id ? { ...r, i2vPrompt } : r)),
604
+ }));
605
+ persistBatchState();
606
+ },
607
+
608
+ updateRowDuration: (id, duration) => {
609
+ set((s) => ({
610
+ rows: s.rows.map((r) => (r.id === id ? { ...r, duration } : r)),
611
+ }));
612
+ persistBatchState();
613
+ },
614
+
615
+ uploadCsv: (base64, _filename) => {
616
+ try {
617
+ const raw = base64
618
+ .replace(/^data:text\/csv;base64,/, "")
619
+ .replace(/^data:application\/csv;base64,/, "")
620
+ .replace(/^data:text\/plain;base64,/, "");
621
+ const bytes = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
622
+ const text = new TextDecoder("utf-8").decode(bytes);
623
+ const rows = parseCsvRows(text);
624
+ set({ rows: rows.length > 0 ? rows : [makeRow()] });
625
+ persistBatchState();
626
+ } catch {
627
+ set({ rows: [makeRow()] });
628
+ }
629
+ },
630
+
631
+ parseCsvText: (text) => {
632
+ try {
633
+ const rows = parseCsvRows(text);
634
+ set({ rows: rows.length > 0 ? rows : [makeRow()] });
635
+ persistBatchState();
636
+ } catch {
637
+ set({ rows: [makeRow()] });
638
+ }
639
+ },
640
+
641
+ setAspectRatio: (aspectRatio) => {
642
+ set({ aspectRatio });
643
+ persistBatchState();
644
+ },
645
+ setResolution: (resolution) => {
646
+ set({ resolution });
647
+ persistBatchState();
648
+ },
649
+ setDuration: (duration) => {
650
+ set({ duration });
651
+ persistBatchState();
652
+ },
653
+ setMode: (mode) => {
654
+ set({ mode });
655
+ persistBatchState();
656
+ },
657
+
658
+ generateAllImages: (projectId) => {
659
+ const ids = get()
660
+ .rows.filter((r) => r.t2iPrompt.trim())
661
+ .map((r) => r.id);
662
+ return runPipeline(projectId, ids, "images");
663
+ },
664
+
665
+ generateAllVideos: (projectId) => {
666
+ const ids = get()
667
+ .rows.filter((r) => r.imagePath && r.i2vPrompt.trim())
668
+ .map((r) => r.id);
669
+ return runPipeline(projectId, ids, "videos");
670
+ },
671
+
672
+ generateAll: (projectId) => {
673
+ const ids = get()
674
+ .rows.filter((r) => r.t2iPrompt.trim() && r.i2vPrompt.trim())
675
+ .map((r) => r.id);
676
+ return runPipeline(projectId, ids, "both");
677
+ },
678
+
679
+ regenerateImage: (projectId, rowId) => {
680
+ const ids = get()
681
+ .rows.filter((r) => r.id === rowId && r.t2iPrompt.trim())
682
+ .map((r) => r.id);
683
+ return runPipeline(projectId, ids, "images");
684
+ },
685
+
686
+ regenerateVideo: (projectId, rowId) => {
687
+ const ids = get()
688
+ .rows.filter(
689
+ (r) => r.id === rowId && r.imagePath && r.i2vPrompt.trim(),
690
+ )
691
+ .map((r) => r.id);
692
+ return runPipeline(projectId, ids, "videos");
693
+ },
694
+
695
+ regenerateBoth: (projectId, rowId) => {
696
+ const ids = get()
697
+ .rows.filter(
698
+ (r) => r.id === rowId && r.t2iPrompt.trim() && r.i2vPrompt.trim(),
699
+ )
700
+ .map((r) => r.id);
701
+ return runPipeline(projectId, ids, "both");
702
+ },
703
+
704
+ cancel: () => {
705
+ set({ cancelRequested: true });
706
+ if (batchAbortController) {
707
+ batchAbortController.abort();
708
+ }
709
+ set({
710
+ running: false,
711
+ progress: null,
712
+ rows: get().rows.map((r) => ({
713
+ ...r,
714
+ imageStatus:
715
+ r.imageStatus === "generating" ? "idle" : r.imageStatus,
716
+ videoStatus:
717
+ r.videoStatus === "generating" ? "idle" : r.videoStatus,
718
+ })),
719
+ });
720
+ fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
721
+ },
722
+
723
+ reset: () =>
724
+ set({
725
+ rows: [makeRow()],
726
+ aspectRatio: "1:1",
727
+ resolution: "480p",
728
+ duration: 5,
729
+ mode: "distilled",
730
+ running: false,
731
+ progress: null,
732
+ cancelRequested: false,
733
+ logs: [],
734
+ }),
735
+ }),
736
+ );