@effectnode/media 0.8.0 → 0.10.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 (33) hide show
  1. package/dist/backend/movie-backend/agent/prompt/script.md +213 -0
  2. package/dist/backend/movie-backend/core.js +33 -0
  3. package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
  4. package/dist/backend/movie-backend/generation-queue.js +82 -6
  5. package/dist/backend/movie-backend/render-media.d.ts +89 -1
  6. package/dist/backend/movie-backend/render-media.js +772 -476
  7. package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
  8. package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
  9. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +67 -51
  10. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
  11. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
  12. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
  13. package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
  14. package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
  15. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +131 -126
  16. package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
  17. package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
  18. package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
  19. package/frontend/src/movie-app/stores/generationStore.ts +250 -345
  20. package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
  21. package/frontend/src/movie-app/stores/projectStore.ts +7 -3
  22. package/frontend/src/movie-app/stores/queueStore.ts +48 -1
  23. package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
  24. package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
  25. package/package.json +1 -1
  26. package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
  27. package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
  28. package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
  29. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
  30. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
  31. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
  32. package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
  33. package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
@@ -1,6 +1,7 @@
1
1
  import { create } from "zustand";
2
2
  import Mustache from "mustache";
3
3
  import Papa from "papaparse";
4
+ import type { QueueTask } from "./queueStore";
4
5
  import {
5
6
  loadTextToImageState,
6
7
  saveTextToImageState,
@@ -15,17 +16,17 @@ export type GenerationTab =
15
16
  | "movieStudio"
16
17
  | "fastImageEdit"
17
18
  | "video"
18
- | "extend"
19
19
  | "agent"
20
20
  | "storyWriter"
21
- | "extract"
22
- | "sceneVisual"
23
21
  | "textToImage"
24
22
  | "batchVideo"
25
23
  | "batchImageToVideo"
26
- | "batchVoice"
27
24
  | "llmServer"
28
- | "aiModels";
25
+ | "aiModels"
26
+ | "upscale"
27
+ | "voiceClone"
28
+ | "audioToVideo"
29
+ | "advancedVoiceClone";
29
30
  export type AspectRatio = "1:1" | "16:9" | "9:16" | "4:3" | "3:4";
30
31
  export type Resolution =
31
32
  | "320p"
@@ -76,16 +77,9 @@ interface VideoState {
76
77
  aspectRatio: AspectRatio;
77
78
  resolution: Resolution;
78
79
  mode: VideoMode;
79
- generating: boolean;
80
- result: string | null;
81
- error: string | null;
82
- logs: string[];
83
- }
84
-
85
- interface ExtendState {
86
- prompt: string;
87
- extendDuration: number;
88
- extendFrames: number;
80
+ model: string;
81
+ stage1Steps: number;
82
+ stage2Steps: number;
89
83
  generating: boolean;
90
84
  result: string | null;
91
85
  error: string | null;
@@ -113,6 +107,8 @@ interface TextToImageState {
113
107
 
114
108
  interface FastImageEditState {
115
109
  prompt: string;
110
+ steps: number;
111
+ upscale: string;
116
112
  referenceImages: ProjectImage[];
117
113
  downloading: boolean;
118
114
  downloadingLogs: string[];
@@ -158,18 +154,14 @@ interface GenerationStore {
158
154
  setVideoAspectRatio: (v: AspectRatio) => void;
159
155
  setVideoResolution: (v: Resolution) => void;
160
156
  setVideoMode: (v: VideoMode) => void;
157
+ setVideoModel: (v: string) => void;
158
+ setVideoStage1Steps: (v: number) => void;
159
+ setVideoStage2Steps: (v: number) => void;
161
160
  clearVideoResult: () => void;
162
161
  generateVideo: (projectId: string, imagePath?: string) => Promise<void>;
162
+ applyVideoQueueTask: (task: QueueTask) => void;
163
163
  cancelGenerate: () => void;
164
164
 
165
- // Video extension
166
- extend: ExtendState;
167
- setExtendPrompt: (v: string) => void;
168
- setExtendDuration: (v: number) => void;
169
- setExtendFrames: (v: number) => void;
170
- clearExtendResult: () => void;
171
- generateExtend: (projectId: string, videoPath: string) => Promise<void>;
172
-
173
165
  // Text-to-image (mlx-gen z-image-turbo)
174
166
  textToImage: TextToImageState;
175
167
  setTextToImagePrompt: (v: string) => void;
@@ -192,12 +184,15 @@ interface GenerationStore {
192
184
  // Fast image edit (mlx-gen FLUX.2 Klein)
193
185
  fastImageEdit: FastImageEditState;
194
186
  setFastImageEditPrompt: (v: string) => void;
187
+ setFastImageEditSteps: (v: number) => void;
188
+ setFastImageEditUpscale: (v: string) => void;
195
189
  toggleFastImageEditImage: (img: ProjectImage) => void;
196
190
  clearFastImageEditImages: () => void;
197
191
  clearFastImageEditResult: () => void;
198
192
  checkFastImageEditStatus: () => Promise<void>;
199
193
  downloadFastImageEditModel: () => Promise<void>;
200
194
  generateFastImageEdit: (projectId: string) => Promise<void>;
195
+ applyFastImageEditQueueTask: (task: QueueTask) => void;
201
196
 
202
197
  // Agent (mlx-vlm)
203
198
  agent: AgentState;
@@ -214,8 +209,6 @@ interface GenerationStore {
214
209
  projectVideos: ProjectVideo[];
215
210
  projectVideosLoading: boolean;
216
211
  fetchProjectVideos: (projectId: string) => Promise<void>;
217
- selectedVideo: ProjectVideo | null;
218
- selectVideo: (video: ProjectVideo | null) => void;
219
212
 
220
213
  // Project audio picker
221
214
  projectAudios: ProjectAudio[];
@@ -241,11 +234,6 @@ interface GenerationStore {
241
234
  selectedImage: ProjectImage | null;
242
235
  selectImage: (img: ProjectImage | null) => void;
243
236
 
244
- // Character sheet picker
245
- characterSheets: ProjectImage[];
246
- characterSheetsLoading: boolean;
247
- fetchCharacterSheets: (projectId: string) => Promise<void>;
248
-
249
237
  // CSV batch generation
250
238
  csvRows: Record<string, string>[];
251
239
  csvColumns: string[];
@@ -270,7 +258,7 @@ interface GenerationStore {
270
258
  export interface ProjectImage {
271
259
  filename: string;
272
260
  url: string;
273
- source: "upload" | "generated" | "characterSheet";
261
+ source: "upload" | "generated";
274
262
  }
275
263
 
276
264
  export interface ProjectVideo {
@@ -391,42 +379,70 @@ async function resizeImageToPng(
391
379
  }
392
380
  }
393
381
 
394
- async function requestFastImageEdit(
395
- body: {
396
- prompt: string;
397
- images: string[];
398
- projectId: string;
399
- },
400
- onLog: (text: string) => void,
401
- ): Promise<{ ok: boolean; error?: string; result?: string }> {
402
- const res = await fetch(`${API_BASE}/api/mlxgen/fast-image-edit`, {
403
- method: "POST",
404
- headers: { "Content-Type": "application/json" },
405
- body: JSON.stringify(body),
406
- });
407
-
408
- if (!res.ok) {
409
- return { ok: false, error: await res.text() };
382
+ /** Enqueue a fast image edit task in the backend queue worker. */
383
+ async function enqueueFastImageEditTask(
384
+ projectId: string,
385
+ prompt: string,
386
+ images: string[],
387
+ steps: number,
388
+ upscale: string,
389
+ ): Promise<{ ok: boolean; error?: string; taskId?: string }> {
390
+ try {
391
+ const res = await fetch(`${API_BASE}/api/queue/enqueue`, {
392
+ method: "POST",
393
+ headers: { "Content-Type": "application/json" },
394
+ body: JSON.stringify({
395
+ projectId,
396
+ type: "fast-image-edit",
397
+ label: "Fast image edit",
398
+ payload: { prompt, images, steps, upscale, projectId },
399
+ }),
400
+ });
401
+ if (!res.ok) {
402
+ return { ok: false, error: await res.text() };
403
+ }
404
+ const task = (await res.json()) as { id?: string };
405
+ return { ok: true, taskId: task.id };
406
+ } catch (e) {
407
+ return { ok: false, error: String(e) };
410
408
  }
409
+ }
411
410
 
412
- let result: string | undefined;
413
- let error: string | undefined;
414
-
415
- await readSSEStream(res, (event, data) => {
416
- switch (event) {
417
- case "log":
418
- onLog(data.text as string);
419
- break;
420
- case "complete":
421
- result = `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`;
422
- break;
423
- case "error":
424
- error = data.error || "Fast image edit failed";
425
- break;
411
+ /** Enqueue a scene video generation task in the backend queue worker. */
412
+ async function enqueueImageToVideoTask(
413
+ projectId: string,
414
+ payload: {
415
+ prompt: string;
416
+ imagePath: string;
417
+ width: number;
418
+ height: number;
419
+ frames: number;
420
+ frameRate: number;
421
+ mode: string;
422
+ model: string;
423
+ stage1Steps: number;
424
+ stage2Steps: number;
425
+ },
426
+ ): Promise<{ ok: boolean; error?: string; taskId?: string }> {
427
+ try {
428
+ const res = await fetch(`${API_BASE}/api/queue/enqueue`, {
429
+ method: "POST",
430
+ headers: { "Content-Type": "application/json" },
431
+ body: JSON.stringify({
432
+ projectId,
433
+ type: "image-to-video",
434
+ label: "Scene video",
435
+ payload,
436
+ }),
437
+ });
438
+ if (!res.ok) {
439
+ return { ok: false, error: await res.text() };
426
440
  }
427
- });
428
-
429
- return { ok: !error, error, result };
441
+ const task = (await res.json()) as { id?: string };
442
+ return { ok: true, taskId: task.id };
443
+ } catch (e) {
444
+ return { ok: false, error: String(e) };
445
+ }
430
446
  }
431
447
 
432
448
  // ========== Abort Controllers ==========
@@ -434,6 +450,22 @@ async function requestFastImageEdit(
434
450
  let generateAbortController: AbortController | null = null;
435
451
  let batchAbortController: AbortController | null = null;
436
452
 
453
+ // ========== Fast Image Edit Queue ==========
454
+
455
+ /** Project whose fast-image-edit task should refresh the image grid on completion. */
456
+ let fastImageEditProjectId: string | null = null;
457
+
458
+ /** The queue task id enqueued by the current generate action, if any. */
459
+ let fastImageEditActiveTaskId: string | null = null;
460
+
461
+ // ========== Scene Video Queue ==========
462
+
463
+ /** Project whose image-to-video task should refresh the video grid on completion. */
464
+ let videoProjectId: string | null = null;
465
+
466
+ /** The queue task id enqueued by the current generate video action, if any. */
467
+ let videoActiveTaskId: string | null = null;
468
+
437
469
  // ========== Beep ==========
438
470
 
439
471
  function playBeep() {
@@ -474,16 +506,9 @@ const initialVideo: VideoState = {
474
506
  aspectRatio: "1:1",
475
507
  resolution: "480p",
476
508
  mode: "distilled",
477
- generating: false,
478
- result: null,
479
- error: null,
480
- logs: [],
481
- };
482
-
483
- const initialExtend: ExtendState = {
484
- prompt: "Continue the scene: the camera holds, motion flows naturally...",
485
- extendDuration: 2,
486
- extendFrames: 2 * 24 + 1,
509
+ model: "dgrauet/ltx-2.3-mlx-q8",
510
+ stage1Steps: 30,
511
+ stage2Steps: 3,
487
512
  generating: false,
488
513
  result: null,
489
514
  error: null,
@@ -536,6 +561,8 @@ const initialTextToImage: TextToImageState = {
536
561
 
537
562
  const initialFastImageEdit: FastImageEditState = {
538
563
  prompt: "",
564
+ steps: 4,
565
+ upscale: "none",
539
566
  referenceImages: [],
540
567
  downloading: false,
541
568
  downloadingLogs: [],
@@ -692,6 +719,21 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
692
719
  setVideoResolution: (resolution) =>
693
720
  set((s) => ({ video: { ...s.video, resolution } })),
694
721
  setVideoMode: (mode) => set((s) => ({ video: { ...s.video, mode } })),
722
+ setVideoModel: (model) => set((s) => ({ video: { ...s.video, model } })),
723
+ setVideoStage1Steps: (stage1Steps) =>
724
+ set((s) => ({
725
+ video: {
726
+ ...s.video,
727
+ stage1Steps: Math.max(1, Math.round(Number(stage1Steps)) || 1),
728
+ },
729
+ })),
730
+ setVideoStage2Steps: (stage2Steps) =>
731
+ set((s) => ({
732
+ video: {
733
+ ...s.video,
734
+ stage2Steps: Math.max(1, Math.round(Number(stage2Steps)) || 1),
735
+ },
736
+ })),
695
737
  clearVideoResult: () =>
696
738
  set((s) => ({
697
739
  video: { ...s.video, result: null, error: null, logs: [] },
@@ -741,10 +783,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
741
783
  // The backend now only accepts bare filenames — extract just the basename
742
784
  resolvedImagePath = resolvedImagePath.split("/").pop() || resolvedImagePath;
743
785
 
744
- // Create a fresh AbortController for this single generate run
745
- generateAbortController = new AbortController();
746
- const signal = generateAbortController.signal;
747
-
748
786
  set((s) => ({
749
787
  video: {
750
788
  ...s.video,
@@ -755,75 +793,63 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
755
793
  },
756
794
  }));
757
795
 
758
- try {
759
- const res = await fetch(`${API_BASE}/api/render/image-to-video`, {
760
- method: "POST",
761
- headers: { "Content-Type": "application/json" },
762
- body: JSON.stringify({
763
- prompt: video.prompt.trim(),
764
- imagePath: resolvedImagePath,
765
- projectId,
766
- width,
767
- height,
768
- frames: video.duration * 24 + 1,
769
- frameRate: 24,
770
- mode: video.mode,
771
- }),
772
- signal,
773
- });
796
+ videoProjectId = projectId;
797
+ const r = await enqueueImageToVideoTask(projectId, {
798
+ prompt: video.prompt.trim(),
799
+ imagePath: resolvedImagePath,
800
+ width,
801
+ height,
802
+ frames: video.duration * 24 + 1,
803
+ frameRate: 24,
804
+ mode: video.mode,
805
+ model: video.model,
806
+ stage1Steps: video.stage1Steps,
807
+ stage2Steps: video.stage2Steps,
808
+ });
809
+ videoActiveTaskId = r.taskId ?? null;
810
+ if (!r.ok) {
811
+ set((s) => ({
812
+ video: {
813
+ ...s.video,
814
+ generating: false,
815
+ error: r.error ?? "Failed to enqueue video",
816
+ },
817
+ }));
818
+ }
819
+ },
774
820
 
775
- if (!res.ok) {
776
- const err = await res.text();
777
- set((s) => ({
778
- video: { ...s.video, generating: false, error: err },
779
- }));
780
- generateAbortController = null;
781
- return;
782
- }
821
+ // Reconcile the scene video state with the latest queue task state. Only the
822
+ // task enqueued by this tab is reflected, so past tasks never surface stale
823
+ // results.
824
+ applyVideoQueueTask: (task) => {
825
+ if (task.type !== "image-to-video") return;
826
+ if (task.id !== videoActiveTaskId) return;
827
+ const v = get().video;
783
828
 
784
- await readSSEStream(res, (event, data) => {
785
- switch (event) {
786
- case "log":
787
- set((s) => ({
788
- video: {
789
- ...s.video,
790
- logs: [...s.video.logs, data.text as string],
791
- },
792
- }));
793
- break;
794
- case "complete":
795
- set((s) => ({
796
- video: {
797
- ...s.video,
798
- generating: false,
799
- result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
800
- },
801
- }));
802
- break;
803
- case "error":
804
- set((s) => ({
805
- video: {
806
- ...s.video,
807
- generating: false,
808
- error: data.error || "Video generation failed",
809
- },
810
- }));
811
- break;
812
- }
829
+ if (task.status === "completed") {
830
+ const url = task.result?.url;
831
+ set({
832
+ video: {
833
+ ...v,
834
+ generating: false,
835
+ result: url ? resolveImageUrl(url) : null,
836
+ error: null,
837
+ },
813
838
  });
814
- } catch (e: any) {
815
- // If the request was aborted, just stop silently
816
- if (e?.name !== "AbortError") {
817
- set((s) => ({
818
- video: { ...s.video, generating: false, error: String(e) },
819
- }));
820
- } else {
821
- set((s) => ({
822
- video: { ...s.video, generating: false },
823
- }));
824
- }
825
- } finally {
826
- generateAbortController = null;
839
+ if (videoProjectId) get().fetchProjectVideos(videoProjectId);
840
+ } else if (task.status === "failed") {
841
+ set({
842
+ video: {
843
+ ...v,
844
+ generating: false,
845
+ error: task.error ?? "Video generation failed",
846
+ },
847
+ });
848
+ } else if (task.status === "cancelled" || task.status === "paused") {
849
+ set({ video: { ...v, generating: false, error: null } });
850
+ } else {
851
+ // pending / running
852
+ set({ video: { ...v, generating: true, error: null } });
827
853
  }
828
854
  },
829
855
 
@@ -892,31 +918,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
892
918
  }
893
919
  },
894
920
 
895
- // ---- Character Sheets ----
896
- characterSheets: [],
897
- characterSheetsLoading: false,
898
-
899
- fetchCharacterSheets: async (projectId) => {
900
- set({ characterSheetsLoading: true });
901
- try {
902
- const res = await fetch(
903
- `${API_BASE}/api/projects/${projectId}/character-sheets`,
904
- );
905
- if (!res.ok) throw new Error(await res.text());
906
- const sheets: { filename: string; url: string }[] = await res.json();
907
- const resolved: ProjectImage[] = sheets.map((sheet) => ({
908
- filename: sheet.filename,
909
- url: sheet.url.startsWith("http")
910
- ? sheet.url
911
- : `http://localhost:${(window as any).PORT}${sheet.url}`,
912
- source: "characterSheet",
913
- }));
914
- set({ characterSheets: resolved, characterSheetsLoading: false });
915
- } catch {
916
- set({ characterSheetsLoading: false });
917
- }
918
- },
919
-
920
921
  // ---- CSV Batch ----
921
922
  csvRows: [],
922
923
  csvColumns: [],
@@ -1076,6 +1077,9 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
1076
1077
  frames: video.duration * 24 + 1,
1077
1078
  frameRate: 24,
1078
1079
  mode: video.mode,
1080
+ model: video.model,
1081
+ stage1Steps: video.stage1Steps,
1082
+ stage2Steps: video.stage2Steps,
1079
1083
  }),
1080
1084
  signal,
1081
1085
  });
@@ -1143,140 +1147,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
1143
1147
  }
1144
1148
  },
1145
1149
 
1146
- // ---- Video Extension ----
1147
- extend: { ...initialExtend },
1148
-
1149
- setExtendPrompt: (prompt) =>
1150
- set((s) => ({ extend: { ...s.extend, prompt, error: null } })),
1151
- setExtendDuration: (extendDuration) =>
1152
- set((s) => ({
1153
- extend: {
1154
- ...s.extend,
1155
- extendDuration,
1156
- extendFrames: extendDuration * 24 + 1,
1157
- },
1158
- })),
1159
- setExtendFrames: (extendFrames) =>
1160
- set((s) => ({
1161
- extend: {
1162
- ...s.extend,
1163
- extendFrames,
1164
- extendDuration: Math.max(0.5, (extendFrames - 1) / 24),
1165
- },
1166
- })),
1167
- clearExtendResult: () =>
1168
- set((s) => ({
1169
- extend: { ...s.extend, result: null, error: null, logs: [] },
1170
- })),
1171
-
1172
- generateExtend: async (projectId, videoPath) => {
1173
- const { extend } = get();
1174
- if (!extend.prompt.trim() || extend.generating) return;
1175
-
1176
- // Extract raw file path from HTTP URL (e.g. http://localhost:PORT/api/files?path=...)
1177
- let resolvedVideoPath = videoPath || "";
1178
- if (!resolvedVideoPath) {
1179
- set((s) => ({
1180
- extend: { ...s.extend, error: "Select a video to extend first." },
1181
- }));
1182
- return;
1183
- }
1184
- if (resolvedVideoPath.includes("/api/files?path=")) {
1185
- try {
1186
- const url = new URL(resolvedVideoPath);
1187
- resolvedVideoPath = url.searchParams.get("path") || resolvedVideoPath;
1188
- } catch {
1189
- // not a valid URL, use as-is
1190
- }
1191
- }
1192
- if (resolvedVideoPath.startsWith("file://")) {
1193
- resolvedVideoPath = resolvedVideoPath.slice(7);
1194
- }
1195
- // The backend only accepts bare filenames — extract just the basename
1196
- resolvedVideoPath = resolvedVideoPath.split("/").pop() || resolvedVideoPath;
1197
-
1198
- // Create a fresh AbortController for this extend run
1199
- generateAbortController = new AbortController();
1200
- const signal = generateAbortController.signal;
1201
-
1202
- set((s) => ({
1203
- extend: {
1204
- ...s.extend,
1205
- generating: true,
1206
- error: null,
1207
- result: null,
1208
- logs: [],
1209
- },
1210
- }));
1211
-
1212
- try {
1213
- const res = await fetch(`${API_BASE}/api/render/extend-video`, {
1214
- method: "POST",
1215
- headers: { "Content-Type": "application/json" },
1216
- body: JSON.stringify({
1217
- prompt: extend.prompt.trim(),
1218
- videoPath: resolvedVideoPath,
1219
- projectId,
1220
- extendFrames: extend.extendDuration * 24 + 1,
1221
- }),
1222
- signal,
1223
- });
1224
-
1225
- if (!res.ok) {
1226
- const err = await res.text();
1227
- set((s) => ({
1228
- extend: { ...s.extend, generating: false, error: err },
1229
- }));
1230
- generateAbortController = null;
1231
- return;
1232
- }
1233
-
1234
- await readSSEStream(res, (event, data) => {
1235
- switch (event) {
1236
- case "log":
1237
- set((s) => ({
1238
- extend: {
1239
- ...s.extend,
1240
- logs: [...s.extend.logs, data.text as string],
1241
- },
1242
- }));
1243
- break;
1244
- case "complete":
1245
- set((s) => ({
1246
- extend: {
1247
- ...s.extend,
1248
- generating: false,
1249
- result: `http://localhost:${(window as any).PORT}/api/files?path=${encodeURIComponent(data.path)}`,
1250
- },
1251
- }));
1252
- // Refresh the video grid so the new extended-*.mp4 appears
1253
- get().fetchProjectVideos(projectId);
1254
- break;
1255
- case "error":
1256
- set((s) => ({
1257
- extend: {
1258
- ...s.extend,
1259
- generating: false,
1260
- error: data.error || "Video extension failed",
1261
- },
1262
- }));
1263
- break;
1264
- }
1265
- });
1266
- } catch (e: any) {
1267
- // If the request was aborted, just stop silently
1268
- if (e?.name !== "AbortError") {
1269
- set((s) => ({
1270
- extend: { ...s.extend, generating: false, error: String(e) },
1271
- }));
1272
- } else {
1273
- set((s) => ({ extend: { ...s.extend, generating: false } }));
1274
- }
1275
- } finally {
1276
- generateAbortController = null;
1277
- }
1278
- },
1279
-
1280
1150
  // ---- Fast Image Edit (FLUX.2 Klein) ----
1281
1151
  fastImageEdit: { ...initialFastImageEdit },
1282
1152
 
@@ -1285,6 +1155,17 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
1285
1155
  fastImageEdit: { ...s.fastImageEdit, prompt, error: null },
1286
1156
  })),
1287
1157
 
1158
+ setFastImageEditSteps: (steps) =>
1159
+ set((s) => ({
1160
+ fastImageEdit: {
1161
+ ...s.fastImageEdit,
1162
+ steps: Math.max(1, Math.round(Number(steps)) || 1),
1163
+ },
1164
+ })),
1165
+
1166
+ setFastImageEditUpscale: (upscale) =>
1167
+ set((s) => ({ fastImageEdit: { ...s.fastImageEdit, upscale } })),
1168
+
1288
1169
  toggleFastImageEditImage: (img) =>
1289
1170
  set((s) => {
1290
1171
  const current = s.fastImageEdit.referenceImages;
@@ -1446,32 +1327,68 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
1446
1327
  images.push(processed.dataUrl);
1447
1328
  }
1448
1329
 
1449
- const result = await requestFastImageEdit(
1450
- {
1451
- prompt: fastImageEdit.prompt.trim(),
1452
- images,
1453
- projectId,
1454
- },
1455
- (text) =>
1456
- set((s) => ({
1457
- fastImageEdit: {
1458
- ...s.fastImageEdit,
1459
- logs: [...s.fastImageEdit.logs, text],
1460
- },
1461
- })),
1330
+ fastImageEditProjectId = projectId;
1331
+ const r = await enqueueFastImageEditTask(
1332
+ projectId,
1333
+ fastImageEdit.prompt.trim(),
1334
+ images,
1335
+ fastImageEdit.steps,
1336
+ fastImageEdit.upscale,
1462
1337
  );
1338
+ fastImageEditActiveTaskId = r.taskId ?? null;
1339
+ if (!r.ok) {
1340
+ set((s) => ({
1341
+ fastImageEdit: {
1342
+ ...s.fastImageEdit,
1343
+ generating: false,
1344
+ error: r.error ?? "Failed to enqueue fast image edit",
1345
+ },
1346
+ }));
1347
+ }
1348
+ },
1463
1349
 
1464
- set((s) => ({
1465
- fastImageEdit: {
1466
- ...s.fastImageEdit,
1467
- generating: false,
1468
- result: result.result ?? null,
1469
- error: result.error ?? null,
1470
- },
1471
- }));
1350
+ // Reconcile the fast image edit state with the latest queue task state.
1351
+ // Only the task enqueued by this tab is reflected, so tasks finished before
1352
+ // the tab opened never surface stale results.
1353
+ applyFastImageEditQueueTask: (task) => {
1354
+ if (task.type !== "fast-image-edit") return;
1355
+ if (task.id !== fastImageEditActiveTaskId) return;
1356
+ const fie = get().fastImageEdit;
1472
1357
 
1473
- if (result.ok) {
1474
- get().fetchProjectImages(projectId);
1358
+ if (task.status === "completed") {
1359
+ const url = task.result?.url;
1360
+ set({
1361
+ fastImageEdit: {
1362
+ ...fie,
1363
+ generating: false,
1364
+ result: url ? resolveImageUrl(url) : null,
1365
+ error: null,
1366
+ },
1367
+ });
1368
+ if (fastImageEditProjectId) {
1369
+ get().fetchProjectImages(fastImageEditProjectId);
1370
+ }
1371
+ } else if (
1372
+ task.status === "failed" ||
1373
+ task.status === "cancelled" ||
1374
+ task.status === "paused"
1375
+ ) {
1376
+ set({
1377
+ fastImageEdit: {
1378
+ ...fie,
1379
+ generating: false,
1380
+ error: task.error ?? "Fast image edit failed",
1381
+ },
1382
+ });
1383
+ } else {
1384
+ // pending / running
1385
+ set({
1386
+ fastImageEdit: {
1387
+ ...fie,
1388
+ generating: true,
1389
+ error: null,
1390
+ },
1391
+ });
1475
1392
  }
1476
1393
  },
1477
1394
 
@@ -2003,7 +1920,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2003
1920
  // ---- Project Videos ----
2004
1921
  projectVideos: [],
2005
1922
  projectVideosLoading: false,
2006
- selectedVideo: null,
2007
1923
 
2008
1924
  fetchProjectVideos: async (projectId) => {
2009
1925
  set({ projectVideosLoading: true });
@@ -2024,17 +1940,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2024
1940
  }
2025
1941
  },
2026
1942
 
2027
- selectVideo: (video) => {
2028
- if (!video) {
2029
- set({ selectedVideo: null });
2030
- return;
2031
- }
2032
- const fullUrl = video.url.startsWith("http")
2033
- ? video.url
2034
- : `http://localhost:${(window as any).PORT}${video.url}`;
2035
- set({ selectedVideo: { ...video, url: fullUrl } });
2036
- },
2037
-
2038
1943
  // ---- Project Audio ----
2039
1944
  projectAudios: [],
2040
1945
  projectAudiosLoading: false,
@@ -2065,7 +1970,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2065
1970
  // Immediately reset generating state so UI returns to ready
2066
1971
  set((s) => ({
2067
1972
  video: { ...s.video, generating: false },
2068
- extend: { ...s.extend, generating: false },
2069
1973
  textToImage: { ...s.textToImage, generating: false },
2070
1974
  }));
2071
1975
  // Also kill the backend spawn process
@@ -2103,12 +2007,15 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2103
2007
  },
2104
2008
 
2105
2009
  // ---- Reset ----
2106
- resetAll: () =>
2010
+ resetAll: () => {
2011
+ fastImageEditProjectId = null;
2012
+ fastImageEditActiveTaskId = null;
2013
+ videoProjectId = null;
2014
+ videoActiveTaskId = null;
2107
2015
  set({
2108
2016
  activeTab: "movieStudio",
2109
2017
  image: { ...initialImage },
2110
2018
  video: { ...initialVideo },
2111
- extend: { ...initialExtend },
2112
2019
  fastImageEdit: { ...initialFastImageEdit },
2113
2020
  textToImage: { ...initialTextToImage },
2114
2021
  textToImageProjectId: null,
@@ -2120,12 +2027,9 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2120
2027
  uploadedImageFilename: null,
2121
2028
  uploadedImagePath: null,
2122
2029
  projectImages: [],
2123
- characterSheets: [],
2124
- characterSheetsLoading: false,
2125
2030
  selectedImage: null,
2126
2031
  projectVideos: [],
2127
2032
  projectVideosLoading: false,
2128
- selectedVideo: null,
2129
2033
  projectAudios: [],
2130
2034
  projectAudiosLoading: false,
2131
2035
  csvRows: [],
@@ -2135,5 +2039,6 @@ export const useGenerationStore = create<GenerationStore>((set, get) => ({
2135
2039
  batchRunning: false,
2136
2040
  batchProgress: null,
2137
2041
  batchCancelRequested: false,
2138
- }),
2042
+ });
2043
+ },
2139
2044
  }));