@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,2140 @@
1
+ import { create } from "zustand";
2
+ import Mustache from "mustache";
3
+ import Papa from "papaparse";
4
+ import {
5
+ loadTextToImageState,
6
+ saveTextToImageState,
7
+ type PersistedTextToImageState,
8
+ } from "../lib/textToImageStorage";
9
+
10
+ const API_BASE = `http://localhost:${(window as any).PORT}`;
11
+
12
+ // ========== Types ==========
13
+
14
+ export type GenerationTab =
15
+ | "movieStudio"
16
+ | "fastImageEdit"
17
+ | "video"
18
+ | "extend"
19
+ | "agent"
20
+ | "storyWriter"
21
+ | "characters"
22
+ | "extract"
23
+ | "sceneVisual"
24
+ | "textToImage"
25
+ | "referencesToVideo"
26
+ | "batchVideo"
27
+ | "batchImageToVideo"
28
+ | "batchVoice"
29
+ | "llmServer";
30
+ export type AspectRatio = "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
31
+ export type Resolution =
32
+ | "320p"
33
+ | "480p"
34
+ | "512p"
35
+ | "640p"
36
+ | "720p"
37
+ | "1080p"
38
+ | "2048p";
39
+ export type TextToImagePreset = "prototype" | "medium" | "optimal";
40
+ export type VideoMode = "distilled" | "one-stage" | "two-stage";
41
+
42
+ function getDimensions(
43
+ aspect: AspectRatio,
44
+ resolution: Resolution,
45
+ ): {
46
+ width: number;
47
+ height: number;
48
+ } {
49
+ const size = parseInt(resolution);
50
+ switch (aspect) {
51
+ case "1:1":
52
+ return { width: size, height: size };
53
+ case "16:9":
54
+ return { width: Math.round((size * 16) / 9), height: size };
55
+ case "9:16":
56
+ return { width: size, height: Math.round((size * 16) / 9) };
57
+ case "4:3":
58
+ return { width: Math.round((size * 4) / 3), height: size };
59
+ case "3:4":
60
+ return { width: size, height: Math.round((size * 4) / 3) };
61
+ }
62
+ }
63
+
64
+ interface ImageState {
65
+ prompt: string;
66
+ aspectRatio: AspectRatio;
67
+ resolution: Resolution;
68
+ generating: boolean;
69
+ result: string | null;
70
+ error: string | null;
71
+ logs: string[];
72
+ }
73
+
74
+ interface VideoState {
75
+ prompt: string;
76
+ duration: number;
77
+ aspectRatio: AspectRatio;
78
+ resolution: Resolution;
79
+ mode: VideoMode;
80
+ generating: boolean;
81
+ result: string | null;
82
+ error: string | null;
83
+ logs: string[];
84
+ }
85
+
86
+ interface ExtendState {
87
+ prompt: string;
88
+ extendDuration: number;
89
+ extendFrames: number;
90
+ generating: boolean;
91
+ result: string | null;
92
+ error: string | null;
93
+ logs: string[];
94
+ }
95
+
96
+ interface TextToImageState {
97
+ prompt: string;
98
+ aspectRatio: AspectRatio;
99
+ resolution: Resolution;
100
+ steps: number;
101
+ installing: boolean;
102
+ installingLogs: string[];
103
+ installingError: string | null;
104
+ downloading: boolean;
105
+ downloadingLogs: string[];
106
+ downloadingError: string | null;
107
+ generating: boolean;
108
+ result: string | null;
109
+ error: string | null;
110
+ logs: string[];
111
+ mlxgenInstalled: boolean | null;
112
+ zModelDownloaded: boolean | null;
113
+ }
114
+
115
+ interface FastImageEditState {
116
+ prompt: string;
117
+ referenceImages: ProjectImage[];
118
+ downloading: boolean;
119
+ downloadingLogs: string[];
120
+ downloadingError: string | null;
121
+ generating: boolean;
122
+ result: string | null;
123
+ error: string | null;
124
+ logs: string[];
125
+ modelDownloaded: boolean | null;
126
+ }
127
+
128
+ interface AgentState {
129
+ model: string;
130
+ port: number;
131
+ installing: boolean;
132
+ installingLogs: string[];
133
+ installingError: string | null;
134
+ installed: boolean | null;
135
+ starting: boolean;
136
+ serverRunning: boolean;
137
+ serverOnline: boolean | null;
138
+ serverLogs: string[];
139
+ serverError: string | null;
140
+ }
141
+
142
+ interface GenerationStore {
143
+ // Tab
144
+ activeTab: GenerationTab;
145
+ setActiveTab: (tab: GenerationTab) => void;
146
+
147
+ // Image generation
148
+ image: ImageState;
149
+ setImagePrompt: (v: string) => void;
150
+ setImageAspectRatio: (v: AspectRatio) => void;
151
+ setImageResolution: (v: Resolution) => void;
152
+ clearImageResult: () => void;
153
+ generateImage: (projectId: string) => Promise<void>;
154
+
155
+ // Video generation
156
+ video: VideoState;
157
+ setVideoPrompt: (v: string) => void;
158
+ setVideoDuration: (v: number) => void;
159
+ setVideoAspectRatio: (v: AspectRatio) => void;
160
+ setVideoResolution: (v: Resolution) => void;
161
+ setVideoMode: (v: VideoMode) => void;
162
+ clearVideoResult: () => void;
163
+ generateVideo: (projectId: string, imagePath?: string) => Promise<void>;
164
+ cancelGenerate: () => void;
165
+
166
+ // Video extension
167
+ extend: ExtendState;
168
+ setExtendPrompt: (v: string) => void;
169
+ setExtendDuration: (v: number) => void;
170
+ setExtendFrames: (v: number) => void;
171
+ clearExtendResult: () => void;
172
+ generateExtend: (projectId: string, videoPath: string) => Promise<void>;
173
+
174
+ // Text-to-image (mlx-gen z-image-turbo)
175
+ textToImage: TextToImageState;
176
+ setTextToImagePrompt: (v: string) => void;
177
+ setTextToImageAspectRatio: (v: AspectRatio) => void;
178
+ setTextToImageResolution: (v: Resolution) => void;
179
+ setTextToImageSteps: (v: number) => void;
180
+ applyTextToImagePreset: (preset: TextToImagePreset) => void;
181
+ clearTextToImageResult: () => void;
182
+ checkTextToImageStatus: () => Promise<void>;
183
+ installTextToImage: () => Promise<void>;
184
+ downloadTextToImageModel: () => Promise<void>;
185
+ generateTextToImage: (projectId: string) => Promise<void>;
186
+
187
+ // Text-to-image persistence
188
+ textToImageProjectId: string | null;
189
+ textToImageHydrated: boolean;
190
+ hydrateTextToImage: (projectId: string) => Promise<void>;
191
+ resetTextToImage: () => void;
192
+
193
+ // Fast image edit (mlx-gen FLUX.2 Klein)
194
+ fastImageEdit: FastImageEditState;
195
+ setFastImageEditPrompt: (v: string) => void;
196
+ toggleFastImageEditImage: (img: ProjectImage) => void;
197
+ clearFastImageEditImages: () => void;
198
+ clearFastImageEditResult: () => void;
199
+ checkFastImageEditStatus: () => Promise<void>;
200
+ downloadFastImageEditModel: () => Promise<void>;
201
+ generateFastImageEdit: (projectId: string) => Promise<void>;
202
+
203
+ // Agent (mlx-vlm)
204
+ agent: AgentState;
205
+ setAgentModel: (v: string) => void;
206
+ setAgentPort: (v: number) => void;
207
+ checkAgentStatus: () => Promise<void>;
208
+ checkServerOnline: () => Promise<void>;
209
+ installMlxVlm: () => Promise<void>;
210
+ startAgentServer: () => Promise<void>;
211
+ stopAgentServer: () => Promise<void>;
212
+ openAgentServer: () => Promise<void>;
213
+
214
+ // Project videos picker
215
+ projectVideos: ProjectVideo[];
216
+ projectVideosLoading: boolean;
217
+ fetchProjectVideos: (projectId: string) => Promise<void>;
218
+ selectedVideo: ProjectVideo | null;
219
+ selectVideo: (video: ProjectVideo | null) => void;
220
+
221
+ // Project audio picker
222
+ projectAudios: ProjectAudio[];
223
+ projectAudiosLoading: boolean;
224
+ fetchProjectAudios: (projectId: string) => Promise<void>;
225
+
226
+ // Upload
227
+ uploading: boolean;
228
+ uploadError: string | null;
229
+ uploadedImageUrl: string | null;
230
+ uploadedImageFilename: string | null;
231
+ uploadedImagePath: string | null;
232
+ uploadImage: (
233
+ projectId: string,
234
+ base64: string,
235
+ filename?: string,
236
+ ) => Promise<string | null>;
237
+
238
+ // Project images picker
239
+ projectImages: ProjectImage[];
240
+ projectImagesLoading: boolean;
241
+ fetchProjectImages: (projectId: string) => Promise<void>;
242
+ selectedImage: ProjectImage | null;
243
+ selectImage: (img: ProjectImage | null) => void;
244
+
245
+ // Character sheet picker
246
+ characterSheets: ProjectImage[];
247
+ characterSheetsLoading: boolean;
248
+ fetchCharacterSheets: (projectId: string) => Promise<void>;
249
+
250
+ // CSV batch generation
251
+ csvRows: Record<string, string>[];
252
+ csvColumns: string[];
253
+ csvFilename: string | null;
254
+ csvSelectedIndices: Set<number>;
255
+ batchRunning: boolean;
256
+ batchProgress: { current: number; total: number } | null;
257
+ batchCancelRequested: boolean;
258
+ uploadCsv: (base64: string, filename: string) => void;
259
+ clearCsvData: () => void;
260
+ toggleCsvRow: (index: number) => void;
261
+ selectAllCsvRows: () => void;
262
+ deselectAllCsvRows: () => void;
263
+ updateCsvCell: (rowIndex: number, column: string, value: string) => void;
264
+ generateBatchVideos: (projectId: string) => Promise<void>;
265
+ cancelBatch: () => void;
266
+
267
+ // Reset
268
+ resetAll: () => void;
269
+ }
270
+
271
+ export interface ProjectImage {
272
+ filename: string;
273
+ url: string;
274
+ source: "upload" | "generated" | "characterSheet";
275
+ }
276
+
277
+ export interface ProjectVideo {
278
+ filename: string;
279
+ url: string;
280
+ }
281
+
282
+ export interface ProjectAudio {
283
+ filename: string;
284
+ url: string;
285
+ }
286
+
287
+ // ========== SSE Stream Reader ==========
288
+
289
+ async function readSSEStream(
290
+ response: Response,
291
+ onEvent: (event: string, data: any) => void,
292
+ ): Promise<void> {
293
+ const reader = response.body?.getReader();
294
+ if (!reader) return;
295
+
296
+ const decoder = new TextDecoder();
297
+ let buffer = "";
298
+
299
+ try {
300
+ while (true) {
301
+ const { done, value } = await reader.read();
302
+ if (done) break;
303
+
304
+ buffer += decoder.decode(value, { stream: true });
305
+ const lines = buffer.split("\n");
306
+ buffer = lines.pop() || "";
307
+
308
+ let eventType = "message";
309
+ for (const line of lines) {
310
+ if (line.startsWith("event: ")) {
311
+ eventType = line.slice(7).trim();
312
+ } else if (line.startsWith("data: ")) {
313
+ try {
314
+ const data = JSON.parse(line.slice(6));
315
+ onEvent(eventType, data);
316
+ } catch {
317
+ // skip malformed lines
318
+ }
319
+ eventType = "message";
320
+ }
321
+ }
322
+ }
323
+ } finally {
324
+ reader.releaseLock();
325
+ }
326
+ }
327
+
328
+ // ========== CSV Parser ==========
329
+
330
+ function parseCsv(text: string): {
331
+ rows: Record<string, string>[];
332
+ columns: string[];
333
+ } {
334
+ const result = Papa.parse<Record<string, string>>(text, {
335
+ header: true,
336
+ skipEmptyLines: true,
337
+ });
338
+ return { rows: result.data, columns: result.meta.fields || [] };
339
+ }
340
+
341
+ // ========== MLX-Gen Image Edit Helper ==========
342
+
343
+ function resolveImageUrl(url: string): string {
344
+ if (url.startsWith("http")) return url;
345
+ return `http://localhost:${(window as any).PORT}${url}`;
346
+ }
347
+
348
+ function loadImage(src: string): Promise<HTMLImageElement> {
349
+ return new Promise((resolve, reject) => {
350
+ const img = new Image();
351
+ img.onload = () => resolve(img);
352
+ img.onerror = () => reject(new Error("Failed to load image"));
353
+ img.src = src;
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Load an image from a URL and re-encode it as a PNG whose longest edge is at
359
+ * most `maxDim` pixels (preserving aspect ratio). PNG is lossless, so the
360
+ * result is always full quality. Returns the base64 data URL together with the
361
+ * original image dimensions, or null on failure.
362
+ */
363
+ async function resizeImageToPng(
364
+ url: string,
365
+ maxDim = 1024,
366
+ ): Promise<{ dataUrl: string; width: number; height: number } | null> {
367
+ try {
368
+ const res = await fetch(url);
369
+ if (!res.ok) return null;
370
+ const blob = await res.blob();
371
+ const objectUrl = URL.createObjectURL(blob);
372
+ try {
373
+ const img = await loadImage(objectUrl);
374
+ const { width, height } = img;
375
+ const longest = Math.max(width, height);
376
+ const scale = longest > maxDim ? maxDim / longest : 1;
377
+ const w = Math.max(1, Math.round(width * scale));
378
+ const h = Math.max(1, Math.round(height * scale));
379
+
380
+ const canvas = document.createElement("canvas");
381
+ canvas.width = w;
382
+ canvas.height = h;
383
+ const ctx = canvas.getContext("2d");
384
+ if (!ctx) return null;
385
+ ctx.drawImage(img, 0, 0, w, h);
386
+ return { dataUrl: canvas.toDataURL("image/png"), width, height };
387
+ } finally {
388
+ URL.revokeObjectURL(objectUrl);
389
+ }
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+
395
+ async function requestFastImageEdit(
396
+ body: {
397
+ prompt: string;
398
+ images: string[];
399
+ projectId: string;
400
+ },
401
+ onLog: (text: string) => void,
402
+ ): Promise<{ ok: boolean; error?: string; result?: string }> {
403
+ const res = await fetch(`${API_BASE}/api/mlxgen/fast-image-edit`, {
404
+ method: "POST",
405
+ headers: { "Content-Type": "application/json" },
406
+ body: JSON.stringify(body),
407
+ });
408
+
409
+ if (!res.ok) {
410
+ return { ok: false, error: await res.text() };
411
+ }
412
+
413
+ let result: string | undefined;
414
+ let error: string | undefined;
415
+
416
+ await readSSEStream(res, (event, data) => {
417
+ switch (event) {
418
+ case "log":
419
+ onLog(data.text as string);
420
+ break;
421
+ case "complete":
422
+ result = `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`;
423
+ break;
424
+ case "error":
425
+ error = data.error || "Fast image edit failed";
426
+ break;
427
+ }
428
+ });
429
+
430
+ return { ok: !error, error, result };
431
+ }
432
+
433
+ // ========== Abort Controllers ==========
434
+
435
+ let generateAbortController: AbortController | null = null;
436
+ let batchAbortController: AbortController | null = null;
437
+
438
+ // ========== Beep ==========
439
+
440
+ function playBeep() {
441
+ try {
442
+ const ctx = new (
443
+ window.AudioContext || (window as any).webkitAudioContext
444
+ )();
445
+ const osc = ctx.createOscillator();
446
+ const gain = ctx.createGain();
447
+ osc.connect(gain);
448
+ gain.connect(ctx.destination);
449
+ osc.type = "sine";
450
+ osc.frequency.setValueAtTime(880, ctx.currentTime);
451
+ gain.gain.setValueAtTime(0.3, ctx.currentTime);
452
+ gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.4);
453
+ osc.start(ctx.currentTime);
454
+ osc.stop(ctx.currentTime + 0.4);
455
+ } catch {
456
+ // silently ignore if audio not available
457
+ }
458
+ }
459
+
460
+ // ========== Initial State ==========
461
+
462
+ const initialImage: ImageState = {
463
+ prompt: "",
464
+ aspectRatio: "1:1",
465
+ resolution: "480p",
466
+ generating: false,
467
+ result: null,
468
+ error: null,
469
+ logs: [],
470
+ };
471
+
472
+ const initialVideo: VideoState = {
473
+ prompt: `[a 4 years old little lamb happily says]: {{book}}. Chapter {{chapter}}. Verse {{verses}}. {{script}}`,
474
+ duration: 5,
475
+ aspectRatio: "1:1",
476
+ resolution: "480p",
477
+ mode: "distilled",
478
+ generating: false,
479
+ result: null,
480
+ error: null,
481
+ logs: [],
482
+ };
483
+
484
+ const initialExtend: ExtendState = {
485
+ prompt: "Continue the scene: the camera holds, motion flows naturally...",
486
+ extendDuration: 2,
487
+ extendFrames: 2 * 24 + 1,
488
+ generating: false,
489
+ result: null,
490
+ error: null,
491
+ logs: [],
492
+ };
493
+
494
+ const TEXT_TO_IMAGE_PRESETS: Record<
495
+ TextToImagePreset,
496
+ {
497
+ aspectRatio: AspectRatio;
498
+ resolution: Resolution;
499
+ steps: number;
500
+ }
501
+ > = {
502
+ prototype: {
503
+ aspectRatio: "1:1",
504
+ resolution: "320p",
505
+ steps: 4,
506
+ },
507
+ medium: {
508
+ aspectRatio: "1:1",
509
+ resolution: "720p",
510
+ steps: 7,
511
+ },
512
+ optimal: {
513
+ aspectRatio: "1:1",
514
+ resolution: "1080p",
515
+ steps: 6,
516
+ },
517
+ };
518
+
519
+ const initialTextToImage: TextToImageState = {
520
+ prompt: "",
521
+ aspectRatio: "1:1",
522
+ resolution: "480p",
523
+ steps: 4,
524
+ installing: false,
525
+ installingLogs: [],
526
+ installingError: null,
527
+ downloading: false,
528
+ downloadingLogs: [],
529
+ downloadingError: null,
530
+ generating: false,
531
+ result: null,
532
+ error: null,
533
+ logs: [],
534
+ mlxgenInstalled: null,
535
+ zModelDownloaded: null,
536
+ };
537
+
538
+ const initialFastImageEdit: FastImageEditState = {
539
+ prompt: "",
540
+ referenceImages: [],
541
+ downloading: false,
542
+ downloadingLogs: [],
543
+ downloadingError: null,
544
+ generating: false,
545
+ result: null,
546
+ error: null,
547
+ logs: [],
548
+ modelDownloaded: null,
549
+ };
550
+
551
+ const initialAgent: AgentState = {
552
+ model: "mlx-community/gemma-4-e4b-it-8bit",
553
+ port: 8881,
554
+ installing: false,
555
+ installingLogs: [],
556
+ installingError: null,
557
+ installed: null,
558
+ starting: false,
559
+ serverRunning: false,
560
+ serverOnline: null,
561
+ serverLogs: [],
562
+ serverError: null,
563
+ };
564
+
565
+ // ========== Store ==========
566
+
567
+ function toPersistedTextToImageState(
568
+ t: TextToImageState,
569
+ ): PersistedTextToImageState {
570
+ return {
571
+ prompt: t.prompt,
572
+ aspectRatio: t.aspectRatio,
573
+ resolution: t.resolution,
574
+ steps: t.steps,
575
+ };
576
+ }
577
+
578
+ // Fire-and-forget save of the current editable text-to-image UI state.
579
+ function persistTextToImageState() {
580
+ const { textToImageProjectId } = useGenerationStore.getState();
581
+ if (!textToImageProjectId) return;
582
+ void saveTextToImageState(
583
+ textToImageProjectId,
584
+ toPersistedTextToImageState(useGenerationStore.getState().textToImage),
585
+ );
586
+ }
587
+
588
+ export const useGenerationStore = create<GenerationStore>((set, get) => ({
589
+ activeTab: "movieStudio",
590
+ setActiveTab: (tab) => set({ activeTab: tab }),
591
+
592
+ // ---- Image ----
593
+ image: { ...initialImage },
594
+
595
+ setImagePrompt: (prompt) =>
596
+ set((s) => ({ image: { ...s.image, prompt, error: null } })),
597
+ setImageAspectRatio: (aspectRatio) =>
598
+ set((s) => ({ image: { ...s.image, aspectRatio } })),
599
+ setImageResolution: (resolution) =>
600
+ set((s) => ({ image: { ...s.image, resolution } })),
601
+ clearImageResult: () =>
602
+ set((s) => ({
603
+ image: { ...s.image, result: null, error: null, logs: [] },
604
+ })),
605
+
606
+ generateImage: async (projectId) => {
607
+ const { image } = get();
608
+ if (!image.prompt.trim() || image.generating) return;
609
+
610
+ const { width, height } = getDimensions(
611
+ image.aspectRatio,
612
+ image.resolution,
613
+ );
614
+
615
+ set((s) => ({
616
+ image: {
617
+ ...s.image,
618
+ generating: true,
619
+ error: null,
620
+ result: null,
621
+ logs: [],
622
+ },
623
+ }));
624
+
625
+ try {
626
+ const res = await fetch(`${API_BASE}/api/render/text-to-image`, {
627
+ method: "POST",
628
+ headers: { "Content-Type": "application/json" },
629
+ body: JSON.stringify({
630
+ prompt: image.prompt.trim(),
631
+ projectId,
632
+ aspect: image.aspectRatio,
633
+ width,
634
+ height,
635
+ device: "mps",
636
+ }),
637
+ });
638
+
639
+ if (!res.ok) {
640
+ const err = await res.text();
641
+ set((s) => ({
642
+ image: { ...s.image, generating: false, error: err },
643
+ }));
644
+ return;
645
+ }
646
+
647
+ await readSSEStream(res, (event, data) => {
648
+ switch (event) {
649
+ case "log":
650
+ set((s) => ({
651
+ image: {
652
+ ...s.image,
653
+ logs: [...s.image.logs, data.text as string],
654
+ },
655
+ }));
656
+ break;
657
+ case "complete":
658
+ set((s) => ({
659
+ image: {
660
+ ...s.image,
661
+ generating: false,
662
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
663
+ },
664
+ }));
665
+ break;
666
+ case "error":
667
+ set((s) => ({
668
+ image: {
669
+ ...s.image,
670
+ generating: false,
671
+ error: data.error || "Image generation failed",
672
+ },
673
+ }));
674
+ break;
675
+ }
676
+ });
677
+ } catch (e) {
678
+ set((s) => ({
679
+ image: { ...s.image, generating: false, error: String(e) },
680
+ }));
681
+ }
682
+ },
683
+
684
+ // ---- Video ----
685
+ video: { ...initialVideo },
686
+
687
+ setVideoPrompt: (prompt) =>
688
+ set((s) => ({ video: { ...s.video, prompt, error: null } })),
689
+ setVideoDuration: (duration) =>
690
+ set((s) => ({ video: { ...s.video, duration } })),
691
+ setVideoAspectRatio: (aspectRatio) =>
692
+ set((s) => ({ video: { ...s.video, aspectRatio } })),
693
+ setVideoResolution: (resolution) =>
694
+ set((s) => ({ video: { ...s.video, resolution } })),
695
+ setVideoMode: (mode) => set((s) => ({ video: { ...s.video, mode } })),
696
+ clearVideoResult: () =>
697
+ set((s) => ({
698
+ video: { ...s.video, result: null, error: null, logs: [] },
699
+ })),
700
+
701
+ generateVideo: async (projectId, imagePath?) => {
702
+ const { video } = get();
703
+ if (!video.prompt.trim() || video.generating) return;
704
+
705
+ const { width, height } = getDimensions(
706
+ video.aspectRatio,
707
+ video.resolution,
708
+ );
709
+
710
+ // Use the provided imagePath, or fall back to uploaded image, or generated image
711
+ let resolvedImagePath =
712
+ imagePath ||
713
+ get().uploadedImagePath ||
714
+ get().uploadedImageUrl ||
715
+ get().image.result;
716
+
717
+ if (!resolvedImagePath) {
718
+ set((s) => ({
719
+ video: {
720
+ ...s.video,
721
+ error: "Image path is required. Upload or generate an image first.",
722
+ },
723
+ }));
724
+ return;
725
+ }
726
+
727
+ // Extract raw file path from HTTP URL (e.g. http://localhost:PORT/api/files?path=...)
728
+ if (resolvedImagePath.includes("/api/files?path=")) {
729
+ try {
730
+ const url = new URL(resolvedImagePath);
731
+ resolvedImagePath = url.searchParams.get("path") || resolvedImagePath;
732
+ } catch {
733
+ // not a valid URL, use as-is
734
+ }
735
+ }
736
+
737
+ // Strip any remaining file:// prefix
738
+ if (resolvedImagePath.startsWith("file://")) {
739
+ resolvedImagePath = resolvedImagePath.slice(7);
740
+ }
741
+
742
+ // The backend now only accepts bare filenames — extract just the basename
743
+ resolvedImagePath = resolvedImagePath.split("/").pop() || resolvedImagePath;
744
+
745
+ // Create a fresh AbortController for this single generate run
746
+ generateAbortController = new AbortController();
747
+ const signal = generateAbortController.signal;
748
+
749
+ set((s) => ({
750
+ video: {
751
+ ...s.video,
752
+ generating: true,
753
+ error: null,
754
+ result: null,
755
+ logs: [],
756
+ },
757
+ }));
758
+
759
+ try {
760
+ const res = await fetch(`${API_BASE}/api/render/image-to-video`, {
761
+ method: "POST",
762
+ headers: { "Content-Type": "application/json" },
763
+ body: JSON.stringify({
764
+ prompt: video.prompt.trim(),
765
+ imagePath: resolvedImagePath,
766
+ projectId,
767
+ width,
768
+ height,
769
+ frames: video.duration * 24 + 1,
770
+ frameRate: 24,
771
+ mode: video.mode,
772
+ }),
773
+ signal,
774
+ });
775
+
776
+ if (!res.ok) {
777
+ const err = await res.text();
778
+ set((s) => ({
779
+ video: { ...s.video, generating: false, error: err },
780
+ }));
781
+ generateAbortController = null;
782
+ return;
783
+ }
784
+
785
+ await readSSEStream(res, (event, data) => {
786
+ switch (event) {
787
+ case "log":
788
+ set((s) => ({
789
+ video: {
790
+ ...s.video,
791
+ logs: [...s.video.logs, data.text as string],
792
+ },
793
+ }));
794
+ break;
795
+ case "complete":
796
+ set((s) => ({
797
+ video: {
798
+ ...s.video,
799
+ generating: false,
800
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
801
+ },
802
+ }));
803
+ break;
804
+ case "error":
805
+ set((s) => ({
806
+ video: {
807
+ ...s.video,
808
+ generating: false,
809
+ error: data.error || "Video generation failed",
810
+ },
811
+ }));
812
+ break;
813
+ }
814
+ });
815
+ } catch (e: any) {
816
+ // If the request was aborted, just stop silently
817
+ if (e?.name !== "AbortError") {
818
+ set((s) => ({
819
+ video: { ...s.video, generating: false, error: String(e) },
820
+ }));
821
+ } else {
822
+ set((s) => ({
823
+ video: { ...s.video, generating: false },
824
+ }));
825
+ }
826
+ } finally {
827
+ generateAbortController = null;
828
+ }
829
+ },
830
+
831
+ // ---- Upload ----
832
+ uploading: false,
833
+ uploadError: null,
834
+ uploadedImageUrl: null,
835
+ uploadedImageFilename: null,
836
+ uploadedImagePath: null,
837
+
838
+ uploadImage: async (projectId, base64, filename) => {
839
+ set({ uploading: true, uploadError: null });
840
+ try {
841
+ const res = await fetch(`${API_BASE}/api/upload/image`, {
842
+ method: "POST",
843
+ headers: { "Content-Type": "application/json" },
844
+ body: JSON.stringify({
845
+ image: base64,
846
+ filename: filename || `upload-${Date.now()}.png`,
847
+ projectId,
848
+ }),
849
+ });
850
+
851
+ if (!res.ok) {
852
+ const err = await res.text();
853
+ set({ uploading: false, uploadError: err });
854
+ return null;
855
+ }
856
+
857
+ const data = await res.json();
858
+ const url = `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`;
859
+ set({
860
+ uploading: false,
861
+ uploadedImageUrl: url,
862
+ uploadedImageFilename: data.filename,
863
+ uploadedImagePath: data.path,
864
+ });
865
+ return data.path as string;
866
+ } catch (e) {
867
+ set({ uploading: false, uploadError: String(e) });
868
+ return null;
869
+ }
870
+ },
871
+
872
+ // ---- Project Images ----
873
+ projectImages: [],
874
+ projectImagesLoading: false,
875
+ selectedImage: null,
876
+
877
+ fetchProjectImages: async (projectId) => {
878
+ set({ projectImagesLoading: true });
879
+ try {
880
+ const res = await fetch(`${API_BASE}/api/projects/${projectId}/images`);
881
+ if (!res.ok) throw new Error(await res.text());
882
+ const images: ProjectImage[] = await res.json();
883
+ // Resolve relative URLs to absolute so they work regardless of page origin
884
+ const resolved = images.map((img) => ({
885
+ ...img,
886
+ url: img.url.startsWith("http")
887
+ ? img.url
888
+ : `http://localhost:${(window as any).PORT}${img.url}`,
889
+ }));
890
+ set({ projectImages: resolved, projectImagesLoading: false });
891
+ } catch {
892
+ set({ projectImagesLoading: false });
893
+ }
894
+ },
895
+
896
+ // ---- Character Sheets ----
897
+ characterSheets: [],
898
+ characterSheetsLoading: false,
899
+
900
+ fetchCharacterSheets: async (projectId) => {
901
+ set({ characterSheetsLoading: true });
902
+ try {
903
+ const res = await fetch(
904
+ `${API_BASE}/api/projects/${projectId}/character-sheets`,
905
+ );
906
+ if (!res.ok) throw new Error(await res.text());
907
+ const sheets: { filename: string; url: string }[] = await res.json();
908
+ const resolved: ProjectImage[] = sheets.map((sheet) => ({
909
+ filename: sheet.filename,
910
+ url: sheet.url.startsWith("http")
911
+ ? sheet.url
912
+ : `http://localhost:${(window as any).PORT}${sheet.url}`,
913
+ source: "characterSheet",
914
+ }));
915
+ set({ characterSheets: resolved, characterSheetsLoading: false });
916
+ } catch {
917
+ set({ characterSheetsLoading: false });
918
+ }
919
+ },
920
+
921
+ // ---- CSV Batch ----
922
+ csvRows: [],
923
+ csvColumns: [],
924
+ csvFilename: null,
925
+ csvSelectedIndices: new Set<number>(),
926
+ batchRunning: false,
927
+ batchProgress: null,
928
+ batchCancelRequested: false,
929
+
930
+ uploadCsv: (base64, filename) => {
931
+ try {
932
+ // Decode base64 (strip data URL prefix if present)
933
+ const raw = base64
934
+ .replace(/^data:text\/csv;base64,/, "")
935
+ .replace(/^data:application\/csv;base64,/, "")
936
+ .replace(/^data:text\/plain;base64,/, "");
937
+ // atob() returns a binary string (bytes as Latin-1), which corrupts
938
+ // multi-byte UTF-8 characters (Chinese, emoji, etc.). Decode properly
939
+ // via TextDecoder so CSV names render correctly in Mustache templates.
940
+ const bytes = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
941
+ const text = new TextDecoder("utf-8").decode(bytes);
942
+ const { rows, columns } = parseCsv(text);
943
+ // Auto-select all rows
944
+ const allIndices = new Set(rows.map((_, i) => i));
945
+ set({
946
+ csvRows: rows,
947
+ csvColumns: columns,
948
+ csvFilename: filename,
949
+ csvSelectedIndices: allIndices,
950
+ });
951
+ } catch {
952
+ set({
953
+ csvRows: [],
954
+ csvColumns: [],
955
+ csvFilename: null,
956
+ csvSelectedIndices: new Set(),
957
+ });
958
+ }
959
+ },
960
+
961
+ clearCsvData: () =>
962
+ set({
963
+ csvRows: [],
964
+ csvColumns: [],
965
+ csvFilename: null,
966
+ csvSelectedIndices: new Set(),
967
+ batchRunning: false,
968
+ batchProgress: null,
969
+ batchCancelRequested: false,
970
+ }),
971
+
972
+ toggleCsvRow: (index) =>
973
+ set((s) => {
974
+ const next = new Set(s.csvSelectedIndices);
975
+ if (next.has(index)) {
976
+ next.delete(index);
977
+ } else {
978
+ next.add(index);
979
+ }
980
+ return { csvSelectedIndices: next };
981
+ }),
982
+
983
+ selectAllCsvRows: () =>
984
+ set((s) => ({
985
+ csvSelectedIndices: new Set(s.csvRows.map((_, i) => i)),
986
+ })),
987
+
988
+ deselectAllCsvRows: () => set({ csvSelectedIndices: new Set() }),
989
+
990
+ updateCsvCell: (rowIndex, column, value) =>
991
+ set((s) => {
992
+ const updated = s.csvRows.map((row, i) =>
993
+ i === rowIndex ? { ...row, [column]: value } : row,
994
+ );
995
+ return { csvRows: updated };
996
+ }),
997
+
998
+ generateBatchVideos: async (projectId) => {
999
+ const { video, csvRows, csvSelectedIndices, batchRunning } = get();
1000
+ // Only process selected rows
1001
+ const selectedIndices = Array.from(csvSelectedIndices).sort(
1002
+ (a, b) => a - b,
1003
+ );
1004
+ if (batchRunning || selectedIndices.length === 0) return;
1005
+
1006
+ const { width, height } = getDimensions(
1007
+ video.aspectRatio,
1008
+ video.resolution,
1009
+ );
1010
+
1011
+ // Create a fresh AbortController for this batch run
1012
+ batchAbortController = new AbortController();
1013
+ const signal = batchAbortController.signal;
1014
+
1015
+ set({
1016
+ batchRunning: true,
1017
+ batchProgress: { current: 0, total: selectedIndices.length },
1018
+ batchCancelRequested: false,
1019
+ });
1020
+
1021
+ for (let batchIdx = 0; batchIdx < selectedIndices.length; batchIdx++) {
1022
+ if (get().batchCancelRequested) break;
1023
+
1024
+ const i = selectedIndices[batchIdx];
1025
+
1026
+ set({
1027
+ batchProgress: { current: batchIdx + 1, total: selectedIndices.length },
1028
+ });
1029
+
1030
+ const rowData = csvRows[i];
1031
+ const renderedPrompt = Mustache.render(`${video.prompt}`, { ...rowData });
1032
+
1033
+ // Build a name-tagged prompt suffix so the output filename reflects the row
1034
+ // const nameTag = rowData.name ? ` [${rowData.name}]` : "";
1035
+
1036
+ // Call the single-video generation with the rendered prompt
1037
+ // We use the existing generateVideo logic but inline it here for batch
1038
+ let resolvedImagePath =
1039
+ get().uploadedImagePath || get().uploadedImageUrl || get().image.result;
1040
+
1041
+ if (!resolvedImagePath) continue;
1042
+
1043
+ if (resolvedImagePath.includes("/api/files?path=")) {
1044
+ try {
1045
+ const url = new URL(resolvedImagePath);
1046
+ resolvedImagePath = url.searchParams.get("path") || resolvedImagePath;
1047
+ } catch {
1048
+ // not a valid URL, use as-is
1049
+ }
1050
+ }
1051
+ if (resolvedImagePath.startsWith("file://")) {
1052
+ resolvedImagePath = resolvedImagePath.slice(7);
1053
+ }
1054
+ resolvedImagePath =
1055
+ resolvedImagePath.split("/").pop() || resolvedImagePath;
1056
+
1057
+ set((s) => ({
1058
+ video: {
1059
+ ...s.video,
1060
+ generating: true,
1061
+ error: null,
1062
+ result: null,
1063
+ logs: [],
1064
+ },
1065
+ }));
1066
+
1067
+ try {
1068
+ const res = await fetch(`${API_BASE}/api/render/image-to-video`, {
1069
+ method: "POST",
1070
+ headers: { "Content-Type": "application/json" },
1071
+ body: JSON.stringify({
1072
+ prompt: `${renderedPrompt}`, //${nameTag}
1073
+ imagePath: resolvedImagePath,
1074
+ projectId,
1075
+ width,
1076
+ height,
1077
+ frames: video.duration * 24 + 1,
1078
+ frameRate: 24,
1079
+ mode: video.mode,
1080
+ }),
1081
+ signal,
1082
+ });
1083
+
1084
+ if (!res.ok) {
1085
+ const err = await res.text();
1086
+ set((s) => ({
1087
+ video: { ...s.video, generating: false, error: err },
1088
+ }));
1089
+ continue;
1090
+ }
1091
+
1092
+ await readSSEStream(res, (event, data) => {
1093
+ switch (event) {
1094
+ case "log":
1095
+ set((s) => ({
1096
+ video: {
1097
+ ...s.video,
1098
+ logs: [
1099
+ ...s.video.logs,
1100
+ `[${batchIdx + 1}/${selectedIndices.length}] ${data.text as string}`,
1101
+ ],
1102
+ },
1103
+ }));
1104
+ break;
1105
+ case "complete":
1106
+ set((s) => ({
1107
+ video: {
1108
+ ...s.video,
1109
+ generating: false,
1110
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
1111
+ },
1112
+ }));
1113
+ break;
1114
+ case "error":
1115
+ set((s) => ({
1116
+ video: {
1117
+ ...s.video,
1118
+ generating: false,
1119
+ error: data.error || "Video generation failed",
1120
+ },
1121
+ }));
1122
+ break;
1123
+ }
1124
+ });
1125
+ } catch (e: any) {
1126
+ // If the request was aborted, just stop silently
1127
+ if (e?.name === "AbortError") break;
1128
+ set((s) => ({
1129
+ video: { ...s.video, generating: false, error: String(e) },
1130
+ }));
1131
+ }
1132
+ }
1133
+
1134
+ const finished = !get().batchCancelRequested;
1135
+ batchAbortController = null;
1136
+ set({
1137
+ batchRunning: false,
1138
+ batchProgress: null,
1139
+ batchCancelRequested: false,
1140
+ });
1141
+
1142
+ if (finished) {
1143
+ playBeep();
1144
+ }
1145
+ },
1146
+
1147
+ // ---- Video Extension ----
1148
+ extend: { ...initialExtend },
1149
+
1150
+ setExtendPrompt: (prompt) =>
1151
+ set((s) => ({ extend: { ...s.extend, prompt, error: null } })),
1152
+ setExtendDuration: (extendDuration) =>
1153
+ set((s) => ({
1154
+ extend: {
1155
+ ...s.extend,
1156
+ extendDuration,
1157
+ extendFrames: extendDuration * 24 + 1,
1158
+ },
1159
+ })),
1160
+ setExtendFrames: (extendFrames) =>
1161
+ set((s) => ({
1162
+ extend: {
1163
+ ...s.extend,
1164
+ extendFrames,
1165
+ extendDuration: Math.max(0.5, (extendFrames - 1) / 24),
1166
+ },
1167
+ })),
1168
+ clearExtendResult: () =>
1169
+ set((s) => ({
1170
+ extend: { ...s.extend, result: null, error: null, logs: [] },
1171
+ })),
1172
+
1173
+ generateExtend: async (projectId, videoPath) => {
1174
+ const { extend } = get();
1175
+ if (!extend.prompt.trim() || extend.generating) return;
1176
+
1177
+ // Extract raw file path from HTTP URL (e.g. http://localhost:PORT/api/files?path=...)
1178
+ let resolvedVideoPath = videoPath || "";
1179
+ if (!resolvedVideoPath) {
1180
+ set((s) => ({
1181
+ extend: { ...s.extend, error: "Select a video to extend first." },
1182
+ }));
1183
+ return;
1184
+ }
1185
+ if (resolvedVideoPath.includes("/api/files?path=")) {
1186
+ try {
1187
+ const url = new URL(resolvedVideoPath);
1188
+ resolvedVideoPath = url.searchParams.get("path") || resolvedVideoPath;
1189
+ } catch {
1190
+ // not a valid URL, use as-is
1191
+ }
1192
+ }
1193
+ if (resolvedVideoPath.startsWith("file://")) {
1194
+ resolvedVideoPath = resolvedVideoPath.slice(7);
1195
+ }
1196
+ // The backend only accepts bare filenames — extract just the basename
1197
+ resolvedVideoPath = resolvedVideoPath.split("/").pop() || resolvedVideoPath;
1198
+
1199
+ // Create a fresh AbortController for this extend run
1200
+ generateAbortController = new AbortController();
1201
+ const signal = generateAbortController.signal;
1202
+
1203
+ set((s) => ({
1204
+ extend: {
1205
+ ...s.extend,
1206
+ generating: true,
1207
+ error: null,
1208
+ result: null,
1209
+ logs: [],
1210
+ },
1211
+ }));
1212
+
1213
+ try {
1214
+ const res = await fetch(`${API_BASE}/api/render/extend-video`, {
1215
+ method: "POST",
1216
+ headers: { "Content-Type": "application/json" },
1217
+ body: JSON.stringify({
1218
+ prompt: extend.prompt.trim(),
1219
+ videoPath: resolvedVideoPath,
1220
+ projectId,
1221
+ extendFrames: extend.extendDuration * 24 + 1,
1222
+ }),
1223
+ signal,
1224
+ });
1225
+
1226
+ if (!res.ok) {
1227
+ const err = await res.text();
1228
+ set((s) => ({
1229
+ extend: { ...s.extend, generating: false, error: err },
1230
+ }));
1231
+ generateAbortController = null;
1232
+ return;
1233
+ }
1234
+
1235
+ await readSSEStream(res, (event, data) => {
1236
+ switch (event) {
1237
+ case "log":
1238
+ set((s) => ({
1239
+ extend: {
1240
+ ...s.extend,
1241
+ logs: [...s.extend.logs, data.text as string],
1242
+ },
1243
+ }));
1244
+ break;
1245
+ case "complete":
1246
+ set((s) => ({
1247
+ extend: {
1248
+ ...s.extend,
1249
+ generating: false,
1250
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
1251
+ },
1252
+ }));
1253
+ // Refresh the video grid so the new extended-*.mp4 appears
1254
+ get().fetchProjectVideos(projectId);
1255
+ break;
1256
+ case "error":
1257
+ set((s) => ({
1258
+ extend: {
1259
+ ...s.extend,
1260
+ generating: false,
1261
+ error: data.error || "Video extension failed",
1262
+ },
1263
+ }));
1264
+ break;
1265
+ }
1266
+ });
1267
+ } catch (e: any) {
1268
+ // If the request was aborted, just stop silently
1269
+ if (e?.name !== "AbortError") {
1270
+ set((s) => ({
1271
+ extend: { ...s.extend, generating: false, error: String(e) },
1272
+ }));
1273
+ } else {
1274
+ set((s) => ({ extend: { ...s.extend, generating: false } }));
1275
+ }
1276
+ } finally {
1277
+ generateAbortController = null;
1278
+ }
1279
+ },
1280
+
1281
+ // ---- Fast Image Edit (FLUX.2 Klein) ----
1282
+ fastImageEdit: { ...initialFastImageEdit },
1283
+
1284
+ setFastImageEditPrompt: (prompt) =>
1285
+ set((s) => ({
1286
+ fastImageEdit: { ...s.fastImageEdit, prompt, error: null },
1287
+ })),
1288
+
1289
+ toggleFastImageEditImage: (img) =>
1290
+ set((s) => {
1291
+ const current = s.fastImageEdit.referenceImages;
1292
+ const exists = current.some((r) => r.filename === img.filename);
1293
+ const next = exists
1294
+ ? current.filter((r) => r.filename !== img.filename)
1295
+ : current.length >= 4
1296
+ ? current
1297
+ : [...current, img];
1298
+ return { fastImageEdit: { ...s.fastImageEdit, referenceImages: next } };
1299
+ }),
1300
+
1301
+ clearFastImageEditImages: () =>
1302
+ set((s) => ({
1303
+ fastImageEdit: { ...s.fastImageEdit, referenceImages: [] },
1304
+ })),
1305
+
1306
+ clearFastImageEditResult: () =>
1307
+ set((s) => ({
1308
+ fastImageEdit: {
1309
+ ...s.fastImageEdit,
1310
+ result: null,
1311
+ error: null,
1312
+ logs: [],
1313
+ },
1314
+ })),
1315
+
1316
+ checkFastImageEditStatus: async () => {
1317
+ try {
1318
+ const res = await fetch(`${API_BASE}/api/mlxgen/status`);
1319
+ if (!res.ok) return;
1320
+ const data = await res.json();
1321
+ set((s) => ({
1322
+ fastImageEdit: {
1323
+ ...s.fastImageEdit,
1324
+ modelDownloaded: Boolean(data.fluxModelDownloaded),
1325
+ },
1326
+ }));
1327
+ } catch {
1328
+ // Leave status unknown (null) if the check fails.
1329
+ }
1330
+ },
1331
+
1332
+ downloadFastImageEditModel: async () => {
1333
+ const { fastImageEdit } = get();
1334
+ if (fastImageEdit.downloading) return;
1335
+
1336
+ set((s) => ({
1337
+ fastImageEdit: {
1338
+ ...s.fastImageEdit,
1339
+ downloading: true,
1340
+ downloadingError: null,
1341
+ downloadingLogs: [],
1342
+ },
1343
+ }));
1344
+
1345
+ try {
1346
+ const res = await fetch(`${API_BASE}/api/mlxgen/download-flux-model`, {
1347
+ method: "POST",
1348
+ });
1349
+
1350
+ if (!res.ok) {
1351
+ const err = await res.text();
1352
+ set((s) => ({
1353
+ fastImageEdit: {
1354
+ ...s.fastImageEdit,
1355
+ downloading: false,
1356
+ downloadingError: err,
1357
+ },
1358
+ }));
1359
+ return;
1360
+ }
1361
+
1362
+ await readSSEStream(res, (event, data) => {
1363
+ switch (event) {
1364
+ case "log":
1365
+ set((s) => ({
1366
+ fastImageEdit: {
1367
+ ...s.fastImageEdit,
1368
+ downloadingLogs: [
1369
+ ...s.fastImageEdit.downloadingLogs,
1370
+ data.text as string,
1371
+ ],
1372
+ },
1373
+ }));
1374
+ break;
1375
+ case "complete":
1376
+ set((s) => ({
1377
+ fastImageEdit: { ...s.fastImageEdit, downloading: false },
1378
+ }));
1379
+ get().checkFastImageEditStatus();
1380
+ break;
1381
+ case "error":
1382
+ set((s) => ({
1383
+ fastImageEdit: {
1384
+ ...s.fastImageEdit,
1385
+ downloading: false,
1386
+ downloadingError: data.error || "Download failed",
1387
+ },
1388
+ }));
1389
+ break;
1390
+ }
1391
+ });
1392
+ } catch (e) {
1393
+ set((s) => ({
1394
+ fastImageEdit: {
1395
+ ...s.fastImageEdit,
1396
+ downloading: false,
1397
+ downloadingError: String(e),
1398
+ },
1399
+ }));
1400
+ }
1401
+ },
1402
+
1403
+ generateFastImageEdit: async (projectId) => {
1404
+ const { fastImageEdit } = get();
1405
+ if (fastImageEdit.generating) return;
1406
+
1407
+ if (!fastImageEdit.prompt.trim()) {
1408
+ set((s) => ({
1409
+ fastImageEdit: { ...s.fastImageEdit, error: "Prompt is required" },
1410
+ }));
1411
+ return;
1412
+ }
1413
+ if (fastImageEdit.referenceImages.length === 0) {
1414
+ set((s) => ({
1415
+ fastImageEdit: {
1416
+ ...s.fastImageEdit,
1417
+ error: "Select at least one reference image.",
1418
+ },
1419
+ }));
1420
+ return;
1421
+ }
1422
+
1423
+ set((s) => ({
1424
+ fastImageEdit: {
1425
+ ...s.fastImageEdit,
1426
+ generating: true,
1427
+ error: null,
1428
+ result: null,
1429
+ logs: [],
1430
+ },
1431
+ }));
1432
+
1433
+ // Preprocess each reference image (max 1024px PNG) and send as base64.
1434
+ const images: string[] = [];
1435
+ for (const img of fastImageEdit.referenceImages) {
1436
+ const processed = await resizeImageToPng(resolveImageUrl(img.url), 1024);
1437
+ if (!processed) {
1438
+ set((s) => ({
1439
+ fastImageEdit: {
1440
+ ...s.fastImageEdit,
1441
+ generating: false,
1442
+ error: "Failed to process reference image.",
1443
+ },
1444
+ }));
1445
+ return;
1446
+ }
1447
+ images.push(processed.dataUrl);
1448
+ }
1449
+
1450
+ const result = await requestFastImageEdit(
1451
+ {
1452
+ prompt: fastImageEdit.prompt.trim(),
1453
+ images,
1454
+ projectId,
1455
+ },
1456
+ (text) =>
1457
+ set((s) => ({
1458
+ fastImageEdit: {
1459
+ ...s.fastImageEdit,
1460
+ logs: [...s.fastImageEdit.logs, text],
1461
+ },
1462
+ })),
1463
+ );
1464
+
1465
+ set((s) => ({
1466
+ fastImageEdit: {
1467
+ ...s.fastImageEdit,
1468
+ generating: false,
1469
+ result: result.result ?? null,
1470
+ error: result.error ?? null,
1471
+ },
1472
+ }));
1473
+
1474
+ if (result.ok) {
1475
+ get().fetchProjectImages(projectId);
1476
+ }
1477
+ },
1478
+
1479
+ // ---- Text-to-image (mlx-gen z-image-turbo) ----
1480
+ textToImage: { ...initialTextToImage },
1481
+ textToImageProjectId: null,
1482
+ textToImageHydrated: false,
1483
+
1484
+ hydrateTextToImage: async (projectId) => {
1485
+ // No-op if we've already hydrated for this project.
1486
+ if (get().textToImageHydrated && get().textToImageProjectId === projectId) {
1487
+ return;
1488
+ }
1489
+
1490
+ // Switching projects: reset to defaults so the previous project's settings
1491
+ // don't leak through, then load the stored state (if any) below.
1492
+ const previous = get().textToImageProjectId;
1493
+ if (previous !== null && previous !== projectId) {
1494
+ get().resetTextToImage();
1495
+ }
1496
+ set({ textToImageHydrated: true, textToImageProjectId: projectId });
1497
+
1498
+ const stored = await loadTextToImageState(projectId);
1499
+ if (!stored) return;
1500
+
1501
+ set((s) => ({
1502
+ textToImage: {
1503
+ ...s.textToImage,
1504
+ prompt: stored.prompt ?? s.textToImage.prompt,
1505
+ aspectRatio: stored.aspectRatio ?? s.textToImage.aspectRatio,
1506
+ resolution: stored.resolution ?? s.textToImage.resolution,
1507
+ steps: stored.steps ?? s.textToImage.steps,
1508
+ },
1509
+ }));
1510
+ },
1511
+
1512
+ resetTextToImage: () => set({ textToImage: { ...initialTextToImage } }),
1513
+
1514
+ setTextToImagePrompt: (prompt) => {
1515
+ set((s) => ({ textToImage: { ...s.textToImage, prompt, error: null } }));
1516
+ persistTextToImageState();
1517
+ },
1518
+
1519
+ setTextToImageAspectRatio: (aspectRatio) => {
1520
+ set((s) => ({ textToImage: { ...s.textToImage, aspectRatio } }));
1521
+ persistTextToImageState();
1522
+ },
1523
+
1524
+ setTextToImageResolution: (resolution) => {
1525
+ set((s) => ({ textToImage: { ...s.textToImage, resolution } }));
1526
+ persistTextToImageState();
1527
+ },
1528
+
1529
+ setTextToImageSteps: (steps) => {
1530
+ set((s) => ({ textToImage: { ...s.textToImage, steps } }));
1531
+ persistTextToImageState();
1532
+ },
1533
+
1534
+ applyTextToImagePreset: (preset) => {
1535
+ const p = TEXT_TO_IMAGE_PRESETS[preset];
1536
+ set((s) => ({ textToImage: { ...s.textToImage, ...p } }));
1537
+ persistTextToImageState();
1538
+ },
1539
+
1540
+ clearTextToImageResult: () =>
1541
+ set((s) => ({
1542
+ textToImage: { ...s.textToImage, result: null, error: null, logs: [] },
1543
+ })),
1544
+
1545
+ checkTextToImageStatus: async () => {
1546
+ try {
1547
+ const res = await fetch(`${API_BASE}/api/mlxgen/status`);
1548
+ if (!res.ok) return;
1549
+ const data = await res.json();
1550
+ set((s) => ({
1551
+ textToImage: {
1552
+ ...s.textToImage,
1553
+ mlxgenInstalled: Boolean(data.installed),
1554
+ zModelDownloaded: Boolean(data.zModelDownloaded),
1555
+ },
1556
+ }));
1557
+ } catch {
1558
+ // Leave status unknown (null) if the check fails.
1559
+ }
1560
+ },
1561
+
1562
+ installTextToImage: async () => {
1563
+ const { textToImage } = get();
1564
+ if (textToImage.installing) return;
1565
+
1566
+ set((s) => ({
1567
+ textToImage: {
1568
+ ...s.textToImage,
1569
+ installing: true,
1570
+ installingError: null,
1571
+ installingLogs: [],
1572
+ },
1573
+ }));
1574
+
1575
+ try {
1576
+ const res = await fetch(`${API_BASE}/api/mlxgen/install`, {
1577
+ method: "POST",
1578
+ });
1579
+
1580
+ if (!res.ok) {
1581
+ const err = await res.text();
1582
+ set((s) => ({
1583
+ textToImage: {
1584
+ ...s.textToImage,
1585
+ installing: false,
1586
+ installingError: err,
1587
+ },
1588
+ }));
1589
+ return;
1590
+ }
1591
+
1592
+ await readSSEStream(res, (event, data) => {
1593
+ switch (event) {
1594
+ case "log":
1595
+ set((s) => ({
1596
+ textToImage: {
1597
+ ...s.textToImage,
1598
+ installingLogs: [
1599
+ ...s.textToImage.installingLogs,
1600
+ data.text as string,
1601
+ ],
1602
+ },
1603
+ }));
1604
+ break;
1605
+ case "complete":
1606
+ set((s) => ({
1607
+ textToImage: { ...s.textToImage, installing: false },
1608
+ }));
1609
+ get().checkTextToImageStatus();
1610
+ break;
1611
+ case "error":
1612
+ set((s) => ({
1613
+ textToImage: {
1614
+ ...s.textToImage,
1615
+ installing: false,
1616
+ installingError: data.error || "Install failed",
1617
+ },
1618
+ }));
1619
+ break;
1620
+ }
1621
+ });
1622
+ } catch (e) {
1623
+ set((s) => ({
1624
+ textToImage: {
1625
+ ...s.textToImage,
1626
+ installing: false,
1627
+ installingError: String(e),
1628
+ },
1629
+ }));
1630
+ }
1631
+ },
1632
+
1633
+ downloadTextToImageModel: async () => {
1634
+ if (get().textToImage.downloading) return;
1635
+
1636
+ set((s) => ({
1637
+ textToImage: {
1638
+ ...s.textToImage,
1639
+ downloading: true,
1640
+ downloadingError: null,
1641
+ downloadingLogs: [],
1642
+ },
1643
+ }));
1644
+
1645
+ try {
1646
+ const res = await fetch(`${API_BASE}/api/mlxgen/download-z-model`, {
1647
+ method: "POST",
1648
+ });
1649
+
1650
+ if (!res.ok) {
1651
+ const err = await res.text();
1652
+ set((s) => ({
1653
+ textToImage: {
1654
+ ...s.textToImage,
1655
+ downloading: false,
1656
+ downloadingError: err,
1657
+ },
1658
+ }));
1659
+ return;
1660
+ }
1661
+
1662
+ await readSSEStream(res, (event, data) => {
1663
+ switch (event) {
1664
+ case "log":
1665
+ set((s) => ({
1666
+ textToImage: {
1667
+ ...s.textToImage,
1668
+ downloadingLogs: [
1669
+ ...s.textToImage.downloadingLogs,
1670
+ data.text as string,
1671
+ ],
1672
+ },
1673
+ }));
1674
+ break;
1675
+ case "complete":
1676
+ set((s) => ({
1677
+ textToImage: {
1678
+ ...s.textToImage,
1679
+ downloading: false,
1680
+ },
1681
+ }));
1682
+ get().checkTextToImageStatus();
1683
+ break;
1684
+ case "error":
1685
+ set((s) => ({
1686
+ textToImage: {
1687
+ ...s.textToImage,
1688
+ downloading: false,
1689
+ downloadingError: data.error || "Download failed",
1690
+ },
1691
+ }));
1692
+ break;
1693
+ }
1694
+ });
1695
+ } catch (e) {
1696
+ set((s) => ({
1697
+ textToImage: {
1698
+ ...s.textToImage,
1699
+ downloading: false,
1700
+ downloadingError: String(e),
1701
+ },
1702
+ }));
1703
+ }
1704
+ },
1705
+
1706
+ generateTextToImage: async (projectId) => {
1707
+ const { textToImage } = get();
1708
+ if (!textToImage.prompt.trim() || textToImage.generating) return;
1709
+
1710
+ const { width, height } = getDimensions(
1711
+ textToImage.aspectRatio,
1712
+ textToImage.resolution,
1713
+ );
1714
+
1715
+ // Create a fresh AbortController so this run can be cancelled independently.
1716
+ generateAbortController = new AbortController();
1717
+ const signal = generateAbortController.signal;
1718
+
1719
+ set((s) => ({
1720
+ textToImage: {
1721
+ ...s.textToImage,
1722
+ generating: true,
1723
+ error: null,
1724
+ result: null,
1725
+ logs: [],
1726
+ },
1727
+ }));
1728
+
1729
+ try {
1730
+ const res = await fetch(`${API_BASE}/api/mlxgen/text-to-image`, {
1731
+ method: "POST",
1732
+ headers: { "Content-Type": "application/json" },
1733
+ body: JSON.stringify({
1734
+ prompt: textToImage.prompt.trim(),
1735
+ projectId,
1736
+ width,
1737
+ height,
1738
+ steps: textToImage.steps,
1739
+ }),
1740
+ signal,
1741
+ });
1742
+
1743
+ if (!res.ok) {
1744
+ const err = await res.text();
1745
+ set((s) => ({
1746
+ textToImage: { ...s.textToImage, generating: false, error: err },
1747
+ }));
1748
+ return;
1749
+ }
1750
+
1751
+ await readSSEStream(res, (event, data) => {
1752
+ switch (event) {
1753
+ case "log":
1754
+ set((s) => ({
1755
+ textToImage: {
1756
+ ...s.textToImage,
1757
+ logs: [...s.textToImage.logs, data.text as string],
1758
+ },
1759
+ }));
1760
+ break;
1761
+ case "complete":
1762
+ set((s) => ({
1763
+ textToImage: {
1764
+ ...s.textToImage,
1765
+ generating: false,
1766
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
1767
+ },
1768
+ }));
1769
+ get().fetchProjectImages(projectId);
1770
+ break;
1771
+ case "error":
1772
+ set((s) => ({
1773
+ textToImage: {
1774
+ ...s.textToImage,
1775
+ generating: false,
1776
+ error: data.error || "Text-to-image generation failed",
1777
+ },
1778
+ }));
1779
+ break;
1780
+ }
1781
+ });
1782
+ } catch (e: any) {
1783
+ // If the request was aborted, just stop silently.
1784
+ if (e?.name === "AbortError") {
1785
+ set((s) => ({ textToImage: { ...s.textToImage, generating: false } }));
1786
+ } else {
1787
+ set((s) => ({
1788
+ textToImage: {
1789
+ ...s.textToImage,
1790
+ generating: false,
1791
+ error: String(e),
1792
+ },
1793
+ }));
1794
+ }
1795
+ } finally {
1796
+ generateAbortController = null;
1797
+ }
1798
+ },
1799
+
1800
+ // ---- Agent (mlx-vlm) ----
1801
+ agent: { ...initialAgent },
1802
+
1803
+ setAgentModel: (model) => set((s) => ({ agent: { ...s.agent, model } })),
1804
+
1805
+ setAgentPort: (port) => set((s) => ({ agent: { ...s.agent, port } })),
1806
+
1807
+ checkAgentStatus: async () => {
1808
+ try {
1809
+ const res = await fetch(`${API_BASE}/api/agent/status`);
1810
+ if (!res.ok) return;
1811
+ const data = await res.json();
1812
+ set((s) => ({
1813
+ agent: {
1814
+ ...s.agent,
1815
+ installed: Boolean(data.installed),
1816
+ serverRunning: Boolean(data.serverRunning),
1817
+ },
1818
+ }));
1819
+ } catch {
1820
+ // Leave status unknown (null) if the check fails.
1821
+ }
1822
+ },
1823
+
1824
+ checkServerOnline: async () => {
1825
+ const { port } = get().agent;
1826
+ try {
1827
+ // Any response (even 4xx/5xx) means the port is listening.
1828
+ await fetch(`http://localhost:${port}/v1/models`, {
1829
+ method: "GET",
1830
+ }).then((r) => {
1831
+ if (!r.ok) {
1832
+ throw new Error("server is offline");
1833
+ }
1834
+ });
1835
+ set((s) => ({ agent: { ...s.agent, serverOnline: true } }));
1836
+ } catch {
1837
+ set((s) => ({ agent: { ...s.agent, serverOnline: false } }));
1838
+ }
1839
+ },
1840
+
1841
+ installMlxVlm: async () => {
1842
+ const { agent } = get();
1843
+ if (agent.installing) return;
1844
+
1845
+ set((s) => ({
1846
+ agent: {
1847
+ ...s.agent,
1848
+ installing: true,
1849
+ installingError: null,
1850
+ installingLogs: [],
1851
+ },
1852
+ }));
1853
+
1854
+ try {
1855
+ const res = await fetch(`${API_BASE}/api/agent/install`, {
1856
+ method: "POST",
1857
+ });
1858
+
1859
+ if (!res.ok) {
1860
+ const err = await res.text();
1861
+ set((s) => ({
1862
+ agent: { ...s.agent, installing: false, installingError: err },
1863
+ }));
1864
+ return;
1865
+ }
1866
+
1867
+ await readSSEStream(res, (event, data) => {
1868
+ switch (event) {
1869
+ case "log":
1870
+ set((s) => ({
1871
+ agent: {
1872
+ ...s.agent,
1873
+ installingLogs: [
1874
+ ...s.agent.installingLogs,
1875
+ data.text as string,
1876
+ ],
1877
+ },
1878
+ }));
1879
+ break;
1880
+ case "complete":
1881
+ set((s) => ({ agent: { ...s.agent, installing: false } }));
1882
+ get().checkAgentStatus();
1883
+ break;
1884
+ case "error":
1885
+ set((s) => ({
1886
+ agent: {
1887
+ ...s.agent,
1888
+ installing: false,
1889
+ installingError: data.error || "Install failed",
1890
+ },
1891
+ }));
1892
+ break;
1893
+ }
1894
+ });
1895
+ } catch (e) {
1896
+ set((s) => ({
1897
+ agent: {
1898
+ ...s.agent,
1899
+ installing: false,
1900
+ installingError: String(e),
1901
+ },
1902
+ }));
1903
+ }
1904
+ },
1905
+
1906
+ startAgentServer: async () => {
1907
+ const { agent } = get();
1908
+ if (agent.starting || agent.serverRunning) return;
1909
+
1910
+ set((s) => ({
1911
+ agent: {
1912
+ ...s.agent,
1913
+ starting: true,
1914
+ serverRunning: true,
1915
+ serverError: null,
1916
+ serverLogs: [],
1917
+ },
1918
+ }));
1919
+
1920
+ try {
1921
+ const res = await fetch(`${API_BASE}/api/agent/start`, {
1922
+ method: "POST",
1923
+ headers: { "Content-Type": "application/json" },
1924
+ body: JSON.stringify({ model: agent.model, port: agent.port }),
1925
+ });
1926
+
1927
+ if (!res.ok) {
1928
+ const err = await res.text();
1929
+ set((s) => ({
1930
+ agent: {
1931
+ ...s.agent,
1932
+ starting: false,
1933
+ serverRunning: false,
1934
+ serverError: err,
1935
+ },
1936
+ }));
1937
+ return;
1938
+ }
1939
+
1940
+ await readSSEStream(res, (event, data) => {
1941
+ switch (event) {
1942
+ case "log":
1943
+ set((s) => ({
1944
+ agent: {
1945
+ ...s.agent,
1946
+ serverLogs: [...s.agent.serverLogs, data.text as string],
1947
+ },
1948
+ }));
1949
+ break;
1950
+ case "complete":
1951
+ set((s) => ({
1952
+ agent: { ...s.agent, starting: false, serverRunning: false },
1953
+ }));
1954
+ break;
1955
+ case "error":
1956
+ set((s) => ({
1957
+ agent: {
1958
+ ...s.agent,
1959
+ starting: false,
1960
+ serverRunning: false,
1961
+ serverError: data.error || "Server error",
1962
+ },
1963
+ }));
1964
+ break;
1965
+ }
1966
+ });
1967
+ } catch (e) {
1968
+ set((s) => ({
1969
+ agent: {
1970
+ ...s.agent,
1971
+ starting: false,
1972
+ serverRunning: false,
1973
+ serverError: String(e),
1974
+ },
1975
+ }));
1976
+ }
1977
+ },
1978
+
1979
+ stopAgentServer: async () => {
1980
+ try {
1981
+ await fetch(`${API_BASE}/api/agent/stop`, { method: "POST" });
1982
+ } catch {
1983
+ // ignore stop failures
1984
+ }
1985
+ set((s) => ({
1986
+ agent: { ...s.agent, starting: false, serverRunning: false },
1987
+ }));
1988
+ },
1989
+
1990
+ openAgentServer: async () => {
1991
+ const { agent } = get();
1992
+ const url = `http://localhost:${agent.port}`;
1993
+ try {
1994
+ await fetch(`${API_BASE}/api/agent/open`, {
1995
+ method: "POST",
1996
+ headers: { "Content-Type": "application/json" },
1997
+ body: JSON.stringify({ url }),
1998
+ });
1999
+ } catch {
2000
+ // ignore open failures
2001
+ }
2002
+ },
2003
+
2004
+ // ---- Project Videos ----
2005
+ projectVideos: [],
2006
+ projectVideosLoading: false,
2007
+ selectedVideo: null,
2008
+
2009
+ fetchProjectVideos: async (projectId) => {
2010
+ set({ projectVideosLoading: true });
2011
+ try {
2012
+ const res = await fetch(`${API_BASE}/api/projects/${projectId}/videos`);
2013
+ if (!res.ok) throw new Error(await res.text());
2014
+ const videos: ProjectVideo[] = await res.json();
2015
+ // Resolve relative URLs to absolute so they work regardless of page origin
2016
+ const resolved = videos.map((v) => ({
2017
+ ...v,
2018
+ url: v.url.startsWith("http")
2019
+ ? v.url
2020
+ : `http://localhost:${(window as any).PORT}${v.url}`,
2021
+ }));
2022
+ set({ projectVideos: resolved, projectVideosLoading: false });
2023
+ } catch {
2024
+ set({ projectVideosLoading: false });
2025
+ }
2026
+ },
2027
+
2028
+ selectVideo: (video) => {
2029
+ if (!video) {
2030
+ set({ selectedVideo: null });
2031
+ return;
2032
+ }
2033
+ const fullUrl = video.url.startsWith("http")
2034
+ ? video.url
2035
+ : `http://localhost:${(window as any).PORT}${video.url}`;
2036
+ set({ selectedVideo: { ...video, url: fullUrl } });
2037
+ },
2038
+
2039
+ // ---- Project Audio ----
2040
+ projectAudios: [],
2041
+ projectAudiosLoading: false,
2042
+
2043
+ fetchProjectAudios: async (projectId) => {
2044
+ set({ projectAudiosLoading: true });
2045
+ try {
2046
+ const res = await fetch(`${API_BASE}/api/projects/${projectId}/audios`);
2047
+ if (!res.ok) throw new Error(await res.text());
2048
+ const audios: ProjectAudio[] = await res.json();
2049
+ const resolved = audios.map((a) => ({
2050
+ ...a,
2051
+ url: a.url.startsWith("http")
2052
+ ? a.url
2053
+ : `http://localhost:${(window as any).PORT}${a.url}`,
2054
+ }));
2055
+ set({ projectAudios: resolved, projectAudiosLoading: false });
2056
+ } catch {
2057
+ set({ projectAudiosLoading: false });
2058
+ }
2059
+ },
2060
+
2061
+ cancelGenerate: () => {
2062
+ if (generateAbortController) {
2063
+ generateAbortController.abort();
2064
+ generateAbortController = null;
2065
+ }
2066
+ // Immediately reset generating state so UI returns to ready
2067
+ set((s) => ({
2068
+ video: { ...s.video, generating: false },
2069
+ extend: { ...s.extend, generating: false },
2070
+ textToImage: { ...s.textToImage, generating: false },
2071
+ }));
2072
+ // Also kill the backend spawn process
2073
+ fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
2074
+ },
2075
+
2076
+ cancelBatch: () => {
2077
+ set({ batchCancelRequested: true });
2078
+ if (batchAbortController) {
2079
+ batchAbortController.abort();
2080
+ }
2081
+ // Immediately reset running state so UI returns to ready
2082
+ set({
2083
+ batchRunning: false,
2084
+ batchProgress: null,
2085
+ batchCancelRequested: false,
2086
+ });
2087
+ // Also kill the backend spawn process
2088
+ fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
2089
+ },
2090
+
2091
+ selectImage: (img) => {
2092
+ if (!img) {
2093
+ set({ selectedImage: null });
2094
+ return;
2095
+ }
2096
+ const fullUrl = img.url.startsWith("http")
2097
+ ? img.url
2098
+ : `http://localhost:${(window as any).PORT}${img.url}`;
2099
+ set({
2100
+ selectedImage: { ...img, url: fullUrl },
2101
+ uploadedImageUrl: fullUrl,
2102
+ uploadedImageFilename: img.filename,
2103
+ });
2104
+ },
2105
+
2106
+ // ---- Reset ----
2107
+ resetAll: () =>
2108
+ set({
2109
+ activeTab: "movieStudio",
2110
+ image: { ...initialImage },
2111
+ video: { ...initialVideo },
2112
+ extend: { ...initialExtend },
2113
+ fastImageEdit: { ...initialFastImageEdit },
2114
+ textToImage: { ...initialTextToImage },
2115
+ textToImageProjectId: null,
2116
+ textToImageHydrated: false,
2117
+ agent: { ...initialAgent },
2118
+ uploading: false,
2119
+ uploadError: null,
2120
+ uploadedImageUrl: null,
2121
+ uploadedImageFilename: null,
2122
+ uploadedImagePath: null,
2123
+ projectImages: [],
2124
+ characterSheets: [],
2125
+ characterSheetsLoading: false,
2126
+ selectedImage: null,
2127
+ projectVideos: [],
2128
+ projectVideosLoading: false,
2129
+ selectedVideo: null,
2130
+ projectAudios: [],
2131
+ projectAudiosLoading: false,
2132
+ csvRows: [],
2133
+ csvColumns: [],
2134
+ csvFilename: null,
2135
+ csvSelectedIndices: new Set(),
2136
+ batchRunning: false,
2137
+ batchProgress: null,
2138
+ batchCancelRequested: false,
2139
+ }),
2140
+ }));