@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,467 @@
1
+ import { create } from "zustand";
2
+ import {
3
+ clearReferencesToVideoState,
4
+ loadReferencesToVideoState,
5
+ saveReferencesToVideoState,
6
+ type PersistedReferencesToVideoState,
7
+ } from "../lib/referencesToVideoStorage";
8
+
9
+ const API_BASE = `http://localhost:${(window as any).PORT}`;
10
+
11
+ // Abort controller for the in-flight generation request, so it can be cancelled.
12
+ let generateAbortController: AbortController | null = null;
13
+
14
+ export type ReferenceKind = "image" | "video" | "audio";
15
+
16
+ export interface ReferenceRef {
17
+ kind: ReferenceKind;
18
+ filename: string | null;
19
+ }
20
+
21
+ interface ReferencesToVideoStore {
22
+ // Model download
23
+ downloading: boolean;
24
+ downloaded: boolean;
25
+ error: string | null;
26
+ logs: string[];
27
+
28
+ // Generation parameters
29
+ prompt: string;
30
+ steps: number;
31
+ width: number;
32
+ height: number;
33
+ seconds: number;
34
+ seed: number;
35
+
36
+ // Reference media (ordered list of image/video references)
37
+ refs: ReferenceRef[];
38
+
39
+ // Generation state
40
+ generating: boolean;
41
+ result: string | null;
42
+ genError: string | null;
43
+ genLogs: string[];
44
+
45
+ // Persistence
46
+ projectId: string | null;
47
+ hydrated: boolean;
48
+ hydrate: (projectId: string) => Promise<void>;
49
+ clear: () => void;
50
+
51
+ checkStatus: () => Promise<void>;
52
+ downloadModel: () => Promise<void>;
53
+
54
+ setPrompt: (v: string) => void;
55
+ setSteps: (v: number) => void;
56
+ setWidth: (v: number) => void;
57
+ setHeight: (v: number) => void;
58
+ setSeconds: (v: number) => void;
59
+ setSeed: (v: number) => void;
60
+
61
+ addRef: (kind: ReferenceKind) => void;
62
+ removeRef: (index: number) => void;
63
+ setRefFilename: (index: number, filename: string) => void;
64
+ uploadVideo: (
65
+ base64: string,
66
+ filename: string,
67
+ projectId: string,
68
+ ) => Promise<string | null>;
69
+ uploadAudio: (
70
+ base64: string,
71
+ filename: string,
72
+ projectId: string,
73
+ ) => Promise<string | null>;
74
+
75
+ generate: (projectId: string) => Promise<void>;
76
+ cancelGenerate: () => void;
77
+
78
+ reset: () => void;
79
+ }
80
+
81
+ async function readSSEStream(
82
+ response: Response,
83
+ onEvent: (event: string, data: any) => void,
84
+ ): Promise<void> {
85
+ const reader = response.body?.getReader();
86
+ if (!reader) return;
87
+
88
+ const decoder = new TextDecoder();
89
+ let buffer = "";
90
+
91
+ try {
92
+ while (true) {
93
+ const { done, value } = await reader.read();
94
+ if (done) break;
95
+
96
+ buffer += decoder.decode(value, { stream: true });
97
+ const lines = buffer.split("\n");
98
+ buffer = lines.pop() || "";
99
+
100
+ let eventType = "message";
101
+ for (const line of lines) {
102
+ if (line.startsWith("event: ")) {
103
+ eventType = line.slice(7).trim();
104
+ } else if (line.startsWith("data: ")) {
105
+ try {
106
+ onEvent(eventType, JSON.parse(line.slice(6)));
107
+ } catch {
108
+ // skip malformed lines
109
+ }
110
+ eventType = "message";
111
+ }
112
+ }
113
+ }
114
+ } finally {
115
+ reader.releaseLock();
116
+ }
117
+ }
118
+
119
+ function makeDefaultRefs(): ReferenceRef[] {
120
+ return [
121
+ { kind: "image", filename: null },
122
+ { kind: "image", filename: null },
123
+ ];
124
+ }
125
+
126
+ function toPersistedState(
127
+ s: Pick<
128
+ ReferencesToVideoStore,
129
+ "prompt" | "steps" | "width" | "height" | "seconds" | "seed" | "refs"
130
+ >,
131
+ ): PersistedReferencesToVideoState {
132
+ return {
133
+ prompt: s.prompt,
134
+ steps: s.steps,
135
+ width: s.width,
136
+ height: s.height,
137
+ seconds: s.seconds,
138
+ seed: s.seed,
139
+ refs: s.refs.map((r) => ({ kind: r.kind, filename: r.filename })),
140
+ };
141
+ }
142
+
143
+ // Fire-and-forget save of the current editable UI state.
144
+ function persistReferencesState() {
145
+ const { projectId } = useReferencesToVideoStore.getState();
146
+ if (!projectId) return;
147
+ void saveReferencesToVideoState(
148
+ projectId,
149
+ toPersistedState(useReferencesToVideoStore.getState()),
150
+ );
151
+ }
152
+
153
+ export const useReferencesToVideoStore = create<ReferencesToVideoStore>(
154
+ (set, get) => ({
155
+ // Model download
156
+ downloading: false,
157
+ downloaded: false,
158
+ error: null,
159
+ logs: [],
160
+
161
+ // Generation parameters
162
+ prompt:
163
+ "with voice [audio1], [image1] say Ghost reporting , ready for combat.",
164
+ steps: 30,
165
+ width: 640,
166
+ height: 320,
167
+ seconds: 10,
168
+ seed: 42,
169
+
170
+ // Reference media
171
+ refs: [
172
+ { kind: "image", filename: null },
173
+ { kind: "image", filename: null },
174
+ ],
175
+
176
+ // Generation state
177
+ generating: false,
178
+ result: null,
179
+ genError: null,
180
+ genLogs: [],
181
+
182
+ projectId: null,
183
+ hydrated: false,
184
+
185
+ hydrate: async (projectId) => {
186
+ // No-op if we've already hydrated for this project.
187
+ if (get().hydrated && get().projectId === projectId) return;
188
+
189
+ // Switching to a different project: reset to defaults so refs and
190
+ // settings from the previous project don't leak through.
191
+ const previous = get().projectId;
192
+ if (previous !== null && previous !== projectId) {
193
+ get().reset();
194
+ }
195
+ set({ hydrated: true, projectId });
196
+
197
+ const stored = await loadReferencesToVideoState(projectId);
198
+ if (!stored) return;
199
+
200
+ set((s) => ({
201
+ prompt: stored.prompt ?? s.prompt,
202
+ steps: stored.steps ?? s.steps,
203
+ width: stored.width ?? s.width,
204
+ height: stored.height ?? s.height,
205
+ seconds: stored.seconds ?? s.seconds,
206
+ seed: stored.seed ?? s.seed,
207
+ refs: stored.refs?.length
208
+ ? stored.refs.map((r) => ({
209
+ kind: r.kind,
210
+ filename: r.filename ?? null,
211
+ }))
212
+ : s.refs,
213
+ }));
214
+ },
215
+
216
+ clear: () => {
217
+ const { projectId } = get();
218
+ get().reset();
219
+ if (projectId) void clearReferencesToVideoState(projectId);
220
+ },
221
+
222
+ checkStatus: async () => {
223
+ try {
224
+ const res = await fetch(`${API_BASE}/api/h3/status`);
225
+ if (!res.ok) return;
226
+ const data = await res.json();
227
+ set({ downloaded: Boolean(data.downloaded) });
228
+ } catch {
229
+ // Leave status unchanged if the check fails.
230
+ }
231
+ },
232
+
233
+ downloadModel: async () => {
234
+ if (get().downloading) return;
235
+
236
+ set({ downloading: true, error: null, logs: [] });
237
+
238
+ try {
239
+ const res = await fetch(`${API_BASE}/api/h3/download-model`, {
240
+ method: "POST",
241
+ });
242
+
243
+ if (!res.ok) {
244
+ const err = await res.text();
245
+ set({ downloading: false, error: err });
246
+ return;
247
+ }
248
+
249
+ await readSSEStream(res, (event, data) => {
250
+ switch (event) {
251
+ case "log":
252
+ set((s) => ({ logs: [...s.logs, data.text as string] }));
253
+ break;
254
+ case "complete":
255
+ set({ downloading: false, downloaded: true });
256
+ break;
257
+ case "error":
258
+ set({
259
+ downloading: false,
260
+ error: data.error || "Model download failed",
261
+ });
262
+ break;
263
+ }
264
+ });
265
+ } catch (e) {
266
+ set({ downloading: false, error: String(e) });
267
+ }
268
+ },
269
+
270
+ setPrompt: (prompt) => {
271
+ set({ prompt });
272
+ persistReferencesState();
273
+ },
274
+ setSteps: (steps) => {
275
+ set({ steps });
276
+ persistReferencesState();
277
+ },
278
+ setWidth: (width) => {
279
+ set({ width });
280
+ persistReferencesState();
281
+ },
282
+ setHeight: (height) => {
283
+ set({ height });
284
+ persistReferencesState();
285
+ },
286
+ setSeconds: (seconds) => {
287
+ set({ seconds });
288
+ persistReferencesState();
289
+ },
290
+ setSeed: (seed) => {
291
+ set({ seed });
292
+ persistReferencesState();
293
+ },
294
+
295
+ addRef: (kind) => {
296
+ set((s) => ({ refs: [...s.refs, { kind, filename: null }] }));
297
+ persistReferencesState();
298
+ },
299
+
300
+ removeRef: (index) => {
301
+ set((s) => ({ refs: s.refs.filter((_, i) => i !== index) }));
302
+ persistReferencesState();
303
+ },
304
+
305
+ setRefFilename: (index, filename) => {
306
+ set((s) => ({
307
+ refs: s.refs.map((r, i) => (i === index ? { ...r, filename } : r)),
308
+ }));
309
+ persistReferencesState();
310
+ },
311
+
312
+ uploadVideo: async (base64, filename, projectId) => {
313
+ try {
314
+ const res = await fetch(`${API_BASE}/api/upload/video`, {
315
+ method: "POST",
316
+ headers: { "Content-Type": "application/json" },
317
+ body: JSON.stringify({
318
+ video: base64,
319
+ filename: filename || `upload-${Date.now()}.mp4`,
320
+ projectId,
321
+ }),
322
+ });
323
+
324
+ if (!res.ok) {
325
+ const err = await res.text();
326
+ set({ genError: err });
327
+ return null;
328
+ }
329
+
330
+ const data = await res.json();
331
+ return data.filename as string;
332
+ } catch (e) {
333
+ set({ genError: String(e) });
334
+ return null;
335
+ }
336
+ },
337
+
338
+ uploadAudio: async (base64, filename, projectId) => {
339
+ try {
340
+ const res = await fetch(`${API_BASE}/api/upload/audio`, {
341
+ method: "POST",
342
+ headers: { "Content-Type": "application/json" },
343
+ body: JSON.stringify({
344
+ audio: base64,
345
+ filename: filename || `upload-${Date.now()}.mp3`,
346
+ projectId,
347
+ }),
348
+ });
349
+
350
+ if (!res.ok) {
351
+ const err = await res.text();
352
+ set({ genError: err });
353
+ return null;
354
+ }
355
+
356
+ const data = await res.json();
357
+ return data.filename as string;
358
+ } catch (e) {
359
+ set({ genError: String(e) });
360
+ return null;
361
+ }
362
+ },
363
+
364
+ generate: async (projectId) => {
365
+ if (get().generating) return;
366
+
367
+ const { prompt, steps, width, height, seconds, seed, refs } = get();
368
+
369
+ const resolvedRefs = refs
370
+ .filter(
371
+ (r): r is { kind: ReferenceKind; filename: string } =>
372
+ !!r.filename && !!r.filename.trim(),
373
+ )
374
+ .map((r) => ({ kind: r.kind, filename: r.filename.trim() }));
375
+
376
+ // Frame count derived from duration at 24fps (+1 keyframe).
377
+ const frames = Math.max(1, Math.round(24 * seconds + 1));
378
+
379
+ // Fresh AbortController so this run can be cancelled independently.
380
+ generateAbortController = new AbortController();
381
+ const signal = generateAbortController.signal;
382
+
383
+ set({ generating: true, genError: null, genLogs: [], result: null });
384
+
385
+ try {
386
+ const res = await fetch(`${API_BASE}/api/h3/generate`, {
387
+ method: "POST",
388
+ headers: { "Content-Type": "application/json" },
389
+ body: JSON.stringify({
390
+ prompt: prompt.trim(),
391
+ refs: resolvedRefs,
392
+ projectId,
393
+ steps,
394
+ width,
395
+ height,
396
+ frames,
397
+ seed,
398
+ }),
399
+ signal,
400
+ });
401
+
402
+ if (!res.ok) {
403
+ const err = await res.text();
404
+ set({ generating: false, genError: err });
405
+ return;
406
+ }
407
+
408
+ await readSSEStream(res, (event, data) => {
409
+ switch (event) {
410
+ case "log":
411
+ set((s) => ({ genLogs: [...s.genLogs, data.text as string] }));
412
+ break;
413
+ case "complete":
414
+ set({
415
+ generating: false,
416
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
417
+ });
418
+ break;
419
+ case "error":
420
+ set({
421
+ generating: false,
422
+ genError: data.error || "Generation failed",
423
+ });
424
+ break;
425
+ }
426
+ });
427
+ } catch (e: any) {
428
+ // If the request was aborted, just stop silently (cancel already reset state).
429
+ if (e?.name !== "AbortError") {
430
+ set({ generating: false, genError: String(e) });
431
+ } else {
432
+ set({ generating: false });
433
+ }
434
+ } finally {
435
+ generateAbortController = null;
436
+ }
437
+ },
438
+
439
+ cancelGenerate: () => {
440
+ if (generateAbortController) {
441
+ generateAbortController.abort();
442
+ generateAbortController = null;
443
+ }
444
+ // Immediately reset generating state so the UI returns to ready.
445
+ set({ generating: false });
446
+ // Also kill the backend spawn process.
447
+ fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(
448
+ () => {},
449
+ );
450
+ },
451
+
452
+ reset: () =>
453
+ set({
454
+ prompt: "[image1] is dancing at [image2]",
455
+ steps: 10,
456
+ width: 320,
457
+ height: 320,
458
+ seconds: 3,
459
+ seed: 42,
460
+ refs: makeDefaultRefs(),
461
+ generating: false,
462
+ result: null,
463
+ genError: null,
464
+ genLogs: [],
465
+ }),
466
+ }),
467
+ );
@@ -0,0 +1,251 @@
1
+ import { create } from "zustand";
2
+
3
+ const API_BASE = `http://localhost:${(window as any).PORT}`;
4
+
5
+ export interface SceneVisualItem {
6
+ id: string;
7
+ prompt: string;
8
+ generating: boolean;
9
+ uploading: boolean;
10
+ result: string | null;
11
+ error: string | null;
12
+ logs: string[];
13
+ }
14
+
15
+ interface SceneVisualStore {
16
+ projectId: string | null;
17
+ items: SceneVisualItem[];
18
+ ensureProject: (projectId: string) => void;
19
+ addItem: () => void;
20
+ removeItem: (id: string) => void;
21
+ setPrompt: (id: string, prompt: string) => void;
22
+ generateItem: (projectId: string, id: string) => Promise<void>;
23
+ uploadItemImage: (
24
+ projectId: string,
25
+ id: string,
26
+ base64: string,
27
+ filename: string,
28
+ ) => Promise<void>;
29
+ haltAll: () => void;
30
+ }
31
+
32
+ function makeId(): string {
33
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
34
+ }
35
+
36
+ async function readSSEStream(
37
+ response: Response,
38
+ onEvent: (event: string, data: any) => void,
39
+ ): Promise<void> {
40
+ const reader = response.body?.getReader();
41
+ if (!reader) return;
42
+
43
+ const decoder = new TextDecoder();
44
+ let buffer = "";
45
+
46
+ try {
47
+ while (true) {
48
+ const { done, value } = await reader.read();
49
+ if (done) break;
50
+
51
+ buffer += decoder.decode(value, { stream: true });
52
+ const lines = buffer.split("\n");
53
+ buffer = lines.pop() || "";
54
+
55
+ let eventType = "message";
56
+ for (const line of lines) {
57
+ if (line.startsWith("event: ")) {
58
+ eventType = line.slice(7).trim();
59
+ } else if (line.startsWith("data: ")) {
60
+ try {
61
+ onEvent(eventType, JSON.parse(line.slice(6)));
62
+ } catch {
63
+ // skip malformed lines
64
+ }
65
+ eventType = "message";
66
+ }
67
+ }
68
+ }
69
+ } finally {
70
+ reader.releaseLock();
71
+ }
72
+ }
73
+
74
+ export const useSceneVisualStore = create<SceneVisualStore>((set, get) => ({
75
+ projectId: null,
76
+ items: [],
77
+
78
+ ensureProject: (projectId) => {
79
+ if (get().projectId !== projectId) {
80
+ set({ projectId, items: [] });
81
+ }
82
+ },
83
+
84
+ addItem: () =>
85
+ set((s) => ({
86
+ items: [
87
+ ...s.items,
88
+ {
89
+ id: makeId(),
90
+ prompt: "",
91
+ generating: false,
92
+ uploading: false,
93
+ result: null,
94
+ error: null,
95
+ logs: [],
96
+ },
97
+ ],
98
+ })),
99
+
100
+ removeItem: (id) =>
101
+ set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
102
+
103
+ setPrompt: (id, prompt) =>
104
+ set((s) => ({
105
+ items: s.items.map((i) =>
106
+ i.id === id ? { ...i, prompt, error: null } : i,
107
+ ),
108
+ })),
109
+
110
+ generateItem: async (projectId, id) => {
111
+ const item = get().items.find((i) => i.id === id);
112
+ if (!item || item.generating || !item.prompt.trim()) return;
113
+
114
+ set((s) => ({
115
+ items: s.items.map((i) =>
116
+ i.id === id
117
+ ? { ...i, generating: true, error: null, result: null, logs: [] }
118
+ : i,
119
+ ),
120
+ }));
121
+
122
+ try {
123
+ const res = await fetch(`${API_BASE}/api/render/text-to-image`, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/json" },
126
+ body: JSON.stringify({
127
+ prompt: item.prompt.trim(),
128
+ projectId,
129
+ aspect: "1:1",
130
+ width: 1080,
131
+ height: 1080,
132
+ device: "mps",
133
+ }),
134
+ });
135
+
136
+ if (!res.ok) {
137
+ const err = await res.text();
138
+ set((s) => ({
139
+ items: s.items.map((i) =>
140
+ i.id === id ? { ...i, generating: false, error: err } : i,
141
+ ),
142
+ }));
143
+ return;
144
+ }
145
+
146
+ await readSSEStream(res, (event, data) => {
147
+ switch (event) {
148
+ case "log":
149
+ set((s) => ({
150
+ items: s.items.map((i) =>
151
+ i.id === id
152
+ ? { ...i, logs: [...i.logs, data.text as string] }
153
+ : i,
154
+ ),
155
+ }));
156
+ break;
157
+ case "complete":
158
+ set((s) => ({
159
+ items: s.items.map((i) =>
160
+ i.id === id
161
+ ? {
162
+ ...i,
163
+ generating: false,
164
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
165
+ }
166
+ : i,
167
+ ),
168
+ }));
169
+ break;
170
+ case "error":
171
+ set((s) => ({
172
+ items: s.items.map((i) =>
173
+ i.id === id
174
+ ? {
175
+ ...i,
176
+ generating: false,
177
+ error: data.error || "Image generation failed",
178
+ }
179
+ : i,
180
+ ),
181
+ }));
182
+ break;
183
+ }
184
+ });
185
+ } catch (e) {
186
+ set((s) => ({
187
+ items: s.items.map((i) =>
188
+ i.id === id ? { ...i, generating: false, error: String(e) } : i,
189
+ ),
190
+ }));
191
+ }
192
+ },
193
+
194
+ uploadItemImage: async (projectId, id, base64, filename) => {
195
+ set((s) => ({
196
+ items: s.items.map((i) =>
197
+ i.id === id ? { ...i, uploading: true, error: null } : i,
198
+ ),
199
+ }));
200
+
201
+ try {
202
+ const res = await fetch(`${API_BASE}/api/upload/image`, {
203
+ method: "POST",
204
+ headers: { "Content-Type": "application/json" },
205
+ body: JSON.stringify({
206
+ image: base64,
207
+ filename: filename || `scene-${Date.now()}.png`,
208
+ projectId,
209
+ }),
210
+ });
211
+
212
+ if (!res.ok) {
213
+ const err = await res.text();
214
+ set((s) => ({
215
+ items: s.items.map((i) =>
216
+ i.id === id ? { ...i, uploading: false, error: err } : i,
217
+ ),
218
+ }));
219
+ return;
220
+ }
221
+
222
+ const data = await res.json();
223
+ set((s) => ({
224
+ items: s.items.map((i) =>
225
+ i.id === id
226
+ ? {
227
+ ...i,
228
+ uploading: false,
229
+ result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
230
+ }
231
+ : i,
232
+ ),
233
+ }));
234
+ } catch (e) {
235
+ set((s) => ({
236
+ items: s.items.map((i) =>
237
+ i.id === id ? { ...i, uploading: false, error: String(e) } : i,
238
+ ),
239
+ }));
240
+ }
241
+ },
242
+
243
+ haltAll: () => {
244
+ set((s) => ({
245
+ items: s.items.map((i) =>
246
+ i.generating ? { ...i, generating: false } : i,
247
+ ),
248
+ }));
249
+ fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
250
+ },
251
+ }));