@effectnode/media 0.5.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.
@@ -3,19 +3,22 @@ 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" | "qwen";
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
- export type AiToolId = "mlxgen" | "mlx-vlm";
9
+ export type AiToolId = "mlxgen" | "mlx-vlm" | "hf-cli";
10
10
 
11
11
  interface AiModelStore {
12
12
  // Tool installation status
13
13
  mlxgenInstalled: boolean | null;
14
14
  mlxVlmInstalled: boolean | null;
15
+ hfInstalled: boolean | null;
15
16
  // Model download status
16
17
  zImageDownloaded: boolean | null;
17
18
  fluxDownloaded: boolean | null;
18
- qwenDownloaded: boolean | null;
19
+ ltxDownloaded: boolean | null;
20
+ ttsDownloaded: boolean | null;
21
+ gemmaDownloaded: boolean | null;
19
22
  // In-flight install/download (a model id or tool id)
20
23
  downloading: string | null;
21
24
  logs: string[];
@@ -65,40 +68,50 @@ async function readSSE(
65
68
  const DOWNLOAD_ENDPOINTS: Record<AiModelId, string> = {
66
69
  "z-image": "/api/mlxgen/download-z-model",
67
70
  flux: "/api/mlxgen/download-flux-model",
68
- qwen: "/api/mlxgen/download-model",
71
+ ltx: "/api/hf/download-ltx",
72
+ tts: "/api/hf/download-tts",
73
+ gemma: "/api/hf/download-mlx-vlm",
69
74
  };
70
75
 
71
76
  const TOOL_ENDPOINTS: Record<AiToolId, string> = {
72
77
  mlxgen: "/api/mlxgen/install",
73
78
  "mlx-vlm": "/api/agent/install",
79
+ "hf-cli": "/api/hf/install",
74
80
  };
75
81
 
76
82
  export const useAiModelStore = create<AiModelStore>((set, get) => ({
77
83
  mlxgenInstalled: null,
78
84
  mlxVlmInstalled: null,
85
+ hfInstalled: null,
79
86
  zImageDownloaded: null,
80
87
  fluxDownloaded: null,
81
- qwenDownloaded: null,
88
+ ltxDownloaded: null,
89
+ ttsDownloaded: null,
90
+ gemmaDownloaded: null,
82
91
  downloading: null,
83
92
  logs: [],
84
93
  error: null,
85
94
 
86
95
  checkStatus: async () => {
87
96
  try {
88
- const [mlxgen, agent] = await Promise.all([
97
+ const [mlxgen, agent, hf] = await Promise.all([
89
98
  fetch(`${API_BASE}/api/mlxgen/status`).then((r) =>
90
99
  r.ok ? r.json() : null,
91
100
  ),
92
101
  fetch(`${API_BASE}/api/agent/status`).then((r) =>
93
102
  r.ok ? r.json() : null,
94
103
  ),
104
+ fetch(`${API_BASE}/api/hf/status`).then((r) => (r.ok ? r.json() : null)),
95
105
  ]);
96
106
  set({
97
107
  mlxgenInstalled: mlxgen ? Boolean(mlxgen.installed) : null,
98
- qwenDownloaded: mlxgen ? Boolean(mlxgen.modelDownloaded) : null,
99
108
  zImageDownloaded: mlxgen ? Boolean(mlxgen.zModelDownloaded) : null,
100
109
  fluxDownloaded: mlxgen ? Boolean(mlxgen.fluxModelDownloaded) : null,
101
110
  mlxVlmInstalled: agent ? Boolean(agent.installed) : null,
111
+ hfInstalled: hf ? Boolean(hf.installed) : null,
112
+ ltxDownloaded: hf ? Boolean(hf.ltxDownloaded) : null,
113
+ ttsDownloaded: hf ? Boolean(hf.ttsDownloaded) : null,
114
+ gemmaDownloaded: hf ? Boolean(hf.mlxVlmDownloaded) : null,
102
115
  });
103
116
  } catch {
104
117
  // Leave status unknown (null) if the checks fail.
@@ -18,7 +18,6 @@ export type GenerationTab =
18
18
  | "extend"
19
19
  | "agent"
20
20
  | "storyWriter"
21
- | "characters"
22
21
  | "extract"
23
22
  | "sceneVisual"
24
23
  | "textToImage"
@@ -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,
@@ -143,6 +247,8 @@ interface MovieStudioStore {
143
247
  projectId: string | null;
144
248
  hydrated: boolean;
145
249
  generating: boolean;
250
+ generateStatus: string | null;
251
+ generateProgress: { current: number; total: number } | null;
146
252
  result: MovieStudioResult | null;
147
253
  error: string | null;
148
254
  rendering: boolean;
@@ -197,6 +303,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
197
303
  projectId: null,
198
304
  hydrated: false,
199
305
  generating: false,
306
+ generateStatus: null,
307
+ generateProgress: null,
200
308
  result: null,
201
309
  error: null,
202
310
  rendering: false,
@@ -250,6 +358,9 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
250
358
  assets: Array.isArray(stored.assets) ? stored.assets : [],
251
359
  videos: Array.isArray(stored.videos) ? stored.videos : [],
252
360
  sceneImages: Array.isArray(stored.sceneImages) ? stored.sceneImages : [],
361
+ renderedScenes: Array.isArray(stored.renderedScenes)
362
+ ? stored.renderedScenes
363
+ : [],
253
364
  });
254
365
  } catch {
255
366
  // Ignore — keep in-memory defaults.
@@ -274,6 +385,30 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
274
385
  const result = get().result;
275
386
  if (!result || get().rendering) return;
276
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
+
277
412
  set({
278
413
  rendering: true,
279
414
  renderStatus: "Queued…",
@@ -281,26 +416,93 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
281
416
  renderError: null,
282
417
  renderProgress: null,
283
418
  });
284
- const r = await enqueueTask(projectId, "render", "Render full movie", {
285
- characters: result.characters,
286
- places: result.places,
287
- scenes: result.scenes,
288
- });
289
- 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);
290
461
  },
291
462
 
292
463
  renderAssets: async (projectId) => {
293
464
  const result = get().result;
294
465
  if (!result || get().assetsRendering) return;
295
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
+
296
489
  set({ assetsRendering: true, assetStatus: "Queued…", assetsError: null });
297
- const r = await enqueueTask(
298
- projectId,
299
- "render-assets",
300
- "Render character & place images",
301
- { characters: result.characters, places: result.places },
302
- );
303
- 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);
304
506
  },
305
507
 
306
508
  regenerateAsset: async (projectId, kind, slug, prompt) => {
@@ -329,19 +531,34 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
329
531
  const result = get().result;
330
532
  if (!result || get().videosRendering) return;
331
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
+
332
540
  set({
333
541
  videosRendering: true,
334
542
  videoStatus: "Queued…",
335
543
  videosError: null,
336
544
  videoProgress: null,
337
545
  });
338
- const r = await enqueueTask(
339
- projectId,
340
- "render-videos",
341
- "Render scene videos",
342
- { characters: result.characters, scenes: result.scenes },
343
- );
344
- 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);
345
562
  },
346
563
 
347
564
  regenerateVideo: async (projectId, slug) => {
@@ -372,19 +589,38 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
372
589
  const result = get().result;
373
590
  if (!result || get().sceneImagesRendering) return;
374
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
+
375
602
  set({
376
603
  sceneImagesRendering: true,
377
604
  sceneImageStatus: "Queued…",
378
605
  sceneImagesError: null,
379
606
  sceneImageProgress: null,
380
607
  });
381
- const r = await enqueueTask(
382
- projectId,
383
- "render-scene-images",
384
- "Render scene images",
385
- { scenes: result.scenes },
386
- );
387
- 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);
388
624
  },
389
625
 
390
626
  regenerateSceneImage: async (projectId, slug) => {
@@ -459,20 +695,40 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
459
695
  applyQueueTask: (task) => {
460
696
  const isActive = task.status === "pending" || task.status === "running";
461
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;
462
704
 
463
705
  switch (task.type) {
464
706
  case "generate": {
465
707
  if (task.status === "completed" && task.result) {
466
708
  if (!appliedCompleted.has(task.id)) {
467
709
  appliedCompleted.add(task.id);
468
- set({ result: task.result, generating: false });
710
+ set({
711
+ result: task.result,
712
+ generating: false,
713
+ generateStatus: null,
714
+ generateProgress: null,
715
+ });
469
716
  persistMovieStudioState();
470
717
  playDing3x();
471
718
  }
472
719
  } else if (err) {
473
- set({ generating: false, error: err });
720
+ set({
721
+ generating: false,
722
+ error: err,
723
+ generateStatus: null,
724
+ generateProgress: null,
725
+ });
474
726
  } else {
475
- set({ generating: isActive });
727
+ set({
728
+ generating: isActive,
729
+ generateStatus: task.status === "running" ? task.statusText : null,
730
+ generateProgress: task.progress,
731
+ });
476
732
  }
477
733
  break;
478
734
  }
@@ -588,6 +844,69 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
588
844
  break;
589
845
  }
590
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
+
591
910
  case "regenerate-asset": {
592
911
  const key = `${task.payload?.kind}:${task.payload?.slug}`;
593
912
  if (task.status === "completed" && task.result) {
@@ -683,6 +1002,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
683
1002
  },
684
1003
 
685
1004
  stop: () => {
1005
+ batches.clear();
1006
+ countedBatch.clear();
686
1007
  fetch(`${API_BASE}/api/render/cancel`, { method: "POST" }).catch(() => {});
687
1008
  const projectId = get().projectId;
688
1009
  if (projectId) {
@@ -704,10 +1025,14 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
704
1025
  });
705
1026
  },
706
1027
 
707
- reset: () =>
1028
+ reset: () => {
1029
+ batches.clear();
1030
+ countedBatch.clear();
708
1031
  set({
709
1032
  idea: "",
710
1033
  generating: false,
1034
+ generateStatus: null,
1035
+ generateProgress: null,
711
1036
  result: null,
712
1037
  error: null,
713
1038
  rendering: false,
@@ -733,7 +1058,8 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
733
1058
  sceneImagesError: null,
734
1059
  sceneImageProgress: null,
735
1060
  regeneratingSceneImages: [],
736
- }),
1061
+ });
1062
+ },
737
1063
  }));
738
1064
 
739
1065
  function persistMovieStudioState() {
@@ -749,6 +1075,7 @@ function persistMovieStudioState() {
749
1075
  assets: s.assets,
750
1076
  videos: s.videos,
751
1077
  sceneImages: s.sceneImages,
1078
+ renderedScenes: s.renderedScenes,
752
1079
  }),
753
1080
  }).catch(() => {});
754
1081
  }