@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.
- package/bin/effectnode-media +2 -0
- package/dist/backend/index.d.ts +8 -0
- package/dist/backend/index.js +13 -0
- package/dist/backend/movie-backend/agent/agent-backend.d.ts +16 -0
- package/dist/backend/movie-backend/agent/agent-backend.js +703 -0
- package/dist/backend/movie-backend/agent/storyprompt.d.ts +1 -0
- package/dist/backend/movie-backend/agent/storyprompt.js +90 -0
- package/dist/backend/movie-backend/agent/tools/edit-image.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/edit-image.js +192 -0
- package/dist/backend/movie-backend/agent/tools/get-time.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/get-time.js +7 -0
- package/dist/backend/movie-backend/agent/tools/grep-files.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/grep-files.js +59 -0
- package/dist/backend/movie-backend/agent/tools/image-to-video-generation.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/image-to-video-generation.js +160 -0
- package/dist/backend/movie-backend/agent/tools/index.d.ts +7 -0
- package/dist/backend/movie-backend/agent/tools/index.js +59 -0
- package/dist/backend/movie-backend/agent/tools/list-files.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/list-files.js +17 -0
- package/dist/backend/movie-backend/agent/tools/read-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/read-file.js +28 -0
- package/dist/backend/movie-backend/agent/tools/remove-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/remove-file.js +22 -0
- package/dist/backend/movie-backend/agent/tools/rename-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/rename-file.js +37 -0
- package/dist/backend/movie-backend/agent/tools/show-image.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/show-image.js +28 -0
- package/dist/backend/movie-backend/agent/tools/stitch-videos.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/stitch-videos.js +96 -0
- package/dist/backend/movie-backend/agent/tools/text-to-video-generation.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/text-to-video-generation.js +125 -0
- package/dist/backend/movie-backend/agent/tools/types.d.ts +15 -0
- package/dist/backend/movie-backend/agent/tools/types.js +1 -0
- package/dist/backend/movie-backend/agent/tools/update-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/update-file.js +27 -0
- package/dist/backend/movie-backend/agent/tools/write-file.d.ts +3 -0
- package/dist/backend/movie-backend/agent/tools/write-file.js +26 -0
- package/dist/backend/movie-backend/agent/workspace.d.ts +25 -0
- package/dist/backend/movie-backend/agent/workspace.js +124 -0
- package/dist/backend/movie-backend/core.d.ts +4 -0
- package/dist/backend/movie-backend/core.js +1167 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +24 -0
- package/dist/backend/movie-backend/generation-queue.js +766 -0
- package/dist/backend/movie-backend/process.d.ts +21 -0
- package/dist/backend/movie-backend/process.js +98 -0
- package/dist/backend/movie-backend/render-media.d.ts +48 -0
- package/dist/backend/movie-backend/render-media.js +3311 -0
- package/dist/backend/routes/health.d.ts +1 -0
- package/dist/backend/routes/health.js +9 -0
- package/dist/backend/routes/index.d.ts +1 -0
- package/dist/backend/routes/index.js +11 -0
- package/dist/backend/routes/media.d.ts +1 -0
- package/dist/backend/routes/media.js +13 -0
- package/dist/backend/routes/projects.d.ts +8 -0
- package/dist/backend/routes/projects.js +68 -0
- package/dist/backend/routes/voice.d.ts +1 -0
- package/dist/backend/routes/voice.js +39 -0
- package/dist/backend/routes.d.ts +1 -0
- package/dist/backend/routes.js +20 -0
- package/dist/backend/server.d.ts +1 -0
- package/dist/backend/server.js +23 -0
- package/dist/backend/workspace.d.ts +5 -0
- package/dist/backend/workspace.js +27 -0
- package/dist/backend/ws/index.d.ts +3 -0
- package/dist/backend/ws/index.js +27 -0
- package/dist/backend/ws/messages.d.ts +8 -0
- package/dist/backend/ws/messages.js +21 -0
- package/dist/backend/ws.d.ts +3 -0
- package/dist/backend/ws.js +29 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +80 -0
- package/frontend/index.html +22 -0
- package/frontend/public/ffmpeg/ffmpeg-core.js +16 -0
- package/frontend/public/ffmpeg/ffmpeg-core.wasm +0 -0
- package/frontend/src/index.css +46 -0
- package/frontend/src/main.tsx +13 -0
- package/frontend/src/movie-app/AppRouter.tsx +14 -0
- package/frontend/src/movie-app/MediaStudio.tsx +9 -0
- package/frontend/src/movie-app/SetupPage.tsx +293 -0
- package/frontend/src/movie-app/components/AgentWorkspace.tsx +574 -0
- package/frontend/src/movie-app/components/ChatUI/ChatUI.tsx +655 -0
- package/frontend/src/movie-app/components/ChatUI/MarkdownMessage.tsx +267 -0
- package/frontend/src/movie-app/components/EditorTabs/AgentTab.tsx +61 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchImageToVideoTab.tsx +927 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchVideoTab.tsx +777 -0
- package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +913 -0
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +234 -0
- package/frontend/src/movie-app/components/EditorTabs/CharactersTab.tsx +664 -0
- package/frontend/src/movie-app/components/EditorTabs/CropTool.tsx +251 -0
- package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +305 -0
- package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +249 -0
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +555 -0
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +722 -0
- package/frontend/src/movie-app/components/EditorTabs/LlmServerTab.tsx +40 -0
- package/frontend/src/movie-app/components/EditorTabs/MlxVlmServerPanel.tsx +490 -0
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +938 -0
- package/frontend/src/movie-app/components/EditorTabs/ReferencesToVideoTab.tsx +881 -0
- package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +267 -0
- package/frontend/src/movie-app/components/EditorTabs/StoryWriterTab.tsx +51 -0
- package/frontend/src/movie-app/components/EditorTabs/TaskQueuePanel.tsx +246 -0
- package/frontend/src/movie-app/components/EditorTabs/TerminalLogPanel.tsx +49 -0
- package/frontend/src/movie-app/components/EditorTabs/TextToImageTab.tsx +574 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceChatPanel.tsx +320 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +636 -0
- package/frontend/src/movie-app/components/ProjectManager.tsx +381 -0
- package/frontend/src/movie-app/index.css +46 -0
- package/frontend/src/movie-app/index.html +16 -0
- package/frontend/src/movie-app/lambobo.png +0 -0
- package/frontend/src/movie-app/lib/batchImageToVideoStorage.ts +69 -0
- package/frontend/src/movie-app/lib/batchVideoStorage.ts +68 -0
- package/frontend/src/movie-app/lib/batchVoiceStorage.ts +75 -0
- package/frontend/src/movie-app/lib/ffmpeg.ts +42 -0
- package/frontend/src/movie-app/lib/referencesToVideoStorage.ts +64 -0
- package/frontend/src/movie-app/lib/textToImageStorage.ts +61 -0
- package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.js +16 -0
- package/frontend/src/movie-app/public/ffmpeg/ffmpeg-core.wasm +0 -0
- package/frontend/src/movie-app/stores/batchImageToVideoStore.ts +736 -0
- package/frontend/src/movie-app/stores/batchVideoStore.ts +670 -0
- package/frontend/src/movie-app/stores/batchVoiceStore.ts +990 -0
- package/frontend/src/movie-app/stores/characterStore.ts +109 -0
- package/frontend/src/movie-app/stores/chatStore.ts +459 -0
- package/frontend/src/movie-app/stores/generationStore.ts +2140 -0
- package/frontend/src/movie-app/stores/logStore.ts +25 -0
- package/frontend/src/movie-app/stores/movieStudioStore.ts +709 -0
- package/frontend/src/movie-app/stores/projectStore.ts +124 -0
- package/frontend/src/movie-app/stores/queueStore.ts +214 -0
- package/frontend/src/movie-app/stores/referencesToVideoStore.ts +467 -0
- package/frontend/src/movie-app/stores/sceneVisualStore.ts +251 -0
- package/frontend/src/movie-app/stores/voiceChatStore.ts +170 -0
- package/frontend/src/movie-app/stores/workspaceStore.ts +158 -0
- package/frontend/src/vite-env.d.ts +1 -0
- package/frontend/tsconfig.json +19 -0
- 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 & 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
|
+
}
|