@effectnode/media 0.9.0 → 0.11.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/ltx.txt +20 -0
- package/dist/backend/movie-backend/agent/prompt/script.md +111 -1
- package/dist/backend/movie-backend/core.js +41 -0
- package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
- package/dist/backend/movie-backend/generation-queue.js +75 -8
- package/dist/backend/movie-backend/render-media.d.ts +79 -2
- package/dist/backend/movie-backend/render-media.js +694 -418
- 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 +47 -0
- 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 +343 -0
- package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
- package/frontend/src/movie-app/components/ProjectEditorPage.tsx +130 -125
- package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +205 -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 +156 -255
- package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
- package/frontend/src/movie-app/stores/queueStore.ts +47 -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/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
|
@@ -14,7 +14,12 @@ export type QueueTaskType =
|
|
|
14
14
|
| "regenerate-asset"
|
|
15
15
|
| "regenerate-video"
|
|
16
16
|
| "regenerate-scene-image"
|
|
17
|
-
| "fast-image-edit"
|
|
17
|
+
| "fast-image-edit"
|
|
18
|
+
| "image-to-video"
|
|
19
|
+
| "upscale"
|
|
20
|
+
| "voice-clone"
|
|
21
|
+
| "audio-to-video"
|
|
22
|
+
| "advanced-voice-clone";
|
|
18
23
|
|
|
19
24
|
export type QueueTaskStatus =
|
|
20
25
|
| "pending"
|
|
@@ -74,6 +79,45 @@ function upsertTask(tasks: QueueTask[], task: QueueTask): QueueTask[] {
|
|
|
74
79
|
return next;
|
|
75
80
|
}
|
|
76
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
|
+
|
|
77
121
|
export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
78
122
|
tasks: [],
|
|
79
123
|
loading: false,
|
|
@@ -142,6 +186,7 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
142
186
|
try {
|
|
143
187
|
const task = JSON.parse((event as MessageEvent).data) as QueueTask;
|
|
144
188
|
set((s) => ({ tasks: upsertTask(s.tasks, task) }));
|
|
189
|
+
maybeDing(task);
|
|
145
190
|
} catch {
|
|
146
191
|
// ignore malformed events
|
|
147
192
|
}
|
|
@@ -197,6 +242,7 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|
|
197
242
|
try {
|
|
198
243
|
const task = JSON.parse((event as MessageEvent).data) as QueueTask;
|
|
199
244
|
set((s) => ({ allTasks: upsertTask(s.allTasks, task) }));
|
|
245
|
+
maybeDing(task);
|
|
200
246
|
} catch {
|
|
201
247
|
// ignore malformed events
|
|
202
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