@effectnode/media 0.6.0 → 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 @wonglok831 x.com, github.com/wonglok
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -146,14 +146,17 @@ const MOVIE_STUDIO_CHARACTERS_PLAN_PROMPT = [
146
146
  '- If the idea does not mention an art style, set "artStyle" to "photo realistic render".',
147
147
  ].join("\n");
148
148
  const CHARACTER_IMAGE_PROMPT = [
149
- "You are a movie pre-production planner. Given a movie idea, its art style, and one character, write that character's standalone image prompt.",
149
+ "You are a movie pre-production planner. Given a movie idea, its art style, and one character, write that character's standalone character-reference image prompt.",
150
150
  "",
151
151
  "Return ONLY valid JSON (no markdown fences, no commentary) matching this exact shape:",
152
152
  "",
153
153
  '{"imagePrompt":"string"}',
154
154
  "",
155
155
  "Rules:",
156
- "- The imagePrompt 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.",
156
+ "- The imagePrompt describes a close-up portrait shot of the character's face on a plain solid white background, framed head and shoulders, facing the camera, evenly lit with soft neutral studio lighting and no props, scenery or background elements.",
157
+ "- The imagePrompt begins with the character's name and then concentrates on the face: age, face shape, skin tone and texture, eyes, eyebrows, nose, mouth, facial hair, hairstyle and hair colour, expression, and any distinctive facial features such as scars, freckles or markings.",
158
+ "- Include the character's cultural background — the era/age, culture, ethnicity, and region they belong to — so the face is historically and culturally accurate and consistent.",
159
+ "- Mention clothing only as far as the collar or neckline is visible in a head-and-shoulders crop; do not describe the full outfit, body build, pose, or action.",
157
160
  "- Apply the given artStyle.",
158
161
  "- Write the prompt as natural-language English sentences, never comma-separated keyword tags.",
159
162
  ].join("\n");
@@ -1,5 +1,5 @@
1
1
  import { type Application } from "express";
2
- export type QueueTaskType = "generate" | "render" | "render-assets" | "render-videos" | "render-scene-images" | "regenerate-asset" | "regenerate-video" | "regenerate-scene-image";
2
+ export type QueueTaskType = "generate" | "render" | "render-assets" | "render-videos" | "render-scene-images" | "render-asset" | "render-scene-image" | "render-video" | "regenerate-asset" | "regenerate-video" | "regenerate-scene-image";
3
3
  export type QueueTaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled" | "paused";
