From b7ef4940f758ed110001a350fcdcd45c2d63577c Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 18 May 2026 11:55:07 +0200 Subject: [PATCH] feat: add Minimax Speech as alternative TTS provider for Reels - Add minimax_config table (migration + schema + storage CRUD) - Add GET/POST /api/minimax/config routes - Pass tts_provider + minimax_api_key through ffmpeg service - Add generate_tts_minimax() in Python using Minimax T2A v2 API - Fall back to ffsubsync for subtitle sync (no WordBoundary events) - Add Minimax config card in Settings page - Add provider toggle (Edge TTS / Minimax) + French voices in new-reel --- client/src/pages/new-reel.tsx | 56 +++++++++++++++++---- client/src/pages/settings.tsx | 84 +++++++++++++++++++++++++++++++- ffmpeg-service/main.py | 92 +++++++++++++++++++++++++++++------ server/migrate.ts | 12 +++++ server/routes.ts | 48 +++++++++++++++++- server/routes/reels.ts | 38 ++++++++++++++- server/services/ffmpeg.ts | 18 ++++++- server/storage.ts | 21 ++++++++ shared/schema.ts | 24 +++++++++ 9 files changed, 364 insertions(+), 29 deletions(-) diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index 960f522..f6860c6 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -66,6 +66,7 @@ export default function NewReel() { const [musicVolume, setMusicVolume] = useState([25]); const [ttsEnabled, setTtsEnabled] = useState(true); const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural"); + const [ttsProvider, setTtsProvider] = useState<"edge_tts" | "minimax">("edge_tts"); const [drawText, setDrawText] = useState(true); const [stabilize, setStabilize] = useState(true); // default to true const [enableEndingEffect, setEnableEndingEffect] = useState(true); @@ -87,9 +88,9 @@ export default function NewReel() { } }, []); - // Auto-calculate TTS sync when text or voice changes + // Auto-calculate TTS sync when text or voice changes (Edge TTS only) useEffect(() => { - if (!ttsEnabled || !overlayText.trim()) { + if (!ttsEnabled || !overlayText.trim() || ttsProvider !== "edge_tts") { setSyncInfo(null); return; } @@ -103,7 +104,7 @@ export default function NewReel() { .catch(() => setSyncInfo(null)); }, 800); return () => clearTimeout(timer); - }, [overlayText, ttsVoice, ttsEnabled]); + }, [overlayText, ttsVoice, ttsEnabled, ttsProvider]); // État audio preview @@ -362,6 +363,7 @@ export default function NewReel() { musicVolume: musicVolume[0] / 100, ttsEnabled, ttsVoice, + ttsProvider, drawText, stabilize: stabilize, enableEndingEffect, @@ -764,27 +766,54 @@ export default function NewReel() {
+
+ + +
+ +
- {[ + {(ttsProvider === "edge_tts" ? [ { id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme (Défaut)", style: "bg-pink-500/10 border-pink-500/50" }, { id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme", style: "bg-blue-500/10 border-blue-500/50" }, { id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme (Calme)", style: "bg-purple-500/10 border-purple-500/50" }, { id: "fr-FR-HenriNeural", label: "Henri", type: "Homme (Grave)", style: "bg-slate-500/10 border-slate-500/50" }, { id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant", style: "bg-orange-500/10 border-orange-500/50" } - ].map((voice) => ( + ] : [ + { id: "French_Female_News", label: "News Féminin", type: "Femme (Défaut)", style: "bg-pink-500/10 border-pink-500/50" }, + { id: "French_Male_Speech_New", label: "Speech Masculin", type: "Homme", style: "bg-blue-500/10 border-blue-500/50" }, + { id: "French_MovieLeadFemale", label: "Cinéma Femme", type: "Femme (Dramatique)", style: "bg-purple-500/10 border-purple-500/50" }, + { id: "French_CasualMan", label: "Casual Homme", type: "Homme (Décontracté)", style: "bg-slate-500/10 border-slate-500/50" }, + { id: "French_FemaleAnchor", label: "Présentatrice", type: "Femme (Média)", style: "bg-rose-500/10 border-rose-500/50" }, + { id: "French_MaleNarrator", label: "Narrateur", type: "Homme (Narration)", style: "bg-indigo-500/10 border-indigo-500/50" } + ]).map((voice) => (
setTtsVoice(voice.id)} - className={`cursor-pointer p-3 rounded-md border-2 transition-all hover:bg-accent ${ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural') + className={`cursor-pointer p-3 rounded-md border-2 transition-all hover:bg-accent ${ttsVoice === voice.id ? `border-primary ${voice.style}` : 'border-transparent bg-muted/30' }`} >
{voice.label} - {(ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural')) && } + {ttsVoice === voice.id && }
{voice.type}
@@ -802,7 +831,8 @@ export default function NewReel() { try { const response = await apiRequest('POST', '/api/reels/tts-preview', { text: textToTest, - voice: ttsVoice + voice: ttsVoice, + ttsProvider, }); const data = await response.json(); if (data.success && data.audioBase64) { @@ -823,7 +853,15 @@ export default function NewReel() {
- {syncInfo && ( + {ttsProvider === "minimax" && ( +
+

+ Minimax utilise ffsubsync pour la synchronisation des sous-titres. Configurez votre clé API dans Paramètres. +

+
+ )} + + {ttsProvider === "edge_tts" && syncInfo && (
Sync texte/voix diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx index e0b43c2..b0e1640 100644 --- a/client/src/pages/settings.tsx +++ b/client/src/pages/settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug } from "lucide-react"; +import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react"; import { useQuery, useMutation } from "@tanstack/react-query"; import Sidebar from "@/components/sidebar"; import TopBar from "@/components/topbar"; @@ -30,6 +30,7 @@ export default function Settings() { const [ffmpegApiUrl, setFfmpegApiUrl] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState(""); const [externalApiKey, setExternalApiKey] = useState(""); + const [minimaxApiKey, setMinimaxApiKey] = useState(""); const { toast } = useToast(); const { data: session } = useQuery<{ id: string; username: string; role: string }>({ @@ -60,6 +61,10 @@ export default function Settings() { enabled: isAdmin, }); + const { data: minimaxConfig } = useQuery({ + queryKey: ['/api/minimax/config'], + }); + useEffect(() => { if (cloudinaryConfig) { setCloudName((cloudinaryConfig as any).cloudName || ""); @@ -258,6 +263,32 @@ export default function Settings() { }); const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured; + const hasExistingMinimaxConfig = !!(minimaxConfig as any)?.apiKey; + + const saveMinimaxMutation = useMutation({ + mutationFn: () => { + const payload: any = {}; + if (minimaxApiKey && minimaxApiKey.trim() !== "") { + payload.apiKey = minimaxApiKey; + } + return apiRequest('POST', '/api/minimax/config', payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/minimax/config'] }); + setMinimaxApiKey(""); + toast({ + title: "Configuration sauvegardée", + description: "Votre clé API Minimax a été enregistrée", + }); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de sauvegarder la configuration Minimax", + variant: "destructive", + }); + }, + }); const saveExternalApiMutation = useMutation({ mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }), @@ -620,6 +651,57 @@ export default function Settings() { + + + + + Configuration Minimax Speech (TTS) + + + Configurez l'API Minimax pour générer des voix françaises premium dans les Reels + + + +
+ + setMinimaxApiKey(e.target.value)} + placeholder={hasExistingMinimaxConfig ? "••••••••••••••••" : "eyJhbGci..."} + data-testid="input-minimax-api-key" + /> +

+ {hasExistingMinimaxConfig ? ( + ✓ Clé API configurée - Laissez vide pour conserver + ) : ( + "Votre clé API depuis platform.minimaxi.com" + )} +

+
+
+

Voix françaises disponibles :

+
+ • French_Female_News + • French_Male_Speech_New + • French_MovieLeadFemale + • French_CasualMan + • French_FemaleAnchor + • French_MaleNarrator +
+
+ +
+
+ {isAdmin && ( diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index c3d02a0..cf8e10b 100644 --- a/ffmpeg-service/main.py +++ b/ffmpeg-service/main.py @@ -159,6 +159,8 @@ class ReelRequest(BaseModel): music_volume: float = 0.25 tts_enabled: bool = False tts_voice: str = "fr-FR-VivienneMultilingualNeural" + tts_provider: str = "edge_tts" # "edge_tts" or "minimax" + minimax_api_key: Optional[str] = None draw_text: bool = True stabilize: bool = False # Stabilisation vidéo via vidstab enable_ending_effect: bool = True @@ -192,6 +194,34 @@ def clean_text_for_tts(text: str) -> str: return " ".join(text.split()) +def generate_tts_minimax(text: str, voice_id: str, api_key: str, audio_path: Path) -> None: + """Generate TTS audio using Minimax Speech API (T2A v2).""" + url = "https://api.minimax.io/v1/t2a_v2" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + payload = { + "model": "speech-2.8-hd", + "text": text, + "voice_setting": {"voice_id": voice_id}, + "audio_setting": {"format": "mp3", "sample_rate": 24000, "bitrate": 128}, + } + response = requests.post(url, headers=headers, json=payload, timeout=60) + response.raise_for_status() + data = response.json() + + base_resp = data.get("base_resp", {}) + if base_resp.get("status_code") != 0: + raise Exception(f"Minimax API error: {base_resp.get('status_msg', 'unknown error')}") + + audio_hex = data["data"]["audio"] + audio_bytes = bytes.fromhex(audio_hex) + with open(audio_path, "wb") as f: + f.write(audio_bytes) + print(f"✅ Minimax TTS audio saved: {audio_path.stat().st_size} bytes") + + async def generate_tts_with_subs( text: str, voice: str, @@ -199,13 +229,44 @@ async def generate_tts_with_subs( ass_path: Path, display_text: Optional[str] = None, delay: float = 0.0, + tts_provider: str = "edge_tts", + minimax_api_key: Optional[str] = None, ): """Generate TTS audio with word-level synchronized subtitles. Uses edge_tts.Communicate.stream() to capture WordBoundary events, providing millisecond-accurate subtitle timing instead of linear estimation. + For Minimax provider, falls back to ffsubsync (no word boundaries available). """ + # Minimax branch: no WordBoundary events, use ffsubsync for subtitle sync + if tts_provider == "minimax": + if not minimax_api_key: + raise Exception("Minimax API key required but not provided") + text_to_display = display_text if display_text else text + print(f"🔊 Minimax TTS with voice: {voice}") + generate_tts_minimax(text, voice, minimax_api_key, audio_path) + + audio_duration = None + try: + dur_cmd = [ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(audio_path), + ] + dur_proc = subprocess.run(dur_cmd, stdout=subprocess.PIPE, text=True) + audio_duration = float(dur_proc.stdout.strip()) + print(f"⏱️ Minimax TTS Duration: {audio_duration:.2f}s") + except Exception as e: + print(f"⚠️ Could not measure Minimax TTS duration: {e}") + + unsynced_srt_path = audio_path.with_suffix(".unsynced.srt") + synced_srt_path = audio_path.with_suffix(".synced.srt") + generate_unsynced_srt(text_to_display, unsynced_srt_path, total_duration=audio_duration) + run_ffsubsync(audio_path, unsynced_srt_path, synced_srt_path) + convert_srt_to_ass(synced_srt_path, ass_path, font_size=65, delay=delay) + print("✅ Minimax TTS synchronisation completed with ffsubsync") + return + # Determine gender of requested voice to choose appropriate fallbacks is_male = any(name in voice for name in ["Remi", "Henri", "Paul"]) @@ -796,15 +857,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): print(f"🔊 TTS enabled. Original: '{request.text}'") print(f"🔊 TTS cleaned: '{tts_clean_text}'") - # Check for male/female voice map + # Check for male/female voice map (edge_tts only) voice = request.tts_voice - if voice == "male": - voice = "fr-FR-RemyMultilingualNeural" - elif voice == "female": - voice = "fr-FR-VivienneMultilingualNeural" - elif not voice or "Neural" not in voice: - # Default if invalid - voice = "fr-FR-VivienneMultilingualNeural" + if request.tts_provider != "minimax": + if voice == "male": + voice = "fr-FR-RemyMultilingualNeural" + elif voice == "female": + voice = "fr-FR-VivienneMultilingualNeural" + elif not voice or "Neural" not in voice: + voice = "fr-FR-VivienneMultilingualNeural" print(f"🔊 Using voice: {voice}") @@ -818,6 +879,8 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): tts_ass_path, display_text=clean_text_for_display(request.text), delay=2.0, + tts_provider=request.tts_provider, + minimax_api_key=request.minimax_api_key, ) # Verify files were created @@ -1189,12 +1252,13 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)): # Determine voice (reuse logic) voice = request.tts_voice - if voice == "male": - voice = "fr-FR-RemyMultilingualNeural" - elif voice == "female": - voice = "fr-FR-VivienneMultilingualNeural" - elif not voice or "Neural" not in voice: - voice = "fr-FR-VivienneMultilingualNeural" + if request.tts_provider != "minimax": + if voice == "male": + voice = "fr-FR-RemyMultilingualNeural" + elif voice == "female": + voice = "fr-FR-VivienneMultilingualNeural" + elif not voice or "Neural" not in voice: + voice = "fr-FR-VivienneMultilingualNeural" # Pass original text for subtitles (implied in SRT for preview too if needed, though mostly audio) # Preview TTS generation doesn't technically need the full 2s video delay, but if the frontend plays it against standard timing it might. diff --git a/server/migrate.ts b/server/migrate.ts index eca3cb5..3e3973a 100644 --- a/server/migrate.ts +++ b/server/migrate.ts @@ -152,6 +152,18 @@ export async function migrate() { ); `); + // minimax_config + await client.query(` + CREATE TABLE IF NOT EXISTS "minimax_config" ( + "id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade, + "api_key" text NOT NULL, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now(), + CONSTRAINT minimax_config_user_id_unique UNIQUE ("user_id") + ); + `); + console.log("[Migration] Safe migration completed."); } catch (error) { console.error("[Migration] Error during migration:", error); diff --git a/server/routes.ts b/server/routes.ts index 8ef0b7e..7e3d172 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -10,7 +10,7 @@ import passport from "./auth"; import { z } from "zod"; import { openRouterService } from "./services/openrouter"; import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio"; -import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertFreesoundConfigSchema, updateFreesoundConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; +import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertFreesoundConfigSchema, updateFreesoundConfigSchema, updateMinimaxConfigSchema, insertMinimaxConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema"; import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema"; import { freeSoundService } from "./services/freesound"; import { analyticsRouter } from "./routes/analytics"; @@ -477,6 +477,52 @@ export async function registerRoutes(app: Express): Promise { } }); + // Minimax Configuration Routes + app.get("/api/minimax/config", requireAuth, async (req, res) => { + try { + const user = req.user as User; + const config = await storage.getMinimaxConfig(user.id); + if (!config) { + return res.json(null); + } + return res.json({ + ...config, + apiKey: config.apiKey ? "********" : "", + }); + } catch (error) { + console.error("Error fetching Minimax config:", error); + res.status(500).json({ error: "Failed to fetch configuration" }); + } + }); + + app.post("/api/minimax/config", requireAuth, async (req, res) => { + try { + const user = req.user as User; + const data = updateMinimaxConfigSchema.parse(req.body); + + let config = await storage.getMinimaxConfig(user.id); + const updateData: any = { ...data }; + + if (!updateData.apiKey || updateData.apiKey === "********") { + delete updateData.apiKey; + } + + if (config) { + config = await storage.updateMinimaxConfig(user.id, updateData); + } else { + if (!data.apiKey) { + return res.status(400).json({ error: "API Key is required" }); + } + config = await storage.createMinimaxConfig({ ...data as any, userId: user.id }); + } + + res.json({ success: true }); + } catch (error) { + console.error("Error saving Minimax config:", error); + res.status(500).json({ error: "Failed to save configuration" }); + } + }); + // Reels & Music Routes app.use("/api", requireAuth, reelsRouter); app.use("/api/remotion", requireAuth, remotionRouter); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index 61bf3ea..6a4e64a 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -298,6 +298,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { overlayText, ttsEnabled, ttsVoice, + ttsProvider, wordDuration = 0.6, fontSize = 64, musicVolume = 0.25, @@ -352,12 +353,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { const finalWordDuration = wordDuration; + let minimaxApiKey: string | undefined; + if (ttsEnabled && ttsProvider === 'minimax') { + const minimaxCfg = await storage.getMinimaxConfig(user.id); + if (!minimaxCfg?.apiKey) { + return res.status(400).json({ error: 'Clé API Minimax non configurée. Allez dans les Paramètres.' }); + } + minimaxApiKey = minimaxCfg.apiKey; + } + // Traiter la vidéo via FFmpeg const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), { text: overlayText, musicUrl: finalMusicUrl, ttsEnabled, ttsVoice, + ttsProvider, + minimaxApiKey, wordDuration: finalWordDuration, fontSize, musicVolume, @@ -389,13 +401,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { */ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => { try { - const { text, voice } = req.body; + const user = req.user as User; + const { text, voice, ttsProvider } = req.body; if (!text) { return res.status(400).json({ error: 'Texte requis' }); } - const result = await ffmpegService.previewTTS(text, voice); + let minimaxApiKey: string | undefined; + if (ttsProvider === 'minimax') { + const minimaxCfg = await storage.getMinimaxConfig(user.id); + if (!minimaxCfg?.apiKey) { + return res.status(400).json({ error: 'Clé API Minimax non configurée. Allez dans les Paramètres.' }); + } + minimaxApiKey = minimaxCfg.apiKey; + } + + const result = await ffmpegService.previewTTS(text, voice, ttsProvider, minimaxApiKey); if (!result.success) { return res.status(500).json({ error: result.error || 'Erreur de génération TTS' }); @@ -487,6 +509,7 @@ async function processReelBackground( description?: string; ttsEnabled?: boolean; ttsVoice?: string; + ttsProvider?: string; pageIds: string[]; scheduledFor?: string; wordDuration?: number; @@ -506,6 +529,7 @@ async function processReelBackground( description, ttsEnabled, ttsVoice, + ttsProvider, pageIds, scheduledFor, wordDuration, @@ -585,11 +609,21 @@ async function processReelBackground( const finalWordDuration = wordDuration ?? 0.6; + let minimaxApiKeyBg: string | undefined; + if (ttsEnabled && ttsProvider === 'minimax') { + const minimaxCfg = await storage.getMinimaxConfig(userId); + if (minimaxCfg?.apiKey) { + minimaxApiKeyBg = minimaxCfg.apiKey; + } + } + const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), { text: overlayText, musicUrl: finalMusicUrl, ttsEnabled, ttsVoice, + ttsProvider, + minimaxApiKey: minimaxApiKeyBg, wordDuration: finalWordDuration, fontSize, musicVolume, diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index f59b907..da0650b 100644 --- a/server/services/ffmpeg.ts +++ b/server/services/ffmpeg.ts @@ -13,6 +13,8 @@ interface FFmpegReelRequest { music_url?: string; // OU URL directe de la musique tts_enabled?: boolean; // Activation du TTS tts_voice?: string; // Voix TTS (ex: fr-FR-VivienneNeural) + tts_provider?: string; // Provider TTS: "edge_tts" (défaut) ou "minimax" + minimax_api_key?: string; // Clé API Minimax (requis si tts_provider=minimax) word_duration?: number; // Durée par mot (default: 0.6s) font_size?: number; // Taille police (default: 24) music_volume?: number; // Volume musique (default: 0.25) @@ -71,6 +73,8 @@ export class FFmpegService { musicUrl?: string; ttsEnabled?: boolean; ttsVoice?: string; + ttsProvider?: string; + minimaxApiKey?: string; wordDuration?: number; fontSize?: number; musicVolume?: number; @@ -90,6 +94,8 @@ export class FFmpegService { music_url: options.musicUrl, tts_enabled: options.ttsEnabled, tts_voice: options.ttsVoice, + tts_provider: options.ttsProvider, + minimax_api_key: options.minimaxApiKey, word_duration: options.wordDuration ?? 0.6, font_size: options.fontSize ?? 64, music_volume: options.musicVolume ?? 0.25, @@ -172,6 +178,8 @@ export class FFmpegService { musicUrl?: string; ttsEnabled?: boolean; ttsVoice?: string; + ttsProvider?: string; + minimaxApiKey?: string; wordDuration?: number; fontSize?: number; musicVolume?: number; @@ -191,6 +199,8 @@ export class FFmpegService { music_url: options.musicUrl, tts_enabled: options.ttsEnabled, tts_voice: options.ttsVoice, + tts_provider: options.ttsProvider, + minimax_api_key: options.minimaxApiKey, word_duration: options.wordDuration ?? 0.6, font_size: options.fontSize ?? 64, music_volume: options.musicVolume ?? 0.25, @@ -285,7 +295,9 @@ export class FFmpegService { } async previewTTS( text: string, - voice: string + voice: string, + ttsProvider?: string, + minimaxApiKey?: string ): Promise<{ success: boolean; audioBase64?: string; error?: string }> { const config = this.ensureConfigured(); @@ -298,7 +310,9 @@ export class FFmpegService { }, body: JSON.stringify({ text, - tts_voice: voice + tts_voice: voice, + tts_provider: ttsProvider, + minimax_api_key: minimaxApiKey, }), }); diff --git a/server/storage.ts b/server/storage.ts index e2cf15e..1fa1847 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -39,6 +39,9 @@ import { type InsertAudioTrack, appConfig, type AppConfig, + minimaxConfig, + type MinimaxConfig, + type InsertMinimaxConfig, } from "@shared/schema"; import { db } from "./db"; import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm"; @@ -609,6 +612,24 @@ export class DatabaseStorage implements IStorage { const [created] = await db.insert(appConfig).values(data).returning(); return created; } + // Minimax Config + async getMinimaxConfig(userId: string): Promise { + const [config] = await db.select().from(minimaxConfig).where(eq(minimaxConfig.userId, userId)); + return config || undefined; + } + + async createMinimaxConfig(config: InsertMinimaxConfig): Promise { + const [newConfig] = await db.insert(minimaxConfig).values(config).returning(); + return newConfig; + } + + async updateMinimaxConfig(userId: string, config: Partial): Promise { + const [updated] = await db.update(minimaxConfig) + .set({ ...config, updatedAt: new Date() }) + .where(eq(minimaxConfig.userId, userId)) + .returning(); + return updated; + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 026f3a4..6b88bde 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -142,6 +142,14 @@ export const freesoundConfig = pgTable("freesound_config", { updatedAt: timestamp("updated_at").defaultNow(), }); +export const minimaxConfig = pgTable("minimax_config", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }), + apiKey: text("api_key").notNull(), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), +}); + export const media = pgTable("media", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), @@ -489,4 +497,20 @@ export type AppConfig = typeof appConfig.$inferSelect; export type InsertFreesoundConfig = z.infer; export type UpdateFreesoundConfig = z.infer; +export const insertMinimaxConfigSchema = createInsertSchema(minimaxConfig).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +export const updateMinimaxConfigSchema = insertMinimaxConfigSchema.omit({ + userId: true +}).partial({ + apiKey: true, +}); + +export type MinimaxConfig = typeof minimaxConfig.$inferSelect; +export type InsertMinimaxConfig = z.infer; +export type UpdateMinimaxConfig = z.infer; +