@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,766 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { randomUUID } from "node:crypto";
5
+ import { generateAssetImage, generateSceneImage, generateSceneVideo, cancelActiveRender, } from "./render-media";
6
+ import { generateMovieStudioBible } from "./agent/agent-backend";
7
+ import { movieStudioStateFile } from "./agent/workspace";
8
+ const APP_DATA_DIR = join(homedir(), "media-studio");
9
+ const TASKS_DIR = join(APP_DATA_DIR, "tasks");
10
+ const LOGS_DIR = join(APP_DATA_DIR, "logs");
11
+ const PROJECT_ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
12
+ // ========== Queue file persistence ==========
13
+ function queueDir(projectId) {
14
+ return join(TASKS_DIR, projectId);
15
+ }
16
+ function queueFile(projectId) {
17
+ return join(queueDir(projectId), "queue.json");
18
+ }
19
+ function logFile(projectId) {
20
+ return join(LOGS_DIR, projectId, "logs.txt");
21
+ }
22
+ /** Append a chunk of terminal output to the project's persistent log file. */
23
+ function appendLog(projectId, text) {
24
+ if (!text)
25
+ return;
26
+ const file = logFile(projectId);
27
+ const dir = dirname(file);
28
+ if (!existsSync(dir))
29
+ mkdirSync(dir, { recursive: true });
30
+ appendFileSync(file, text, "utf-8");
31
+ broadcast(projectId, "log", { text });
32
+ }
33
+ function slugify(v) {
34
+ return String(v || "")
35
+ .trim()
36
+ .replace(/[^a-zA-Z0-9_-]/g, "_");
37
+ }
38
+ function isValidProjectId(id) {
39
+ return PROJECT_ID_RE.test(id);
40
+ }
41
+ // In-memory queues, keyed by project id. Persisted to disk on every mutation so
42
+ // the queue survives app restarts and is visible at tasks/:projectId/queue.json.
43
+ const queues = new Map();
44
+ // Global FIFO enqueue order so pending tasks are picked in submission order
45
+ // across projects (there is a single GPU worker).
46
+ const order = [];
47
+ let pumping = false;
48
+ let runningRef = null;
49
+ let runningAbort = null;
50
+ // Resolves the `uv` binary, injected by core.ts on startup.
51
+ let resolveUvPath = async () => {
52
+ throw new Error("uv path not configured");
53
+ };
54
+ // Projects whose queue is paused. While a project is paused, its running task
55
+ // is killed and remembered as "paused", and no new tasks are started for that
56
+ // project until it is resumed.
57
+ const pausedProjects = new Set();
58
+ const sseClients = new Set();
59
+ /** Push an event to every SSE client watching the given project. */
60
+ function broadcast(projectId, event, data) {
61
+ for (const client of sseClients) {
62
+ if (client.projectId !== projectId)
63
+ continue;
64
+ try {
65
+ client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
66
+ }
67
+ catch {
68
+ sseClients.delete(client);
69
+ }
70
+ }
71
+ }
72
+ function loadState(projectId) {
73
+ const existing = queues.get(projectId);
74
+ if (existing)
75
+ return existing;
76
+ const file = queueFile(projectId);
77
+ let tasks = [];
78
+ if (existsSync(file)) {
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
81
+ if (Array.isArray(parsed)) {
82
+ tasks = parsed;
83
+ }
84
+ }
85
+ catch {
86
+ // Corrupt queue file — start fresh.
87
+ tasks = [];
88
+ }
89
+ }
90
+ // A task left "running" (or "paused") by a previous session was interrupted —
91
+ // reset it so the worker picks it back up on restart.
92
+ for (const t of tasks) {
93
+ if (t.status === "running" || t.status === "paused") {
94
+ t.status = "pending";
95
+ t.startedAt = null;
96
+ }
97
+ }
98
+ const state = { tasks };
99
+ queues.set(projectId, state);
100
+ // Rebuild the FIFO order for persisted pending tasks so they resume.
101
+ let hasPending = false;
102
+ for (const t of tasks) {
103
+ if (t.status !== "pending")
104
+ continue;
105
+ hasPending = true;
106
+ if (!order.some((o) => o.projectId === projectId && o.taskId === t.id)) {
107
+ order.push({ projectId, taskId: t.id });
108
+ }
109
+ }
110
+ if (hasPending)
111
+ void pump();
112
+ return state;
113
+ }
114
+ function persist(projectId) {
115
+ const state = queues.get(projectId);
116
+ if (!state)
117
+ return;
118
+ const dir = queueDir(projectId);
119
+ if (!existsSync(dir))
120
+ mkdirSync(dir, { recursive: true });
121
+ writeFileSync(queueFile(projectId), JSON.stringify(state.tasks, null, 2), "utf-8");
122
+ }
123
+ function updateTask(projectId, taskId, patch) {
124
+ const state = queues.get(projectId);
125
+ if (!state)
126
+ return null;
127
+ const index = state.tasks.findIndex((t) => t.id === taskId);
128
+ if (index === -1)
129
+ return null;
130
+ state.tasks[index] = { ...state.tasks[index], ...patch };
131
+ persist(projectId);
132
+ broadcast(projectId, "task", state.tasks[index]);
133
+ return state.tasks[index];
134
+ }
135
+ function getTask(projectId, taskId) {
136
+ const state = queues.get(projectId);
137
+ return state?.tasks.find((t) => t.id === taskId) ?? null;
138
+ }
139
+ function enqueue(projectId, type, label, payload) {
140
+ const state = loadState(projectId);
141
+ const task = {
142
+ id: randomUUID(),
143
+ type,
144
+ label,
145
+ status: "pending",
146
+ progress: null,
147
+ statusText: null,
148
+ error: null,
149
+ payload,
150
+ result: null,
151
+ createdAt: Date.now(),
152
+ startedAt: null,
153
+ completedAt: null,
154
+ };
155
+ state.tasks.push(task);
156
+ order.push({ projectId, taskId: task.id });
157
+ persist(projectId);
158
+ broadcast(projectId, "task", task);
159
+ void pump();
160
+ return task;
161
+ }
162
+ function nextPending() {
163
+ for (const entry of order) {
164
+ if (pausedProjects.has(entry.projectId))
165
+ continue;
166
+ const state = queues.get(entry.projectId);
167
+ if (!state)
168
+ continue;
169
+ const task = state.tasks.find((t) => t.id === entry.taskId);
170
+ if (task && task.status === "pending")
171
+ return entry;
172
+ }
173
+ return null;
174
+ }
175
+ function throwIfAborted(signal) {
176
+ if (signal.aborted)
177
+ throw new Error("Aborted");
178
+ }
179
+ async function runRenderAssets(ctx, characters, places) {
180
+ const chars = Array.isArray(characters) ? characters : [];
181
+ const placesList = Array.isArray(places) ? places : [];
182
+ const total = chars.length + placesList.length;
183
+ const assets = [];
184
+ let current = 0;
185
+ for (const c of chars) {
186
+ throwIfAborted(ctx.signal);
187
+ const slug = slugify(c?.slug);
188
+ const prompt = String(c?.imagePrompt || "").trim();
189
+ if (!slug || !prompt)
190
+ continue;
191
+ current += 1;
192
+ ctx.update({
193
+ statusText: `Generating character: ${c?.name || slug}`,
194
+ progress: { current, total },
195
+ });
196
+ const r = await generateAssetImage(ctx.projectId, "character", slug, prompt, ctx.log);
197
+ throwIfAborted(ctx.signal);
198
+ if ("error" in r)
199
+ throw new Error(r.error);
200
+ assets.push({
201
+ kind: "character",
202
+ slug,
203
+ filename: r.filename,
204
+ url: r.url,
205
+ updatedAt: Date.now(),
206
+ });
207
+ ctx.update({ result: { assets: [...assets] } });
208
+ }
209
+ for (const p of placesList) {
210
+ throwIfAborted(ctx.signal);
211
+ const slug = slugify(p?.slug);
212
+ const prompt = String(p?.imagePrompt || "").trim();
213
+ if (!slug || !prompt)
214
+ continue;
215
+ current += 1;
216
+ ctx.update({
217
+ statusText: `Generating place: ${p?.name || slug}`,
218
+ progress: { current, total },
219
+ });
220
+ const r = await generateAssetImage(ctx.projectId, "place", slug, prompt, ctx.log);
221
+ throwIfAborted(ctx.signal);
222
+ if ("error" in r)
223
+ throw new Error(r.error);
224
+ assets.push({
225
+ kind: "place",
226
+ slug,
227
+ filename: r.filename,
228
+ url: r.url,
229
+ updatedAt: Date.now(),
230
+ });
231
+ ctx.update({ result: { assets: [...assets] } });
232
+ }
233
+ ctx.update({ result: { assets } });
234
+ return { assets };
235
+ }
236
+ async function runRenderSceneImages(ctx, scenes) {
237
+ const list = Array.isArray(scenes) ? scenes : [];
238
+ const total = list.length;
239
+ const sceneImages = [];
240
+ let current = 0;
241
+ for (const sc of list) {
242
+ throwIfAborted(ctx.signal);
243
+ const slug = slugify(sc?.slug);
244
+ if (!slug)
245
+ continue;
246
+ current += 1;
247
+ ctx.update({
248
+ statusText: `Generating scene image: ${slug}`,
249
+ progress: { current, total },
250
+ });
251
+ const r = await generateSceneImage(ctx.projectId, sc, ctx.log);
252
+ throwIfAborted(ctx.signal);
253
+ if ("error" in r)
254
+ throw new Error(r.error);
255
+ sceneImages.push({
256
+ slug,
257
+ filename: r.filename,
258
+ url: r.url,
259
+ updatedAt: Date.now(),
260
+ });
261
+ ctx.update({ result: { sceneImages: [...sceneImages] } });
262
+ }
263
+ ctx.update({ result: { sceneImages } });
264
+ return { sceneImages };
265
+ }
266
+ async function runRenderVideos(ctx, uvPath, scenes, characters) {
267
+ const list = Array.isArray(scenes) ? scenes : [];
268
+ const chars = Array.isArray(characters) ? characters : [];
269
+ const total = list.length;
270
+ const videos = [];
271
+ let current = 0;
272
+ for (const sc of list) {
273
+ throwIfAborted(ctx.signal);
274
+ const slug = slugify(sc?.slug);
275
+ if (!slug)
276
+ continue;
277
+ current += 1;
278
+ ctx.update({
279
+ statusText: `Generating video: ${slug}`,
280
+ progress: { current, total },
281
+ });
282
+ const r = await generateSceneVideo(uvPath, ctx.projectId, sc, chars, ctx.log);
283
+ throwIfAborted(ctx.signal);
284
+ if ("error" in r)
285
+ throw new Error(r.error);
286
+ videos.push({
287
+ slug,
288
+ filename: r.filename,
289
+ url: r.url,
290
+ updatedAt: Date.now(),
291
+ });
292
+ ctx.update({ result: { videos: [...videos] } });
293
+ }
294
+ ctx.update({ result: { videos } });
295
+ return { videos };
296
+ }
297
+ async function runFullRender(ctx, uvPath, characters, places, scenes) {
298
+ const { assets } = await runRenderAssets(ctx, characters, places);
299
+ const { sceneImages } = await runRenderSceneImages(ctx, scenes);
300
+ const { videos } = await runRenderVideos(ctx, uvPath, scenes, characters);
301
+ const imgBySlug = new Map(sceneImages.map((i) => [i.slug, i.url]));
302
+ const vidBySlug = new Map(videos.map((v) => [v.slug, v.url]));
303
+ const renderedScenes = (Array.isArray(scenes) ? scenes : [])
304
+ .map((sc) => {
305
+ const slug = slugify(sc?.slug);
306
+ if (!slug)
307
+ return null;
308
+ return {
309
+ slug,
310
+ imageUrl: imgBySlug.get(slug) ?? null,
311
+ videoUrl: vidBySlug.get(slug) ?? null,
312
+ };
313
+ })
314
+ .filter(Boolean);
315
+ const result = { assets, sceneImages, videos, renderedScenes };
316
+ ctx.update({ result });
317
+ return result;
318
+ }
319
+ const handlers = {
320
+ generate: async (ctx) => {
321
+ const { idea, model } = ctx.task.payload || {};
322
+ if (typeof idea !== "string" || !idea.trim()) {
323
+ throw new Error("Idea is required");
324
+ }
325
+ ctx.log(`Planning production bible (characters → places → scenes)…\n`);
326
+ const result = await generateMovieStudioBible(ctx.projectId, idea.trim(), typeof model === "string" && model.trim() ? model.trim() : undefined);
327
+ ctx.log("Production bible ready.\n");
328
+ return result;
329
+ },
330
+ "render-assets": async (ctx) => {
331
+ const { characters, places } = ctx.task.payload || {};
332
+ return runRenderAssets(ctx, characters, places);
333
+ },
334
+ "render-scene-images": async (ctx) => {
335
+ return runRenderSceneImages(ctx, ctx.task.payload?.scenes);
336
+ },
337
+ "render-videos": async (ctx, getUvPath) => {
338
+ const { scenes, characters } = ctx.task.payload || {};
339
+ return runRenderVideos(ctx, await getUvPath(), scenes, characters);
340
+ },
341
+ render: async (ctx, getUvPath) => {
342
+ const { characters, places, scenes } = ctx.task.payload || {};
343
+ return runFullRender(ctx, await getUvPath(), characters, places, scenes);
344
+ },
345
+ "regenerate-asset": async (ctx) => {
346
+ const { kind, slug, prompt } = ctx.task.payload || {};
347
+ if (kind !== "character" && kind !== "place") {
348
+ throw new Error("kind must be 'character' or 'place'");
349
+ }
350
+ const s = slugify(slug);
351
+ const p = String(prompt || "").trim();
352
+ if (!s || !p)
353
+ throw new Error("slug and prompt are required");
354
+ const r = await generateAssetImage(ctx.projectId, kind, s, p, ctx.log);
355
+ if ("error" in r)
356
+ throw new Error(r.error);
357
+ return {
358
+ kind,
359
+ slug: s,
360
+ filename: r.filename,
361
+ url: r.url,
362
+ updatedAt: Date.now(),
363
+ };
364
+ },
365
+ "regenerate-video": async (ctx, getUvPath) => {
366
+ const { scene, characters } = ctx.task.payload || {};
367
+ if (!scene || typeof scene !== "object")
368
+ throw new Error("scene is required");
369
+ const r = await generateSceneVideo(await getUvPath(), ctx.projectId, scene, Array.isArray(characters) ? characters : [], ctx.log);
370
+ if ("error" in r)
371
+ throw new Error(r.error);
372
+ return {
373
+ slug: slugify(scene?.slug),
374
+ filename: r.filename,
375
+ url: r.url,
376
+ updatedAt: Date.now(),
377
+ };
378
+ },
379
+ "regenerate-scene-image": async (ctx) => {
380
+ const { scene } = ctx.task.payload || {};
381
+ if (!scene || typeof scene !== "object")
382
+ throw new Error("scene is required");
383
+ const r = await generateSceneImage(ctx.projectId, scene, ctx.log);
384
+ if ("error" in r)
385
+ throw new Error(r.error);
386
+ return {
387
+ slug: slugify(scene?.slug),
388
+ filename: r.filename,
389
+ url: r.url,
390
+ updatedAt: Date.now(),
391
+ };
392
+ },
393
+ };
394
+ // ========== Worker ==========
395
+ async function pump() {
396
+ if (pumping)
397
+ return;
398
+ pumping = true;
399
+ try {
400
+ while (true) {
401
+ const next = nextPending();
402
+ if (!next)
403
+ break;
404
+ const state = queues.get(next.projectId);
405
+ const task = state?.tasks.find((t) => t.id === next.taskId);
406
+ if (!task)
407
+ break;
408
+ const handler = handlers[task.type];
409
+ if (!handler) {
410
+ updateTask(next.projectId, task.id, {
411
+ status: "failed",
412
+ error: `Unknown task type: ${task.type}`,
413
+ completedAt: Date.now(),
414
+ });
415
+ continue;
416
+ }
417
+ updateTask(next.projectId, task.id, {
418
+ status: "running",
419
+ startedAt: Date.now(),
420
+ statusText: "Starting…",
421
+ error: null,
422
+ });
423
+ const controller = new AbortController();
424
+ runningRef = next;
425
+ runningAbort = controller;
426
+ const ctx = {
427
+ projectId: next.projectId,
428
+ task,
429
+ update: (patch) => updateTask(next.projectId, task.id, patch),
430
+ signal: controller.signal,
431
+ log: (text) => appendLog(next.projectId, text),
432
+ };
433
+ try {
434
+ const result = await handler(ctx, resolveUvPath);
435
+ if (controller.signal.aborted) {
436
+ const current = getTask(next.projectId, task.id);
437
+ if (current && current.status === "running") {
438
+ updateTask(next.projectId, task.id, {
439
+ status: pausedProjects.has(next.projectId)
440
+ ? "paused"
441
+ : "cancelled",
442
+ statusText: null,
443
+ completedAt: Date.now(),
444
+ });
445
+ }
446
+ }
447
+ else {
448
+ updateTask(next.projectId, task.id, {
449
+ status: "completed",
450
+ result,
451
+ statusText: null,
452
+ error: null,
453
+ completedAt: Date.now(),
454
+ });
455
+ try {
456
+ syncToMovieStudioState(next.projectId, task.type, result);
457
+ }
458
+ catch {
459
+ // State sync is best-effort; the queue file is still authoritative.
460
+ }
461
+ }
462
+ }
463
+ catch (e) {
464
+ if (controller.signal.aborted) {
465
+ const current = getTask(next.projectId, task.id);
466
+ if (current && current.status === "running") {
467
+ updateTask(next.projectId, task.id, {
468
+ status: pausedProjects.has(next.projectId)
469
+ ? "paused"
470
+ : "cancelled",
471
+ statusText: null,
472
+ completedAt: Date.now(),
473
+ });
474
+ }
475
+ }
476
+ else {
477
+ updateTask(next.projectId, task.id, {
478
+ status: "failed",
479
+ error: String(e),
480
+ statusText: null,
481
+ completedAt: Date.now(),
482
+ });
483
+ }
484
+ }
485
+ finally {
486
+ runningRef = null;
487
+ runningAbort = null;
488
+ }
489
+ }
490
+ }
491
+ finally {
492
+ pumping = false;
493
+ }
494
+ }
495
+ /** Merge a completed task's result into the movie studio persisted UI state. */
496
+ function syncToMovieStudioState(projectId, type, result) {
497
+ const file = movieStudioStateFile(projectId);
498
+ let state = {};
499
+ if (existsSync(file)) {
500
+ try {
501
+ state = JSON.parse(readFileSync(file, "utf-8")) || {};
502
+ }
503
+ catch {
504
+ state = {};
505
+ }
506
+ }
507
+ if (type === "generate" && result) {
508
+ state.result = result;
509
+ }
510
+ if (type === "render-assets" || type === "render") {
511
+ state.assets = Array.isArray(result?.assets) ? result.assets : state.assets ?? [];
512
+ }
513
+ if (type === "render-scene-images" || type === "render") {
514
+ state.sceneImages = Array.isArray(result?.sceneImages)
515
+ ? result.sceneImages
516
+ : state.sceneImages ?? [];
517
+ }
518
+ if (type === "render-videos" || type === "render") {
519
+ state.videos = Array.isArray(result?.videos) ? result.videos : state.videos ?? [];
520
+ }
521
+ if (type === "regenerate-asset" && result) {
522
+ const key = `${result.kind}:${result.slug}`;
523
+ const arr = Array.isArray(state.assets) ? state.assets : [];
524
+ state.assets = [
525
+ ...arr.filter((a) => `${a.kind}:${a.slug}` !== key),
526
+ result,
527
+ ];
528
+ }
529
+ if (type === "regenerate-video" && result) {
530
+ const arr = Array.isArray(state.videos) ? state.videos : [];
531
+ state.videos = [
532
+ ...arr.filter((v) => v.slug !== result.slug),
533
+ result,
534
+ ];
535
+ }
536
+ if (type === "regenerate-scene-image" && result) {
537
+ const arr = Array.isArray(state.sceneImages) ? state.sceneImages : [];
538
+ state.sceneImages = [
539
+ ...arr.filter((i) => i.slug !== result.slug),
540
+ result,
541
+ ];
542
+ }
543
+ const dir = dirname(file);
544
+ if (!existsSync(dir))
545
+ mkdirSync(dir, { recursive: true });
546
+ writeFileSync(file, JSON.stringify(state, null, 2), "utf-8");
547
+ }
548
+ // ========== Routes ==========
549
+ /**
550
+ * CSRF guard for the localhost server. Rejects mutating requests whose Origin
551
+ * points at another site (a malicious page in the user's browser). Same-origin
552
+ * and custom-protocol (views://) clients send no Origin or an Origin of
553
+ * "null"/localhost, so they pass through.
554
+ */
555
+ function assertLocalRequest(req, res) {
556
+ const origin = req.headers.origin;
557
+ if (!origin || origin === "null")
558
+ return true;
559
+ try {
560
+ const host = new URL(origin).hostname;
561
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1") {
562
+ return true;
563
+ }
564
+ }
565
+ catch {
566
+ // Fall through to reject malformed origins.
567
+ }
568
+ res.status(403).json({ error: "Forbidden" });
569
+ return false;
570
+ }
571
+ export function generationQueueSetup({ app, getUvPath, }) {
572
+ resolveUvPath = getUvPath;
573
+ // List the current queue for a project (plus whether that project is paused).
574
+ app.get("/api/queue", (req, res) => {
575
+ const projectId = String(req.query.projectId ?? "");
576
+ if (!isValidProjectId(projectId)) {
577
+ res.status(400).json({ error: "Invalid project ID" });
578
+ return;
579
+ }
580
+ res.json({
581
+ tasks: loadState(projectId).tasks,
582
+ paused: pausedProjects.has(projectId),
583
+ });
584
+ });
585
+ // Read a project's persisted terminal log (tail, so large logs stay bounded).
586
+ app.get("/api/logs", (req, res) => {
587
+ const projectId = String(req.query.projectId ?? "");
588
+ if (!isValidProjectId(projectId)) {
589
+ res.status(400).json({ error: "Invalid project ID" });
590
+ return;
591
+ }
592
+ const file = logFile(projectId);
593
+ if (!existsSync(file)) {
594
+ res.json({ logs: "" });
595
+ return;
596
+ }
597
+ try {
598
+ const content = readFileSync(file, "utf-8");
599
+ const MAX = 200_000;
600
+ const tail = content.length > MAX ? content.slice(content.length - MAX) : content;
601
+ res.json({ logs: tail });
602
+ }
603
+ catch {
604
+ res.json({ logs: "" });
605
+ }
606
+ });
607
+ // Server-Sent Events: push queue/task and log updates to a watching client.
608
+ app.get("/api/events", (req, res) => {
609
+ const projectId = String(req.query.projectId ?? "");
610
+ if (!isValidProjectId(projectId)) {
611
+ res.status(400).json({ error: "Invalid project ID" });
612
+ return;
613
+ }
614
+ res.writeHead(200, {
615
+ "Content-Type": "text/event-stream",
616
+ "Cache-Control": "no-cache",
617
+ Connection: "keep-alive",
618
+ "X-Accel-Buffering": "no",
619
+ });
620
+ res.write(`event: hello\ndata: {}\n\n`);
621
+ const client = { res, projectId };
622
+ sseClients.add(client);
623
+ req.on("close", () => {
624
+ sseClients.delete(client);
625
+ });
626
+ });
627
+ // Enqueue a new generation task.
628
+ app.post("/api/queue/enqueue", (req, res) => {
629
+ if (!assertLocalRequest(req, res))
630
+ return;
631
+ const { projectId, type, label, payload } = req.body || {};
632
+ if (!projectId || !isValidProjectId(String(projectId))) {
633
+ res.status(400).json({ error: "Invalid project ID" });
634
+ return;
635
+ }
636
+ if (!type || !(type in handlers)) {
637
+ res.status(400).json({ error: "Invalid task type" });
638
+ return;
639
+ }
640
+ const task = enqueue(String(projectId), type, typeof label === "string" && label.trim()
641
+ ? label.trim()
642
+ : String(type), payload ?? {});
643
+ res.status(201).json(task);
644
+ });
645
+ // Pause a project's queue: kill its running task but remember it as "paused".
646
+ app.post("/api/queue/pause", (req, res) => {
647
+ if (!assertLocalRequest(req, res))
648
+ return;
649
+ const { projectId } = req.body || {};
650
+ if (!projectId || !isValidProjectId(String(projectId))) {
651
+ res.status(400).json({ error: "Invalid project ID" });
652
+ return;
653
+ }
654
+ pausedProjects.add(String(projectId));
655
+ if (runningRef && runningRef.projectId === String(projectId)) {
656
+ const current = getTask(runningRef.projectId, runningRef.taskId);
657
+ if (current && current.status === "running") {
658
+ updateTask(runningRef.projectId, runningRef.taskId, {
659
+ status: "paused",
660
+ statusText: null,
661
+ });
662
+ }
663
+ runningAbort?.abort();
664
+ cancelActiveRender();
665
+ }
666
+ res.json({ paused: true });
667
+ });
668
+ // Resume a project's queue and re-queue any of its paused tasks.
669
+ app.post("/api/queue/resume", (req, res) => {
670
+ if (!assertLocalRequest(req, res))
671
+ return;
672
+ const { projectId } = req.body || {};
673
+ if (!projectId || !isValidProjectId(String(projectId))) {
674
+ res.status(400).json({ error: "Invalid project ID" });
675
+ return;
676
+ }
677
+ pausedProjects.delete(String(projectId));
678
+ const state = queues.get(String(projectId));
679
+ if (state) {
680
+ for (const t of state.tasks) {
681
+ if (t.status === "paused") {
682
+ updateTask(String(projectId), t.id, {
683
+ status: "pending",
684
+ startedAt: null,
685
+ });
686
+ }
687
+ }
688
+ }
689
+ void pump();
690
+ res.json({ paused: false });
691
+ });
692
+ // Cancel a single task (pending or running).
693
+ app.post("/api/queue/cancel", (req, res) => {
694
+ if (!assertLocalRequest(req, res))
695
+ return;
696
+ const { projectId, taskId } = req.body || {};
697
+ if (!projectId || !isValidProjectId(String(projectId))) {
698
+ res.status(400).json({ error: "Invalid project ID" });
699
+ return;
700
+ }
701
+ const state = queues.get(String(projectId));
702
+ const task = state?.tasks.find((t) => t.id === String(taskId ?? ""));
703
+ if (!task) {
704
+ res.status(404).json({ error: "Task not found" });
705
+ return;
706
+ }
707
+ if (task.status === "pending" || task.status === "paused") {
708
+ updateTask(String(projectId), task.id, {
709
+ status: "cancelled",
710
+ completedAt: Date.now(),
711
+ });
712
+ }
713
+ else if (task.status === "running") {
714
+ if (runningRef && runningRef.taskId === task.id) {
715
+ runningAbort?.abort();
716
+ cancelActiveRender();
717
+ }
718
+ }
719
+ res.json({ ok: true });
720
+ });
721
+ // Cancel every queued/running task for a project (the "Stop" button).
722
+ app.post("/api/queue/cancel-active", (req, res) => {
723
+ if (!assertLocalRequest(req, res))
724
+ return;
725
+ const { projectId } = req.body || {};
726
+ if (!projectId || !isValidProjectId(String(projectId))) {
727
+ res.status(400).json({ error: "Invalid project ID" });
728
+ return;
729
+ }
730
+ const state = queues.get(String(projectId));
731
+ if (state) {
732
+ for (const task of state.tasks) {
733
+ if (task.status === "pending" || task.status === "paused") {
734
+ updateTask(String(projectId), task.id, {
735
+ status: "cancelled",
736
+ completedAt: Date.now(),
737
+ });
738
+ }
739
+ }
740
+ }
741
+ if (runningRef &&
742
+ runningRef.projectId === String(projectId)) {
743
+ runningAbort?.abort();
744
+ cancelActiveRender();
745
+ }
746
+ res.json({ ok: true });
747
+ });
748
+ // Remove finished tasks (completed/failed/cancelled) from the queue.
749
+ app.post("/api/queue/clear", (req, res) => {
750
+ if (!assertLocalRequest(req, res))
751
+ return;
752
+ const { projectId } = req.body || {};
753
+ if (!projectId || !isValidProjectId(String(projectId))) {
754
+ res.status(400).json({ error: "Invalid project ID" });
755
+ return;
756
+ }
757
+ const state = queues.get(String(projectId));
758
+ if (state) {
759
+ state.tasks = state.tasks.filter((t) => t.status === "pending" ||
760
+ t.status === "running" ||
761
+ t.status === "paused");
762
+ persist(String(projectId));
763
+ }
764
+ res.json({ ok: true });
765
+ });
766
+ }