@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
@@ -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
- set({ projects, loading: false });
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, project] });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectnode/media",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": "",