@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,938 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useMovieStudioStore } from "../../stores/movieStudioStore";
3
+ import { useGenerationStore } from "../../stores/generationStore";
4
+ import { useProjectStore } from "../../stores/projectStore";
5
+ import { useQueueStore } from "../../stores/queueStore";
6
+ import { OnlyStartButton } from "./MlxVlmServerPanel";
7
+ import TaskQueuePanel from "./TaskQueuePanel";
8
+ import TerminalLogPanel from "./TerminalLogPanel";
9
+
10
+ interface Props {
11
+ projectId: string;
12
+ }
13
+
14
+ export default function MovieStudioTab({ projectId }: Props) {
15
+ const store = useMovieStudioStore();
16
+ const gen = useGenerationStore();
17
+ const queue = useQueueStore();
18
+ const { openFolder } = useProjectStore();
19
+ const model = gen.agent.model;
20
+ const [preview, setPreview] = useState<{
21
+ url: string;
22
+ filename: string;
23
+ type: "image" | "video";
24
+ } | null>(null);
25
+
26
+ // Hydrate the persisted idea for this project.
27
+ useEffect(() => {
28
+ store.hydrate(projectId);
29
+ // eslint-disable-next-line react-hooks/exhaustive-deps
30
+ }, [projectId]);
31
+
32
+ // Poll the generation queue for this project.
33
+ useEffect(() => {
34
+ queue.startStreaming(projectId);
35
+ return () => queue.stopStreaming();
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [projectId]);
38
+
39
+ // Reconcile the movie studio store with the latest queue task state.
40
+ // Prime once per project so already-finished tasks aren't re-applied.
41
+ const primedProjectRef = useRef<string | null>(null);
42
+ useEffect(() => {
43
+ if (primedProjectRef.current !== projectId) {
44
+ store.primeAppliedQueue(queue.tasks);
45
+ primedProjectRef.current = projectId;
46
+ }
47
+ for (const task of queue.tasks) store.applyQueueTask(task);
48
+ // eslint-disable-next-line react-hooks/exhaustive-deps
49
+ }, [queue.tasks, projectId]);
50
+
51
+ // List output-folder images so the tables can show generated thumbnails.
52
+ useEffect(() => {
53
+ gen.fetchProjectImages(projectId);
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ }, [
56
+ projectId,
57
+ store.assets.length,
58
+ store.sceneImages.length,
59
+ store.videos.length,
60
+ store.renderedScenes.length,
61
+ ]);
62
+
63
+ // Close the preview modal on Escape.
64
+ useEffect(() => {
65
+ if (!preview) return;
66
+ const onKey = (e: KeyboardEvent) => {
67
+ if (e.key === "Escape") setPreview(null);
68
+ };
69
+ window.addEventListener("keydown", onKey);
70
+ return () => window.removeEventListener("keydown", onKey);
71
+ }, [preview]);
72
+
73
+ // ========== SVG Icons ==========
74
+
75
+ const ClapperIcon = (
76
+ <svg
77
+ width="18"
78
+ height="18"
79
+ viewBox="0 0 24 24"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ strokeWidth="2"
83
+ strokeLinecap="round"
84
+ strokeLinejoin="round"
85
+ >
86
+ <path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" />
87
+ <path d="m6.2 5.3 3.1 3.9" />
88
+ <path d="m12.4 3.4 3.1 4" />
89
+ <path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
90
+ </svg>
91
+ );
92
+
93
+ const SparkleIcon = (
94
+ <svg
95
+ width="16"
96
+ height="16"
97
+ viewBox="0 0 24 24"
98
+ fill="none"
99
+ stroke="currentColor"
100
+ strokeWidth="2"
101
+ >
102
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
103
+ </svg>
104
+ );
105
+
106
+ const SpinnerIcon = (
107
+ <svg
108
+ className="animate-spin text-tiffany-600"
109
+ width="16"
110
+ height="16"
111
+ viewBox="0 0 24 24"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ strokeWidth="2"
115
+ >
116
+ <circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
117
+ <path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
118
+ </svg>
119
+ );
120
+
121
+ const RefreshIcon = (
122
+ <svg
123
+ width="12"
124
+ height="12"
125
+ viewBox="0 0 24 24"
126
+ fill="none"
127
+ stroke="currentColor"
128
+ strokeWidth="2"
129
+ strokeLinecap="round"
130
+ strokeLinejoin="round"
131
+ >
132
+ <polyline points="23 4 23 10 17 10" />
133
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
134
+ </svg>
135
+ );
136
+
137
+ const StopIcon = (
138
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
139
+ <rect x="5" y="5" width="14" height="14" rx="2" />
140
+ </svg>
141
+ );
142
+
143
+ const FolderIcon = (
144
+ <svg
145
+ width="16"
146
+ height="16"
147
+ viewBox="0 0 24 24"
148
+ fill="none"
149
+ stroke="currentColor"
150
+ strokeWidth="2"
151
+ strokeLinecap="round"
152
+ strokeLinejoin="round"
153
+ >
154
+ <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
155
+ </svg>
156
+ );
157
+
158
+ const CloseIcon = (
159
+ <svg
160
+ width="20"
161
+ height="20"
162
+ viewBox="0 0 24 24"
163
+ fill="none"
164
+ stroke="currentColor"
165
+ strokeWidth="2"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ >
169
+ <line x1="18" y1="6" x2="6" y2="18" />
170
+ <line x1="6" y1="6" x2="18" y2="18" />
171
+ </svg>
172
+ );
173
+
174
+ const TableHead = ({
175
+ columns,
176
+ }: {
177
+ columns: { key: string; label: string; className?: string }[];
178
+ }) => (
179
+ <thead className="bg-ink-50">
180
+ <tr className="border-b border-ink-200">
181
+ {columns.map((c) => (
182
+ <th
183
+ key={c.key}
184
+ className={`border border-ink-200 px-2 py-1.5 text-left font-semibold text-ink-700 whitespace-nowrap ${
185
+ c.className ?? ""
186
+ }`}
187
+ >
188
+ {c.label}
189
+ </th>
190
+ ))}
191
+ </tr>
192
+ </thead>
193
+ );
194
+
195
+ const PromptCell = ({ text }: { text: string }) => (
196
+ <span className="text-ink-700 whitespace-pre-wrap leading-relaxed">
197
+ {text}
198
+ </span>
199
+ );
200
+
201
+ const slugify = (v: string): string =>
202
+ (v || "").trim().replace(/[^a-zA-Z0-9_-]/g, "_");
203
+ const resolveUrl = (url?: string): string | null =>
204
+ url
205
+ ? url.startsWith("http")
206
+ ? url
207
+ : `http://localhost:${(window as any).PORT}${url}`
208
+ : null;
209
+ const imageUrlFor = (prefix: string, slug: string): string | null => {
210
+ const img = gen.projectImages.find(
211
+ (i) => i.filename === `${prefix}-${slugify(slug)}.png`,
212
+ );
213
+ return img ? resolveUrl(img.url) : null;
214
+ };
215
+
216
+ const openPreview = (
217
+ url: string,
218
+ filename: string,
219
+ type: "image" | "video",
220
+ ) => setPreview({ url, filename, type });
221
+
222
+ const Thumb = ({
223
+ url,
224
+ filename,
225
+ }: {
226
+ url: string | null;
227
+ filename?: string;
228
+ }) =>
229
+ url ? (
230
+ <img
231
+ src={url}
232
+ alt={filename || ""}
233
+ onClick={() => openPreview(url, filename || "", "image")}
234
+ className="w-full h-full object-cover rounded-lg border border-ink-200 cursor-zoom-in hover:border-tiffany-500 transition-colors"
235
+ />
236
+ ) : (
237
+ <span className="text-ink-300 text-xs">—</span>
238
+ );
239
+
240
+ return (
241
+ <div className="flex flex-col gap-7">
242
+ <div className="flex items-center gap-2">
243
+ <span className="text-tiffany-600">{ClapperIcon}</span>
244
+ <h2 className="text-base font-semibold text-ink-900">Movie Studio</h2>
245
+ <button
246
+ onClick={() => openFolder(projectId, "output")}
247
+ className="ml-auto flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:border-ink-300 hover:text-ink-900 transition-colors"
248
+ >
249
+ {FolderIcon}
250
+ Show Output Folder
251
+ </button>
252
+ </div>
253
+
254
+ <TaskQueuePanel projectId={projectId} />
255
+
256
+ <TerminalLogPanel />
257
+
258
+ {gen.agent.serverRunning && gen.agent.serverOnline ? (
259
+ <>
260
+ {/* ===== Idea box ===== */}
261
+ <div>
262
+ <label className="block text-xs font-semibold text-ink-700 uppercase tracking-wider mb-2">
263
+ Story Idea
264
+ </label>
265
+ <textarea
266
+ value={store.idea}
267
+ onChange={(e) => store.setIdea(e.target.value)}
268
+ placeholder="Describe your movie or story idea, e.g. A little lamb and a ninja become unlikely friends and open a photo studio together."
269
+ rows={5}
270
+ disabled={store.generating}
271
+ className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl text-ink-900 text-sm placeholder-ink-500/40 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30 transition-all resize-none disabled:opacity-50"
272
+ />
273
+ </div>
274
+
275
+ {/* ===== Submit ===== */}
276
+ {store.generating ? (
277
+ <div className="flex items-center gap-3">
278
+ <div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
279
+ {SpinnerIcon}
280
+ <span className="text-sm font-medium text-ink-700">
281
+ Generating production bible...
282
+ </span>
283
+ </div>
284
+ <button
285
+ onClick={() => store.stop()}
286
+ className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
287
+ >
288
+ {StopIcon}
289
+ Stop
290
+ </button>
291
+ </div>
292
+ ) : (
293
+ <button
294
+ onClick={() => store.generate(projectId, model)}
295
+ disabled={!store.idea.trim()}
296
+ className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 disabled:bg-ink-200 disabled:text-ink-500 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm hover:shadow-md disabled:shadow-none"
297
+ >
298
+ {SparkleIcon}
299
+ Generate
300
+ </button>
301
+ )}
302
+
303
+ {/* ===== Error ===== */}
304
+ {store.error && (
305
+ <div className="p-5 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-sm">
306
+ {store.error}
307
+ </div>
308
+ )}
309
+
310
+ {/* ===== Result tables ===== */}
311
+ {store.result && (
312
+ <>
313
+ {/* Characters */}
314
+ <div>
315
+ <h3 className="text-sm font-semibold text-ink-900 mb-2">
316
+ Characters
317
+ </h3>
318
+ {store.result.characters.length === 0 ? (
319
+ <p className="text-xs text-ink-500 italic py-3 border border-dashed border-ink-200 rounded-2xl text-center">
320
+ No characters found.
321
+ </p>
322
+ ) : (
323
+ <div className="overflow-x-auto rounded-xl border border-ink-200">
324
+ <table className="w-full border-collapse text-xs">
325
+ <TableHead
326
+ columns={[
327
+ { key: "slug", label: "Slug", className: "w-32" },
328
+ { key: "name", label: "Name", className: "w-40" },
329
+ { key: "image", label: "Image", className: "w-16" },
330
+ { key: "prompt", label: "Image Prompt" },
331
+ ]}
332
+ />
333
+ <tbody>
334
+ {store.result.characters.map((c) => (
335
+ <tr key={c.slug} className="border-b border-ink-200">
336
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
337
+ {c.slug}
338
+ </td>
339
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-medium text-ink-800">
340
+ {c.name}
341
+ </td>
342
+ <td className="border border-ink-200 px-2 py-1.5 align-middle">
343
+ <Thumb
344
+ url={imageUrlFor("character", c.slug)}
345
+ filename={c.slug}
346
+ />
347
+ </td>
348
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
349
+ <PromptCell text={c.imagePrompt} />
350
+ </td>
351
+ </tr>
352
+ ))}
353
+ </tbody>
354
+ </table>
355
+ </div>
356
+ )}
357
+ </div>
358
+
359
+ {/* Places */}
360
+ <div>
361
+ <h3 className="text-sm font-semibold text-ink-900 mb-2">
362
+ Places
363
+ </h3>
364
+ {store.result.places.length === 0 ? (
365
+ <p className="text-xs text-ink-500 italic py-3 border border-dashed border-ink-200 rounded-2xl text-center">
366
+ No places found.
367
+ </p>
368
+ ) : (
369
+ <div className="overflow-x-auto rounded-xl border border-ink-200">
370
+ <table className="w-full border-collapse text-xs">
371
+ <TableHead
372
+ columns={[
373
+ { key: "slug", label: "Slug", className: "w-32" },
374
+ { key: "name", label: "Name", className: "w-40" },
375
+ { key: "image", label: "Image", className: "w-16" },
376
+ { key: "prompt", label: "Image Prompt" },
377
+ ]}
378
+ />
379
+ <tbody>
380
+ {store.result.places.map((p) => (
381
+ <tr key={p.slug} className="border-b border-ink-200">
382
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
383
+ {p.slug}
384
+ </td>
385
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-medium text-ink-800">
386
+ {p.name}
387
+ </td>
388
+ <td className="border border-ink-200 px-2 py-1.5 align-middle">
389
+ <Thumb
390
+ url={imageUrlFor("place", p.slug)}
391
+ filename={p.slug}
392
+ />
393
+ </td>
394
+ <td className="border border-ink-200 px-2 py-1.5 align-top">
395
+ <PromptCell text={p.imagePrompt} />
396
+ </td>
397
+ </tr>
398
+ ))}
399
+ </tbody>
400
+ </table>
401
+ </div>
402
+ )}
403
+ </div>
404
+
405
+ {/* Scenes */}
406
+ <div>
407
+ <h3 className="text-sm font-semibold text-ink-900 mb-2">
408
+ Scenes
409
+ </h3>
410
+ {store.result.scenes.length === 0 ? (
411
+ <p className="text-xs text-ink-500 italic py-3 border border-dashed border-ink-200 rounded-2xl text-center">
412
+ No scenes found.
413
+ </p>
414
+ ) : (
415
+ <div className="overflow-x-auto rounded-xl border border-ink-200">
416
+ <table className="w-full border-collapse text-xs min-w-[1000px]">
417
+ <TableHead
418
+ columns={[
419
+ { key: "slug", label: "Slug", className: "w-24" },
420
+ {
421
+ key: "duration",
422
+ label: "Duration",
423
+ className: "w-16",
424
+ },
425
+ {
426
+ key: "description",
427
+ label: "Description",
428
+ className: "w-44",
429
+ },
430
+ {
431
+ key: "characters",
432
+ label: "Characters",
433
+ className: "w-28",
434
+ },
435
+ { key: "place", label: "Place", className: "w-24" },
436
+ { key: "image", label: "Image", className: "w-16" },
437
+ { key: "script", label: "Script", className: "w-60" },
438
+ {
439
+ key: "voiceover",
440
+ label: "Voice Over",
441
+ className: "w-60",
442
+ },
443
+ { key: "prompt", label: "Image Prompt" },
444
+ ]}
445
+ />
446
+ <tbody>
447
+ {store.result.scenes.map((s) => (
448
+ <tr key={s.slug} className="border-b border-ink-200">
449
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-tiffany-700">
450
+ {s.slug}
451
+ </td>
452
+ <td className="border border-ink-200 px-2 py-1.5 align-top text-ink-800 tabular-nums whitespace-nowrap">
453
+ {s.duration > 0 ? `${s.duration}s` : "—"}
454
+ </td>
455
+ <td className="border border-ink-200 px-2 py-1.5 align-top text-ink-800 min-w-[150px]">
456
+ {s.description}
457
+ </td>
458
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-ink-600 min-w-[150px]">
459
+ {s.characterSlugs.join(", ")}
460
+ </td>
461
+ <td className="border border-ink-200 px-2 py-1.5 align-top font-mono text-[11px] text-ink-600 min-w-[150px]">
462
+ {s.placeSlug}
463
+ </td>
464
+ <td className="border border-ink-200 px-2 py-1.5 align-middle min-w-[200px]">
465
+ <Thumb
466
+ url={imageUrlFor("scene", s.slug)}
467
+ filename={s.slug}
468
+ />
469
+ </td>
470
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
471
+ {s.scriptLines.length === 0 ? (
472
+ <span className="text-ink-400">—</span>
473
+ ) : (
474
+ <div className="flex flex-col gap-1">
475
+ {s.scriptLines.map((line, i) => (
476
+ <div key={i} className="text-ink-700">
477
+ <span className="font-mono text-[11px] font-medium text-tiffany-700">
478
+ {line.characterSlug}:
479
+ </span>{" "}
480
+ {line.line}
481
+ </div>
482
+ ))}
483
+ </div>
484
+ )}
485
+ </td>
486
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
487
+ {s.voiceOver ? (
488
+ <PromptCell text={s.voiceOver} />
489
+ ) : (
490
+ <span className="text-ink-400">—</span>
491
+ )}
492
+ </td>
493
+ <td className="border border-ink-200 px-2 py-1.5 align-top min-w-[300px]">
494
+ <PromptCell text={s.imagePrompt} />
495
+ </td>
496
+ </tr>
497
+ ))}
498
+ </tbody>
499
+ </table>
500
+ </div>
501
+ )}
502
+ </div>
503
+ </>
504
+ )}
505
+
506
+ {/* ===== Assets ===== */}
507
+ {store.result && (
508
+ <div className="flex flex-col gap-3">
509
+ <div className="flex items-center justify-between gap-3">
510
+ <h3 className="text-sm font-semibold text-ink-900">
511
+ Character &amp; Place Images
512
+ </h3>
513
+ {store.assetsRendering ? (
514
+ <div className="flex items-center gap-2">
515
+ <span className="flex items-center gap-1.5 text-xs text-tiffany-600">
516
+ {SpinnerIcon}
517
+ {store.assetStatus ?? "Rendering..."}
518
+ </span>
519
+ <button
520
+ onClick={() => store.stop()}
521
+ className="flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors"
522
+ >
523
+ {StopIcon}
524
+ Stop
525
+ </button>
526
+ </div>
527
+ ) : (
528
+ <button
529
+ onClick={() => store.renderAssets(projectId)}
530
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
531
+ >
532
+ {SparkleIcon}
533
+ Render Characters & Places Images
534
+ </button>
535
+ )}
536
+ </div>
537
+
538
+ {store.assetsError && (
539
+ <div className="p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
540
+ {store.assetsError}
541
+ </div>
542
+ )}
543
+
544
+ {store.assets.length > 0 && (
545
+ <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-3">
546
+ {store.assets.map((asset) => {
547
+ const key = `${asset.kind}:${asset.slug}`;
548
+ const isRegenerating = store.regenerating.includes(key);
549
+ const fullUrl = asset.url.startsWith("http")
550
+ ? asset.url
551
+ : `http://localhost:${(window as any).PORT}${asset.url}`;
552
+ const prompt =
553
+ asset.kind === "character"
554
+ ? (store.result?.characters.find(
555
+ (c) => c.slug === asset.slug,
556
+ )?.imagePrompt ?? "")
557
+ : (store.result?.places.find(
558
+ (p) => p.slug === asset.slug,
559
+ )?.imagePrompt ?? "");
560
+ return (
561
+ <div key={key} className="flex flex-col gap-1.5">
562
+ <div className="relative rounded-xl border border-ink-200 overflow-hidden">
563
+ <img
564
+ src={`${fullUrl}&t=${asset.updatedAt}`}
565
+ alt={asset.slug}
566
+ onClick={() =>
567
+ openPreview(fullUrl, asset.slug, "image")
568
+ }
569
+ className="aspect-square object-cover object-center w-full cursor-zoom-in"
570
+ />
571
+ <span className="absolute top-1 left-1 px-1.5 py-0.5 rounded bg-black/50 text-white text-[10px] font-medium uppercase">
572
+ {asset.kind}
573
+ </span>
574
+ </div>
575
+ <div className="flex items-center justify-between gap-1">
576
+ <span className="text-[11px] text-ink-600 truncate font-mono">
577
+ {asset.slug}
578
+ </span>
579
+ <button
580
+ onClick={() =>
581
+ store.regenerateAsset(
582
+ projectId,
583
+ asset.kind,
584
+ asset.slug,
585
+ prompt,
586
+ )
587
+ }
588
+ disabled={isRegenerating}
589
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-lg border border-ink-200 text-ink-600 hover:border-tiffany-400 hover:text-tiffany-600 transition-colors disabled:opacity-50"
590
+ >
591
+ {isRegenerating ? SpinnerIcon : RefreshIcon}
592
+ Regenerate
593
+ </button>
594
+ </div>
595
+ </div>
596
+ );
597
+ })}
598
+ </div>
599
+ )}
600
+ </div>
601
+ )}
602
+
603
+ {/* ===== Scene Images ===== */}
604
+ {store.result && (
605
+ <div className="flex flex-col gap-3">
606
+ <div className="flex items-center justify-between gap-3">
607
+ <h3 className="text-sm font-semibold text-ink-900">
608
+ Scene Images
609
+ </h3>
610
+ {store.sceneImagesRendering ? (
611
+ <span className="flex items-center gap-1.5 text-xs text-tiffany-600">
612
+ {SpinnerIcon}
613
+ {store.sceneImageStatus ?? "Rendering..."}
614
+ </span>
615
+ ) : (
616
+ <button
617
+ onClick={() => store.renderSceneImages(projectId)}
618
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
619
+ >
620
+ {SparkleIcon}
621
+ Render Scene Images
622
+ </button>
623
+ )}
624
+ </div>
625
+
626
+ {store.sceneImagesRendering &&
627
+ store.sceneImageProgress &&
628
+ store.sceneImageProgress.total > 0 && (
629
+ <div className="flex items-center gap-3 px-1">
630
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
631
+ <div
632
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
633
+ style={{
634
+ width: `${(store.sceneImageProgress.current / store.sceneImageProgress.total) * 100}%`,
635
+ }}
636
+ />
637
+ </div>
638
+ <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
639
+ {store.sceneImageProgress.current}/
640
+ {store.sceneImageProgress.total}
641
+ </span>
642
+ </div>
643
+ )}
644
+
645
+ {store.sceneImagesError && (
646
+ <div className="p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
647
+ {store.sceneImagesError}
648
+ </div>
649
+ )}
650
+
651
+ {store.sceneImages.length > 0 && (
652
+ <div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-3">
653
+ {store.sceneImages.map((img) => {
654
+ const isRegenerating =
655
+ store.regeneratingSceneImages.includes(img.slug);
656
+ const fullUrl = img.url.startsWith("http")
657
+ ? img.url
658
+ : `http://localhost:${(window as any).PORT}${img.url}`;
659
+ return (
660
+ <div key={img.slug} className="flex flex-col gap-1.5">
661
+ <div className="relative rounded-xl border border-ink-200 overflow-hidden">
662
+ <img
663
+ src={`${fullUrl}&t=${img.updatedAt}`}
664
+ alt={img.slug}
665
+ onClick={() =>
666
+ openPreview(fullUrl, img.slug, "image")
667
+ }
668
+ className="aspect-[9/16] object-cover object-center w-full cursor-zoom-in"
669
+ />
670
+ </div>
671
+ <div className="flex items-center justify-between gap-1">
672
+ <span className="text-[11px] font-mono text-ink-600 truncate">
673
+ {img.slug}
674
+ </span>
675
+ <button
676
+ onClick={() =>
677
+ store.regenerateSceneImage(projectId, img.slug)
678
+ }
679
+ disabled={isRegenerating}
680
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-lg border border-ink-200 text-ink-600 hover:border-tiffany-400 hover:text-tiffany-600 transition-colors disabled:opacity-50"
681
+ >
682
+ {isRegenerating ? SpinnerIcon : RefreshIcon}
683
+ Regenerate
684
+ </button>
685
+ </div>
686
+ </div>
687
+ );
688
+ })}
689
+ </div>
690
+ )}
691
+ </div>
692
+ )}
693
+
694
+ {/* ===== Videos ===== */}
695
+ {store.result && (
696
+ <div className="flex flex-col gap-3">
697
+ <div className="flex items-center justify-between gap-3">
698
+ <h3 className="text-sm font-semibold text-ink-900">Videos</h3>
699
+ {store.videosRendering ? (
700
+ <span className="flex items-center gap-1.5 text-xs text-tiffany-600">
701
+ {SpinnerIcon}
702
+ {store.videoStatus ?? "Rendering..."}
703
+ </span>
704
+ ) : (
705
+ <button
706
+ onClick={() => store.renderVideos(projectId)}
707
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
708
+ >
709
+ {SparkleIcon}
710
+ Render Videos
711
+ </button>
712
+ )}
713
+ </div>
714
+
715
+ {store.videosRendering &&
716
+ store.videoProgress &&
717
+ store.videoProgress.total > 0 && (
718
+ <div className="flex items-center gap-3 px-1">
719
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
720
+ <div
721
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
722
+ style={{
723
+ width: `${(store.videoProgress.current / store.videoProgress.total) * 100}%`,
724
+ }}
725
+ />
726
+ </div>
727
+ <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
728
+ {store.videoProgress.current}/{store.videoProgress.total}
729
+ </span>
730
+ </div>
731
+ )}
732
+
733
+ {store.videosError && (
734
+ <div className="p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
735
+ {store.videosError}
736
+ </div>
737
+ )}
738
+
739
+ {store.videos.length > 0 && (
740
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
741
+ {store.videos.map((video) => {
742
+ const isRegenerating = store.regeneratingVideos.includes(
743
+ video.slug,
744
+ );
745
+ const fullUrl = video.url.startsWith("http")
746
+ ? video.url
747
+ : `http://localhost:${(window as any).PORT}${video.url}`;
748
+ return (
749
+ <div
750
+ key={video.slug}
751
+ className="flex flex-col gap-1.5 border border-ink-200 rounded-xl p-2 bg-white"
752
+ >
753
+ <video
754
+ src={`${fullUrl}&t=${video.updatedAt}`}
755
+ controls
756
+ onClick={() =>
757
+ openPreview(fullUrl, video.slug, "video")
758
+ }
759
+ className="w-full rounded-lg border border-ink-200 cursor-pointer"
760
+ />
761
+ <div className="flex items-center justify-between gap-1">
762
+ <span className="text-[11px] font-mono text-ink-600 truncate">
763
+ {video.slug}
764
+ </span>
765
+ <button
766
+ onClick={() =>
767
+ store.regenerateVideo(projectId, video.slug)
768
+ }
769
+ disabled={isRegenerating}
770
+ className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium rounded-lg border border-ink-200 text-ink-600 hover:border-tiffany-400 hover:text-tiffany-600 transition-colors disabled:opacity-50"
771
+ >
772
+ {isRegenerating ? SpinnerIcon : RefreshIcon}
773
+ Regenerate
774
+ </button>
775
+ </div>
776
+ </div>
777
+ );
778
+ })}
779
+ </div>
780
+ )}
781
+ </div>
782
+ )}
783
+
784
+ {/* ===== Render ===== */}
785
+ {store.result && (
786
+ <div className="flex flex-col gap-3">
787
+ {store.rendering ? (
788
+ <div className="flex flex-col gap-2">
789
+ <div className="flex items-center gap-3">
790
+ <div className="flex items-center gap-2 flex-1 px-4 py-3 bg-ink-50 border border-ink-200 rounded-2xl">
791
+ {SpinnerIcon}
792
+ <span className="text-sm font-medium text-ink-700">
793
+ {store.renderStatus ?? "Rendering..."}
794
+ </span>
795
+ </div>
796
+ <button
797
+ onClick={() => store.stop()}
798
+ className="flex items-center justify-center gap-2 px-4 py-3 bg-red-500 hover:bg-red-600 active:bg-red-700 text-white text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm"
799
+ >
800
+ {StopIcon}
801
+ Stop
802
+ </button>
803
+ </div>
804
+ {store.renderProgress && store.renderProgress.total > 0 && (
805
+ <div className="flex items-center gap-3 px-1">
806
+ <div className="flex-1 h-2 bg-ink-200 rounded-full overflow-hidden">
807
+ <div
808
+ className="h-full bg-tiffany-500 rounded-full transition-all duration-300"
809
+ style={{
810
+ width: `${(store.renderProgress.current / store.renderProgress.total) * 100}%`,
811
+ }}
812
+ />
813
+ </div>
814
+ <span className="text-xs font-semibold text-ink-700 tabular-nums whitespace-nowrap">
815
+ {store.renderProgress.current}/
816
+ {store.renderProgress.total}
817
+ </span>
818
+ </div>
819
+ )}
820
+ </div>
821
+ ) : (
822
+ <button
823
+ onClick={() => store.render(projectId)}
824
+ className="flex items-center justify-center gap-2 w-full px-4 py-3 bg-tiffany-500 hover:bg-tiffany-600 active:bg-tiffany-700 text-ink-950 text-sm font-semibold rounded-2xl transition-all duration-150 shadow-sm hover:shadow-md"
825
+ >
826
+ {SparkleIcon}
827
+ Render Movie
828
+ </button>
829
+ )}
830
+
831
+ {store.renderError && (
832
+ <div className="p-4 bg-red-50 border border-red-200 rounded-2xl text-red-600 text-xs">
833
+ {store.renderError}
834
+ </div>
835
+ )}
836
+
837
+ {store.renderedScenes.length > 0 && (
838
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
839
+ {store.renderedScenes.map((scene) => {
840
+ const imgUrl = scene.imageUrl
841
+ ? scene.imageUrl.startsWith("http")
842
+ ? scene.imageUrl
843
+ : `http://localhost:${(window as any).PORT}${scene.imageUrl}`
844
+ : null;
845
+ const vidUrl = scene.videoUrl
846
+ ? scene.videoUrl.startsWith("http")
847
+ ? scene.videoUrl
848
+ : `http://localhost:${(window as any).PORT}${scene.videoUrl}`
849
+ : null;
850
+ return (
851
+ <div
852
+ key={scene.slug}
853
+ className="flex flex-col gap-1.5 border border-ink-200 rounded-xl p-2 bg-white"
854
+ >
855
+ <span className="text-[11px] font-mono text-ink-600 truncate">
856
+ {scene.slug}
857
+ </span>
858
+ {imgUrl && (
859
+ <img
860
+ src={imgUrl}
861
+ alt={scene.slug}
862
+ onClick={() =>
863
+ openPreview(imgUrl, scene.slug, "image")
864
+ }
865
+ className="w-full rounded-lg border border-ink-200 cursor-zoom-in"
866
+ />
867
+ )}
868
+ {vidUrl && (
869
+ <video
870
+ src={vidUrl}
871
+ controls
872
+ onClick={() =>
873
+ openPreview(vidUrl, scene.slug, "video")
874
+ }
875
+ className="w-full rounded-lg border border-ink-200 cursor-pointer"
876
+ />
877
+ )}
878
+ </div>
879
+ );
880
+ })}
881
+ </div>
882
+ )}
883
+ </div>
884
+ )}
885
+ </>
886
+ ) : (
887
+ <>
888
+ <OnlyStartButton></OnlyStartButton>
889
+ {/* <MlxVlmServerPanel /> */}
890
+ <div className="flex flex-col items-center justify-center gap-2 p-8 border border-dashed border-ink-200 rounded-2xl">
891
+ <span className="text-ink-500">{ClapperIcon}</span>
892
+ <p className="text-xs text-ink-500 italic">
893
+ Start the LLM server to begin planning your movie.
894
+ </p>
895
+ </div>
896
+ </>
897
+ )}
898
+
899
+ {/* ===== Preview modal ===== */}
900
+ {preview && (
901
+ <div
902
+ className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-8"
903
+ onClick={() => setPreview(null)}
904
+ >
905
+ <div
906
+ className="relative max-w-[90vw] max-h-[90vh] flex flex-col items-center gap-3"
907
+ onClick={(e) => e.stopPropagation()}
908
+ >
909
+ {preview.type === "video" ? (
910
+ <video
911
+ src={preview.url}
912
+ controls
913
+ autoPlay
914
+ className="max-w-full max-h-[80vh] rounded-2xl shadow-2xl"
915
+ />
916
+ ) : (
917
+ <img
918
+ src={preview.url}
919
+ alt={preview.filename}
920
+ className="max-w-full max-h-[80vh] object-contain rounded-2xl shadow-2xl"
921
+ />
922
+ )}
923
+ <span className="text-xs text-white/70 truncate max-w-full">
924
+ {preview.filename}
925
+ </span>
926
+ <button
927
+ onClick={() => setPreview(null)}
928
+ className="absolute -top-3 -right-3 flex items-center justify-center w-9 h-9 bg-white text-ink-700 rounded-full shadow-lg hover:bg-ink-200 transition-colors"
929
+ title="Close (Esc)"
930
+ >
931
+ {CloseIcon}
932
+ </button>
933
+ </div>
934
+ </div>
935
+ )}
936
+ </div>
937
+ );
938
+ }