@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.
Files changed (32) hide show
  1. package/dist/backend/movie-backend/agent/prompt/ltx.txt +20 -0
  2. package/dist/backend/movie-backend/agent/prompt/script.md +111 -1
  3. package/dist/backend/movie-backend/core.js +41 -0
  4. package/dist/backend/movie-backend/generation-queue.d.ts +1 -1
  5. package/dist/backend/movie-backend/generation-queue.js +75 -8
  6. package/dist/backend/movie-backend/render-media.d.ts +79 -2
  7. package/dist/backend/movie-backend/render-media.js +694 -418
  8. package/frontend/src/movie-app/components/EditorTabs/AdvancedVoiceCloneTab.tsx +366 -0
  9. package/frontend/src/movie-app/components/EditorTabs/AudioToVideoTab.tsx +406 -0
  10. package/frontend/src/movie-app/components/EditorTabs/FastImageEditTab.tsx +47 -0
  11. package/frontend/src/movie-app/components/EditorTabs/GenerateVideoTab.tsx +155 -1
  12. package/frontend/src/movie-app/components/EditorTabs/MovieStudioTab.tsx +33 -13
  13. package/frontend/src/movie-app/components/EditorTabs/SetupAiModelTab.tsx +26 -5
  14. package/frontend/src/movie-app/components/EditorTabs/UpscaleTab.tsx +343 -0
  15. package/frontend/src/movie-app/components/EditorTabs/VoiceCloneTab.tsx +365 -0
  16. package/frontend/src/movie-app/components/ProjectEditorPage.tsx +130 -125
  17. package/frontend/src/movie-app/stores/advancedVoiceCloneStore.ts +205 -0
  18. package/frontend/src/movie-app/stores/aiModelStore.ts +25 -2
  19. package/frontend/src/movie-app/stores/audioToVideoStore.ts +274 -0
  20. package/frontend/src/movie-app/stores/generationStore.ts +156 -255
  21. package/frontend/src/movie-app/stores/movieStudioStore.ts +10 -3
  22. package/frontend/src/movie-app/stores/queueStore.ts +47 -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/ExtendVideoTab.tsx +0 -305
  28. package/frontend/src/movie-app/components/EditorTabs/ExtractImageTab.tsx +0 -249
  29. package/frontend/src/movie-app/components/EditorTabs/SceneVisualTab.tsx +0 -267
  30. package/frontend/src/movie-app/lib/batchVoiceStorage.ts +0 -75
  31. package/frontend/src/movie-app/stores/batchVoiceStore.ts +0 -990
  32. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectnode/media",
3
- "version": "0.9.0",
3
+ "version": "0.11.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": "",