@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.
- package/dist/backend/movie-backend/agent/prompt/script.md +213 -0
- package/dist/backend/movie-backend/core.js +33 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +82 -6
- package/dist/backend/movie-backend/render-media.d.ts +89 -1
- package/dist/backend/movie-backend/render-media.js +772 -476
- package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
- package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
- package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +67 -51
- package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
- package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
- package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
- package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +342 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +131 -126
- package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +207 -0
- package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
- package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
- package/frontend/src/movie-app/stores/generationStore.ts +250 -345
- package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
- package/frontend/src/movie-app/stores/projectStore.ts +7 -3
- package/frontend/src/movie-app/stores/queueStore.ts +48 -1
- package/frontend/src/movie-app/stores/upscaleStore.ts +118 -0
- package/frontend/src/movie-app/stores/voiceCloneStore.ts +227 -0
- package/package.json +1 -1
- package/frontend/src/movie-app/components/EditorTabs/BatchVoiceVideoTab.tsx +0 -913
- package/frontend/src/movie-app/components/EditorTabs/CharacterSheet.tsx +0 -234
- package/frontend/src/movie-app/components/EditorTabs/ExtendVideoTab.tsx +0 -305
- package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
- package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
- package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
- package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
- package/frontend/src/movie-app/stores/sceneVisualStore.ts +0 -251
|
@@ -274,7 +274,9 @@ interface MovieStudioStore {
|
|
|
274
274
|
sceneImagesError: string | null;
|
|
275
275
|
sceneImageProgress: { current: number; total: number } | null;
|
|
276
276
|
regeneratingSceneImages: string[];
|
|
277
|
+
sceneImageSteps: number;
|
|
277
278
|
setIdea: (v: string) => void;
|
|
279
|
+
setSceneImageSteps: (v: number) => void;
|
|
278
280
|
hydrate: (projectId: string) => Promise<void>;
|
|
279
281
|
generate: (projectId: string, model: string) => Promise<void>;
|
|
280
282
|
render: (projectId: string) => Promise<void>;
|
|
@@ -330,12 +332,16 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
330
332
|
sceneImagesError: null,
|
|
331
333
|
sceneImageProgress: null,
|
|
332
334
|
regeneratingSceneImages: [],
|
|
335
|
+
sceneImageSteps: 6,
|
|
333
336
|
|
|
334
337
|
setIdea: (idea) => {
|
|
335
338
|
set({ idea, error: null });
|
|
336
339
|
persistMovieStudioState();
|
|
337
340
|
},
|
|
338
341
|
|
|
342
|
+
setSceneImageSteps: (steps) =>
|
|
343
|
+
set({ sceneImageSteps: Math.max(1, Math.round(Number(steps)) || 1) }),
|
|
344
|
+
|
|
339
345
|
hydrate: async (projectId) => {
|
|
340
346
|
// Switching projects: reset to defaults so the previous project's idea
|
|
341
347
|
// doesn't leak through, then load the stored state (if any) below.
|
|
@@ -435,7 +441,7 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
435
441
|
projectId,
|
|
436
442
|
"render-scene-image",
|
|
437
443
|
`Render scene image: ${scene.slug}`,
|
|
438
|
-
{ scene, batchId },
|
|
444
|
+
{ scene, batchId, steps: get().sceneImageSteps },
|
|
439
445
|
);
|
|
440
446
|
if (!r.ok) {
|
|
441
447
|
const b = batches.get(batchId);
|
|
@@ -611,7 +617,7 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
611
617
|
projectId,
|
|
612
618
|
"render-scene-image",
|
|
613
619
|
`Render scene image: ${scene.slug}`,
|
|
614
|
-
{ scene, batchId },
|
|
620
|
+
{ scene, batchId, steps: get().sceneImageSteps },
|
|
615
621
|
);
|
|
616
622
|
if (!r.ok) {
|
|
617
623
|
const b = batches.get(batchId);
|
|
@@ -637,7 +643,7 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
637
643
|
projectId,
|
|
638
644
|
"regenerate-scene-image",
|
|
639
645
|
`Regenerate scene image: ${slug}`,
|
|
640
|
-
{ slug, scene },
|
|
646
|
+
{ slug, scene, steps: get().sceneImageSteps },
|
|
641
647
|
);
|
|
642
648
|
if (!r.ok) {
|
|
643
649
|
set((s) => ({
|
|
@@ -1058,6 +1064,7 @@ export const useMovieStudioStore = create<MovieStudioStore>((set, get) => ({
|
|
|
1058
1064
|
sceneImagesError: null,
|
|
1059
1065
|
sceneImageProgress: null,
|
|
1060
1066
|
regeneratingSceneImages: [],
|
|
1067
|
+
sceneImageSteps: 6,
|
|
1061
1068
|
});
|
|
1062
1069
|
},
|
|
1063
1070
|
}));
|
|
@@ -32,8 +32,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
|
32
32
|
set({ loading: true, error: null });
|
|
33
33
|
try {
|
|
34
34
|
const res = await fetch(`${API_BASE}/api/projects`);
|
|
35
|
-
const projects = await res.json();
|
|
36
|
-
|
|
35
|
+
const projects: Project[] = await res.json();
|
|
36
|
+
const sorted = [...projects].sort(
|
|
37
|
+
(a, b) =>
|
|
38
|
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
39
|
+
);
|
|
40
|
+
set({ projects: sorted, loading: false });
|
|
37
41
|
} catch (e) {
|
|
38
42
|
set({ error: String(e), loading: false });
|
|
39
43
|
}
|
|
@@ -49,7 +53,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
|
|
|
49
53
|
});
|
|
50
54
|
if (!res.ok) throw new Error(await res.text());
|
|
51
55
|
const project = await res.json();
|
|
52
|
-
set({ projects: [...get().projects
|
|
56
|
+
set({ projects: [project, ...get().projects] });
|
|
53
57
|
return project;
|
|
54
58
|
} catch (e) {
|
|
55
59
|
set({ error: String(e) });
|
|
@@ -13,7 +13,13 @@ export type QueueTaskType =
|
|
|
13
13
|
| "render-video"
|
|
14
14
|
| "regenerate-asset"
|
|
15
15
|
| "regenerate-video"
|
|
16
|
-
| "regenerate-scene-image"
|
|
16
|
+
| "regenerate-scene-image"
|
|
17
|
+
| "fast-image-edit"
|
|
18
|
+
| "image-to-video"
|
|
19
|
+
| "upscale"
|
|
20
|
+
| "voice-clone"
|
|
21
|
+
| "audio-to-video"
|
|
22
|
+
| "advanced-voice-clone";
|
|
17
23
|
|
|
18
24
|
export type QueueTaskStatus =
|
|
19
25
|
| "pending"
|
|
@@ -73,6 +79,45 @@ function upsertTask(tasks: QueueTask[], task: QueueTask): QueueTask[] {
|
|
|
73
79
|
return next;
|
|
74
80
|
}
|
|
75
81
|
|
|
82
|
+
/** Task ids already observed as completed, so we only ding once per task. */
|
|
83
|
+
const completedTaskIds = new Set<string>();
|
|
84
|
+
|
|
85
|
+
/** Play a short "ding" notification tone via the Web Audio API. */
|
|
86
|
+
function playDing() {
|
|
87
|
+
try {
|
|
88
|
+
const Ctx =
|
|
89
|
+
window.AudioContext ||
|
|
90
|
+
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
|
91
|
+
.webkitAudioContext;
|
|
92
|
+
const ctx = new Ctx();
|
|
93
|
+
const now = ctx.currentTime;
|
|
94
|
+
const osc = ctx.createOscillator();
|
|
95
|
+
const gain = ctx.createGain();
|
|
96
|
+
osc.type = "sine";
|
|
97
|
+
osc.frequency.setValueAtTime(1046.5, now); // C6
|
|
98
|
+
gain.gain.setValueAtTime(0.0001, now);
|
|
99
|
+
gain.gain.exponentialRampToValueAtTime(0.3, now + 0.01);
|
|
100
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.9);
|
|
101
|
+
osc.connect(gain);
|
|
102
|
+
gain.connect(ctx.destination);
|
|
103
|
+
osc.start(now);
|
|
104
|
+
osc.stop(now + 0.95);
|
|
105
|
+
osc.onended = () => ctx.close();
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore audio failures (e.g. blocked autoplay).
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Ding once when a task first reports `completed`. */
|
|
112
|
+
function maybeDing(task: QueueTask) {
|
|
113
|
+
if (task.status !== "completed") return;
|
|
114
|
+
if (completedTaskIds.has(task.id)) return;
|
|
115
|
+
completedTaskIds.add(task.id);
|
|
116
|
+
// Bound the set so a very long-lived session never grows it unboundedly.
|
|
117
|
+
if (completedTaskIds.size > 1000) completedTaskIds.clear();
|
|
118
|
+
playDing();
|
|
119
|
+
}
|
|
120
|
+
|
|
76
121
|
export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
77
122
|
tasks: [],
|
|
78
123
|
loading: false,
|
|
@@ -141,6 +186,7 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
141
186
|
try {
|
|
142
187
|
const task = JSON.parse((event as MessageEvent).data) as QueueTask;
|
|
143
188
|
set((s) => ({ tasks: upsertTask(s.tasks, task) }));
|
|
189
|
+
maybeDing(task);
|
|
144
190
|
} catch {
|
|
145
191
|
// ignore malformed events
|
|
146
192
|
}
|
|
@@ -196,6 +242,7 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
196
242
|
try {
|
|
197
243
|
const task = JSON.parse((event as MessageEvent).data) as QueueTask;
|
|
198
244
|
set((s) => ({ allTasks: upsertTask(s.allTasks, task) }));
|
|
245
|
+
maybeDing(task);
|
|
199
246
|
} catch {
|
|
200
247
|
// ignore malformed events
|
|
201
248
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type { QueueTask } from "./queueStore";
|
|
3
|
+
|
|
4
|
+
const API_BASE = `http://localhost:${(window as any).PORT}`;
|
|
5
|
+
|
|
6
|
+
export type UpscaleMode = "1x" | "2048";
|
|
7
|
+
|
|
8
|
+
export interface UpscaleImage {
|
|
9
|
+
filename: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function resolveUrl(url: string): string {
|
|
14
|
+
return url.startsWith("http") ? url : `${API_BASE}${url}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface UpscaleStore {
|
|
18
|
+
mode: UpscaleMode;
|
|
19
|
+
image: UpscaleImage | null;
|
|
20
|
+
generating: boolean;
|
|
21
|
+
result: string | null;
|
|
22
|
+
error: string | null;
|
|
23
|
+
|
|
24
|
+
setMode: (mode: UpscaleMode) => void;
|
|
25
|
+
setImage: (image: UpscaleImage | null) => void;
|
|
26
|
+
clearResult: () => void;
|
|
27
|
+
generate: (projectId: string) => Promise<void>;
|
|
28
|
+
applyQueueTask: (task: QueueTask) => void;
|
|
29
|
+
reset: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The queue task id enqueued by the current generate action, if any. */
|
|
33
|
+
let upscaleActiveTaskId: string | null = null;
|
|
34
|
+
|
|
35
|
+
async function enqueueUpscaleTask(
|
|
36
|
+
projectId: string,
|
|
37
|
+
imagePath: string,
|
|
38
|
+
resolution: UpscaleMode,
|
|
39
|
+
): Promise<{ ok: boolean; error?: string; taskId?: string }> {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${API_BASE}/api/queue/enqueue`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
projectId,
|
|
46
|
+
type: "upscale",
|
|
47
|
+
label: `Upscale image (${resolution})`,
|
|
48
|
+
payload: { imagePath, resolution },
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
return { ok: false, error: await res.text() };
|
|
53
|
+
}
|
|
54
|
+
const task = (await res.json()) as { id?: string };
|
|
55
|
+
return { ok: true, taskId: task.id };
|
|
56
|
+
} catch (e) {
|
|
57
|
+
return { ok: false, error: String(e) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const useUpscaleStore = create<UpscaleStore>((set, get) => ({
|
|
62
|
+
mode: "1x",
|
|
63
|
+
image: null,
|
|
64
|
+
generating: false,
|
|
65
|
+
result: null,
|
|
66
|
+
error: null,
|
|
67
|
+
|
|
68
|
+
setMode: (mode) => set({ mode, error: null }),
|
|
69
|
+
setImage: (image) => set({ image, error: null }),
|
|
70
|
+
clearResult: () => set({ result: null, error: null }),
|
|
71
|
+
|
|
72
|
+
generate: async (projectId) => {
|
|
73
|
+
const { image, mode, generating } = get();
|
|
74
|
+
if (generating || !image) return;
|
|
75
|
+
|
|
76
|
+
set({ generating: true, error: null, result: null });
|
|
77
|
+
|
|
78
|
+
const r = await enqueueUpscaleTask(projectId, image.filename, mode);
|
|
79
|
+
upscaleActiveTaskId = r.taskId ?? null;
|
|
80
|
+
if (!r.ok) {
|
|
81
|
+
set({ generating: false, error: r.error ?? "Failed to enqueue upscale" });
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
// Reconcile the upscale state with the latest queue task state. Only the task
|
|
86
|
+
// enqueued by this tab is reflected, so past tasks never surface stale results.
|
|
87
|
+
applyQueueTask: (task) => {
|
|
88
|
+
if (task.type !== "upscale") return;
|
|
89
|
+
if (task.id !== upscaleActiveTaskId) return;
|
|
90
|
+
|
|
91
|
+
if (task.status === "completed") {
|
|
92
|
+
const url = task.result?.url;
|
|
93
|
+
set({
|
|
94
|
+
generating: false,
|
|
95
|
+
result: url ? resolveUrl(url) : null,
|
|
96
|
+
error: null,
|
|
97
|
+
});
|
|
98
|
+
} else if (task.status === "failed") {
|
|
99
|
+
set({ generating: false, error: task.error ?? "Upscale failed" });
|
|
100
|
+
} else if (task.status === "cancelled" || task.status === "paused") {
|
|
101
|
+
set({ generating: false, error: null });
|
|
102
|
+
} else {
|
|
103
|
+
// pending / running
|
|
104
|
+
set({ generating: true, error: null });
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
reset: () => {
|
|
109
|
+
upscaleActiveTaskId = null;
|
|
110
|
+
set({
|
|
111
|
+
mode: "1x",
|
|
112
|
+
image: null,
|
|
113
|
+
generating: false,
|
|
114
|
+
result: null,
|
|
115
|
+
error: null,
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
}));
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type { QueueTask } from "./queueStore";
|
|
3
|
+
|
|
4
|
+
const API_BASE = `http://localhost:${(window as any).PORT}`;
|
|
5
|
+
|
|
6
|
+
export type VoiceQuality = "low" | "high";
|
|
7
|
+
|
|
8
|
+
export interface VoiceAudio {
|
|
9
|
+
filename: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface GeneratedVoice {
|
|
14
|
+
id: string;
|
|
15
|
+
transcript: string;
|
|
16
|
+
quality: string;
|
|
17
|
+
refAudioFilename: string | null;
|
|
18
|
+
filename: string;
|
|
19
|
+
createdAt: string | null;
|
|
20
|
+
url: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function resolveUrl(url: string): string {
|
|
24
|
+
return url.startsWith("http") ? url : `${API_BASE}${url}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface VoiceCloneStore {
|
|
28
|
+
quality: VoiceQuality;
|
|
29
|
+
transcript: string;
|
|
30
|
+
refAudio: VoiceAudio | null;
|
|
31
|
+
audios: VoiceAudio[];
|
|
32
|
+
audiosLoading: boolean;
|
|
33
|
+
voices: GeneratedVoice[];
|
|
34
|
+
voicesLoading: boolean;
|
|
35
|
+
uploading: boolean;
|
|
36
|
+
generating: boolean;
|
|
37
|
+
result: string | null;
|
|
38
|
+
error: string | null;
|
|
39
|
+
|
|
40
|
+
setQuality: (q: VoiceQuality) => void;
|
|
41
|
+
setTranscript: (t: string) => void;
|
|
42
|
+
setRefAudio: (a: VoiceAudio | null) => void;
|
|
43
|
+
clearResult: () => void;
|
|
44
|
+
fetchAudios: (projectId: string) => Promise<void>;
|
|
45
|
+
fetchVoices: (projectId: string) => Promise<void>;
|
|
46
|
+
uploadAudio: (
|
|
47
|
+
projectId: string,
|
|
48
|
+
base64: string,
|
|
49
|
+
filename: string,
|
|
50
|
+
) => Promise<void>;
|
|
51
|
+
generate: (projectId: string) => Promise<void>;
|
|
52
|
+
applyQueueTask: (task: QueueTask) => void;
|
|
53
|
+
reset: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The queue task id enqueued by the current generate action, if any. */
|
|
57
|
+
let voiceCloneActiveTaskId: string | null = null;
|
|
58
|
+
|
|
59
|
+
/** Project whose voice-clone task should refresh the generated list on completion. */
|
|
60
|
+
let voiceCloneProjectId: string | null = null;
|
|
61
|
+
|
|
62
|
+
async function enqueueVoiceCloneTask(
|
|
63
|
+
projectId: string,
|
|
64
|
+
text: string,
|
|
65
|
+
refAudioPath: string,
|
|
66
|
+
quality: VoiceQuality,
|
|
67
|
+
): Promise<{ ok: boolean; error?: string; taskId?: string }> {
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${API_BASE}/api/queue/enqueue`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "Content-Type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
projectId,
|
|
74
|
+
type: "voice-clone",
|
|
75
|
+
label: "Voice clone",
|
|
76
|
+
payload: { text, refAudioPath, quality },
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
if (!res.ok) {
|
|
80
|
+
return { ok: false, error: await res.text() };
|
|
81
|
+
}
|
|
82
|
+
const task = (await res.json()) as { id?: string };
|
|
83
|
+
return { ok: true, taskId: task.id };
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return { ok: false, error: String(e) };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const useVoiceCloneStore = create<VoiceCloneStore>((set, get) => ({
|
|
90
|
+
quality: "high",
|
|
91
|
+
transcript: "",
|
|
92
|
+
refAudio: null,
|
|
93
|
+
audios: [],
|
|
94
|
+
audiosLoading: false,
|
|
95
|
+
voices: [],
|
|
96
|
+
voicesLoading: false,
|
|
97
|
+
uploading: false,
|
|
98
|
+
generating: false,
|
|
99
|
+
result: null,
|
|
100
|
+
error: null,
|
|
101
|
+
|
|
102
|
+
setQuality: (quality) => set({ quality, error: null }),
|
|
103
|
+
setTranscript: (transcript) => set({ transcript, error: null }),
|
|
104
|
+
setRefAudio: (refAudio) => set({ refAudio, error: null }),
|
|
105
|
+
clearResult: () => set({ result: null, error: null }),
|
|
106
|
+
|
|
107
|
+
fetchAudios: async (projectId) => {
|
|
108
|
+
set({ audiosLoading: true });
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`${API_BASE}/api/projects/${projectId}/audios`);
|
|
111
|
+
if (!res.ok) throw new Error(await res.text());
|
|
112
|
+
const audios: VoiceAudio[] = await res.json();
|
|
113
|
+
set({
|
|
114
|
+
audios: audios.map((a) => ({ ...a, url: resolveUrl(a.url) })),
|
|
115
|
+
audiosLoading: false,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
set({ audiosLoading: false });
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
fetchVoices: async (projectId) => {
|
|
123
|
+
set({ voicesLoading: true });
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`${API_BASE}/api/projects/${projectId}/voices`);
|
|
126
|
+
if (!res.ok) throw new Error(await res.text());
|
|
127
|
+
const voices: GeneratedVoice[] = await res.json();
|
|
128
|
+
set({
|
|
129
|
+
voices: voices.map((v) => ({ ...v, url: resolveUrl(v.url) })),
|
|
130
|
+
voicesLoading: false,
|
|
131
|
+
});
|
|
132
|
+
} catch {
|
|
133
|
+
set({ voicesLoading: false });
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
uploadAudio: async (projectId, base64, filename) => {
|
|
138
|
+
set({ uploading: true, error: null });
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`${API_BASE}/api/upload/audio`, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/json" },
|
|
143
|
+
body: JSON.stringify({ audio: base64, filename, projectId }),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
set({ uploading: false, error: await res.text() });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
set({
|
|
151
|
+
uploading: false,
|
|
152
|
+
refAudio: {
|
|
153
|
+
filename: data.filename,
|
|
154
|
+
url: resolveUrl(
|
|
155
|
+
`/api/files?path=${encodeURIComponent(data.path)}`,
|
|
156
|
+
),
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
void get().fetchAudios(projectId);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
set({ uploading: false, error: String(e) });
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
generate: async (projectId) => {
|
|
166
|
+
const { refAudio, transcript, quality, generating } = get();
|
|
167
|
+
if (generating || !refAudio || !transcript.trim()) return;
|
|
168
|
+
|
|
169
|
+
set({ generating: true, error: null, result: null });
|
|
170
|
+
|
|
171
|
+
voiceCloneProjectId = projectId;
|
|
172
|
+
const r = await enqueueVoiceCloneTask(
|
|
173
|
+
projectId,
|
|
174
|
+
transcript.trim(),
|
|
175
|
+
refAudio.filename,
|
|
176
|
+
quality,
|
|
177
|
+
);
|
|
178
|
+
voiceCloneActiveTaskId = r.taskId ?? null;
|
|
179
|
+
if (!r.ok) {
|
|
180
|
+
set({ generating: false, error: r.error ?? "Failed to enqueue voice clone" });
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// Reconcile the voice clone state with the latest queue task state. Only the
|
|
185
|
+
// task enqueued by this tab is reflected, so past tasks never surface stale results.
|
|
186
|
+
applyQueueTask: (task) => {
|
|
187
|
+
if (task.type !== "voice-clone") return;
|
|
188
|
+
if (task.id !== voiceCloneActiveTaskId) return;
|
|
189
|
+
|
|
190
|
+
if (task.status === "completed") {
|
|
191
|
+
const url = task.result?.url;
|
|
192
|
+
set({
|
|
193
|
+
generating: false,
|
|
194
|
+
result: url ? resolveUrl(url) : null,
|
|
195
|
+
error: null,
|
|
196
|
+
});
|
|
197
|
+
if (voiceCloneProjectId) {
|
|
198
|
+
void get().fetchVoices(voiceCloneProjectId);
|
|
199
|
+
}
|
|
200
|
+
} else if (task.status === "failed") {
|
|
201
|
+
set({ generating: false, error: task.error ?? "Voice clone failed" });
|
|
202
|
+
} else if (task.status === "cancelled" || task.status === "paused") {
|
|
203
|
+
set({ generating: false, error: null });
|
|
204
|
+
} else {
|
|
205
|
+
// pending / running
|
|
206
|
+
set({ generating: true, error: null });
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
reset: () => {
|
|
211
|
+
voiceCloneActiveTaskId = null;
|
|
212
|
+
voiceCloneProjectId = null;
|
|
213
|
+
set({
|
|
214
|
+
quality: "high",
|
|
215
|
+
transcript: "",
|
|
216
|
+
refAudio: null,
|
|
217
|
+
audios: [],
|
|
218
|
+
audiosLoading: false,
|
|
219
|
+
voices: [],
|
|
220
|
+
voicesLoading: false,
|
|
221
|
+
uploading: false,
|
|
222
|
+
generating: false,
|
|
223
|
+
result: null,
|
|
224
|
+
error: null,
|
|
225
|
+
});
|
|
226
|
+
},
|
|
227
|
+
}));
|
package/package.json
CHANGED