@effectnode/media 0.9.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 +111 -1
- 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 +75 -8
- package/dist/backend/movie-backend/render-media.d.ts +79 -2
- package/dist/backend/movie-backend/render-media.js +722 -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 +342 -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 +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 +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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, realpathSync, readdirSync, statSync, unlinkSync, rmSync, copyFileSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, sep } from "node:path";
|
|
3
|
+
import { dirname, join, sep } from "node:path";
|
|
4
4
|
import { spawn, whichSync, mimeType } from "./process.js";
|
|
5
5
|
// Track the currently active spawn process so it can be cancelled
|
|
6
6
|
let activeProc = null;
|
|
@@ -16,7 +16,6 @@ const APP_DATA_DIR = join(homedir(), "media-studio");
|
|
|
16
16
|
const OUTPUT_DIR = join(APP_DATA_DIR, "output");
|
|
17
17
|
const UPLOAD_DIR = join(APP_DATA_DIR, "upload");
|
|
18
18
|
const AGENT_UPLOAD_DIR = join(APP_DATA_DIR, "agent-upload");
|
|
19
|
-
const EXTRACTED_FRAMES_DIR = join(APP_DATA_DIR, "extracted-frames");
|
|
20
19
|
const AGENTS_DIR = join(APP_DATA_DIR, "agents");
|
|
21
20
|
const JSON_DIR = join(APP_DATA_DIR, "json");
|
|
22
21
|
const PYTHON_DIR = join(APP_DATA_DIR, "python-src");
|
|
@@ -25,7 +24,10 @@ const PROJECTS_FILE = join(JSON_DIR, "projects.json");
|
|
|
25
24
|
const CHARACTERS_FILE = join(JSON_DIR, "characters.json");
|
|
26
25
|
const Z_IMAGE_MODEL = "AbstractFramework/z-image-turbo-8bit";
|
|
27
26
|
const FLUX_KLEIN_MODEL = "AbstractFramework/flux.2-klein-4b-8bit";
|
|
27
|
+
const SEEDVR2_MODEL = "AbstractFramework/seedvr2-7b-8bit";
|
|
28
28
|
const MLX_VLM_MODEL = "mlx-community/gemma-4-e4b-it-8bit";
|
|
29
|
+
const LTX_MODEL_HIGH_QUALITY = "dgrauet/ltx-2.3-mlx";
|
|
30
|
+
const LTX_MODEL_STANDARD = "dgrauet/ltx-2.3-mlx-q8";
|
|
29
31
|
const VIDEO_STAGE_FLAGS = {
|
|
30
32
|
distilled: "--distilled",
|
|
31
33
|
"one-stage": "--one-stage",
|
|
@@ -41,6 +43,12 @@ function stageFlagFor(mode) {
|
|
|
41
43
|
? (VIDEO_STAGE_FLAGS[mode] ?? "--distilled")
|
|
42
44
|
: "--distilled";
|
|
43
45
|
}
|
|
46
|
+
/** Resolve a ltx-2-mlx model id, falling back to the q8 standard model. */
|
|
47
|
+
function resolveLtxModel(model) {
|
|
48
|
+
return String(model) === LTX_MODEL_HIGH_QUALITY
|
|
49
|
+
? LTX_MODEL_HIGH_QUALITY
|
|
50
|
+
: LTX_MODEL_STANDARD;
|
|
51
|
+
}
|
|
44
52
|
// ========== Project Helpers ==========
|
|
45
53
|
function ensureDir(dir) {
|
|
46
54
|
if (!existsSync(dir)) {
|
|
@@ -187,22 +195,6 @@ function resolveSafePath(candidate, projectId) {
|
|
|
187
195
|
}
|
|
188
196
|
return null;
|
|
189
197
|
}
|
|
190
|
-
/** Resolve and validate a user-supplied video filename. Only bare .mp4 names in the output dir. */
|
|
191
|
-
function resolveSafeVideoPath(candidate, projectId) {
|
|
192
|
-
const base = candidate.split(/[/\\]/).pop() || candidate;
|
|
193
|
-
if (base !== candidate || base.includes("..") || base.startsWith(".")) {
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
if (!base.toLowerCase().endsWith(".mp4"))
|
|
197
|
-
return null;
|
|
198
|
-
const candidatePath = join(OUTPUT_DIR, projectId, base);
|
|
199
|
-
if (!existsSync(candidatePath))
|
|
200
|
-
return null;
|
|
201
|
-
const resolved = realpathSync(candidatePath);
|
|
202
|
-
if (isPathAllowed(resolved))
|
|
203
|
-
return resolved;
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
198
|
/**
|
|
207
199
|
* Resolve the `mlxgen` executable installed via `uv tool install --upgrade mlx-gen`.
|
|
208
200
|
* uv tool installs binaries into `~/.local/bin`; fall back to relying on PATH.
|
|
@@ -219,6 +211,63 @@ async function getMlxgenBin() {
|
|
|
219
211
|
}
|
|
220
212
|
return "mlxgen";
|
|
221
213
|
}
|
|
214
|
+
/** Resolve the `dots-tts` executable installed via `uv tool install dots-tts`. */
|
|
215
|
+
async function getDotsTtsBin() {
|
|
216
|
+
const candidates = [
|
|
217
|
+
join(homedir(), ".local", "bin", "dots-tts"),
|
|
218
|
+
"/opt/homebrew/bin/dots-tts",
|
|
219
|
+
"/usr/local/bin/dots-tts",
|
|
220
|
+
];
|
|
221
|
+
for (const p of candidates) {
|
|
222
|
+
if (existsSync(p))
|
|
223
|
+
return p;
|
|
224
|
+
}
|
|
225
|
+
return "dots-tts";
|
|
226
|
+
}
|
|
227
|
+
/** Resolve the `mlx_whisper` executable installed via `uv tool install mlx-whisper`. */
|
|
228
|
+
async function getMlxWhisperBin() {
|
|
229
|
+
const candidates = [
|
|
230
|
+
join(homedir(), ".local", "bin", "mlx_whisper"),
|
|
231
|
+
"/opt/homebrew/bin/mlx_whisper",
|
|
232
|
+
"/usr/local/bin/mlx_whisper",
|
|
233
|
+
];
|
|
234
|
+
for (const p of candidates) {
|
|
235
|
+
if (existsSync(p))
|
|
236
|
+
return p;
|
|
237
|
+
}
|
|
238
|
+
return "mlx_whisper";
|
|
239
|
+
}
|
|
240
|
+
/** The cloned dots-tts-mlx project directory. */
|
|
241
|
+
const DOTS_TTS_FOLDER = join(APP_DATA_DIR, "python-src", "dots-tts-mlx");
|
|
242
|
+
/** Base directory where dots-tts MLX weights live (inside the project folder). */
|
|
243
|
+
const DOTS_TTS_WEIGHTS_DIR = join(DOTS_TTS_FOLDER, "dots-tts-mlx-weights");
|
|
244
|
+
/**
|
|
245
|
+
* Resolve a dots-tts `--model` value. Accepts `./dots-tts-mlx-weights/mf-int4`
|
|
246
|
+
* (relative to the dots-tts-mlx folder), a bare variant (`mf-int4`), or an
|
|
247
|
+
* explicit path.
|
|
248
|
+
*/
|
|
249
|
+
function resolveDotsTtsModel(model) {
|
|
250
|
+
if (!model)
|
|
251
|
+
return join(DOTS_TTS_WEIGHTS_DIR, "mf-int4");
|
|
252
|
+
if (model.startsWith("./"))
|
|
253
|
+
return join(DOTS_TTS_FOLDER, model.slice(2));
|
|
254
|
+
if (model.includes("/"))
|
|
255
|
+
return model;
|
|
256
|
+
return join(DOTS_TTS_WEIGHTS_DIR, model);
|
|
257
|
+
}
|
|
258
|
+
/** True when the dots-tts `mf-int4` weights have been downloaded. */
|
|
259
|
+
function isDotsTtsModelDownloaded() {
|
|
260
|
+
return existsSync(join(DOTS_TTS_WEIGHTS_DIR, "mf-int4"));
|
|
261
|
+
}
|
|
262
|
+
/** Resolve the ffmpeg binary installed via Homebrew (Apple Silicon then Intel). */
|
|
263
|
+
async function getFfmpegBin() {
|
|
264
|
+
const candidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"];
|
|
265
|
+
for (const p of candidates) {
|
|
266
|
+
if (existsSync(p))
|
|
267
|
+
return p;
|
|
268
|
+
}
|
|
269
|
+
return "ffmpeg";
|
|
270
|
+
}
|
|
222
271
|
/** True when the `mlxgen` executable is installed (known paths or PATH). */
|
|
223
272
|
function isMlxgenInstalled() {
|
|
224
273
|
const candidates = [
|
|
@@ -293,18 +342,6 @@ function isMlxVlmInstalled() {
|
|
|
293
342
|
return false;
|
|
294
343
|
}
|
|
295
344
|
}
|
|
296
|
-
/**
|
|
297
|
-
* Resolve the ffmpeg binary installed via Homebrew (Apple Silicon then Intel).
|
|
298
|
-
* Falls back to relying on PATH when neither known location exists.
|
|
299
|
-
*/
|
|
300
|
-
async function getFfmpegBin() {
|
|
301
|
-
const candidates = ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"];
|
|
302
|
-
for (const p of candidates) {
|
|
303
|
-
if (existsSync(p))
|
|
304
|
-
return p;
|
|
305
|
-
}
|
|
306
|
-
return "ffmpeg";
|
|
307
|
-
}
|
|
308
345
|
/** Fixed filename mlx_audio.tts.generate writes its output clip as. */
|
|
309
346
|
const TTS_OUTPUT_FILENAME = "audio_000.mp3";
|
|
310
347
|
/**
|
|
@@ -574,6 +611,76 @@ export async function generateSceneVideo(uvPath, projectId, scene, characters, o
|
|
|
574
611
|
url: `/api/files?path=${encodeURIComponent(videoPath)}`,
|
|
575
612
|
};
|
|
576
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Generate a video from a project image via LTX-2.3. `imagePath` must be a bare
|
|
616
|
+
* filename previously uploaded/generated for this project. Used by the queue
|
|
617
|
+
* worker for the "Scene Video Generation" tab.
|
|
618
|
+
*/
|
|
619
|
+
export async function generateImageToVideo(uvPath, projectId, params, onLog) {
|
|
620
|
+
const { prompt, imagePath, mode } = params;
|
|
621
|
+
if (!isValidProjectId(projectId))
|
|
622
|
+
return { error: "Invalid project ID" };
|
|
623
|
+
if (!prompt || !prompt.trim())
|
|
624
|
+
return { error: "Prompt is required" };
|
|
625
|
+
if (!imagePath)
|
|
626
|
+
return { error: "Image path is required" };
|
|
627
|
+
const resolvedImage = resolveSafePath(imagePath, projectId);
|
|
628
|
+
if (!resolvedImage) {
|
|
629
|
+
return {
|
|
630
|
+
error: "Invalid image path. Provide a filename previously uploaded to this project.",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const ltxFolder = join(PYTHON_DIR, "ltx-2-mlx");
|
|
634
|
+
if (!existsSync(ltxFolder)) {
|
|
635
|
+
return { error: "ltx-2-mlx not found. Run setup first." };
|
|
636
|
+
}
|
|
637
|
+
const projectOutputDir = resolveOutputDir(undefined, projectId);
|
|
638
|
+
if (!projectOutputDir)
|
|
639
|
+
return { error: "Invalid output directory." };
|
|
640
|
+
const outputFile = `video-${Date.now()}.mp4`;
|
|
641
|
+
const outputPath = join(projectOutputDir, outputFile);
|
|
642
|
+
const videoWidth = Number(params.width) || 480;
|
|
643
|
+
const videoHeight = Number(params.height) || 480;
|
|
644
|
+
const videoFrames = Number(params.frames) || 121;
|
|
645
|
+
const videoFps = Number(params.frameRate) || 24;
|
|
646
|
+
const stage1Steps = Math.max(1, Math.round(Number(params.stage1Steps)) || 30);
|
|
647
|
+
const stage2Steps = Math.max(1, Math.round(Number(params.stage2Steps)) || 3);
|
|
648
|
+
const result = await runCommand([
|
|
649
|
+
uvPath,
|
|
650
|
+
"run",
|
|
651
|
+
"ltx-2-mlx",
|
|
652
|
+
"generate",
|
|
653
|
+
"--model",
|
|
654
|
+
resolveLtxModel(params.model),
|
|
655
|
+
"--prompt",
|
|
656
|
+
prompt.trim(),
|
|
657
|
+
stageFlagFor(mode),
|
|
658
|
+
"--stage1-steps",
|
|
659
|
+
String(stage1Steps),
|
|
660
|
+
"--stage2-steps",
|
|
661
|
+
String(stage2Steps),
|
|
662
|
+
"--frames",
|
|
663
|
+
String(videoFrames),
|
|
664
|
+
"--width",
|
|
665
|
+
String(videoWidth),
|
|
666
|
+
"--height",
|
|
667
|
+
String(videoHeight),
|
|
668
|
+
"--frame-rate",
|
|
669
|
+
String(videoFps),
|
|
670
|
+
"--image",
|
|
671
|
+
resolvedImage,
|
|
672
|
+
"--output",
|
|
673
|
+
outputPath,
|
|
674
|
+
], { cwd: ltxFolder, onLog });
|
|
675
|
+
if (!result.success || !existsSync(outputPath)) {
|
|
676
|
+
return { error: result.output || "Video generation failed" };
|
|
677
|
+
}
|
|
678
|
+
backupFile(outputPath, projectId);
|
|
679
|
+
return {
|
|
680
|
+
filename: outputFile,
|
|
681
|
+
url: `/api/files?path=${encodeURIComponent(outputPath)}`,
|
|
682
|
+
};
|
|
683
|
+
}
|
|
577
684
|
/** Normalize a value into a safe filesystem slug. */
|
|
578
685
|
function slugify(v) {
|
|
579
686
|
return String(v || "")
|
|
@@ -584,10 +691,11 @@ function slugify(v) {
|
|
|
584
691
|
* Generate a single scene image via fast-image-edit (FLUX.2 Klein), using the
|
|
585
692
|
* already-generated character/place images referenced by the scene's slugs.
|
|
586
693
|
*/
|
|
587
|
-
export async function generateSceneImage(projectId, scene, onLog) {
|
|
694
|
+
export async function generateSceneImage(projectId, scene, steps, onLog) {
|
|
588
695
|
const s = slugify(scene?.slug);
|
|
589
696
|
if (!s)
|
|
590
697
|
return { error: "Invalid scene slug" };
|
|
698
|
+
const stepCount = Math.max(1, Number(steps) || 6);
|
|
591
699
|
const outputDir = join(OUTPUT_DIR, projectId);
|
|
592
700
|
const mlxgen = await getMlxgenBin();
|
|
593
701
|
const refImages = [];
|
|
@@ -611,7 +719,7 @@ export async function generateSceneImage(projectId, scene, onLog) {
|
|
|
611
719
|
const fluxArgs = [mlxgen, "generate", "--model", FLUX_KLEIN_MODEL];
|
|
612
720
|
for (const p of refImages)
|
|
613
721
|
fluxArgs.push("--image", p);
|
|
614
|
-
fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps",
|
|
722
|
+
fluxArgs.push("--prompt", String(scene?.imagePrompt || ""), "--output", sceneImagePath, "--mlx-cache-limit-gb", "20", "--steps", String(stepCount), "--seed", "42", "--width", "1024", "--height", "1024");
|
|
615
723
|
const result = await runCommand(fluxArgs, { onLog });
|
|
616
724
|
if (!result.success || !existsSync(sceneImagePath)) {
|
|
617
725
|
return { error: result.output || `Failed to generate scene image ${s}` };
|
|
@@ -622,12 +730,368 @@ export async function generateSceneImage(projectId, scene, onLog) {
|
|
|
622
730
|
url: `/api/files?path=${encodeURIComponent(sceneImagePath)}`,
|
|
623
731
|
};
|
|
624
732
|
}
|
|
733
|
+
/**
|
|
734
|
+
* Upscale/refine a project image via mlxgen (SeedVR2). `imagePath` must be a
|
|
735
|
+
* bare filename previously uploaded/generated for this project. `resolution` is
|
|
736
|
+
* either "1x" (refine at native resolution) or "2048" (upscale to 2048px).
|
|
737
|
+
*/
|
|
738
|
+
export async function generateUpscale(projectId, imagePath, resolution, onLog) {
|
|
739
|
+
if (!isValidProjectId(projectId))
|
|
740
|
+
return { error: "Invalid project ID" };
|
|
741
|
+
if (!imagePath)
|
|
742
|
+
return { error: "Image path is required" };
|
|
743
|
+
const resolvedImage = resolveSafePath(imagePath, projectId);
|
|
744
|
+
if (!resolvedImage) {
|
|
745
|
+
return {
|
|
746
|
+
error: "Invalid image path. Provide a filename previously uploaded to this project.",
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const target = /^(1x|\d+)$/.test(resolution) ? resolution : "1x";
|
|
750
|
+
const mlxgen = await getMlxgenBin();
|
|
751
|
+
const projectOutputDir = join(OUTPUT_DIR, projectId);
|
|
752
|
+
ensureDir(projectOutputDir);
|
|
753
|
+
const outputFile = `upscale-${target}-${Date.now()}.png`;
|
|
754
|
+
const outputPath = join(projectOutputDir, outputFile);
|
|
755
|
+
const result = await runCommand([
|
|
756
|
+
mlxgen,
|
|
757
|
+
"upscale",
|
|
758
|
+
"--model",
|
|
759
|
+
SEEDVR2_MODEL,
|
|
760
|
+
"--image-path",
|
|
761
|
+
resolvedImage,
|
|
762
|
+
"--resolution",
|
|
763
|
+
target,
|
|
764
|
+
"--seed",
|
|
765
|
+
"42",
|
|
766
|
+
"--mlx-cache-limit-gb",
|
|
767
|
+
"100",
|
|
768
|
+
"--output",
|
|
769
|
+
outputPath,
|
|
770
|
+
], { onLog });
|
|
771
|
+
if (!result.success || !existsSync(outputPath)) {
|
|
772
|
+
return { error: result.output || "Upscale failed" };
|
|
773
|
+
}
|
|
774
|
+
backupFile(outputPath, projectId);
|
|
775
|
+
return {
|
|
776
|
+
filename: outputFile,
|
|
777
|
+
url: `/api/files?path=${encodeURIComponent(outputPath)}`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Clone a reference voice and speak `text` via mlx_audio.tts.generate. `refAudioPath`
|
|
782
|
+
* must be a bare filename previously uploaded to this project. `quality` is "low"
|
|
783
|
+
* or "high" (maps to a TTS model). Output is saved under <output>/voices/.
|
|
784
|
+
*/
|
|
785
|
+
export async function generateVoiceClone(uvPath, projectId, text, refAudioPath, quality, onLog) {
|
|
786
|
+
if (!isValidProjectId(projectId))
|
|
787
|
+
return { error: "Invalid project ID" };
|
|
788
|
+
if (!refAudioPath)
|
|
789
|
+
return { error: "Reference audio is required" };
|
|
790
|
+
// Sanitize the transcript: strip control characters (incl. newlines) and
|
|
791
|
+
// collapse whitespace. spawn() runs with shell:false (array args), so shell
|
|
792
|
+
// metacharacters cannot execute, but this keeps `--text` a single well-formed
|
|
793
|
+
// argument and out of the terminal log.
|
|
794
|
+
const cleanText = text
|
|
795
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
796
|
+
.replace(/\s+/g, " ")
|
|
797
|
+
.trim();
|
|
798
|
+
if (!cleanText)
|
|
799
|
+
return { error: "Text is required" };
|
|
800
|
+
const resolvedRef = resolveSafePath(refAudioPath, projectId);
|
|
801
|
+
if (!resolvedRef) {
|
|
802
|
+
return {
|
|
803
|
+
error: "Invalid reference audio path. Provide a filename previously uploaded to this project.",
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
const model = quality === "low" ? TTS_MODELS.low : TTS_MODELS.high;
|
|
807
|
+
const projectOutputDir = resolveOutputDir(undefined, projectId);
|
|
808
|
+
if (!projectOutputDir)
|
|
809
|
+
return { error: "Invalid output directory." };
|
|
810
|
+
const voiceId = `voice-${Date.now()}`;
|
|
811
|
+
const voiceDir = join(projectOutputDir, "voices", voiceId);
|
|
812
|
+
ensureDir(voiceDir);
|
|
813
|
+
const result = await runCommand([
|
|
814
|
+
uvPath,
|
|
815
|
+
"run",
|
|
816
|
+
"mlx_audio.tts.generate",
|
|
817
|
+
"--model",
|
|
818
|
+
model,
|
|
819
|
+
"--text",
|
|
820
|
+
cleanText,
|
|
821
|
+
"--ref_audio",
|
|
822
|
+
resolvedRef,
|
|
823
|
+
"--output",
|
|
824
|
+
voiceDir,
|
|
825
|
+
"--audio_format",
|
|
826
|
+
"mp3",
|
|
827
|
+
"--play",
|
|
828
|
+
"--instruct",
|
|
829
|
+
"slow down speech",
|
|
830
|
+
], { cwd: voiceDir, onLog });
|
|
831
|
+
if (!result.success) {
|
|
832
|
+
return { error: result.output || "Voice generation failed" };
|
|
833
|
+
}
|
|
834
|
+
const path = resolveAudioFile(voiceDir);
|
|
835
|
+
if (!path) {
|
|
836
|
+
return { error: "TTS completed but no audio file was produced" };
|
|
837
|
+
}
|
|
838
|
+
const filename = path.split(sep).pop() || TTS_OUTPUT_FILENAME;
|
|
839
|
+
// Persist a per-voice metadata file so the generated-voice list can be rebuilt
|
|
840
|
+
// from the folder structure (each voice in its own folder with meta.json), with
|
|
841
|
+
// no central JSON index.
|
|
842
|
+
const meta = {
|
|
843
|
+
id: voiceId,
|
|
844
|
+
transcript: cleanText,
|
|
845
|
+
quality,
|
|
846
|
+
refAudioFilename: refAudioPath,
|
|
847
|
+
filename,
|
|
848
|
+
createdAt: new Date().toISOString(),
|
|
849
|
+
};
|
|
850
|
+
writeFileSync(join(voiceDir, "meta.json"), JSON.stringify(meta, null, 2), "utf-8");
|
|
851
|
+
return {
|
|
852
|
+
filename,
|
|
853
|
+
url: `/api/files?path=${encodeURIComponent(path)}`,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
/** Find the newest video file (mp4/webm/mov) inside a directory. */
|
|
857
|
+
function findNewestVideo(dir) {
|
|
858
|
+
let entries = [];
|
|
859
|
+
try {
|
|
860
|
+
entries = readdirSync(dir);
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
let newest = null;
|
|
866
|
+
for (const name of entries) {
|
|
867
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
868
|
+
if (ext !== ".mp4" && ext !== ".webm" && ext !== ".mov")
|
|
869
|
+
continue;
|
|
870
|
+
const full = join(dir, name);
|
|
871
|
+
try {
|
|
872
|
+
const st = statSync(full);
|
|
873
|
+
if (!st.isFile())
|
|
874
|
+
continue;
|
|
875
|
+
if (!newest || st.mtimeMs > newest.mtime) {
|
|
876
|
+
newest = { path: full, mtime: st.mtimeMs };
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
// skip unreadable entries
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return newest ? newest.path : null;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Generate a video from an image + audio via ltx-2-mlx `a2v` (audio-to-video).
|
|
887
|
+
* `imagePath` and `audioPath` must be bare filenames previously uploaded/generated
|
|
888
|
+
* for this project. `stage1Steps` maps to `--stage1-steps` (15 default, 30 HD).
|
|
889
|
+
*/
|
|
890
|
+
export async function generateAudioToVideo(uvPath, projectId, params, onLog) {
|
|
891
|
+
if (!isValidProjectId(projectId))
|
|
892
|
+
return { error: "Invalid project ID" };
|
|
893
|
+
if (!params.imagePath)
|
|
894
|
+
return { error: "Image path is required" };
|
|
895
|
+
if (!params.audioPath)
|
|
896
|
+
return { error: "Audio path is required" };
|
|
897
|
+
const resolvedImage = resolveSafePath(params.imagePath, projectId);
|
|
898
|
+
if (!resolvedImage) {
|
|
899
|
+
return {
|
|
900
|
+
error: "Invalid image path. Provide a filename previously uploaded to this project.",
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const resolvedAudio = resolveSafePath(params.audioPath, projectId);
|
|
904
|
+
if (!resolvedAudio) {
|
|
905
|
+
return {
|
|
906
|
+
error: "Invalid audio path. Provide a filename previously uploaded to this project.",
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
// Collapse whitespace so the prompt stays a single well-formed CLI argument.
|
|
910
|
+
const cleanPrompt = String(params.prompt || "")
|
|
911
|
+
.replace(/\s+/g, " ")
|
|
912
|
+
.trim() || "scene";
|
|
913
|
+
const stage1Steps = Math.max(1, Math.round(Number(params.stage1Steps)) || 15);
|
|
914
|
+
// 1 second = 24 frames, plus a terminal frame (24n + 1).
|
|
915
|
+
const frames = Math.max(1, Math.round(Number(params.frames)) || 25);
|
|
916
|
+
const ltxFolder = join(PYTHON_DIR, "ltx-2-mlx");
|
|
917
|
+
if (!existsSync(ltxFolder)) {
|
|
918
|
+
return { error: "ltx-2-mlx not found. Run setup first." };
|
|
919
|
+
}
|
|
920
|
+
const outputDir = join(OUTPUT_DIR, projectId, `a2v-${Date.now()}`);
|
|
921
|
+
ensureDir(outputDir);
|
|
922
|
+
const result = await runCommand([
|
|
923
|
+
uvPath,
|
|
924
|
+
"run",
|
|
925
|
+
"ltx-2-mlx",
|
|
926
|
+
"a2v",
|
|
927
|
+
"--image",
|
|
928
|
+
resolvedImage,
|
|
929
|
+
"--audio",
|
|
930
|
+
resolvedAudio,
|
|
931
|
+
"--frame-rate",
|
|
932
|
+
"24",
|
|
933
|
+
"--frames",
|
|
934
|
+
String(frames),
|
|
935
|
+
"--output",
|
|
936
|
+
outputDir,
|
|
937
|
+
"--prompt",
|
|
938
|
+
cleanPrompt,
|
|
939
|
+
"--stage1-steps",
|
|
940
|
+
String(stage1Steps),
|
|
941
|
+
"--stage2-steps",
|
|
942
|
+
"3",
|
|
943
|
+
], { cwd: ltxFolder, onLog });
|
|
944
|
+
if (!result.success) {
|
|
945
|
+
return { error: result.output || "Audio-to-video generation failed" };
|
|
946
|
+
}
|
|
947
|
+
const videoPath = findNewestVideo(outputDir);
|
|
948
|
+
if (!videoPath) {
|
|
949
|
+
return { error: "a2v completed but no video file was produced" };
|
|
950
|
+
}
|
|
951
|
+
backupFile(videoPath, projectId);
|
|
952
|
+
return {
|
|
953
|
+
filename: videoPath.split(sep).pop() || "a2v.mp4",
|
|
954
|
+
url: `/api/files?path=${encodeURIComponent(videoPath)}`,
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Clone a reference voice via dots-tts. Output is written to
|
|
959
|
+
* <output>/<projectId>/dots-tts/ as `<prefix>_000.wav`.
|
|
960
|
+
*/
|
|
961
|
+
export async function generateAdvancedVoiceClone(projectId, params, onLog) {
|
|
962
|
+
if (!isValidProjectId(projectId))
|
|
963
|
+
return { error: "Invalid project ID" };
|
|
964
|
+
const cleanText = String(params.text || "")
|
|
965
|
+
.replace(/\s+/g, " ")
|
|
966
|
+
.trim();
|
|
967
|
+
if (!cleanText)
|
|
968
|
+
return { error: "Text is required" };
|
|
969
|
+
if (!params.refAudioPath)
|
|
970
|
+
return { error: "Reference audio is required" };
|
|
971
|
+
const resolvedRef = resolveSafePath(params.refAudioPath, projectId);
|
|
972
|
+
if (!resolvedRef) {
|
|
973
|
+
return {
|
|
974
|
+
error: "Invalid reference audio path. Provide a filename previously uploaded to this project.",
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
const language = String(params.language || "YUE").trim() || "YUE";
|
|
978
|
+
const prefix = String(params.outPrefix || "voice")
|
|
979
|
+
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
980
|
+
.slice(0, 64) || "voice";
|
|
981
|
+
const model = resolveDotsTtsModel(String(params.model || ""));
|
|
982
|
+
// dots-tts and mlx_whisper both expect a WAV reference — convert mp3/other
|
|
983
|
+
// formats to 16-bit PCM WAV first.
|
|
984
|
+
let refAudio = resolvedRef;
|
|
985
|
+
if (!resolvedRef.toLowerCase().endsWith(".wav")) {
|
|
986
|
+
const tempDir = join(TEMP_DIR, String(projectId));
|
|
987
|
+
ensureDir(tempDir);
|
|
988
|
+
const convertedRef = join(tempDir, `avc-ref-${Date.now()}.wav`);
|
|
989
|
+
const ffmpegBin = await getFfmpegBin();
|
|
990
|
+
const conv = await runCommand([
|
|
991
|
+
ffmpegBin,
|
|
992
|
+
"-y",
|
|
993
|
+
"-i",
|
|
994
|
+
resolvedRef,
|
|
995
|
+
"-c:a",
|
|
996
|
+
"pcm_s16le",
|
|
997
|
+
"-ar",
|
|
998
|
+
"44100",
|
|
999
|
+
"-ac",
|
|
1000
|
+
"2",
|
|
1001
|
+
convertedRef,
|
|
1002
|
+
], { onLog });
|
|
1003
|
+
if (!conv.success || !existsSync(convertedRef)) {
|
|
1004
|
+
return { error: "Failed to convert reference audio to WAV" };
|
|
1005
|
+
}
|
|
1006
|
+
refAudio = convertedRef;
|
|
1007
|
+
}
|
|
1008
|
+
// dots-tts requires `--ref-text` (the reference audio's transcript). Transcribe
|
|
1009
|
+
// the WAV reference with mlx_whisper to supply it.
|
|
1010
|
+
const whisperBin = await getMlxWhisperBin();
|
|
1011
|
+
const transcribeDir = join(TEMP_DIR, String(projectId));
|
|
1012
|
+
ensureDir(transcribeDir);
|
|
1013
|
+
const whisperResult = await runCommand([whisperBin, refAudio, "--output-dir", transcribeDir], { onLog });
|
|
1014
|
+
const refTxtPath = join(transcribeDir, (refAudio.split(sep).pop() || "ref").replace(/\.[^.]+$/, "") + ".txt");
|
|
1015
|
+
const refText = whisperResult.success && existsSync(refTxtPath)
|
|
1016
|
+
? readFileSync(refTxtPath, "utf-8").replace(/\s+/g, " ").trim()
|
|
1017
|
+
: "";
|
|
1018
|
+
if (!refText) {
|
|
1019
|
+
return { error: "Failed to transcribe reference audio (mlx_whisper)" };
|
|
1020
|
+
}
|
|
1021
|
+
const dotsTtsBin = await getDotsTtsBin();
|
|
1022
|
+
// Mirror the voice-clone tab's timestamped-folder layout so outputs never
|
|
1023
|
+
// overwrite each other.
|
|
1024
|
+
const voiceId = `voice-${Date.now()}`;
|
|
1025
|
+
const outDir = join(OUTPUT_DIR, projectId, "dots-tts", voiceId);
|
|
1026
|
+
ensureDir(outDir);
|
|
1027
|
+
console.log("debug-info:", [
|
|
1028
|
+
dotsTtsBin,
|
|
1029
|
+
"--model",
|
|
1030
|
+
model,
|
|
1031
|
+
"--text",
|
|
1032
|
+
cleanText || "please provide text",
|
|
1033
|
+
"--ref-audio",
|
|
1034
|
+
refAudio,
|
|
1035
|
+
"--ref-text",
|
|
1036
|
+
refText,
|
|
1037
|
+
"--language",
|
|
1038
|
+
language,
|
|
1039
|
+
"--out-path",
|
|
1040
|
+
outDir,
|
|
1041
|
+
"--out-prefix",
|
|
1042
|
+
prefix,
|
|
1043
|
+
]);
|
|
1044
|
+
const result = await runCommand([
|
|
1045
|
+
dotsTtsBin,
|
|
1046
|
+
"--model",
|
|
1047
|
+
model,
|
|
1048
|
+
"--text",
|
|
1049
|
+
cleanText || "please provide text",
|
|
1050
|
+
"--ref-audio",
|
|
1051
|
+
refAudio,
|
|
1052
|
+
"--ref-text",
|
|
1053
|
+
refText,
|
|
1054
|
+
"--language",
|
|
1055
|
+
language,
|
|
1056
|
+
"--out-path",
|
|
1057
|
+
outDir,
|
|
1058
|
+
"--out-prefix",
|
|
1059
|
+
prefix,
|
|
1060
|
+
], { cwd: dirname(dotsTtsBin), onLog });
|
|
1061
|
+
if (!result.success) {
|
|
1062
|
+
return { error: result.output || "Advanced voice clone failed" };
|
|
1063
|
+
}
|
|
1064
|
+
const outputFile = `${prefix}_000.wav`;
|
|
1065
|
+
const outputPath = join(outDir, outputFile);
|
|
1066
|
+
if (!existsSync(outputPath)) {
|
|
1067
|
+
return { error: `Expected output ${outputFile} was not produced` };
|
|
1068
|
+
}
|
|
1069
|
+
// Mirror the voice-clone tab's on-disk conventions: a timestamped backup copy
|
|
1070
|
+
// plus a per-voice meta.json so the output can be listed without a central index.
|
|
1071
|
+
backupFile(outputPath, projectId);
|
|
1072
|
+
const meta = {
|
|
1073
|
+
id: voiceId,
|
|
1074
|
+
transcript: cleanText,
|
|
1075
|
+
refText,
|
|
1076
|
+
language,
|
|
1077
|
+
model,
|
|
1078
|
+
refAudioFilename: params.refAudioPath,
|
|
1079
|
+
outPrefix: prefix,
|
|
1080
|
+
filename: outputFile,
|
|
1081
|
+
createdAt: new Date().toISOString(),
|
|
1082
|
+
};
|
|
1083
|
+
writeFileSync(join(outDir, "meta.json"), JSON.stringify(meta, null, 2), "utf-8");
|
|
1084
|
+
return {
|
|
1085
|
+
filename: outputFile,
|
|
1086
|
+
url: `/api/files?path=${encodeURIComponent(outputPath)}`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
625
1089
|
/**
|
|
626
1090
|
* Generate a composite image via fast-image-edit (FLUX.2 Klein). `images` are
|
|
627
1091
|
* base64 data URLs that are decoded into temp files and passed to the model as
|
|
628
1092
|
* separate `--image` inputs. Used by the generation queue worker.
|
|
629
1093
|
*/
|
|
630
|
-
export async function generateFastImageEditImage(projectId, prompt, images, onLog) {
|
|
1094
|
+
export async function generateFastImageEditImage(projectId, prompt, images, steps, upscaleResolution, onLog) {
|
|
631
1095
|
if (!isValidProjectId(projectId))
|
|
632
1096
|
return { error: "Invalid project ID" };
|
|
633
1097
|
if (!prompt || !prompt.trim())
|
|
@@ -635,6 +1099,7 @@ export async function generateFastImageEditImage(projectId, prompt, images, onLo
|
|
|
635
1099
|
if (!Array.isArray(images) || images.length === 0) {
|
|
636
1100
|
return { error: "At least one reference image is required" };
|
|
637
1101
|
}
|
|
1102
|
+
const stepCount = Math.max(1, Number(steps) || 4);
|
|
638
1103
|
// Decode each base64 reference image into a temp workspace file so the
|
|
639
1104
|
// FLUX model receives them as separate `--image` inputs.
|
|
640
1105
|
const tempDir = join(TEMP_DIR, String(projectId));
|
|
@@ -661,12 +1126,21 @@ export async function generateFastImageEditImage(projectId, prompt, images, onLo
|
|
|
661
1126
|
const args = [mlxgen, "generate", "--model", FLUX_KLEIN_MODEL];
|
|
662
1127
|
for (const path of tempImagePaths)
|
|
663
1128
|
args.push("--image", path);
|
|
664
|
-
args.push("--prompt", prompt.trim(), "--output", outputPath, "--mlx-cache-limit-gb", "20", "--steps",
|
|
1129
|
+
args.push("--prompt", prompt.trim(), "--output", outputPath, "--mlx-cache-limit-gb", "20", "--steps", String(stepCount), "--seed", "42", "--width", "1024", "--height", "1024");
|
|
665
1130
|
const result = await runCommand(args, { onLog });
|
|
666
1131
|
if (!result.success || !existsSync(outputPath)) {
|
|
667
1132
|
return { error: result.output || "Fast image edit failed" };
|
|
668
1133
|
}
|
|
669
1134
|
backupFile(outputPath, projectId);
|
|
1135
|
+
// Optionally upscale the generated result (1x / 1500px / 2000px).
|
|
1136
|
+
if (upscaleResolution && upscaleResolution !== "none") {
|
|
1137
|
+
if (onLog)
|
|
1138
|
+
onLog(`Upscaling result (${upscaleResolution})…\n`);
|
|
1139
|
+
const upscaled = await generateUpscale(projectId, outputFile, upscaleResolution, onLog);
|
|
1140
|
+
if ("error" in upscaled)
|
|
1141
|
+
return { error: upscaled.error };
|
|
1142
|
+
return upscaled;
|
|
1143
|
+
}
|
|
670
1144
|
return {
|
|
671
1145
|
filename: outputFile,
|
|
672
1146
|
url: `/api/files?path=${encodeURIComponent(outputPath)}`,
|
|
@@ -973,6 +1447,60 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
973
1447
|
raw.sort((a, b) => b.birthtime - a.birthtime);
|
|
974
1448
|
res.json(raw.map(({ filename, url }) => ({ filename, url })));
|
|
975
1449
|
});
|
|
1450
|
+
// List previously generated voice clones by scanning the voices folder. Each
|
|
1451
|
+
// voice lives in its own subfolder with a meta.json (no central JSON index).
|
|
1452
|
+
app.get("/api/projects/:id/voices", (req, res) => {
|
|
1453
|
+
const { id } = req.params;
|
|
1454
|
+
if (!isValidProjectId(id)) {
|
|
1455
|
+
res.status(400).json({ error: "Invalid project ID" });
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const voicesRoot = join(OUTPUT_DIR, id, "voices");
|
|
1459
|
+
const results = [];
|
|
1460
|
+
if (existsSync(voicesRoot)) {
|
|
1461
|
+
let names = [];
|
|
1462
|
+
try {
|
|
1463
|
+
names = readdirSync(voicesRoot);
|
|
1464
|
+
}
|
|
1465
|
+
catch {
|
|
1466
|
+
names = [];
|
|
1467
|
+
}
|
|
1468
|
+
for (const name of names) {
|
|
1469
|
+
const voiceDir = join(voicesRoot, name);
|
|
1470
|
+
let isDir = false;
|
|
1471
|
+
try {
|
|
1472
|
+
isDir = statSync(voiceDir).isDirectory();
|
|
1473
|
+
}
|
|
1474
|
+
catch {
|
|
1475
|
+
isDir = false;
|
|
1476
|
+
}
|
|
1477
|
+
if (!isDir)
|
|
1478
|
+
continue;
|
|
1479
|
+
let meta = null;
|
|
1480
|
+
try {
|
|
1481
|
+
meta = JSON.parse(readFileSync(join(voiceDir, "meta.json"), "utf-8"));
|
|
1482
|
+
}
|
|
1483
|
+
catch {
|
|
1484
|
+
continue; // folder without a meta.json — skip
|
|
1485
|
+
}
|
|
1486
|
+
const filename = String(meta?.filename || TTS_OUTPUT_FILENAME);
|
|
1487
|
+
const audioPath = join(voiceDir, filename);
|
|
1488
|
+
if (!existsSync(audioPath))
|
|
1489
|
+
continue;
|
|
1490
|
+
results.push({
|
|
1491
|
+
id: name,
|
|
1492
|
+
transcript: String(meta?.transcript ?? ""),
|
|
1493
|
+
quality: String(meta?.quality ?? "high"),
|
|
1494
|
+
refAudioFilename: meta?.refAudioFilename ?? null,
|
|
1495
|
+
filename,
|
|
1496
|
+
createdAt: meta?.createdAt ?? null,
|
|
1497
|
+
url: `/api/files?path=${encodeURIComponent(audioPath)}`,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
results.sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")));
|
|
1502
|
+
res.json(results);
|
|
1503
|
+
});
|
|
976
1504
|
// ========== Render: Text-to-Image ==========
|
|
977
1505
|
app.post("/api/render/text-to-image", async (req, res) => {
|
|
978
1506
|
const { prompt, projectId, width = 512, height = 512, device = "mps", } = req.body || {};
|
|
@@ -1066,7 +1594,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1066
1594
|
});
|
|
1067
1595
|
// ========== Render: Image-to-Video ==========
|
|
1068
1596
|
app.post("/api/render/image-to-video", async (req, res) => {
|
|
1069
|
-
const { prompt, imagePath, projectId, outputDir, width = 480, height = 480, frames = 121, frameRate = 24, mode = "distilled", } = req.body || {};
|
|
1597
|
+
const { prompt, imagePath, projectId, outputDir, width = 480, height = 480, frames = 121, frameRate = 24, mode = "distilled", stage1Steps, stage2Steps, model, } = req.body || {};
|
|
1070
1598
|
if (!prompt) {
|
|
1071
1599
|
res.status(400).json({ error: "Prompt is required" });
|
|
1072
1600
|
return;
|
|
@@ -1122,6 +1650,8 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1122
1650
|
const videoHeight = Number(height) || 480;
|
|
1123
1651
|
const videoFrames = Number(frames) || 121;
|
|
1124
1652
|
const videoFps = Number(frameRate) || 24;
|
|
1653
|
+
const stage1 = Math.max(1, Math.round(Number(stage1Steps)) || 30);
|
|
1654
|
+
const stage2 = Math.max(1, Math.round(Number(stage2Steps)) || 3);
|
|
1125
1655
|
send("progress", {
|
|
1126
1656
|
status: "starting",
|
|
1127
1657
|
label: "Generating video...",
|
|
@@ -1139,10 +1669,14 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1139
1669
|
"ltx-2-mlx",
|
|
1140
1670
|
"generate",
|
|
1141
1671
|
"--model",
|
|
1142
|
-
|
|
1672
|
+
resolveLtxModel(model),
|
|
1143
1673
|
"--prompt",
|
|
1144
1674
|
prompt,
|
|
1145
1675
|
stageFlagFor(mode),
|
|
1676
|
+
"--stage1-steps",
|
|
1677
|
+
String(stage1),
|
|
1678
|
+
"--stage2-steps",
|
|
1679
|
+
String(stage2),
|
|
1146
1680
|
// "--low-ram",
|
|
1147
1681
|
"--frames",
|
|
1148
1682
|
String(videoFrames),
|
|
@@ -1738,26 +2272,39 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1738
2272
|
res.end();
|
|
1739
2273
|
}
|
|
1740
2274
|
});
|
|
1741
|
-
// ==========
|
|
1742
|
-
app.
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
2275
|
+
// ========== MLX-Audio: Status ==========
|
|
2276
|
+
app.get("/api/mlxaudio/status", async (_req, res) => {
|
|
2277
|
+
// Never throw on a missing uv — installation state is based on the folder.
|
|
2278
|
+
try {
|
|
2279
|
+
await getUvPath();
|
|
2280
|
+
}
|
|
2281
|
+
catch {
|
|
2282
|
+
// uv not found; installed is still reported from the folder below.
|
|
2283
|
+
}
|
|
2284
|
+
res.json({
|
|
2285
|
+
installed: existsSync(join(PYTHON_DIR, "mlx-audio")),
|
|
2286
|
+
});
|
|
2287
|
+
});
|
|
2288
|
+
// ========== Render: Voice Chat (TTS with reference voice) ==========
|
|
2289
|
+
app.post("/api/render/voice-chat", async (req, res) => {
|
|
2290
|
+
const { text, refAudioPath, projectId } = req.body || {};
|
|
2291
|
+
if (!text || typeof text !== "string" || !text.trim()) {
|
|
2292
|
+
res.status(400).json({ error: "Text is required" });
|
|
1746
2293
|
return;
|
|
1747
2294
|
}
|
|
1748
|
-
if (!
|
|
1749
|
-
res.status(400).json({ error: "
|
|
2295
|
+
if (!refAudioPath) {
|
|
2296
|
+
res.status(400).json({ error: "Reference audio is required" });
|
|
1750
2297
|
return;
|
|
1751
2298
|
}
|
|
1752
|
-
if (!projectId) {
|
|
1753
|
-
res.status(400).json({ error: "
|
|
2299
|
+
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
2300
|
+
res.status(400).json({ error: "Invalid project ID" });
|
|
1754
2301
|
return;
|
|
1755
2302
|
}
|
|
1756
|
-
// Resolve
|
|
1757
|
-
const
|
|
1758
|
-
if (!
|
|
2303
|
+
// Resolve reference audio — only bare filenames in this project's dirs.
|
|
2304
|
+
const resolvedRef = resolveSafePath(refAudioPath, String(projectId));
|
|
2305
|
+
if (!resolvedRef) {
|
|
1759
2306
|
res.status(400).json({
|
|
1760
|
-
error: "Invalid
|
|
2307
|
+
error: "Invalid reference audio path. Provide a filename previously uploaded to this project.",
|
|
1761
2308
|
});
|
|
1762
2309
|
return;
|
|
1763
2310
|
}
|
|
@@ -1772,59 +2319,65 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1772
2319
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1773
2320
|
};
|
|
1774
2321
|
try {
|
|
1775
|
-
const
|
|
1776
|
-
|
|
1777
|
-
|
|
2322
|
+
const uvPath = await getUvPath();
|
|
2323
|
+
const projectOutputDir = resolveOutputDir(null, String(projectId));
|
|
2324
|
+
if (!projectOutputDir) {
|
|
2325
|
+
send("error", { error: "Invalid output directory." });
|
|
1778
2326
|
res.end();
|
|
1779
2327
|
return;
|
|
1780
2328
|
}
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
const outputPath = join(projectOutputDir, outputFile);
|
|
1786
|
-
const framesToAdd = Number(extendFrames) || 2;
|
|
2329
|
+
// Isolate each utterance so a new response never overwrites a previous
|
|
2330
|
+
// one. mlx_audio writes the clip as <dir>/audio_000.mp3.
|
|
2331
|
+
const voiceDir = join(projectOutputDir, "voice-chat", `chat-${Date.now()}`);
|
|
2332
|
+
ensureDir(voiceDir);
|
|
1787
2333
|
send("progress", {
|
|
1788
2334
|
status: "starting",
|
|
1789
|
-
label: "
|
|
1790
|
-
inputFile: resolvedVideo,
|
|
1791
|
-
outputFile,
|
|
1792
|
-
settings: { extendFrames: framesToAdd },
|
|
2335
|
+
label: "Generating voice...",
|
|
1793
2336
|
});
|
|
1794
2337
|
const proc = spawn([
|
|
1795
2338
|
uvPath,
|
|
1796
2339
|
"run",
|
|
1797
|
-
"
|
|
1798
|
-
"extend",
|
|
2340
|
+
"mlx_audio.tts.generate",
|
|
1799
2341
|
"--model",
|
|
1800
|
-
|
|
1801
|
-
"--
|
|
1802
|
-
|
|
1803
|
-
"--
|
|
1804
|
-
|
|
1805
|
-
"--
|
|
1806
|
-
String(framesToAdd),
|
|
2342
|
+
TTS_MODELS.high,
|
|
2343
|
+
"--text",
|
|
2344
|
+
text.trim(),
|
|
2345
|
+
"--ref_audio",
|
|
2346
|
+
resolvedRef,
|
|
2347
|
+
// "--play",
|
|
1807
2348
|
"--output",
|
|
1808
|
-
|
|
2349
|
+
voiceDir,
|
|
2350
|
+
"--audio_format",
|
|
2351
|
+
"mp3",
|
|
2352
|
+
// "--stream",
|
|
2353
|
+
// "--save",
|
|
2354
|
+
"--instruct",
|
|
2355
|
+
"slow down",
|
|
1809
2356
|
], {
|
|
1810
2357
|
env: process.env,
|
|
1811
|
-
cwd:
|
|
2358
|
+
cwd: voiceDir,
|
|
1812
2359
|
stdout: "pipe",
|
|
1813
2360
|
stderr: "pipe",
|
|
1814
2361
|
});
|
|
1815
2362
|
activeProc = proc;
|
|
1816
|
-
|
|
1817
|
-
const
|
|
1818
|
-
const stderrText = await streamToSSE(proc.stderr, "Extend", send);
|
|
2363
|
+
const stdoutPromise = streamToSSE(proc.stdout, "VoiceChat", send);
|
|
2364
|
+
const stderrText = await streamToSSE(proc.stderr, "VoiceChat", send);
|
|
1819
2365
|
await stdoutPromise;
|
|
1820
2366
|
const exitCode = await proc.exited;
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2367
|
+
if (exitCode === 0) {
|
|
2368
|
+
const path = resolveAudioFile(voiceDir);
|
|
2369
|
+
if (path) {
|
|
2370
|
+
send("complete", {
|
|
2371
|
+
success: true,
|
|
2372
|
+
path,
|
|
2373
|
+
filename: path.split(sep).pop(),
|
|
2374
|
+
});
|
|
2375
|
+
}
|
|
2376
|
+
else {
|
|
2377
|
+
send("error", {
|
|
2378
|
+
error: "TTS completed but no audio file was produced",
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
1828
2381
|
}
|
|
1829
2382
|
else {
|
|
1830
2383
|
send("error", {
|
|
@@ -1841,47 +2394,17 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1841
2394
|
res.end();
|
|
1842
2395
|
}
|
|
1843
2396
|
});
|
|
1844
|
-
// ========== MLX-
|
|
1845
|
-
app.get("/api/
|
|
1846
|
-
// Never throw on a missing uv — installation state is based on the folder.
|
|
1847
|
-
try {
|
|
1848
|
-
await getUvPath();
|
|
1849
|
-
}
|
|
1850
|
-
catch {
|
|
1851
|
-
// uv not found; installed is still reported from the folder below.
|
|
1852
|
-
}
|
|
2397
|
+
// ========== MLX-Gen: Status ==========
|
|
2398
|
+
app.get("/api/mlxgen/status", (_req, res) => {
|
|
1853
2399
|
res.json({
|
|
1854
|
-
installed:
|
|
2400
|
+
installed: isMlxgenInstalled(),
|
|
2401
|
+
zModelDownloaded: isModelDownloaded(Z_IMAGE_MODEL),
|
|
2402
|
+
fluxModelDownloaded: isModelDownloaded(FLUX_KLEIN_MODEL),
|
|
2403
|
+
seedvr2Downloaded: isModelDownloaded(SEEDVR2_MODEL),
|
|
1855
2404
|
});
|
|
1856
2405
|
});
|
|
1857
|
-
// ==========
|
|
1858
|
-
app.post("/api/
|
|
1859
|
-
const { text, refAudioPath, projectId, outputDir, quality, voiceId } = req.body || {};
|
|
1860
|
-
if (!text || typeof text !== "string" || !text.trim()) {
|
|
1861
|
-
res.status(400).json({ error: "Text is required" });
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1864
|
-
if (!refAudioPath) {
|
|
1865
|
-
res.status(400).json({ error: "Reference audio is required" });
|
|
1866
|
-
return;
|
|
1867
|
-
}
|
|
1868
|
-
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
1869
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
1870
|
-
return;
|
|
1871
|
-
}
|
|
1872
|
-
if (quality !== "low" && quality !== "high") {
|
|
1873
|
-
res.status(400).json({ error: "Quality must be 'low' or 'high'" });
|
|
1874
|
-
return;
|
|
1875
|
-
}
|
|
1876
|
-
// Resolve reference audio — only bare filenames in this project's dirs
|
|
1877
|
-
const resolvedRef = resolveSafePath(refAudioPath, projectId);
|
|
1878
|
-
if (!resolvedRef) {
|
|
1879
|
-
res.status(400).json({
|
|
1880
|
-
error: "Invalid reference audio path. Provide a filename previously uploaded to this project.",
|
|
1881
|
-
});
|
|
1882
|
-
return;
|
|
1883
|
-
}
|
|
1884
|
-
// SSE headers
|
|
2406
|
+
// ========== MLX-Gen: Install ==========
|
|
2407
|
+
app.post("/api/mlxgen/install", async (_req, res) => {
|
|
1885
2408
|
res.writeHead(200, {
|
|
1886
2409
|
"Content-Type": "text/event-stream",
|
|
1887
2410
|
"Cache-Control": "no-cache",
|
|
@@ -1893,81 +2416,21 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1893
2416
|
};
|
|
1894
2417
|
try {
|
|
1895
2418
|
const uvPath = await getUvPath();
|
|
1896
|
-
const projectOutputDir = resolveOutputDir(outputDir, projectId);
|
|
1897
|
-
if (!projectOutputDir) {
|
|
1898
|
-
send("error", { error: "Invalid output directory." });
|
|
1899
|
-
res.end();
|
|
1900
|
-
return;
|
|
1901
|
-
}
|
|
1902
|
-
// TTS audio is saved under <projectOutputDir>/voices/<voiceId>/ so each
|
|
1903
|
-
// row's voiceover stays isolated in its own folder. Sanitize voiceId to
|
|
1904
|
-
// alphanumerics/_/- so it can never escape the voices directory.
|
|
1905
|
-
const safeVoiceId = String(voiceId ?? `voice-${Date.now()}`)
|
|
1906
|
-
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
1907
|
-
.slice(0, 64);
|
|
1908
|
-
const voiceDir = join(projectOutputDir, "voices", safeVoiceId);
|
|
1909
|
-
ensureDir(voiceDir);
|
|
1910
|
-
// Regenerating a row must overwrite the previous clip: delete any
|
|
1911
|
-
// existing output so the generator writes a fresh audio_000.mp3.
|
|
1912
|
-
const prevAudio = join(voiceDir, TTS_OUTPUT_FILENAME);
|
|
1913
|
-
if (existsSync(prevAudio)) {
|
|
1914
|
-
try {
|
|
1915
|
-
unlinkSync(prevAudio);
|
|
1916
|
-
}
|
|
1917
|
-
catch {
|
|
1918
|
-
// Ignore — the generator will overwrite the file regardless.
|
|
1919
|
-
}
|
|
1920
|
-
}
|
|
1921
2419
|
send("progress", {
|
|
1922
2420
|
status: "starting",
|
|
1923
|
-
label: "
|
|
1924
|
-
model: TTS_MODELS[quality],
|
|
2421
|
+
label: "Installing mlx-gen...",
|
|
1925
2422
|
});
|
|
1926
|
-
|
|
1927
|
-
// lands in <projectOutputDir>/voices/<voiceId>/, which is servable via
|
|
1928
|
-
// /api/files and resolvable via resolveSafePath for the mux step.
|
|
1929
|
-
const proc = spawn([
|
|
1930
|
-
uvPath,
|
|
1931
|
-
"run",
|
|
1932
|
-
"mlx_audio.tts.generate",
|
|
1933
|
-
"--model",
|
|
1934
|
-
TTS_MODELS[quality],
|
|
1935
|
-
"--text",
|
|
1936
|
-
text,
|
|
1937
|
-
"--ref_audio",
|
|
1938
|
-
resolvedRef,
|
|
1939
|
-
"--output",
|
|
1940
|
-
voiceDir,
|
|
1941
|
-
"--audio_format",
|
|
1942
|
-
"mp3",
|
|
1943
|
-
"--play",
|
|
1944
|
-
"--instruct",
|
|
1945
|
-
"slow down speech",
|
|
1946
|
-
], {
|
|
1947
|
-
env: process.env,
|
|
1948
|
-
cwd: voiceDir,
|
|
2423
|
+
const proc = spawn([uvPath, "tool", "install", "--upgrade", "mlx-gen"], {
|
|
1949
2424
|
stdout: "pipe",
|
|
1950
2425
|
stderr: "pipe",
|
|
1951
2426
|
});
|
|
1952
2427
|
activeProc = proc;
|
|
1953
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
1954
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2428
|
+
const stdoutPromise = streamToSSE(proc.stdout, "Install", send);
|
|
2429
|
+
const stderrText = await streamToSSE(proc.stderr, "Install", send);
|
|
1955
2430
|
await stdoutPromise;
|
|
1956
2431
|
const exitCode = await proc.exited;
|
|
1957
2432
|
if (exitCode === 0) {
|
|
1958
|
-
|
|
1959
|
-
if (path) {
|
|
1960
|
-
send("complete", {
|
|
1961
|
-
success: true,
|
|
1962
|
-
path,
|
|
1963
|
-
filename: path.split(sep).pop(),
|
|
1964
|
-
});
|
|
1965
|
-
}
|
|
1966
|
-
else {
|
|
1967
|
-
send("error", {
|
|
1968
|
-
error: "TTS completed but no audio file was produced",
|
|
1969
|
-
});
|
|
1970
|
-
}
|
|
2433
|
+
send("complete", { success: true });
|
|
1971
2434
|
}
|
|
1972
2435
|
else {
|
|
1973
2436
|
send("error", {
|
|
@@ -1984,30 +2447,8 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
1984
2447
|
res.end();
|
|
1985
2448
|
}
|
|
1986
2449
|
});
|
|
1987
|
-
// ==========
|
|
1988
|
-
app.post("/api/
|
|
1989
|
-
const { text, refAudioPath, projectId } = req.body || {};
|
|
1990
|
-
if (!text || typeof text !== "string" || !text.trim()) {
|
|
1991
|
-
res.status(400).json({ error: "Text is required" });
|
|
1992
|
-
return;
|
|
1993
|
-
}
|
|
1994
|
-
if (!refAudioPath) {
|
|
1995
|
-
res.status(400).json({ error: "Reference audio is required" });
|
|
1996
|
-
return;
|
|
1997
|
-
}
|
|
1998
|
-
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
1999
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
2000
|
-
return;
|
|
2001
|
-
}
|
|
2002
|
-
// Resolve reference audio — only bare filenames in this project's dirs.
|
|
2003
|
-
const resolvedRef = resolveSafePath(refAudioPath, String(projectId));
|
|
2004
|
-
if (!resolvedRef) {
|
|
2005
|
-
res.status(400).json({
|
|
2006
|
-
error: "Invalid reference audio path. Provide a filename previously uploaded to this project.",
|
|
2007
|
-
});
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
// SSE headers
|
|
2450
|
+
// ========== MLX-Gen: Download Z-Image Model ==========
|
|
2451
|
+
app.post("/api/mlxgen/download-z-model", async (_req, res) => {
|
|
2011
2452
|
res.writeHead(200, {
|
|
2012
2453
|
"Content-Type": "text/event-stream",
|
|
2013
2454
|
"Cache-Control": "no-cache",
|
|
@@ -2017,66 +2458,24 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2017
2458
|
const send = (event, data) => {
|
|
2018
2459
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2019
2460
|
};
|
|
2461
|
+
const model = Z_IMAGE_MODEL;
|
|
2020
2462
|
try {
|
|
2021
|
-
const
|
|
2022
|
-
const projectOutputDir = resolveOutputDir(null, String(projectId));
|
|
2023
|
-
if (!projectOutputDir) {
|
|
2024
|
-
send("error", { error: "Invalid output directory." });
|
|
2025
|
-
res.end();
|
|
2026
|
-
return;
|
|
2027
|
-
}
|
|
2028
|
-
// Isolate each utterance so a new response never overwrites a previous
|
|
2029
|
-
// one. mlx_audio writes the clip as <dir>/audio_000.mp3.
|
|
2030
|
-
const voiceDir = join(projectOutputDir, "voice-chat", `chat-${Date.now()}`);
|
|
2031
|
-
ensureDir(voiceDir);
|
|
2463
|
+
const mlxgen = await getMlxgenBin();
|
|
2032
2464
|
send("progress", {
|
|
2033
2465
|
status: "starting",
|
|
2034
|
-
label:
|
|
2466
|
+
label: `Downloading model ${model}...`,
|
|
2035
2467
|
});
|
|
2036
|
-
const proc = spawn([
|
|
2037
|
-
uvPath,
|
|
2038
|
-
"run",
|
|
2039
|
-
"mlx_audio.tts.generate",
|
|
2040
|
-
"--model",
|
|
2041
|
-
TTS_MODELS.high,
|
|
2042
|
-
"--text",
|
|
2043
|
-
text.trim(),
|
|
2044
|
-
"--ref_audio",
|
|
2045
|
-
resolvedRef,
|
|
2046
|
-
// "--play",
|
|
2047
|
-
"--output",
|
|
2048
|
-
voiceDir,
|
|
2049
|
-
"--audio_format",
|
|
2050
|
-
"mp3",
|
|
2051
|
-
// "--stream",
|
|
2052
|
-
// "--save",
|
|
2053
|
-
"--instruct",
|
|
2054
|
-
"slow down",
|
|
2055
|
-
], {
|
|
2056
|
-
env: process.env,
|
|
2057
|
-
cwd: voiceDir,
|
|
2468
|
+
const proc = spawn([mlxgen, "download", "--model", model], {
|
|
2058
2469
|
stdout: "pipe",
|
|
2059
2470
|
stderr: "pipe",
|
|
2060
2471
|
});
|
|
2061
2472
|
activeProc = proc;
|
|
2062
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
2063
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2473
|
+
const stdoutPromise = streamToSSE(proc.stdout, "Download", send);
|
|
2474
|
+
const stderrText = await streamToSSE(proc.stderr, "Download", send);
|
|
2064
2475
|
await stdoutPromise;
|
|
2065
2476
|
const exitCode = await proc.exited;
|
|
2066
2477
|
if (exitCode === 0) {
|
|
2067
|
-
|
|
2068
|
-
if (path) {
|
|
2069
|
-
send("complete", {
|
|
2070
|
-
success: true,
|
|
2071
|
-
path,
|
|
2072
|
-
filename: path.split(sep).pop(),
|
|
2073
|
-
});
|
|
2074
|
-
}
|
|
2075
|
-
else {
|
|
2076
|
-
send("error", {
|
|
2077
|
-
error: "TTS completed but no audio file was produced",
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2478
|
+
send("complete", { success: true });
|
|
2080
2479
|
}
|
|
2081
2480
|
else {
|
|
2082
2481
|
send("error", {
|
|
@@ -2093,38 +2492,8 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2093
2492
|
res.end();
|
|
2094
2493
|
}
|
|
2095
2494
|
});
|
|
2096
|
-
// ==========
|
|
2097
|
-
app.post("/api/
|
|
2098
|
-
const { videoPath, audioPath, projectId, outputDir } = req.body || {};
|
|
2099
|
-
if (!videoPath) {
|
|
2100
|
-
res.status(400).json({ error: "Video path is required" });
|
|
2101
|
-
return;
|
|
2102
|
-
}
|
|
2103
|
-
if (!audioPath) {
|
|
2104
|
-
res.status(400).json({ error: "Audio path is required" });
|
|
2105
|
-
return;
|
|
2106
|
-
}
|
|
2107
|
-
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
2108
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
2109
|
-
return;
|
|
2110
|
-
}
|
|
2111
|
-
// Resolve video path — only bare .mp4 filenames in this project's output dir
|
|
2112
|
-
const resolvedVideo = resolveSafeVideoPath(videoPath, projectId);
|
|
2113
|
-
if (!resolvedVideo) {
|
|
2114
|
-
res.status(400).json({
|
|
2115
|
-
error: "Invalid video path. Provide a filename previously generated in this project.",
|
|
2116
|
-
});
|
|
2117
|
-
return;
|
|
2118
|
-
}
|
|
2119
|
-
// Resolve audio path — only bare filenames in this project's dirs
|
|
2120
|
-
const resolvedAudio = resolveSafePath(audioPath, projectId);
|
|
2121
|
-
if (!resolvedAudio) {
|
|
2122
|
-
res.status(400).json({
|
|
2123
|
-
error: "Invalid audio path. Provide a filename previously generated or uploaded to this project.",
|
|
2124
|
-
});
|
|
2125
|
-
return;
|
|
2126
|
-
}
|
|
2127
|
-
// SSE headers
|
|
2495
|
+
// ========== MLX-Gen: Download FLUX.2 Klein Model ==========
|
|
2496
|
+
app.post("/api/mlxgen/download-flux-model", async (_req, res) => {
|
|
2128
2497
|
res.writeHead(200, {
|
|
2129
2498
|
"Content-Type": "text/event-stream",
|
|
2130
2499
|
"Cache-Control": "no-cache",
|
|
@@ -2135,54 +2504,22 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2135
2504
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2136
2505
|
};
|
|
2137
2506
|
try {
|
|
2138
|
-
const
|
|
2139
|
-
const projectOutputDir = resolveOutputDir(outputDir, projectId);
|
|
2140
|
-
if (!projectOutputDir) {
|
|
2141
|
-
send("error", { error: "Invalid output directory." });
|
|
2142
|
-
res.end();
|
|
2143
|
-
return;
|
|
2144
|
-
}
|
|
2145
|
-
const finalFile = `voice-${Date.now()}.mp4`;
|
|
2146
|
-
const finalPath = join(projectOutputDir, finalFile);
|
|
2507
|
+
const mlxgen = await getMlxgenBin();
|
|
2147
2508
|
send("progress", {
|
|
2148
2509
|
status: "starting",
|
|
2149
|
-
label:
|
|
2150
|
-
outputFile: finalFile,
|
|
2510
|
+
label: `Downloading model ${FLUX_KLEIN_MODEL}...`,
|
|
2151
2511
|
});
|
|
2152
|
-
const proc = spawn([
|
|
2153
|
-
ffmpegBin,
|
|
2154
|
-
"-y",
|
|
2155
|
-
"-i",
|
|
2156
|
-
resolvedVideo,
|
|
2157
|
-
"-i",
|
|
2158
|
-
resolvedAudio,
|
|
2159
|
-
"-map",
|
|
2160
|
-
"0:v",
|
|
2161
|
-
"-map",
|
|
2162
|
-
"1:a",
|
|
2163
|
-
"-c:v",
|
|
2164
|
-
"copy",
|
|
2165
|
-
"-c:a",
|
|
2166
|
-
"aac",
|
|
2167
|
-
"-b:a",
|
|
2168
|
-
"192k",
|
|
2169
|
-
"-shortest",
|
|
2170
|
-
finalPath,
|
|
2171
|
-
], {
|
|
2512
|
+
const proc = spawn([mlxgen, "download", "--model", FLUX_KLEIN_MODEL], {
|
|
2172
2513
|
stdout: "pipe",
|
|
2173
2514
|
stderr: "pipe",
|
|
2174
2515
|
});
|
|
2175
2516
|
activeProc = proc;
|
|
2176
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
2177
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2517
|
+
const stdoutPromise = streamToSSE(proc.stdout, "Download", send);
|
|
2518
|
+
const stderrText = await streamToSSE(proc.stderr, "Download", send);
|
|
2178
2519
|
await stdoutPromise;
|
|
2179
2520
|
const exitCode = await proc.exited;
|
|
2180
|
-
if (exitCode === 0
|
|
2181
|
-
send("complete", {
|
|
2182
|
-
success: true,
|
|
2183
|
-
path: finalPath,
|
|
2184
|
-
filename: finalFile,
|
|
2185
|
-
});
|
|
2521
|
+
if (exitCode === 0) {
|
|
2522
|
+
send("complete", { success: true });
|
|
2186
2523
|
}
|
|
2187
2524
|
else {
|
|
2188
2525
|
send("error", {
|
|
@@ -2199,16 +2536,8 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2199
2536
|
res.end();
|
|
2200
2537
|
}
|
|
2201
2538
|
});
|
|
2202
|
-
// ========== MLX-Gen:
|
|
2203
|
-
app.
|
|
2204
|
-
res.json({
|
|
2205
|
-
installed: isMlxgenInstalled(),
|
|
2206
|
-
zModelDownloaded: isModelDownloaded(Z_IMAGE_MODEL),
|
|
2207
|
-
fluxModelDownloaded: isModelDownloaded(FLUX_KLEIN_MODEL),
|
|
2208
|
-
});
|
|
2209
|
-
});
|
|
2210
|
-
// ========== MLX-Gen: Install ==========
|
|
2211
|
-
app.post("/api/mlxgen/install", async (_req, res) => {
|
|
2539
|
+
// ========== MLX-Gen: Download SeedVR2 Model ==========
|
|
2540
|
+
app.post("/api/mlxgen/download-seedvr2-model", async (_req, res) => {
|
|
2212
2541
|
res.writeHead(200, {
|
|
2213
2542
|
"Content-Type": "text/event-stream",
|
|
2214
2543
|
"Cache-Control": "no-cache",
|
|
@@ -2219,18 +2548,18 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2219
2548
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2220
2549
|
};
|
|
2221
2550
|
try {
|
|
2222
|
-
const
|
|
2551
|
+
const mlxgen = await getMlxgenBin();
|
|
2223
2552
|
send("progress", {
|
|
2224
2553
|
status: "starting",
|
|
2225
|
-
label:
|
|
2554
|
+
label: `Downloading model ${SEEDVR2_MODEL}...`,
|
|
2226
2555
|
});
|
|
2227
|
-
const proc = spawn([
|
|
2556
|
+
const proc = spawn([mlxgen, "download", "--model", SEEDVR2_MODEL], {
|
|
2228
2557
|
stdout: "pipe",
|
|
2229
2558
|
stderr: "pipe",
|
|
2230
2559
|
});
|
|
2231
2560
|
activeProc = proc;
|
|
2232
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
2233
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2561
|
+
const stdoutPromise = streamToSSE(proc.stdout, "Download", send);
|
|
2562
|
+
const stderrText = await streamToSSE(proc.stderr, "Download", send);
|
|
2234
2563
|
await stdoutPromise;
|
|
2235
2564
|
const exitCode = await proc.exited;
|
|
2236
2565
|
if (exitCode === 0) {
|
|
@@ -2251,8 +2580,25 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2251
2580
|
res.end();
|
|
2252
2581
|
}
|
|
2253
2582
|
});
|
|
2254
|
-
// ==========
|
|
2255
|
-
app.
|
|
2583
|
+
// ========== H3: Download Model ==========
|
|
2584
|
+
app.get("/api/h3/status", (_req, res) => {
|
|
2585
|
+
res.json({ downloaded: isH3ModelDownloaded() });
|
|
2586
|
+
});
|
|
2587
|
+
// ========== Hugging Face CLI + LTX Video Model ==========
|
|
2588
|
+
app.get("/api/hf/status", (_req, res) => {
|
|
2589
|
+
res.json({
|
|
2590
|
+
installed: whichSync("hf") !== null,
|
|
2591
|
+
ltxDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx-q8"),
|
|
2592
|
+
ltxBaseDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx"),
|
|
2593
|
+
ttsDownloaded: isModelDownloaded("Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
|
|
2594
|
+
mlxVlmDownloaded: isModelDownloaded(MLX_VLM_MODEL),
|
|
2595
|
+
});
|
|
2596
|
+
});
|
|
2597
|
+
// ========== Dots-TTS: Status + Model Download ==========
|
|
2598
|
+
app.get("/api/dots-tts/status", (_req, res) => {
|
|
2599
|
+
res.json({ downloaded: isDotsTtsModelDownloaded() });
|
|
2600
|
+
});
|
|
2601
|
+
app.post("/api/dots-tts/download-model", async (_req, res) => {
|
|
2256
2602
|
res.writeHead(200, {
|
|
2257
2603
|
"Content-Type": "text/event-stream",
|
|
2258
2604
|
"Cache-Control": "no-cache",
|
|
@@ -2262,20 +2608,24 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2262
2608
|
const send = (event, data) => {
|
|
2263
2609
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2264
2610
|
};
|
|
2265
|
-
const model = Z_IMAGE_MODEL;
|
|
2266
2611
|
try {
|
|
2267
|
-
|
|
2612
|
+
ensureDir(DOTS_TTS_FOLDER);
|
|
2268
2613
|
send("progress", {
|
|
2269
2614
|
status: "starting",
|
|
2270
|
-
label:
|
|
2271
|
-
});
|
|
2272
|
-
const proc = spawn([mlxgen, "download", "--model", model], {
|
|
2273
|
-
stdout: "pipe",
|
|
2274
|
-
stderr: "pipe",
|
|
2615
|
+
label: "Downloading dots-tts model (mf-int4)...",
|
|
2275
2616
|
});
|
|
2617
|
+
const proc = spawn([
|
|
2618
|
+
"hf",
|
|
2619
|
+
"download",
|
|
2620
|
+
"shraey/dots-tts-mlx",
|
|
2621
|
+
"--include",
|
|
2622
|
+
"mf-int4/*",
|
|
2623
|
+
"--local-dir",
|
|
2624
|
+
"./dots-tts-mlx-weights",
|
|
2625
|
+
], { cwd: DOTS_TTS_FOLDER, stdout: "pipe", stderr: "pipe" });
|
|
2276
2626
|
activeProc = proc;
|
|
2277
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
2278
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2627
|
+
const stdoutPromise = streamToSSE(proc.stdout, "dots-tts", send);
|
|
2628
|
+
const stderrText = await streamToSSE(proc.stderr, "dots-tts", send);
|
|
2279
2629
|
await stdoutPromise;
|
|
2280
2630
|
const exitCode = await proc.exited;
|
|
2281
2631
|
if (exitCode === 0) {
|
|
@@ -2296,8 +2646,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2296
2646
|
res.end();
|
|
2297
2647
|
}
|
|
2298
2648
|
});
|
|
2299
|
-
|
|
2300
|
-
app.post("/api/mlxgen/download-flux-model", async (_req, res) => {
|
|
2649
|
+
app.post("/api/hf/install", async (_req, res) => {
|
|
2301
2650
|
res.writeHead(200, {
|
|
2302
2651
|
"Content-Type": "text/event-stream",
|
|
2303
2652
|
"Cache-Control": "no-cache",
|
|
@@ -2308,18 +2657,14 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2308
2657
|
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2309
2658
|
};
|
|
2310
2659
|
try {
|
|
2311
|
-
const mlxgen = await getMlxgenBin();
|
|
2312
2660
|
send("progress", {
|
|
2313
2661
|
status: "starting",
|
|
2314
|
-
label:
|
|
2315
|
-
});
|
|
2316
|
-
const proc = spawn([mlxgen, "download", "--model", FLUX_KLEIN_MODEL], {
|
|
2317
|
-
stdout: "pipe",
|
|
2318
|
-
stderr: "pipe",
|
|
2662
|
+
label: "Installing huggingface-cli...",
|
|
2319
2663
|
});
|
|
2664
|
+
const proc = spawn(["bash", "-c", "curl -LsSf https://hf.co/cli/install.sh | bash"], { stdout: "pipe", stderr: "pipe" });
|
|
2320
2665
|
activeProc = proc;
|
|
2321
|
-
const stdoutPromise = streamToSSE(proc.stdout, "
|
|
2322
|
-
const stderrText = await streamToSSE(proc.stderr, "
|
|
2666
|
+
const stdoutPromise = streamToSSE(proc.stdout, "HF Install", send);
|
|
2667
|
+
const stderrText = await streamToSSE(proc.stderr, "HF Install", send);
|
|
2323
2668
|
await stdoutPromise;
|
|
2324
2669
|
const exitCode = await proc.exited;
|
|
2325
2670
|
if (exitCode === 0) {
|
|
@@ -2340,20 +2685,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2340
2685
|
res.end();
|
|
2341
2686
|
}
|
|
2342
2687
|
});
|
|
2343
|
-
|
|
2344
|
-
app.get("/api/h3/status", (_req, res) => {
|
|
2345
|
-
res.json({ downloaded: isH3ModelDownloaded() });
|
|
2346
|
-
});
|
|
2347
|
-
// ========== Hugging Face CLI + LTX Video Model ==========
|
|
2348
|
-
app.get("/api/hf/status", (_req, res) => {
|
|
2349
|
-
res.json({
|
|
2350
|
-
installed: whichSync("hf") !== null,
|
|
2351
|
-
ltxDownloaded: isModelDownloaded("dgrauet/ltx-2.3-mlx-q8"),
|
|
2352
|
-
ttsDownloaded: isModelDownloaded("Qwen/Qwen3-TTS-12Hz-1.7B-Base"),
|
|
2353
|
-
mlxVlmDownloaded: isModelDownloaded(MLX_VLM_MODEL),
|
|
2354
|
-
});
|
|
2355
|
-
});
|
|
2356
|
-
app.post("/api/hf/install", async (_req, res) => {
|
|
2688
|
+
app.post("/api/hf/download-ltx", async (_req, res) => {
|
|
2357
2689
|
res.writeHead(200, {
|
|
2358
2690
|
"Content-Type": "text/event-stream",
|
|
2359
2691
|
"Cache-Control": "no-cache",
|
|
@@ -2366,12 +2698,15 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2366
2698
|
try {
|
|
2367
2699
|
send("progress", {
|
|
2368
2700
|
status: "starting",
|
|
2369
|
-
label: "
|
|
2701
|
+
label: "Downloading dgrauet/ltx-2.3-mlx-q8...",
|
|
2702
|
+
});
|
|
2703
|
+
const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx-q8"], {
|
|
2704
|
+
stdout: "pipe",
|
|
2705
|
+
stderr: "pipe",
|
|
2370
2706
|
});
|
|
2371
|
-
const proc = spawn(["bash", "-c", "curl -LsSf https://hf.co/cli/install.sh | bash"], { stdout: "pipe", stderr: "pipe" });
|
|
2372
2707
|
activeProc = proc;
|
|
2373
|
-
const stdoutPromise = streamToSSE(proc.stdout, "HF
|
|
2374
|
-
const stderrText = await streamToSSE(proc.stderr, "HF
|
|
2708
|
+
const stdoutPromise = streamToSSE(proc.stdout, "HF Download", send);
|
|
2709
|
+
const stderrText = await streamToSSE(proc.stderr, "HF Download", send);
|
|
2375
2710
|
await stdoutPromise;
|
|
2376
2711
|
const exitCode = await proc.exited;
|
|
2377
2712
|
if (exitCode === 0) {
|
|
@@ -2392,7 +2727,7 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2392
2727
|
res.end();
|
|
2393
2728
|
}
|
|
2394
2729
|
});
|
|
2395
|
-
app.post("/api/hf/download-ltx", async (_req, res) => {
|
|
2730
|
+
app.post("/api/hf/download-ltx-base", async (_req, res) => {
|
|
2396
2731
|
res.writeHead(200, {
|
|
2397
2732
|
"Content-Type": "text/event-stream",
|
|
2398
2733
|
"Cache-Control": "no-cache",
|
|
@@ -2405,9 +2740,9 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
2405
2740
|
try {
|
|
2406
2741
|
send("progress", {
|
|
2407
2742
|
status: "starting",
|
|
2408
|
-
label: "Downloading dgrauet/ltx-2.3-mlx
|
|
2743
|
+
label: "Downloading dgrauet/ltx-2.3-mlx...",
|
|
2409
2744
|
});
|
|
2410
|
-
const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx
|
|
2745
|
+
const proc = spawn(["hf", "download", "dgrauet/ltx-2.3-mlx"], {
|
|
2411
2746
|
stdout: "pipe",
|
|
2412
2747
|
stderr: "pipe",
|
|
2413
2748
|
});
|
|
@@ -3041,37 +3376,6 @@ export async function renderMediaRoutes({ app, getUvPath, }) {
|
|
|
3041
3376
|
writeCharacters(characters);
|
|
3042
3377
|
res.json(removed);
|
|
3043
3378
|
});
|
|
3044
|
-
// Save an extracted video frame into the project's extracted-frames folder.
|
|
3045
|
-
app.post("/api/extracted-frames", (req, res) => {
|
|
3046
|
-
const { image, filename, projectId } = req.body || {};
|
|
3047
|
-
if (!image) {
|
|
3048
|
-
res.status(400).json({ error: "Image data is required (base64)" });
|
|
3049
|
-
return;
|
|
3050
|
-
}
|
|
3051
|
-
if (!projectId || !isValidProjectId(String(projectId))) {
|
|
3052
|
-
res.status(400).json({ error: "Invalid project ID" });
|
|
3053
|
-
return;
|
|
3054
|
-
}
|
|
3055
|
-
try {
|
|
3056
|
-
const base64 = String(image).replace(/^data:[^;]+;base64,/, "");
|
|
3057
|
-
const buffer = Buffer.from(base64, "base64");
|
|
3058
|
-
const dir = join(EXTRACTED_FRAMES_DIR, String(projectId));
|
|
3059
|
-
ensureDir(dir);
|
|
3060
|
-
const safeName = (filename || `frame-${Date.now()}.png`).replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3061
|
-
writeFileSync(join(dir, safeName), buffer);
|
|
3062
|
-
res.json({
|
|
3063
|
-
success: true,
|
|
3064
|
-
path: join(dir, safeName),
|
|
3065
|
-
filename: safeName,
|
|
3066
|
-
size: buffer.length,
|
|
3067
|
-
});
|
|
3068
|
-
}
|
|
3069
|
-
catch (e) {
|
|
3070
|
-
res
|
|
3071
|
-
.status(500)
|
|
3072
|
-
.json({ error: "Failed to save frame", details: String(e) });
|
|
3073
|
-
}
|
|
3074
|
-
});
|
|
3075
3379
|
// Open project folder in Finder
|
|
3076
3380
|
app.post("/api/projects/:id/open-folder", (req, res) => {
|
|
3077
3381
|
const { type } = req.body || {};
|