4
4
  export interface QueueTask {
5
5
  id: string;
@@ -1,4 +1,4 @@
1
- import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from "node:fs";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { randomUUID } from "node:crypto";
@@ -78,10 +78,18 @@ const sseClients = new Set();
78
78
  /** Push an event to every SSE client watching the given project. */
79
79
  function broadcast(projectId, event, data) {
80
80
  for (const client of sseClients) {
81
- if (client.projectId !== projectId)
81
+ if (client.projectId !== null && client.projectId !== projectId)
82
82
  continue;
83
+ let payload = data;
84
+ // Global watchers need to know which project each task belongs to.
85
+ if (client.projectId === null &&
86
+ event === "task" &&
87
+ data &&
88
+ typeof data === "object") {
89
+ payload = { ...data, projectId };
90
+ }
83
91
  try {
84
- client.res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
92
+ client.res.write(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
85
93
  }
86
94
  catch {
87
95
  sseClients.delete(client);
@@ -130,6 +138,24 @@ function loadState(projectId) {
130
138
  void pump();
131
139
  return state;
132
140
  }
141
+ /** Load every persisted project queue into memory so it is visible and resumed. */
142
+ function loadAllStates() {
143
+ let entries = [];
144
+ try {
145
+ entries = existsSync(TASKS_DIR) ? readdirSync(TASKS_DIR) : [];
146
+ }
147
+ catch {
148
+ entries = [];
149
+ }
150
+ entries.sort();
151
+ for (const entry of entries) {
152
+ if (!isValidProjectId(entry))
153
+ continue;
154
+ if (!existsSync(queueFile(entry)))
155
+ continue;
156
+ loadState(entry);
157
+ }
158
+ }
133
159
  function persist(projectId) {
134
160
  const state = queues.get(projectId);
135
161
  if (!state)
@@ -400,6 +426,78 @@ const handlers = {
400
426
  const { characters, places, scenes } = ctx.task.payload || {};
401
427
  return runFullRender(ctx, await getUvPath(), characters, places, scenes);
402
428
  },
429
+ // Generate a single character/place image, skipping if it already exists.
430
+ "render-asset": async (ctx) => {
431
+ const { kind, slug, prompt } = ctx.task.payload || {};
432
+ if (kind !== "character" && kind !== "place") {
433
+ throw new Error("kind must be 'character' or 'place'");
434
+ }
435
+ const s = slugify(slug);
436
+ const p = String(prompt || "").trim();
437
+ if (!s || !p)
438
+ throw new Error("slug and prompt are required");
439
+ const existing = existingOutput(ctx.projectId, `${kind}-${s}.png`);
440
+ if (existing) {
441
+ ctx.log(`Already generated: ${kind}-${s}.png — skipping\n`);
442
+ return { kind, slug: s, ...existing };
443
+ }
444
+ const r = await generateAssetImage(ctx.projectId, kind, s, p, ctx.log);
445
+ if ("error" in r)
446
+ throw new Error(r.error);
447
+ return {
448
+ kind,
449
+ slug: s,
450
+ filename: r.filename,
451
+ url: r.url,
452
+ updatedAt: Date.now(),
453
+ };
454
+ },
455
+ // Generate a single scene image, skipping if it already exists.
456
+ "render-scene-image": async (ctx) => {
457
+ const { scene } = ctx.task.payload || {};
458
+ if (!scene || typeof scene !== "object")
459
+ throw new Error("scene is required");
460
+ const s = slugify(scene?.slug);
461
+ if (!s)
462
+ throw new Error("Invalid scene slug");
463
+ const existing = existingOutput(ctx.projectId, `scene-${s}.png`);
464
+ if (existing) {
465
+ ctx.log(`Already generated: scene-${s}.png — skipping\n`);
466
+ return { slug: s, ...existing };
467
+ }
468
+ const r = await generateSceneImage(ctx.projectId, scene, ctx.log);
469
+ if ("error" in r)
470
+ throw new Error(r.error);
471
+ return {
472
+ slug: s,
473
+ filename: r.filename,
474
+ url: r.url,
475
+ updatedAt: Date.now(),
476
+ };
477
+ },
478
+ // Generate a single scene video, skipping if it already exists.
479
+ "render-video": async (ctx, getUvPath) => {
480
+ const { scene, characters } = ctx.task.payload || {};
481
+ if (!scene || typeof scene !== "object")
482
+ throw new Error("scene is required");
483
+ const s = slugify(scene?.slug);
484
+ if (!s)
485
+ throw new Error("Invalid scene slug");
486
+ const existing = existingOutput(ctx.projectId, `scene-${s}.mp4`);
487
+ if (existing) {
488
+ ctx.log(`Already generated: scene-${s}.mp4 — skipping\n`);
489
+ return { slug: s, ...existing };
490
+ }
491
+ const r = await generateSceneVideo(await getUvPath(), ctx.projectId, scene, Array.isArray(characters) ? characters : [], ctx.log);
492
+ if ("error" in r)
493
+ throw new Error(r.error);
494
+ return {
495
+ slug: s,
496
+ filename: r.filename,
497
+ url: r.url,
498
+ updatedAt: Date.now(),
499
+ };
500
+ },
403
501
  "regenerate-asset": async (ctx) => {
404
502
  const { kind, slug, prompt } = ctx.task.payload || {};
405
503
  if (kind !== "character" && kind !== "place") {
@@ -581,7 +679,7 @@ function syncToMovieStudioState(projectId, type, result) {
581
679
  ? result.renderedScenes
582
680
  : state.renderedScenes ?? [];
583
681
  }
584
- if (type === "regenerate-asset" && result) {
682
+ if ((type === "render-asset" || type === "regenerate-asset") && result) {
585
683
  const key = `${result.kind}:${result.slug}`;
586
684
  const arr = Array.isArray(state.assets) ? state.assets : [];
587
685
  state.assets = [
@@ -589,14 +687,15 @@ function syncToMovieStudioState(projectId, type, result) {
589
687
  result,
590
688
  ];
591
689
  }
592
- if (type === "regenerate-video" && result) {
690
+ if ((type === "render-video" || type === "regenerate-video") && result) {
593
691
  const arr = Array.isArray(state.videos) ? state.videos : [];
594
692
  state.videos = [
595
693
  ...arr.filter((v) => v.slug !== result.slug),
596
694
  result,
597
695
  ];
598
696
  }
599
- if (type === "regenerate-scene-image" && result) {
697
+ if ((type === "render-scene-image" || type === "regenerate-scene-image") &&
698
+ result) {
600
699
  const arr = Array.isArray(state.sceneImages) ? state.sceneImages : [];
601
700
  state.sceneImages = [
602
701
  ...arr.filter((i) => i.slug !== result.slug),
@@ -645,6 +744,18 @@ export function generationQueueSetup({ app, getUvPath, }) {
645
744
  paused: pausedProjects.has(projectId),
646
745
  });
647
746
  });
747
+ // List every task across all projects, each tagged with its project id.
748
+ app.get("/api/queue/all", (_req, res) => {
749
+ loadAllStates();
750
+ const tasks = [];
751
+ for (const [projectId, state] of queues) {
752
+ for (const t of state.tasks) {
753
+ tasks.push({ ...t, projectId });
754
+ }
755
+ }
756
+ tasks.sort((a, b) => a.createdAt - b.createdAt);
757
+ res.json({ tasks });
758
+ });
648
759
  // Read a project's persisted terminal log (tail, so large logs stay bounded).
649
760
  app.get("/api/logs", (req, res) => {
650
761
  const projectId = String(req.query.projectId ?? "");
@@ -670,7 +781,8 @@ export function generationQueueSetup({ app, getUvPath, }) {
670
781
  // Server-Sent Events: push queue/task and log updates to a watching client.
671
782
  app.get("/api/events", (req, res) => {
672
783
  const projectId = String(req.query.projectId ?? "");
673
- if (!isValidProjectId(projectId)) {
784
+ const all = projectId === "*";
785
+ if (!all && !isValidProjectId(projectId)) {
674
786
  res.status(400).json({ error: "Invalid project ID" });
675
787
  return;
676
788
  }
@@ -681,7 +793,7 @@ export function generationQueueSetup({ app, getUvPath, }) {
681
793
  "X-Accel-Buffering": "no",
682
794
  });
683
795
  res.write(`event: hello\ndata: {}\n\n`);
684
- const client = { res, projectId };
796
+ const client = { res, projectId: all ? null : projectId };
685
797
  sseClients.add(client);
686
798
  req.on("close", () => {
687
799
  sseClients.delete(client);
@@ -27,7 +27,7 @@ const PROJECTS_FILE = join(JSON_DIR, "projects.json");
27
27
  const CHARACTERS_FILE = join(JSON_DIR, "characters.json");
28
28
  const Z_IMAGE_MODEL = "AbstractFramework/z-image-turbo-8bit";
29
29
  const FLUX_KLEIN_MODEL = "AbstractFramework/flux.2-klein-4b-8bit";
30
- const MLX_VLM_MODEL = "mlx-community/gemma-4-e2b-it-4bit";
30
+ const MLX_VLM_MODEL = "mlx-community/gemma-4-e4b-it-8bit";
31
31
  const VIDEO_STAGE_FLAGS = {
32
32
  distilled: "--distilled",
33
33
  "one-stage": "--one-stage",
@@ -614,7 +614,7 @@ export async function generateSceneImage(projectId, scene, onLog) {
614
614
  const fluxArgs = [mlxgen, "generate", "--model", FLUX_KLEIN_MODEL];
615
615
  for (const p of refImages)
616
616
  fluxArgs.push("--image", p);
617
- fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps", "5", "--seed", "42", "--width", "448", "--height", "796");
617
+ fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps", "5", "--seed", "42", "--width", "1024", "--height", "1024");
618
618
  const result = await runCommand(fluxArgs, { onLog });
619
619
  if (!result.success || !existsSync(sceneImagePath)) {
620
620
  return { error: result.output || `Failed to generate scene image ${s}` };
@@ -2286,6 +2286,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2286
2286
  installed: whichSync("hf") !== null,
2287
2287
  ltxDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx-q8"),
2288
2288
  ttsDownloaded: isModelDownloaded("Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
2289
+ mlxVlmDownloaded: isModelDownloaded(MLX_VLM_MODEL),
2289
2290
  });
2290
2291
  });
2291
2292
  app.post("/api/hf/install", async (_req, res) => {
@@ -2342,7 +2343,52 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2342
2343
  status: "starting",
2343
2344
  label: "Downloading dgrauet/ltx-2.3-mlx-q8...",
2344
2345
  });
2345
- const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], { stdout: "pipe", stderr: "pipe" });
2346
+ const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], {
2347
+ stdout: "pipe",
2348
+ stderr: "pipe",
2349
+ });
2350
+ activeProc = proc;
2351
+ const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
2352
+ const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
2353
+ await stdoutPromise;
2354
+ const exitCode = await proc.exited;
2355
+ if (exitCode === 0) {
2356
+ send("complete", { success: true });
2357
+ }
2358
+ else {
2359
+ send("error", {
2360
+ error: stderrText || `Process exited with code ${exitCode}`,
2361
+ exitCode,
2362
+ });
2363
+ }
2364
+ }
2365
+ catch (e) {
2366
+ send("error", { error: String(e) });
2367
+ }
2368
+ finally {
2369
+ activeProc = null;
2370
+ res.end();
2371
+ }
2372
+ });
2373
+ app.post("/api/hf/download-mlx-vlm", async (_req, res) => {
2374
+ res.writeHead(200, {
2375
+ "Content-Type": "text/event-stream",
2376
+ "Cache-Control": "no-cache",
2377
+ Connection: "keep-alive",
2378
+ "X-Accel-Buffering": "no",
2379
+ });
2380
+ const send = (event, data) => {
2381
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
2382
+ };
2383
+ try {
2384
+ send("progress", {
2385
+ status: "starting",
2386
+ label: `Downloading ${MLX_VLM_MODEL}...`,
2387
+ });
2388
+ const proc = spawn(["hf", "download", MLX_VLM_MODEL], {
2389
+ stdout: "pipe",
2390
+ stderr: "pipe",
2391
+ });
2346
2392
  activeProc = proc;
2347
2393
  const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
2348
2394
  const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
@@ -2381,7 +2427,10 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
2381
2427
  status: "starting",
2382
2428
  label: "Downloading Qwen/Qwen3-TTS-12Hz-1.7B-Base...",
2383
2429
  });
2384
- const proc = spawn(["hf", "download", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"], { stdout: "pipe", stderr: "pipe" });
2430
+ const proc = spawn(["hf", "download", "Qwen/Qwen3-TTS-12Hz-1.7B-Base"], {
2431
+ stdout: "pipe",
2432
+ stderr: "pipe",
2433
+ });
2385
2434
  activeProc = proc;
2386
2435
  const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
2387
2436
  const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
@@ -200,6 +200,11 @@ const DOWNLOAD_MODELS: {
200
200
  name: "Qwen/Qwen3-TTS-12Hz-1.7B-Base",
201
201
  desc: "Voice-over & speech",
202
202
  },
203
+ {
204
+ id: "gemma",
205
+ name: "mlx-community/gemma-4-e4b-it-8bit",
206
+ desc: "Story planning & dialogue (LLM)",
207
+ },
203
208
  ];
204
209
 
205
210
  export default function SetupAiModelTab() {
@@ -233,7 +238,10 @@ export default function SetupAiModelTab() {
233
238
  <section>
234
239
  <h3 className="mb-2 text-sm font-semibold text-ink-900">Engines</h3>
235
240
  <div className="space-y-2">
236
- <ModelRow name="mlx-gen" desc="Image generation engine (z-image · flux)">
241
+ <ModelRow
242
+ name="mlx-gen"
243
+ desc="Image generation engine (z-image · flux)"
244
+ >
237
245
  <StatusBadge
238
246
  value={store.mlxgenInstalled}
239
247
  okText="Installed"
@@ -287,20 +295,6 @@ export default function SetupAiModelTab() {
287
295
  <section>
288
296
  <h3 className="mb-2 text-sm font-semibold text-ink-900">Models</h3>
289
297
  <div className="space-y-2">
290
- <ModelRow
291
- name="mlx-community/gemma-4-e2b-it-4bit"
292
- desc="Story planning & dialogue (LLM)"
293
- >
294
- <StatusBadge
295
- value={store.mlxVlmInstalled}
296
- okText="Ready"
297
- missingText="Not set up"
298
- />
299
- <span className="text-xs italic text-ink-400">
300
- Downloads on first server start
301
- </span>
302
- </ModelRow>
303
-
304
298
  {DOWNLOAD_MODELS.map((m) => {
305
299
  const downloaded =
306
300
  m.id === "z-image"
@@ -309,7 +303,9 @@ export default function SetupAiModelTab() {
309
303
  ? store.fluxDownloaded
310
304
  : m.id === "ltx"
311
305
  ? store.ltxDownloaded
312
- : store.ttsDownloaded;
306
+ : m.id === "gemma"
307
+ ? store.gemmaDownloaded
308
+ : store.ttsDownloaded;
313
309
  return (
314
310
  <ModelRow key={m.id} name={m.name} desc={m.desc}>
315
311
  <StatusBadge value={downloaded} />
@@ -126,9 +126,11 @@ function StatusBadge({ status }: { status: QueueTaskStatus }) {
126
126
 
127
127
  function TaskRow({
128
128
  task,
129
+ showProject = false,
129
130
  onCancel,
130
131
  }: {
131
132
  task: QueueTask;
133
+ showProject?: boolean;
132
134
  onCancel: () => void;
133
135
  }) {
134
136
  const active = task.status === "pending" || task.status === "running";
@@ -142,6 +144,11 @@ function TaskRow({
142
144
  <li className="flex flex-col gap-1.5 px-3 py-2 rounded-xl border border-ink-200 bg-white">
143
145
  <div className="flex items-center gap-2">
144
146
  <StatusBadge status={task.status} />
147
+ {showProject && task.projectId && (
148
+ <span className="px-1.5 py-0.5 rounded bg-ink-100 text-ink-500 text-[10px] font-mono whitespace-nowrap">
149
+ {task.projectId}
150
+ </span>
151
+ )}
145
152
  <span className="flex-1 text-sm font-medium text-ink-800 truncate">
146
153
  {task.label}
147
154
  </span>
@@ -179,19 +186,25 @@ function TaskRow({
179
186
 
180
187
  export default function TaskQueuePanel({ projectId }: Props) {
181
188
  const tasks = useQueueStore((s) => s.tasks);
189
+ const allTasks = useQueueStore((s) => s.allTasks);
190
+ const showAll = useQueueStore((s) => s.showAll);
182
191
  const paused = useQueueStore((s) => s.paused);
183
192
  const cancel = useQueueStore((s) => s.cancel);
184
193
  const pause = useQueueStore((s) => s.pause);
185
194
  const resume = useQueueStore((s) => s.resume);
186
195
  const clearFinished = useQueueStore((s) => s.clearFinished);
196
+ const setShowAll = useQueueStore((s) => s.setShowAll);
197
+
198
+ const displayTasks = showAll ? allTasks : tasks;
187
199
 
188
- if (tasks.length === 0 && !paused) return null;
200
+ if (!showAll && tasks.length === 0 && !paused) return null;
189
201
 
190
- const hasActive = tasks.some(
202
+ const hasActive = displayTasks.some(
191
203
  (t) => t.status === "pending" || t.status === "running",
192
204
  );
193
- const hasFinished = tasks.some(
194
- (t) => t.status !== "pending" && t.status !== "running" && t.status !== "paused",
205
+ const hasFinished = displayTasks.some(
206
+ (t) =>
207
+ t.status !== "pending" && t.status !== "running" && t.status !== "paused",
195
208
  );
196
209
 
197
210
  return (
@@ -199,9 +212,22 @@ export default function TaskQueuePanel({ projectId }: Props) {
199
212
  <div className="flex items-center gap-2">
200
213
  <span className="text-tiffany-600">{QueueIcon}</span>
201
214
  <h3 className="text-sm font-semibold text-ink-900">Generation Queue</h3>
202
- <span className="text-xs text-ink-500">{tasks.length}</span>
215
+ <span className="text-xs text-ink-500">{displayTasks.length}</span>
216
+
217
+ <button
218
+ onClick={() => setShowAll(!showAll)}
219
+ title={showAll ? "Show only this project" : "Show all projects"}
220
+ className={`flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xl transition-colors ${
221
+ showAll
222
+ ? "bg-tiffany-500 text-ink-950 hover:bg-tiffany-600"
223
+ : "border border-ink-200 text-ink-600 hover:border-ink-300 hover:text-ink-900"
224
+ }`}
225
+ >
226
+ {showAll ? "All projects" : "This project"}
227
+ </button>
228
+
203
229
  <div className="ml-auto flex items-center gap-2">
204
- {paused ? (
230
+ {!showAll && paused ? (
205
231
  <button
206
232
  onClick={() => resume(projectId)}
207
233
  className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
@@ -210,6 +236,7 @@ export default function TaskQueuePanel({ projectId }: Props) {
210
236
  Resume
211
237
  </button>
212
238
  ) : (
239
+ !showAll &&
213
240
  hasActive && (
214
241
  <button
215
242
  onClick={() => pause(projectId)}
@@ -220,7 +247,7 @@ export default function TaskQueuePanel({ projectId }: Props) {
220
247
  </button>
221
248
  )
222
249
  )}
223
- {hasFinished && (
250
+ {!showAll && hasFinished && (
224
251
  <button
225
252
  onClick={() => clearFinished(projectId)}
226
253
  className="flex items-center gap-1.5 px-2.5 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"
@@ -233,11 +260,12 @@ export default function TaskQueuePanel({ projectId }: Props) {
233
260
  </div>
234
261
 
235
262
  <ul className="flex flex-col gap-1.5">
236
- {tasks.map((task) => (
263
+ {displayTasks.map((task) => (
237
264
  <TaskRow
238
265
  key={task.id}
239
266
  task={task}
240
- onCancel={() => cancel(projectId, task.id)}
267
+ showProject={showAll}
268
+ onCancel={() => cancel(task.projectId ?? projectId, task.id)}
241
269
  />
242
270
  ))}
243
271
  </ul>
@@ -3,7 +3,7 @@ import { create } from "zustand";
3
3
  const API_BASE = `http://localhost:${(window as any).PORT}`;
4
4
 
5
5
  /** Models that have a dedicated download endpoint in the backend. */
6
- export type AiModelId = "z-image" | "flux" | "ltx" | "tts";
6
+ export type AiModelId = "z-image" | "flux" | "ltx" | "tts" | "gemma";
7
7
 
8
8
  /** Tools whose "install" step must run before their models can be downloaded. */
9
9
  export type AiToolId = "mlxgen" | "mlx-vlm" | "hf-cli";
@@ -18,6 +18,7 @@ interface AiModelStore {
18
18
  fluxDownloaded: boolean | null;
19
19
  ltxDownloaded: boolean | null;
20
20
  ttsDownloaded: boolean | null;
21
+ gemmaDownloaded: boolean | null;
21
22
  // In-flight install/download (a model id or tool id)
22
23
  downloading: string | null;
23
24
  logs: string[];
@@ -69,6 +70,7 @@ const DOWNLOAD_ENDPOINTS: Record<AiModelId, string> = {
69
70
  flux: "/api/mlxgen/download-flux-model",
70
71
  ltx: "/api/hf/download-ltx",
71
72
  tts: "/api/hf/download-tts",
73
+ gemma: "/api/hf/download-mlx-vlm",
72
74
  };
73
75
 
74
76
  const TOOL_ENDPOINTS: Record<AiToolId, string> = {
@@ -85,6 +87,7 @@ export const useAiModelStore = create<AiModelStore>((set, get) => ({
85
87
  fluxDownloaded: null,
86
88
  ltxDownloaded: null,
87
89
  ttsDownloaded: null,
90
+ gemmaDownloaded: null,
88
91
  downloading: null,
89
92
  logs: [],
90
93
  error: null,
@@ -108,6 +111,7 @@ export const useAiModelStore = create<AiModelStore>((set, get) => ({
108
111
  hfInstalled: hf ? Boolean(hf.installed) : null,
109
112
  ltxDownloaded: hf ? Boolean(hf.ltxDownloaded) : null,
110
113
  ttsDownloaded: hf ? Boolean(hf.ttsDownloaded) : null,
114
+ gemmaDownloaded: hf ? Boolean(hf.mlxVlmDownloaded) : null,
111
115
  });
112
116
  } catch {
113
117
  // Leave status unknown (null) if the checks fail.
@@ -30,6 +30,98 @@ function playDing3x() {
30
30
  /** Track task ids whose completed result has already been applied. */
31
31
  const appliedCompleted = new Set<string>();
32
32
 
33
+ /** Track task ids already counted toward their batch's completion. */
34
+ const countedBatch = new Set<string>();
35
+
36
+ interface BatchTracker {
37
+ kind: "assets" | "sceneImages" | "videos" | "render";
38
+ total: number;
39
+ finished: number;
40
+ }
41
+
42
+ /** In-flight bulk render batches, keyed by the batchId stamped on each task. */
43
+ const batches = new Map<string, BatchTracker>();
44
+
45
+ function makeBatchId(): string {
46
+ try {
47
+ return crypto.randomUUID();
48
+ } catch {
49
+ return `b-${Date.now()}-${Math.random().toString(36).slice(2)}`;
50
+ }
51
+ }
52
+
53
+ /** Reset the aggregate spinner once a batch finishes and play a completion ding. */
54
+ function finalizeBatch(batchId: string, set: (patch: any) => void): void {
55
+ const b = batches.get(batchId);
56
+ if (!b) return;
57
+ batches.delete(batchId);
58
+ switch (b.kind) {
59
+ case "assets":
60
+ set({
61
+ assetsRendering: false,
62
+ assetStatus: b.finished > 0 ? "Assets rendered" : null,
63
+ });
64
+ break;
65
+ case "sceneImages":
66
+ set({
67
+ sceneImagesRendering: false,
68
+ sceneImageStatus: b.finished > 0 ? "Scene images rendered" : null,
69
+ sceneImageProgress: null,
70
+ });
71
+ break;
72
+ case "videos":
73
+ set({
74
+ videosRendering: false,
75
+ videoStatus: b.finished > 0 ? "Videos rendered" : null,
76
+ videoProgress: null,
77
+ });
78
+ break;
79
+ case "render":
80
+ set({
81
+ rendering: false,
82
+ renderStatus: b.finished > 0 ? "Render complete" : null,
83
+ renderProgress: null,
84
+ });
85
+ break;
86
+ }
87
+ if (b.finished > 0) playDing3x();
88
+ }
89
+
90
+ /** Count one finished batch task; update progress and finalize when complete. */
91
+ function finishBatch(batchId: string, set: (patch: any) => void): void {
92
+ const b = batches.get(batchId);
93
+ if (!b) return;
94
+ b.finished += 1;
95
+ if (b.finished < b.total) {
96
+ const status = `${b.finished}/${b.total} rendered`;
97
+ if (b.kind === "assets") set({ assetStatus: status });
98
+ else if (b.kind === "sceneImages") set({ sceneImageStatus: status });
99
+ else if (b.kind === "videos") set({ videoStatus: status });
100
+ else if (b.kind === "render") set({ renderStatus: status });
101
+ return;
102
+ }
103
+ finalizeBatch(batchId, set);
104
+ }
105
+
106
+ /** Surface a per-item failure and count it toward its batch on terminal status. */
107
+ function handleBatchTerminal(task: QueueTask, set: (patch: any) => void): void {
108
+ const batchId = task.payload?.batchId;
109
+ if (!batchId) return;
110
+ if (countedBatch.has(task.id)) return;
111
+ countedBatch.add(task.id);
112
+
113
+ if (task.status === "failed") {
114
+ const b = batches.get(batchId);
115
+ if (b) {
116
+ if (b.kind === "assets") set({ assetsError: task.error });
117
+ else if (b.kind === "sceneImages") set({ sceneImagesError: task.error });
118
+ else if (b.kind === "videos") set({ videosError: task.error });
119
+ else if (b.kind === "render") set({ renderError: task.error });
120
+ }
121
+ }
122
+ finishBatch(batchId, set);
123
+ }
124
+
33
125
  export interface MovieCharacter {
34
126
  slug: string;
35
127
  name: string;
@@ -116,6 +208,18 @@ function upsertSceneImage(
116
208
  return [...images, { slug, filename: "", url: "", updatedAt: 0, ...patch }];
117
209
  }
118
210
 
211
+ function upsertRenderedScene(
212
+ scenes: RenderedScene[],
213
+ slug: string,
214
+ patch: Partial<RenderedScene>,
215
+ ): RenderedScene[] {
216
+ const existing = scenes.find((s) => s.slug === slug);
217
+ if (existing) {
218
+ return scenes.map((s) => (s.slug === slug ? { ...s, ...patch } : s));
219
+ }
220
+ return [...scenes, { slug, imageUrl: null, videoUrl: null, ...patch }];
221
+ }
222
+
119
223
  /** Enqueue a generation task in the backend worker. */
120
224
  async function enqueueTask(
121
225
  projectId: string,
@@ -281,6 +385,30 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
281
385
  const result = get().result;
282
386
  if (!result || get().rendering) return;
283
387
 
388
+ const assets: { kind: "character" | "place"; slug: string; prompt: string }[] = [
389
+ ...result.characters
390
+ .filter((c) => c.slug && String(c.imagePrompt || "").trim())
391
+ .map((c) => ({
392
+ kind: "character" as const,
393
+ slug: c.slug,
394
+ prompt: c.imagePrompt,
395
+ })),
396
+ ...result.places
397
+ .filter((p) => p.slug && String(p.imagePrompt || "").trim())
398
+ .map((p) => ({
399
+ kind: "place" as const,
400
+ slug: p.slug,
401
+ prompt: p.imagePrompt,
402
+ })),
403
+ ];
404
+ const scenes = result.scenes.filter((s) => s.slug);
405
+ // One queue task per output: each asset image, scene image, and scene video.
406
+ const total = assets.length + scenes.length * 2;
407
+ if (total === 0) return;
408
+
409
+ const batchId = makeBatchId();
410
+ batches.set(batchId, { kind: "render", total, finished: 0 });
411
+
284
412
  set({
285
413
  rendering: true,
286
414
  renderStatus: "Queued…",
@@ -288,26 +416,93 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
288
416
  renderError: null,
289
417
  renderProgress: null,
290
418
  });
291
- const r = await enqueueTask(projectId, "render", "Render full movie", {
292
- characters: result.characters,
293
- places: result.places,
294
- scenes: result.scenes,
295
- });
296
- if (!r.ok) set({ rendering: false, renderError: r.error });
419
+
420
+ // FIFO order matters: assets → scene images → scene videos.
421
+ for (const item of assets) {
422
+ const r = await enqueueTask(
423
+ projectId,
424
+ "render-asset",
425
+ `Render ${item.kind}: ${item.slug}`,
426
+ { ...item, batchId },
427
+ );
428
+ if (!r.ok) {
429
+ const b = batches.get(batchId);
430
+ if (b) b.total -= 1;
431
+ }
432
+ }
433
+ for (const scene of scenes) {
434
+ const r = await enqueueTask(
435
+ projectId,
436
+ "render-scene-image",
437
+ `Render scene image: ${scene.slug}`,
438
+ { scene, batchId },
439
+ );
440
+ if (!r.ok) {
441
+ const b = batches.get(batchId);
442
+ if (b) b.total -= 1;
443
+ }
444
+ }
445
+ for (const scene of scenes) {
446
+ const r = await enqueueTask(
447
+ projectId,
448
+ "render-video",
449
+ `Render video: ${scene.slug}`,
450
+ { scene, characters: result.characters, batchId },
451
+ );
452
+ if (!r.ok) {
453
+ const b = batches.get(batchId);
454
+ if (b) b.total -= 1;
455
+ }
456
+ }
457
+
458
+ // If nothing could be enqueued, reset the spinner immediately.
459
+ const b = batches.get(batchId);
460
+ if (b && b.total <= b.finished) finalizeBatch(batchId, set);
297
461
  },
298
462
 
299
463
  renderAssets: async (projectId) => {
300
464
  const result = get().result;
301
465
  if (!result || get().assetsRendering) return;
302
466
 
467
+ const items: { kind: "character" | "place"; slug: string; prompt: string }[] = [
468
+ ...result.characters
469
+ .filter((c) => c.slug && String(c.imagePrompt || "").trim())
470
+ .map((c) => ({
471
+ kind: "character" as const,
472
+ slug: c.slug,
473
+ prompt: c.imagePrompt,
474
+ })),
475
+ ...result.places
476
+ .filter((p) => p.slug && String(p.imagePrompt || "").trim())
477
+ .map((p) => ({
478
+ kind: "place" as const,
479
+ slug: p.slug,
480
+ prompt: p.imagePrompt,
481
+ })),
482
+ ];
483
+
484
+ if (items.length === 0) return;
485
+
486
+ const batchId = makeBatchId();
487
+ batches.set(batchId, { kind: "assets", total: items.length, finished: 0 });
488
+
303
489
  set({ assetsRendering: true, assetStatus: "Queued…", assetsError: null });
304
- const r = await enqueueTask(
305
- projectId,
306
- "render-assets",
307
- "Render character & place images",
308
- { characters: result.characters, places: result.places },
309
- );
310
- if (!r.ok) set({ assetsRendering: false, assetsError: r.error });
490
+
491
+ for (const item of items) {
492
+ const r = await enqueueTask(
493
+ projectId,
494
+ "render-asset",
495
+ `Render ${item.kind}: ${item.slug}`,
496
+ { ...item, batchId },
497
+ );
498
+ if (!r.ok) {
499
+ const b = batches.get(batchId);
500
+ if (b) b.total -= 1;
501
+ }
502
+ }
503
+
504
+ const b = batches.get(batchId);
505
+ if (b && b.total <= b.finished) finalizeBatch(batchId, set);
311
506
  },
312
507
 
313
508
  regenerateAsset: async (projectId, kind, slug, prompt) => {
@@ -336,19 +531,34 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
336
531
  const result = get().result;
337
532
  if (!result || get().videosRendering) return;
338
533
 
534
+ const scenes = result.scenes.filter((s) => s.slug);
535
+ if (scenes.length === 0) return;
536
+
537
+ const batchId = makeBatchId();
538
+ batches.set(batchId, { kind: "videos", total: scenes.length, finished: 0 });
539
+
339
540
  set({
340
541
  videosRendering: true,
341
542
  videoStatus: "Queued…",
342
543
  videosError: null,
343
544
  videoProgress: null,
344
545
  });
345
- const r = await enqueueTask(
346
- projectId,
347
- "render-videos",
348
- "Render scene videos",
349
- { characters: result.characters, scenes: result.scenes },
350
- );
351
- if (!r.ok) set({ videosRendering: false, videosError: r.error });
546
+
547
+ for (const scene of scenes) {
548
+ const r = await enqueueTask(
549
+ projectId,
550
+ "render-video",
551
+ `Render video: ${scene.slug}`,
552
+ { scene, characters: result.characters, batchId },
553
+ );
554
+ if (!r.ok) {
555
+ const b = batches.get(batchId);
556
+ if (b) b.total -= 1;
557
+ }
558
+ }
559
+
560
+ const b = batches.get(batchId);
561
+ if (b && b.total <= b.finished) finalizeBatch(batchId, set);
352
562
  },
353
563
 
354
564
  regenerateVideo: async (projectId, slug) => {
@@ -379,19 +589,38 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
379
589
  const result = get().result;
380
590
  if (!result || get().sceneImagesRendering) return;
381
591
 
592
+ const scenes = result.scenes.filter((s) => s.slug);
593
+ if (scenes.length === 0) return;
594
+
595
+ const batchId = makeBatchId();
596
+ batches.set(batchId, {
597
+ kind: "sceneImages",
598
+ total: scenes.length,
599
+ finished: 0,
600
+ });
601
+
382
602
  set({
383
603
  sceneImagesRendering: true,
384
604
  sceneImageStatus: "Queued…",
385
605
  sceneImagesError: null,
386
606
  sceneImageProgress: null,
387
607
  });
388
- const r = await enqueueTask(
389
- projectId,
390
- "render-scene-images",
391
- "Render scene images",
392
- { scenes: result.scenes },
393
- );
394
- if (!r.ok) set({ sceneImagesRendering: false, sceneImagesError: r.error });
608
+
609
+ for (const scene of scenes) {
610
+ const r = await enqueueTask(
611
+ projectId,
612
+ "render-scene-image",
613
+ `Render scene image: ${scene.slug}`,
614
+ { scene, batchId },
615
+ );
616
+ if (!r.ok) {
617
+ const b = batches.get(batchId);
618
+ if (b) b.total -= 1;
619
+ }
620
+ }
621
+
622
+ const b = batches.get(batchId);
623
+ if (b && b.total <= b.finished) finalizeBatch(batchId, set);
395
624
  },
396
625
 
397
626
  regenerateSceneImage: async (projectId, slug) => {
@@ -466,6 +695,12 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
466
695
  applyQueueTask: (task) => {
467
696
  const isActive = task.status === "pending" || task.status === "running";
468
697
  const err = task.status === "failed" ? task.error : null;
698
+ const terminal =
699
+ task.status === "completed" ||
700
+ task.status === "failed" ||
701
+ task.status === "cancelled" ||
702
+ task.status === "paused";
703
+ const batchKind = batches.get(task.payload?.batchId)?.kind;
469
704
 
470
705
  switch (task.type) {
471
706
  case "generate": {
@@ -609,6 +844,69 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
609
844
  break;
610
845
  }
611
846
 
847
+ case "render-asset": {
848
+ const key = `${task.payload?.kind}:${task.payload?.slug}`;
849
+ if (task.status === "completed" && task.result) {
850
+ if (!appliedCompleted.has(task.id)) {
851
+ appliedCompleted.add(task.id);
852
+ const r = task.result;
853
+ set((s) => ({
854
+ assets: [
855
+ ...s.assets.filter((a) => `${a.kind}:${a.slug}` !== key),
856
+ r,
857
+ ],
858
+ }));
859
+ persistMovieStudioState();
860
+ }
861
+ }
862
+ if (terminal) handleBatchTerminal(task, set);
863
+ break;
864
+ }
865
+
866
+ case "render-scene-image": {
867
+ const slug = task.payload?.slug;
868
+ if (task.status === "completed" && task.result) {
869
+ if (!appliedCompleted.has(task.id)) {
870
+ appliedCompleted.add(task.id);
871
+ const r = task.result;
872
+ set((s) => ({
873
+ sceneImages: upsertSceneImage(s.sceneImages, slug, r),
874
+ renderedScenes:
875
+ batchKind === "render"
876
+ ? upsertRenderedScene(s.renderedScenes, slug, {
877
+ imageUrl: r.url,
878
+ })
879
+ : s.renderedScenes,
880
+ }));
881
+ persistMovieStudioState();
882
+ }
883
+ }
884
+ if (terminal) handleBatchTerminal(task, set);
885
+ break;
886
+ }
887
+
888
+ case "render-video": {
889
+ const slug = task.payload?.slug;
890
+ if (task.status === "completed" && task.result) {
891
+ if (!appliedCompleted.has(task.id)) {
892
+ appliedCompleted.add(task.id);
893
+ const r = task.result;
894
+ set((s) => ({
895
+ videos: upsertVideo(s.videos, slug, r),
896
+ renderedScenes:
897
+ batchKind === "render"
898
+ ? upsertRenderedScene(s.renderedScenes, slug, {
899
+ videoUrl: r.url,
900
+ })
901
+ : s.renderedScenes,
902
+ }));
903
+ persistMovieStudioState();
904
+ }
905
+ }
906
+ if (terminal) handleBatchTerminal(task, set);
907
+ break;
908
+ }
909
+
612
910
  case "regenerate-asset": {
613
911
  const key = `${task.payload?.kind}:${task.payload?.slug}`;
614
912
  if (task.status === "completed" && task.result) {
@@ -704,6 +1002,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
704
1002
  },
705
1003
 
706
1004
  stop: () => {
1005
+ batches.clear();
1006
+ countedBatch.clear();
707
1007
  fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
708
1008
  const projectId = get().projectId;
709
1009
  if (projectId) {
@@ -725,7 +1025,9 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
725
1025
  });
726
1026
  },
727
1027
 
728
- reset: () =>
1028
+ reset: () => {
1029
+ batches.clear();
1030
+ countedBatch.clear();
729
1031
  set({
730
1032
  idea: "",
731
1033
  generating: false,
@@ -756,7 +1058,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
756
1058
  sceneImagesError: null,
757
1059
  sceneImageProgress: null,
758
1060
  regeneratingSceneImages: [],
759
- }),
1061
+ });
1062
+ },
760
1063
  }));
761
1064
 
762
1065
  function persistMovieStudioState() {
@@ -8,6 +8,9 @@ export type QueueTaskType =
8
8
  | "render-assets"
9
9
  | "render-videos"
10
10
  | "render-scene-images"
11
+ | "render-asset"
12
+ | "render-scene-image"
13
+ | "render-video"
11
14
  | "regenerate-asset"
12
15
  | "regenerate-video"
13
16
  | "regenerate-scene-image";
@@ -33,6 +36,8 @@ export interface QueueTask {
33
36
  createdAt: number;
34
37
  startedAt: number | null;
35
38
  completedAt: number | null;
39
+ /** Only present in the "all projects" view — the task's owning project. */
40
+ projectId?: string;
36
41
  }
37
42
 
38
43
  interface QueueStore {
@@ -41,10 +46,14 @@ interface QueueStore {
41
46
  projectId: string | null;
42
47
  paused: boolean;
43
48
  logs: string;
49
+ showAll: boolean;
50
+ allTasks: QueueTask[];
44
51
  refresh: (projectId: string) => Promise<void>;
45
52
  refreshLogs: (projectId: string) => Promise<void>;
53
+ refreshAll: () => Promise<void>;
46
54
  startStreaming: (projectId: string) => void;
47
55
  stopStreaming: () => void;
56
+ setShowAll: (show: boolean) => void;
48
57
  cancel: (projectId: string, taskId: string) => Promise<void>;
49
58
  cancelActive: (projectId: string) => Promise<void>;
50
59
  clearFinished: (projectId: string) => Promise<void>;
@@ -53,6 +62,7 @@ interface QueueStore {
53
62
  }
54
63
 
55
64
  let eventSource: EventSource | null = null;
65
+ let allEventSource: EventSource | null = null;
56
66
 
57
67
  /** Upsert a task into the list, replacing any existing entry with the same id. */
58
68
  function upsertTask(tasks: QueueTask[], task: QueueTask): QueueTask[] {
@@ -69,6 +79,8 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
69
79
  projectId: null,
70
80
  paused: false,
71
81
  logs: "",
82
+ showAll: false,
83
+ allTasks: [],
72
84
 
73
85
  refresh: async (projectId) => {
74
86
  try {
@@ -101,6 +113,19 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
101
113
  }
102
114
  },
103
115
 
116
+ refreshAll: async () => {
117
+ try {
118
+ const res = await fetch(`${API_BASE}/api/queue/all`);
119
+ if (!res.ok) return;
120
+ const data = (await res.json()) as { tasks: QueueTask[] };
121
+ const tasks = Array.isArray(data.tasks) ? data.tasks : [];
122
+ tasks.sort((a, b) => a.createdAt - b.createdAt);
123
+ set({ allTasks: tasks });
124
+ } catch {
125
+ // ignore fetch failures
126
+ }
127
+ },
128
+
104
129
  startStreaming: (projectId) => {
105
130
  get().stopStreaming();
106
131
  set({ projectId, loading: true });
@@ -144,7 +169,47 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
144
169
  eventSource.close();
145
170
  eventSource = null;
146
171
  }
147
- set({ tasks: [], projectId: null, loading: false, paused: false, logs: "" });
172
+ if (allEventSource) {
173
+ allEventSource.close();
174
+ allEventSource = null;
175
+ }
176
+ set({
177
+ tasks: [],
178
+ allTasks: [],
179
+ projectId: null,
180
+ loading: false,
181
+ paused: false,
182
+ logs: "",
183
+ showAll: false,
184
+ });
185
+ },
186
+
187
+ setShowAll: (show) => {
188
+ if (show) {
189
+ if (allEventSource) allEventSource.close();
190
+ set({ showAll: true });
191
+ void get().refreshAll();
192
+
193
+ const es = new EventSource(`${API_BASE}/api/events?projectId=*`);
194
+ allEventSource = es;
195
+ es.addEventListener("task", (event) => {
196
+ try {
197
+ const task = JSON.parse((event as MessageEvent).data) as QueueTask;
198
+ set((s) => ({ allTasks: upsertTask(s.allTasks, task) }));
199
+ } catch {
200
+ // ignore malformed events
201
+ }
202
+ });
203
+ es.onopen = () => {
204
+ void get().refreshAll();
205
+ };
206
+ } else {
207
+ if (allEventSource) {
208
+ allEventSource.close();
209
+ allEventSource = null;
210
+ }
211
+ set({ showAll: false, allTasks: [] });
212
+ }
148
213
  },
149
214
 
150
215
  cancel: async (projectId, taskId) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectnode/media",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Start a full-stack media app: Vite + React + TypeScript frontend, Express backend with REST + WebSocket API",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "deploy": "npm run build; npm version minor; npm publish --access public",
21
21
  "build": "tsc && node -e \"const fs=require('node:fs');fs.cpSync('src/backend/movie-backend/agent/prompt','dist/backend/movie-backend/agent/prompt',{recursive:true})\"",
22
- "dev": "concurrently -k -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\"; open http://localhsot:5177",
22
+ "dev": "open http://localhost:5177; concurrently -k -n backend,frontend -c blue,green \"bun run dev:backend\" \"bun run dev:frontend\";",
23
23
  "dev:backend": "nodemon",
24
24
  "dev:frontend": "vite",
25
25
  "start": "node bin/effectnode-media"