@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,703 @@
1
+ import { existsSync, readFileSync, writeFileSync, renameSync, rmSync, } from "node:fs";
2
+ import { join, dirname } from "node:path";
3
+ import { spawn } from "../process";
4
+ import OpenAI from "openai";
5
+ import { getAgentServerPort } from "../render-media";
6
+ import { workspaceDir, resolveWorkspacePath, walkFiles, classifyFile, ensureDir, movieStudioDataDir, movieStudioStateFile, } from "./workspace";
7
+ import { TOOLS, toolDefinitions, runTool } from "./tools";
8
+ // Node.js cannot `import` a bare `.txt` file (Bun allowed `with { type: "txt" }`).
9
+ // Read the bundled prompt as UTF-8 text at module load instead.
10
+ const story = readFileSync(new URL("./prompt/story-writer.txt", import.meta.url), "utf-8");
11
+ // ========== Constants ==========
12
+ const DEFAULT_MODEL = "mlx-community/gemma-4-e4b-it-8bit";
13
+ const MAX_ITERATIONS = 500;
14
+ const STORY_WRITER_AGENT = "story-writer";
15
+ /** Only accept inline base64 image data URLs (SSRF guard). */
16
+ function isValidImageDataUrl(url) {
17
+ return /^data:image\/[a-z0-9.+-]+;base64,/i.test(url);
18
+ }
19
+ /** Convert internal messages into OpenAI-compatible message params. */
20
+ function toOpenAIMessages(messages) {
21
+ return messages.map((m) => {
22
+ if (m.role === "tool") {
23
+ return {
24
+ role: "tool",
25
+ tool_call_id: m.tool_call_id,
26
+ content: m.content ?? "",
27
+ };
28
+ }
29
+ if (m.role === "assistant" && m.tool_calls) {
30
+ return {
31
+ role: "assistant",
32
+ content: m.content,
33
+ tool_calls: m.tool_calls,
34
+ };
35
+ }
36
+ if (m.role === "user" && m.image) {
37
+ const parts = [];
38
+ if (m.content && m.content.trim()) {
39
+ parts.push({ type: "text", text: m.content });
40
+ }
41
+ parts.push({ type: "image_url", image_url: { url: m.image } });
42
+ return { role: "user", content: parts };
43
+ }
44
+ return { role: m.role, content: m.content ?? "" };
45
+ });
46
+ }
47
+ /** Build the system prompt, listing every tool and its description. */
48
+ function buildSystemPrompt() {
49
+ const toolList = TOOLS.map((t) => `- ${t.name}: ${t.description}`).join("\n");
50
+ return [
51
+ "You are a media assistant that helps the user edit images or render videos in their workspace.",
52
+ "",
53
+ "Available tools:",
54
+ toolList,
55
+ "",
56
+ "Rules:",
57
+ "- Call a tool only when you actually need its result. Once you have enough information, answer the user directly WITHOUT calling any tool.",
58
+ "- Do not call the same tool twice with the same arguments.",
59
+ "- ALWAYS confirm with the user before generating or editing. Never call `image_to_video_generation`, `text_to_video_generation`, or `edit_image` until the user has explicitly said yes.",
60
+ "",
61
+ "## Getting started",
62
+ "",
63
+ 'When the user greets you (e.g. says "hi" or "hello") or asks for help, FIRST ask them what they want to do:',
64
+ "1. Edit an image",
65
+ "2. Render a video from text",
66
+ "3. Render a video from an image",
67
+ "",
68
+ "Then follow the matching workflow below.",
69
+ "",
70
+ "## Workflow: Edit an image",
71
+ "",
72
+ "### Step 1 — Image",
73
+ "- Call `list_files` to see what is in the agent workspace.",
74
+ '- List every image file (files whose `kind` is "image") to the user as a numbered list so they can pick one.',
75
+ "- You may call `show_image` to preview an image before the user decides.",
76
+ "- If there are no images, tell the user and ask them to upload one first.",
77
+ "",
78
+ "### Step 2 — Edit instruction",
79
+ "- Plan the edit together with the user. Ask what they want to change: background, style, color, lighting, expression, object, and so on.",
80
+ "- Confirm the wording of the edit prompt before moving on.",
81
+ "",
82
+ "### Step 3 — Summary and confirmation",
83
+ "- Prepare a short summary containing the image and the edit instruction, and ask the user to confirm.",
84
+ "- Do NOT start editing yet. Wait for the user to say yes.",
85
+ "- Only after the user confirms, call `edit_image` with the image and prompt.",
86
+ "",
87
+ "## Workflow: Render a video",
88
+ "",
89
+ "To generate a video you need a motion PROMPT and a DURATION. An IMAGE is optional but recommended.",
90
+ "",
91
+ "### Step 1 — Image (optional)",
92
+ "- Ask the user whether they want to animate an image or generate a video from text alone.",
93
+ "- If they want an image, call `list_files` to see what is in the agent workspace.",
94
+ '- List every image file (files whose `kind` is "image") to the user as a numbered list so they can pick one.',
95
+ "- You may call `show_image` to preview an image before the user decides.",
96
+ "- If there are no images, tell the user and ask them to upload one, or offer to proceed with text only.",
97
+ "",
98
+ "### Step 2 — Prompt",
99
+ "- Plan the prompt together with the user. Ask what they want the video to show: the subject, the action or motion, camera movement, style, lighting, and mood.",
100
+ "- Ask the user to specify the character's NAME, its EMOTION, and WHAT IT WILL SAY in the video.",
101
+ "- Discuss and refine the prompt with them until it clearly describes the desired animation. Confirm the wording before moving on.",
102
+ "",
103
+ "### Step 3 — Duration",
104
+ "- Estimate a sensible duration in seconds based on the complexity of the prompt, then add 1–2 seconds to your estimate.",
105
+ "- The duration must never exceed 20 seconds.",
106
+ "- Propose the duration to the user and let them accept it or give a different duration (still capped at 20 seconds).",
107
+ "",
108
+ "### Step 4 — Mode",
109
+ "- Ask the user which generation mode they want and explain the tradeoff:",
110
+ " - `distilled` (default): fastest, lower quality.",
111
+ " - `one-stage`: balanced speed and quality.",
112
+ " - `two-stage`: slowest, highest quality.",
113
+ "- If the user has no preference, use `distilled`.",
114
+ "",
115
+ "### Step 5 — Summary and confirmation",
116
+ "- Prepare a short summary containing the image (if any), the final prompt, the duration, and the mode, and ask the user to confirm.",
117
+ "- Do NOT start generation yet. Wait for the user to say yes.",
118
+ "- After the user confirms:",
119
+ " - If an image was chosen, call `image_to_video_generation` with the image, prompt, duration, and mode.",
120
+ " - Otherwise, call `text_to_video_generation` with the prompt, duration, and mode.",
121
+ ].join("\n");
122
+ }
123
+ /** Normalize a client-supplied agent id to a safe storage key (defaults to "default"). */
124
+ function safeAgent(value) {
125
+ const s = typeof value === "string" ? value : "";
126
+ return /^[a-zA-Z0-9_-]{1,32}$/.test(s) ? s : "default";
127
+ }
128
+ /** Load the Story Writer system prompt from the bundled `prompt/` file. */
129
+ function storyWriterSystemPrompt() {
130
+ return `${story}`;
131
+ }
132
+ /** Build a sanitized trace of the conversation for the client (no secrets/system). */
133
+ function sanitizeTrace(messages) {
134
+ return messages
135
+ .filter((m) => m.role !== "system")
136
+ .map((m) => {
137
+ if (m.role === "tool") {
138
+ const text = typeof m.content === "string" ? m.content : "";
139
+ return {
140
+ role: "tool",
141
+ name: m.name ?? "",
142
+ content: text.length > 500 ? `${text.slice(0, 500)}…` : text,
143
+ };
144
+ }
145
+ const out = { role: m.role, content: m.content ?? "" };
146
+ if (m.tool_calls)
147
+ out.tool_calls = m.tool_calls;
148
+ if (m.image)
149
+ out.image = "(image)";
150
+ return out;
151
+ });
152
+ }
153
+ // ========== Movie Studio ==========
154
+ const MOVIE_STUDIO_CHARACTERS_PROMPT = [
155
+ "You are a movie pre-production planner. Given a movie or story idea, identify the characters.",
156
+ "",
157
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
158
+ "",
159
+ '{"artStyle":"string","characters":[{"slug":"string","name":"string","imagePrompt":"string"}]}',
160
+ "",
161
+ "Rules:",
162
+ '- "slug" is a short lowercase hyphenated identifier (e.g. "the-lamb").',
163
+ '- "artStyle" is ONE consistent art style for the entire film (e.g. cinematic photorealistic, 3D animation, hand-painted watercolor, anime).',
164
+ '- If the idea does not mention an art style, set "artStyle" to "photo realistic render".',
165
+ "- A character's imagePrompt is a standalone text-to-image prompt that begins with the character's name and fully describes their appearance (age, face, build, outfit, distinctive features) together with their cultural background — the era/age, culture, ethnicity, and region the character belongs to — so the generated image is historically and culturally accurate and consistent.",
166
+ "- Apply the same artStyle to every character imagePrompt.",
167
+ "- Write prompts as natural-language English sentences, never comma-separated keyword tags.",
168
+ ].join("\n");
169
+ const MOVIE_STUDIO_PLACES_PROMPT = [
170
+ "You are a movie pre-production planner. Given a movie idea, its art style, and its characters, identify the places/locations.",
171
+ "",
172
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
173
+ "",
174
+ '{"places":[{"slug":"string","name":"string","imagePrompt":"string"}]}',
175
+ "",
176
+ "Rules:",
177
+ '- "slug" is a short lowercase hyphenated identifier (e.g. "sunny-meadow").',
178
+ "- A place's imagePrompt is a standalone text-to-image prompt that fully describes the location/environment (time of day, lighting, atmosphere, visual style).",
179
+ "- Apply the given artStyle to every place imagePrompt so it matches the characters.",
180
+ "- Write prompts as natural-language English sentences, never comma-separated keyword tags.",
181
+ ].join("\n");
182
+ const MOVIE_STUDIO_SCENES_PROMPT = [
183
+ "You are a movie pre-production planner. Given a movie idea, its art style, its characters, and its places, break the story into scenes.",
184
+ "",
185
+ "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
186
+ "",
187
+ '{"scenes":[{"slug":"string","duration":number,"description":"string","characterSlugs":["string"],"placeSlug":"string","scriptLines":[{"characterSlug":"string","line":"string"}],"voiceOver":"string","imagePrompt":"string"}]}',
188
+ "",
189
+ "Rules:",
190
+ "- Break the story into many small scenes: each scene is a single camera moment with one action beat.",
191
+ "- Generate as many scenes as possible: maximize the scene count. Every distinct beat, camera change, or exchange of dialogue should become its own scene — when in doubt, split into more scenes rather than fewer.",
192
+ "- Never reduce or summarize the dialogue: distribute every single spoken line across the resulting small scenes so no line is lost.",
193
+ "- Preserve as much detail as possible in every description and imagePrompt.",
194
+ "- Each scene references the characters and the place involved via their slugs (characterSlugs is a list; placeSlug is a single slug).",
195
+ "- A scene's imagePrompt is ONE coherent shot that combines the referenced characters AND the place together, using the given artStyle.",
196
+ "- scriptLines is one entry per spoken line, each with the speaking character's slug and the exact spoken line. Include every single line of dialogue in the scene.",
197
+ "- voiceOver is the narration/voiceover for that scene (use an empty string when there is none).",
198
+ "- Each scene has a duration in seconds (typically 3-20) matching how long the shot should last.",
199
+ "- Apply the same artStyle to every scene imagePrompt.",
200
+ "- Write prompts and dialogue as natural-language English sentences, never comma-separated keyword tags.",
201
+ ].join("\n");
202
+ /** Extract the first JSON object from a model response (handles code fences). */
203
+ function extractJsonObject(text) {
204
+ let t = text.trim();
205
+ const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/i);
206
+ if (fence)
207
+ t = fence[1].trim();
208
+ const start = t.indexOf("{");
209
+ const end = t.lastIndexOf("}");
210
+ if (start === -1 || end === -1 || end <= start) {
211
+ throw new Error("Model did not return valid JSON");
212
+ }
213
+ return JSON.parse(t.slice(start, end + 1));
214
+ }
215
+ /**
216
+ * Generate the movie production bible (characters, places, scenes) from an idea.
217
+ * Persists the result to studio/:projectId/data/*.json and returns the parsed
218
+ * result. Used both by the HTTP route below and by the generation queue worker.
219
+ */
220
+ export async function generateMovieStudioBible(projectId, idea, model) {
221
+ const resolvedModel = typeof model === "string" && model.trim() ? model.trim() : DEFAULT_MODEL;
222
+ const client = new OpenAI({
223
+ baseURL: "http://localhost:8881/v1",
224
+ apiKey: "local",
225
+ });
226
+ const toStr = (v) => (typeof v === "string" ? v : "");
227
+ const toNum = (v) => {
228
+ const n = typeof v === "number" ? v : Number(v);
229
+ return Number.isFinite(n) ? n : 0;
230
+ };
231
+ const ask = async (systemPrompt, userContent) => {
232
+ const completion = await client.chat.completions.create({
233
+ model: resolvedModel,
234
+ messages: [
235
+ { role: "system", content: systemPrompt },
236
+ { role: "user", content: userContent },
237
+ ],
238
+ temperature: 0.7,
239
+ });
240
+ const text = completion.choices?.[0]?.message?.content ?? "";
241
+ return extractJsonObject(text);
242
+ };
243
+ // 1. Characters + art style.
244
+ const charData = await ask(MOVIE_STUDIO_CHARACTERS_PROMPT, idea.trim());
245
+ const artStyle = toStr(charData?.artStyle);
246
+ const characters = (Array.isArray(charData.characters) ? charData.characters : []).map((c) => ({
247
+ slug: toStr(c?.slug),
248
+ name: toStr(c?.name),
249
+ imagePrompt: toStr(c?.imagePrompt),
250
+ }));
251
+ // 2. Places (with art style + characters as context).
252
+ const placeData = await ask(MOVIE_STUDIO_PLACES_PROMPT, `Art style: ${artStyle || "photo realistic render"}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters)}`);
253
+ const places = (Array.isArray(placeData.places) ? placeData.places : []).map((p) => ({
254
+ slug: toStr(p?.slug),
255
+ name: toStr(p?.name),
256
+ imagePrompt: toStr(p?.imagePrompt),
257
+ }));
258
+ // 3. Scenes (with art style + characters + places as context).
259
+ const sceneData = await ask(MOVIE_STUDIO_SCENES_PROMPT, `Art style: ${artStyle || "photo realistic render"}\n\nIdea: ${idea.trim()}\n\nCharacters:\n${JSON.stringify(characters)}\n\nPlaces:\n${JSON.stringify(places)}`);
260
+ const scenes = (Array.isArray(sceneData.scenes) ? sceneData.scenes : []).map((s) => ({
261
+ slug: toStr(s?.slug),
262
+ duration: toNum(s?.duration),
263
+ description: toStr(s?.description),
264
+ characterSlugs: Array.isArray(s?.characterSlugs)
265
+ ? s.characterSlugs.map(toStr).filter(Boolean)
266
+ : [],
267
+ placeSlug: toStr(s?.placeSlug),
268
+ scriptLines: Array.isArray(s?.scriptLines)
269
+ ? s.scriptLines.map((l) => ({
270
+ characterSlug: toStr(l?.characterSlug),
271
+ line: toStr(l?.line),
272
+ }))
273
+ : [],
274
+ voiceOver: toStr(s?.voiceOver),
275
+ imagePrompt: toStr(s?.imagePrompt),
276
+ }));
277
+ // Persist the generated production bible to studio/:projectId/data/*.json
278
+ const dataDir = movieStudioDataDir(projectId);
279
+ ensureDir(dataDir);
280
+ writeFileSync(join(dataDir, "characters.json"), JSON.stringify(characters, null, 2), "utf-8");
281
+ writeFileSync(join(dataDir, "places.json"), JSON.stringify(places, null, 2), "utf-8");
282
+ writeFileSync(join(dataDir, "scenes.json"), JSON.stringify(scenes, null, 2), "utf-8");
283
+ return { characters, places, scenes };
284
+ }
285
+ // ========== Routes ==========
286
+ export async function agentBackend({ app, getUvPath: _getUvPath, backendPort, }) {
287
+ // ===== Workspace file manager =====
288
+ app.get("/api/agent/files", (req, res) => {
289
+ const projectId = String(req.query.projectId ?? "");
290
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(projectId)) {
291
+ res.status(400).json({ error: "Invalid project ID" });
292
+ return;
293
+ }
294
+ const base = workspaceDir(projectId);
295
+ if (!existsSync(base)) {
296
+ res.json({ files: [] });
297
+ return;
298
+ }
299
+ res.json({ files: walkFiles(base) });
300
+ });
301
+ app.get("/api/agent/file/content", (req, res) => {
302
+ const projectId = String(req.query.projectId ?? "");
303
+ const path = String(req.query.path ?? "");
304
+ const abs = resolveWorkspacePath(projectId, path);
305
+ if (!abs || !existsSync(abs)) {
306
+ res.status(404).json({ error: "File not found" });
307
+ return;
308
+ }
309
+ res.json({ content: readFileSync(abs, "utf-8") });
310
+ });
311
+ app.post("/api/agent/file/content", (req, res) => {
312
+ const { projectId, path, content } = req.body || {};
313
+ const abs = resolveWorkspacePath(projectId, path);
314
+ if (!abs) {
315
+ res.status(400).json({ error: "Invalid path" });
316
+ return;
317
+ }
318
+ ensureDir(dirname(abs));
319
+ writeFileSync(abs, String(content ?? ""), "utf-8");
320
+ res.json({ ok: true });
321
+ });
322
+ app.post("/api/agent/rename", (req, res) => {
323
+ const { projectId, path, newName } = req.body || {};
324
+ const abs = resolveWorkspacePath(projectId, path);
325
+ const newAbs = resolveWorkspacePath(projectId, newName);
326
+ if (!abs || !newAbs) {
327
+ res.status(400).json({ error: "Invalid path" });
328
+ return;
329
+ }
330
+ if (!existsSync(abs)) {
331
+ res.status(404).json({ error: "File not found" });
332
+ return;
333
+ }
334
+ ensureDir(dirname(newAbs));
335
+ renameSync(abs, newAbs);
336
+ res.json({ ok: true });
337
+ });
338
+ app.post("/api/agent/delete", (req, res) => {
339
+ const { projectId, path } = req.body || {};
340
+ const abs = resolveWorkspacePath(projectId, path);
341
+ if (!abs) {
342
+ res.status(400).json({ error: "Invalid path" });
343
+ return;
344
+ }
345
+ if (!existsSync(abs)) {
346
+ res.status(404).json({ error: "File not found" });
347
+ return;
348
+ }
349
+ rmSync(abs, { recursive: true, force: true });
350
+ res.json({ ok: true });
351
+ });
352
+ app.get("/api/agent/file/preview", (req, res) => {
353
+ const projectId = String(req.query.projectId ?? "");
354
+ const path = String(req.query.path ?? "");
355
+ const abs = resolveWorkspacePath(projectId, path);
356
+ if (!abs || !existsSync(abs)) {
357
+ res.status(404).json({ error: "File not found" });
358
+ return;
359
+ }
360
+ // Only serve non-executable media; block HTML/SVG/JS/etc. to prevent
361
+ // stored XSS from being served in the app's origin.
362
+ const kind = classifyFile(path);
363
+ if (kind !== "image" && kind !== "video") {
364
+ res
365
+ .status(415)
366
+ .json({ error: "Preview only supports images and videos" });
367
+ return;
368
+ }
369
+ res.setHeader("X-Content-Type-Options", "nosniff");
370
+ res.setHeader("Content-Security-Policy", "sandbox");
371
+ res.sendFile(abs);
372
+ });
373
+ // Save user-uploaded images/videos into the agent's workspace.
374
+ app.post("/api/agent/upload", async (req, res) => {
375
+ const { image, filename, projectId } = req.body || {};
376
+ if (!image) {
377
+ res.status(400).json({ error: "File data is required (base64)" });
378
+ return;
379
+ }
380
+ if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
381
+ res.status(400).json({ error: "Invalid project ID" });
382
+ return;
383
+ }
384
+ try {
385
+ const base64 = String(image).replace(/^data:[^;]+;base64,/, "");
386
+ const buffer = Buffer.from(base64, "base64");
387
+ const dir = workspaceDir(String(projectId));
388
+ ensureDir(dir);
389
+ const safeName = (filename || `upload-${Date.now()}`).replace(/[^a-zA-Z0-9._-]/g, "_");
390
+ const filePath = join(dir, safeName);
391
+ writeFileSync(filePath, buffer);
392
+ res.json({
393
+ success: true,
394
+ path: filePath,
395
+ filename: safeName,
396
+ size: buffer.length,
397
+ });
398
+ }
399
+ catch (e) {
400
+ res
401
+ .status(500)
402
+ .json({ error: "Failed to save file", details: String(e) });
403
+ }
404
+ });
405
+ app.post("/api/agent/open-workspace", (req, res) => {
406
+ const { projectId } = req.body || {};
407
+ if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
408
+ res.status(400).json({ error: "Invalid project ID" });
409
+ return;
410
+ }
411
+ const dir = workspaceDir(String(projectId));
412
+ ensureDir(dir);
413
+ spawn(["open", dir], { stdout: "ignore", stderr: "ignore" });
414
+ res.json({ ok: true });
415
+ });
416
+ // ===== Chat threads persistence =====
417
+ app.get("/api/agent/threads", (req, res) => {
418
+ const projectId = String(req.query.projectId ?? "");
419
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(projectId)) {
420
+ res.status(400).json({ error: "Invalid project ID" });
421
+ return;
422
+ }
423
+ const agent = safeAgent(req.query.agent);
424
+ const file = join(workspaceDir(projectId), "chat", agent, "threads.json");
425
+ if (!existsSync(file)) {
426
+ res.json({ threads: [] });
427
+ return;
428
+ }
429
+ try {
430
+ res.json({ threads: JSON.parse(readFileSync(file, "utf-8")) });
431
+ }
432
+ catch {
433
+ res.json({ threads: [] });
434
+ }
435
+ });
436
+ app.post("/api/agent/threads", (req, res) => {
437
+ const { projectId, threads, agent } = req.body || {};
438
+ if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
439
+ res.status(400).json({ error: "Invalid project ID" });
440
+ return;
441
+ }
442
+ const dir = join(workspaceDir(String(projectId)), "chat", safeAgent(agent));
443
+ ensureDir(dir);
444
+ writeFileSync(join(dir, "threads.json"), JSON.stringify(threads ?? [], null, 2), "utf-8");
445
+ res.json({ ok: true });
446
+ });
447
+ app.get("/api/agent/thread/chat", (req, res) => {
448
+ const projectId = String(req.query.projectId ?? "");
449
+ const threadId = String(req.query.threadId ?? "");
450
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(projectId) ||
451
+ !/^[a-zA-Z0-9_-]{1,64}$/.test(threadId)) {
452
+ res.status(400).json({ error: "Invalid ID" });
453
+ return;
454
+ }
455
+ const agent = safeAgent(req.query.agent);
456
+ const file = join(workspaceDir(projectId), "chat", agent, threadId, "chat.json");
457
+ if (!existsSync(file)) {
458
+ res.json({ messages: [] });
459
+ return;
460
+ }
461
+ try {
462
+ res.json({ messages: JSON.parse(readFileSync(file, "utf-8")) });
463
+ }
464
+ catch {
465
+ res.json({ messages: [] });
466
+ }
467
+ });
468
+ app.post("/api/agent/thread/chat", (req, res) => {
469
+ const { projectId, threadId, messages, agent } = req.body || {};
470
+ if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
471
+ res.status(400).json({ error: "Invalid project ID" });
472
+ return;
473
+ }
474
+ if (!threadId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(threadId))) {
475
+ res.status(400).json({ error: "Invalid thread ID" });
476
+ return;
477
+ }
478
+ const dir = join(workspaceDir(String(projectId)), "chat", safeAgent(agent), String(threadId));
479
+ ensureDir(dir);
480
+ writeFileSync(join(dir, "chat.json"), JSON.stringify(messages ?? [], null, 2), "utf-8");
481
+ res.json({ ok: true });
482
+ });
483
+ app.post("/api/agent/chat", async (req, res) => {
484
+ res.writeHead(200, {
485
+ "Content-Type": "text/event-stream",
486
+ "Cache-Control": "no-cache",
487
+ Connection: "keep-alive",
488
+ "X-Accel-Buffering": "no",
489
+ });
490
+ const send = (event, data) => {
491
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
492
+ };
493
+ const body = req.body || {};
494
+ // Use the port the app itself started the mlx-vlm server on (SSRF guard).
495
+ const port = getAgentServerPort();
496
+ if (port === null) {
497
+ send("error", {
498
+ error: "Model server is not running. Start it from the Agent tab first.",
499
+ });
500
+ res.end();
501
+ return;
502
+ }
503
+ const model = typeof body.model === "string" && body.model.trim()
504
+ ? body.model.trim()
505
+ : DEFAULT_MODEL;
506
+ const projectId = typeof body.projectId === "string" &&
507
+ /^[a-zA-Z0-9_-]{1,64}$/.test(body.projectId)
508
+ ? body.projectId
509
+ : null;
510
+ if (projectId === null) {
511
+ send("error", { error: "Invalid project ID" });
512
+ res.end();
513
+ return;
514
+ }
515
+ const incoming = Array.isArray(body.messages) ? body.messages : [];
516
+ const history = incoming.map((m) => ({
517
+ role: m.role,
518
+ content: typeof m.content === "string" ? m.content : "",
519
+ image: typeof m.image === "string" && isValidImageDataUrl(m.image)
520
+ ? m.image
521
+ : undefined,
522
+ }));
523
+ const agent = safeAgent(body.agent);
524
+ const systemPrompt = agent === STORY_WRITER_AGENT
525
+ ? storyWriterSystemPrompt()
526
+ : buildSystemPrompt();
527
+ const messages = [
528
+ { role: "system", content: systemPrompt },
529
+ ...history,
530
+ ];
531
+ const client = new OpenAI({
532
+ baseURL: `http://localhost:${port}/v1`,
533
+ apiKey: "local",
534
+ });
535
+ try {
536
+ const seenToolKeys = new Set();
537
+ let streamedAny = false;
538
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
539
+ const stream = await client.chat.completions.create({
540
+ model,
541
+ messages: toOpenAIMessages(messages),
542
+ tools: toolDefinitions(TOOLS),
543
+ tool_choice: "auto",
544
+ temperature: 0.7,
545
+ reasoning_effort: "max",
546
+ stream: true,
547
+ }, { signal: req.signal });
548
+ let content = "";
549
+ const toolCalls = [];
550
+ for await (const chunk of stream) {
551
+ const choice = chunk.choices?.[0];
552
+ const delta = choice?.delta;
553
+ if (!delta) {
554
+ continue;
555
+ }
556
+ // Reasoning / thinking tokens (reasoning models emit these in a
557
+ // separate field from the final answer).
558
+ const reasoning = delta.reasoning ?? delta.reasoning_content;
559
+ if (reasoning) {
560
+ send("thinking", { text: reasoning });
561
+ }
562
+ if (delta.content) {
563
+ content += delta.content;
564
+ send("delta", { text: delta.content });
565
+ streamedAny = true;
566
+ }
567
+ if (delta.tool_calls) {
568
+ for (const tc of delta.tool_calls) {
569
+ const index = tc.index ?? 0;
570
+ if (!toolCalls[index]) {
571
+ toolCalls[index] = {
572
+ id: tc.id ?? "",
573
+ function: { name: "", arguments: "" },
574
+ };
575
+ }
576
+ if (tc.id)
577
+ toolCalls[index].id = tc.id;
578
+ if (tc.function?.name) {
579
+ toolCalls[index].function.name += tc.function.name;
580
+ }
581
+ if (tc.function?.arguments) {
582
+ toolCalls[index].function.arguments += tc.function.arguments;
583
+ }
584
+ }
585
+ }
586
+ }
587
+ // If the model requested tools, run them and feed results back in.
588
+ if (toolCalls.length > 0) {
589
+ const toolKey = toolCalls
590
+ .map((tc) => `${tc.function.name}:${tc.function.arguments}`)
591
+ .join("||");
592
+ if (toolKey && seenToolKeys.has(toolKey)) {
593
+ // Stuck repeating a tool call — stop.
594
+ break;
595
+ }
596
+ if (toolKey)
597
+ seenToolKeys.add(toolKey);
598
+ messages.push({
599
+ role: "assistant",
600
+ content: content || null,
601
+ tool_calls: toolCalls.map((tc) => ({
602
+ id: tc.id,
603
+ type: "function",
604
+ function: {
605
+ name: tc.function.name,
606
+ arguments: tc.function.arguments,
607
+ },
608
+ })),
609
+ });
610
+ for (const tc of toolCalls) {
611
+ const output = await runTool(TOOLS, tc.function.name, tc.function.arguments, {
612
+ projectId,
613
+ emit: send,
614
+ backendPort,
615
+ signal: req.signal,
616
+ });
617
+ send("tool", {
618
+ name: tc.function.name,
619
+ arguments: tc.function.arguments,
620
+ output,
621
+ });
622
+ messages.push({
623
+ role: "tool",
624
+ tool_call_id: tc.id,
625
+ name: tc.function.name,
626
+ content: output,
627
+ });
628
+ }
629
+ continue;
630
+ }
631
+ // No tool calls → the streamed content is the final answer.
632
+ break;
633
+ }
634
+ if (!streamedAny) {
635
+ send("delta", { text: "Done." });
636
+ }
637
+ send("messages", { messages: sanitizeTrace(messages) });
638
+ send("done", {});
639
+ }
640
+ catch (e) {
641
+ send("error", { error: String(e) });
642
+ }
643
+ finally {
644
+ res.end();
645
+ }
646
+ });
647
+ // ===== Movie Studio: idea → characters / places / scenes =====
648
+ app.post("/api/movie-studio/generate", async (req, res) => {
649
+ const { idea, model, projectId } = req.body || {};
650
+ if (typeof idea !== "string" || !idea.trim()) {
651
+ res.status(400).json({ error: "Idea is required" });
652
+ return;
653
+ }
654
+ if (typeof projectId !== "string" ||
655
+ !/^[a-zA-Z0-9_-]{1,64}$/.test(projectId)) {
656
+ res.status(400).json({ error: "Invalid project ID" });
657
+ return;
658
+ }
659
+ try {
660
+ const result = await generateMovieStudioBible(projectId, idea.trim(), model);
661
+ res.json(result);
662
+ }
663
+ catch (e) {
664
+ res.status(500).json({ error: String(e) });
665
+ }
666
+ });
667
+ // ===== Movie Studio: persisted UI state (JSON on disk) =====
668
+ app.get("/api/movie-studio/state", (req, res) => {
669
+ const projectId = String(req.query.projectId ?? "");
670
+ if (!/^[a-zA-Z0-9_-]{1,64}$/.test(projectId)) {
671
+ res.status(400).json({ error: "Invalid project ID" });
672
+ return;
673
+ }
674
+ const file = movieStudioStateFile(projectId);
675
+ if (!existsSync(file)) {
676
+ res.json(null);
677
+ return;
678
+ }
679
+ try {
680
+ res.json(JSON.parse(readFileSync(file, "utf-8")));
681
+ }
682
+ catch {
683
+ res.json(null);
684
+ }
685
+ });
686
+ app.post("/api/movie-studio/state", (req, res) => {
687
+ const { projectId, idea, result, assets, videos, sceneImages } = req.body || {};
688
+ if (!projectId || !/^[a-zA-Z0-9_-]{1,64}$/.test(String(projectId))) {
689
+ res.status(400).json({ error: "Invalid project ID" });
690
+ return;
691
+ }
692
+ const file = movieStudioStateFile(String(projectId));
693
+ ensureDir(dirname(file));
694
+ writeFileSync(file, JSON.stringify({
695
+ idea: String(idea ?? ""),
696
+ result: result ?? null,
697
+ assets: Array.isArray(assets) ? assets : [],
698
+ videos: Array.isArray(videos) ? videos : [],
699
+ sceneImages: Array.isArray(sceneImages) ? sceneImages : [],
700
+ }, null, 2), "utf-8");
701
+ res.json({ ok: true });
702
+ });
703
+ }