From 72b13b3e427df9bfac9f3d5ceda6910a4f854a08 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 19 May 2026 12:17:42 +0200 Subject: [PATCH] Add Google Gemini TTS as alternative to Edge TTS - Store Gemini API key globally in appConfig (single key for all users) - Add /api/settings/gemini GET/POST/DELETE routes for API key management - Backend: add tts_engine parameter ("edge" or "gemini") to FFmpeg service - Backend: add generate_tts_gemini() using Google Cloud TTS REST API - Frontend: Settings page shows Google Gemini API key input card - Frontend: new-reel, mobile/new-reel, remotion-video, mobile/remotion-video pages now have Edge/Gemini engine toggle and French voice selector - Fix tts-preview route to extract ttsVoice from req.body instead of undefined voice variable --- client/src/pages/mobile/new-reel.tsx | 85 +++++++++- client/src/pages/mobile/remotion-video.tsx | 58 ++++++- client/src/pages/new-reel.tsx | 86 +++++++++- client/src/pages/remotion-video.tsx | 58 ++++++- client/src/pages/settings.tsx | 84 ++++++++++ ffmpeg-service/main.py | 174 +++++++++++++++++++-- ffmpeg-service/requirements.txt | 1 + server/routes.ts | 35 +++++ server/routes/reels.ts | 32 +++- server/services/ffmpeg.ts | 16 +- server/storage.ts | 4 +- shared/schema.ts | 1 + 12 files changed, 605 insertions(+), 29 deletions(-) diff --git a/client/src/pages/mobile/new-reel.tsx b/client/src/pages/mobile/new-reel.tsx index bad2c05..0860cb4 100644 --- a/client/src/pages/mobile/new-reel.tsx +++ b/client/src/pages/mobile/new-reel.tsx @@ -58,6 +58,29 @@ export default function MobileNewReel() { // État TTS const [ttsEnabled, setTtsEnabled] = useState(false); + const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); + const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); + + // French Gemini voices + const geminiVoices = [ + { label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' }, + { label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' }, + { label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' }, + { label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' }, + { label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' }, + { label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' }, + { label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' }, + { label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' }, + ]; + + // French Edge TTS voices + const edgeVoices = [ + { label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' }, + { label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' }, + { label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' }, + { label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' }, + { label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' }, + ]; // État audio preview const [isPlaying, setIsPlaying] = useState(null); @@ -82,14 +105,15 @@ export default function MobileNewReel() { const timer = setTimeout(() => { apiRequest('POST', '/api/reels/sync-info', { text: overlayText, - voice: "fr-FR-VivienneMultilingualNeural", + ttsEngine, + ttsVoice, }) .then(r => r.json()) .then(data => setSyncInfo(data)) .catch(() => setSyncInfo(null)); }, 800); return () => clearTimeout(timer); - }, [overlayText, ttsEnabled]); + }, [overlayText, ttsEnabled, ttsEngine, ttsVoice]); const { data: pages = [] } = useQuery({ @@ -230,6 +254,8 @@ export default function MobileNewReel() { stabilize: stabilize, drawText: true, ttsEnabled, + ttsEngine, + ttsVoice, enableEndingEffect, }); }; @@ -450,7 +476,58 @@ export default function MobileNewReel() { TTS — voix activée -
+ + {/* TTS Engine Selector */} +
+ +
+ + +
+
+ + {/* Voice Selector */} +
+ + +
+ +
+ +
+
+ + {/* Voice Selector */} +
+ + +
+ diff --git a/client/src/pages/new-reel.tsx b/client/src/pages/new-reel.tsx index 0c823e9..3e74513 100644 --- a/client/src/pages/new-reel.tsx +++ b/client/src/pages/new-reel.tsx @@ -78,6 +78,31 @@ export default function NewReel() { warnings: string[]; } | null>(null); + // TTS Engine & Voice + const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge'); + const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural'); + + // French Gemini voices + const geminiVoices = [ + { label: 'Français - Standard A (Femme)', value: 'fr-FR-Standard-A' }, + { label: 'Français - Standard B (Homme)', value: 'fr-FR-Standard-B' }, + { label: 'Français - Standard C (Femme)', value: 'fr-FR-Standard-C' }, + { label: 'Français - Standard D (Homme)', value: 'fr-FR-Standard-D' }, + { label: 'Français - Wavenet A (Femme)', value: 'fr-FR-Wavenet-A' }, + { label: 'Français - Wavenet B (Homme)', value: 'fr-FR-Wavenet-B' }, + { label: 'Français - Wavenet C (Femme)', value: 'fr-FR-Wavenet-C' }, + { label: 'Français - Wavenet D (Homme)', value: 'fr-FR-Wavenet-D' }, + ]; + + // French Edge TTS voices + const edgeVoices = [ + { label: 'Vivienne (Femme)', value: 'fr-FR-VivienneMultilingualNeural' }, + { label: 'Henri (Homme)', value: 'fr-FR-HenriNeural' }, + { label: 'Denise (Femme)', value: 'fr-FR-DeniseNeural' }, + { label: 'Rémy (Homme)', value: 'fr-FR-RemyMultilingualNeural' }, + { label: 'Jenny (Anglaise, Femme)', value: 'en-US-JennyNeural' }, + ]; + // Enable TTS by default on mobile useEffect(() => { const isMobile = window.innerWidth < 768; @@ -86,7 +111,7 @@ export default function NewReel() { } }, []); - // Auto-calculate TTS sync when text changes (edge_tts fallback) + // Auto-calculate TTS sync when text changes useEffect(() => { if (!ttsEnabled || !overlayText.trim()) { setSyncInfo(null); @@ -95,14 +120,14 @@ export default function NewReel() { const timer = setTimeout(() => { apiRequest('POST', '/api/reels/sync-info', { text: overlayText, - voice: "fr-FR-VivienneMultilingualNeural", + voice: ttsVoice, }) .then(r => r.json()) .then(data => setSyncInfo(data)) .catch(() => setSyncInfo(null)); }, 800); return () => clearTimeout(timer); - }, [overlayText, ttsEnabled]); + }, [overlayText, ttsEnabled, ttsVoice]); // État audio preview @@ -360,6 +385,8 @@ export default function NewReel() { scheduledFor: scheduledDate?.toISOString(), musicVolume: musicVolume[0] / 100, ttsEnabled, + ttsEngine, + ttsVoice, drawText, stabilize: stabilize, enableEndingEffect, @@ -765,6 +792,57 @@ export default function NewReel() { TTS — voix activée + {/* TTS Engine Selector */} +
+ +
+ + +
+
+ + {/* Voice Selector */} +
+ + +
+
+ +
+ + + {/* Voice Selector */} +
+ + +
+ diff --git a/client/src/pages/settings.tsx b/client/src/pages/settings.tsx index 6e20e8c..82f3f91 100644 --- a/client/src/pages/settings.tsx +++ b/client/src/pages/settings.tsx @@ -30,6 +30,7 @@ export default function Settings() { const [ffmpegApiUrl, setFfmpegApiUrl] = useState(""); const [ffmpegApiKey, setFfmpegApiKey] = useState(""); const [externalApiKey, setExternalApiKey] = useState(""); + const [geminiApiKey, setGeminiApiKey] = useState(""); const { toast } = useToast(); const { data: session } = useQuery<{ id: string; username: string; role: string }>({ @@ -60,6 +61,11 @@ export default function Settings() { enabled: isAdmin, }); + const { data: geminiConfig } = useQuery({ + queryKey: ['/api/settings/gemini'], + enabled: isAdmin, + }); + useEffect(() => { if (cloudinaryConfig) { setCloudName((cloudinaryConfig as any).cloudName || ""); @@ -86,9 +92,16 @@ export default function Settings() { } }, [ffmpegConfig]); + useEffect(() => { + if (geminiConfig) { + // Don't pre-fill - user must enter key manually + } + }, [geminiConfig]); + // Vérifier si une config OpenRouter existe déjà const hasExistingConfig = !!openrouterConfig; const hasExistingFfmpegConfig = !!ffmpegConfig; + const hasExistingGeminiConfig = !!(geminiConfig as any)?.configured; const saveCloudinaryMutation = useMutation({ mutationFn: () => { @@ -157,6 +170,31 @@ export default function Settings() { }, }); + const saveGeminiMutation = useMutation({ + mutationFn: () => { + const payload: any = {}; + if (geminiApiKey && geminiApiKey.trim() !== "") { + payload.apiKey = geminiApiKey; + } + return apiRequest('POST', '/api/settings/gemini', payload); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['/api/settings/gemini'] }); + toast({ + title: "Configuration sauvegardée", + description: "Votre clé API Gemini a été enregistrée", + }); + setGeminiApiKey(""); + }, + onError: () => { + toast({ + title: "Erreur", + description: "Impossible de sauvegarder la configuration Gemini", + variant: "destructive", + }); + }, + }); + const uploadLogoMutation = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); @@ -521,6 +559,52 @@ export default function Settings() { + {/* Google Gemini TTS */} + {isAdmin && ( + + + + + Google Gemini TTS + + + Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels + + + +
+ + setGeminiApiKey(e.target.value)} + placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."} + data-testid="input-gemini-api-key" + /> +

+ {hasExistingGeminiConfig ? ( + ✓ Clé API configurée + ) : ( + "Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials" + )} +

+
+ +
+
+ )} + {isAdmin && ( diff --git a/ffmpeg-service/main.py b/ffmpeg-service/main.py index d90ca0d..9efd966 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_engine: str = "edge" # "edge" or "gemini" + gemini_api_key: Optional[str] = None # Google Cloud API key for Gemini TTS draw_text: bool = True stabilize: bool = False # Stabilisation vidéo via vidstab enable_ending_effect: bool = True @@ -192,6 +194,123 @@ def clean_text_for_tts(text: str) -> str: return " ".join(text.split()) +async def generate_tts_gemini( + text: str, + voice: str, + api_key: str, + audio_path: Path, + ass_path: Path, + display_text: Optional[str] = None, + delay: float = 0.0, +): + """Generate TTS audio using Google Cloud TTS API with word-level synchronized subtitles.""" + try: + import httpx + + print(f"\ud83d\udd0a Gemini TTS request: voice={voice}, text_len={len(text)}") + + # Google Cloud TTS REST API + url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}" + + # Map voice name to Google Cloud TTS voice settings + # Gemini/GCloud uses format: languageCode-Name-Sex (e.g., "fr-FR-Standard-A") + # voice parameter is already in format: fr-FR-Standard-A or fr-FR-Wavenet-A + voice_name = voice + + # Determine gender from voice name + is_male = any(name in voice for name in ["B", "D", "M"]) # B, D are typically male + + payload = { + "input": {"text": text}, + "voice": { + "languageCode": voice.rsplit("-", 2)[0] if "-" in voice else "fr-FR", + "name": voice, + }, + "audioConfig": { + "audioEncoding": "MP3", + "speakingRate": 1.0, + "pitch": 0.0, + }, + "enableWordTimeOffsets": True, + } + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + data = response.json() + + audio_content = base64.b64decode(data["audioContent"]) + with open(audio_path, "wb") as f: + f.write(audio_content) + + print(f"\u2705 Gemini TTS audio saved: {audio_path.stat().st_size} bytes") + + # Parse word time offsets from response + word_boundaries = [] + if "wordTimeOffsets" in data.get("timepoints", []): + for tp in data["timepoints"]: + if "word" in tp and "startTime" in tp and "endTime" in tp: + # Parse Google duration format: "0s" or "0.123s" + start_str = tp["startTime"].rstrip("s") + end_str = tp["endTime"].rstrip("s") + try: + start_sec = float(start_str) + end_sec = float(end_str) + word_boundaries.append({ + "text": tp["word"], + "offset": start_sec, + "duration": end_sec - start_sec, + }) + except ValueError: + pass + + print(f"\ud83d\udccd Captured {len(word_boundaries)} word boundaries from Gemini") + + # Measure total audio duration + audio_duration = None + try: + duration_cmd = [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + str(audio_path), + ] + dur_proc = subprocess.run(duration_cmd, stdout=subprocess.PIPE, text=True) + audio_duration = float(dur_proc.stdout.strip()) + print(f"\u23f1\ufe0f TTS Audio Duration: {audio_duration:.2f}s") + except Exception as e: + print(f"\u26a0\ufe0f Could not measure TTS duration: {e}") + + text_to_display = display_text if display_text else text + + if len(word_boundaries) == 0: + print("\u26a0\ufe0f No word boundaries from Gemini, falling back to ffsubsync") + 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(f"\u2705 TTS synchronisation completed with ffsubsync fallback") + return + + print("\ud83c\udfaf Using precise word-boundary timing from Gemini TTS") + generate_ass_from_word_boundaries( + word_boundaries, + text_to_display, + ass_path, + font_size=65, + total_duration=audio_duration, + delay=delay, + ) + print(f"\u2705 TTS synchronisation completed with word-boundary timing") + return + + except Exception as e: + print(f"\u274c Gemini TTS failed: {e}") + raise + + async def generate_tts_with_subs( text: str, voice: str, @@ -807,14 +926,27 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)): if tts_clean_text: print(f"🔊 Generating TTS audio to: {tts_audio_path}") - await generate_tts_with_subs( - tts_clean_text, - voice, - tts_audio_path, - tts_ass_path, - display_text=clean_text_for_display(request.text), - delay=2.0, - ) + engine = request.tts_engine or "edge" + + if engine == "gemini" and request.gemini_api_key: + await generate_tts_gemini( + tts_clean_text, + voice, + request.gemini_api_key, + tts_audio_path, + tts_ass_path, + display_text=clean_text_for_display(request.text), + delay=2.0, + ) + else: + await generate_tts_with_subs( + tts_clean_text, + voice, + tts_audio_path, + tts_ass_path, + display_text=clean_text_for_display(request.text), + delay=2.0, + ) # Verify files were created if tts_audio_path.exists() and tts_audio_path.stat().st_size > 0: @@ -1191,13 +1323,25 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)): elif not voice or "Neural" not in voice: voice = "fr-FR-VivienneMultilingualNeural" - await generate_tts_with_subs( - clean_text, - voice, - tts_audio_path, - tts_srt_path, - display_text=clean_text_for_display(request.text), - ) + engine = request.tts_engine or "edge" + + if engine == "gemini" and request.gemini_api_key: + await generate_tts_gemini( + clean_text, + voice, + request.gemini_api_key, + tts_audio_path, + tts_srt_path, + display_text=clean_text_for_display(request.text), + ) + else: + await generate_tts_with_subs( + clean_text, + voice, + tts_audio_path, + tts_srt_path, + display_text=clean_text_for_display(request.text), + ) if not tts_audio_path.exists(): raise Exception("TTS generation failed (file missing)") diff --git a/ffmpeg-service/requirements.txt b/ffmpeg-service/requirements.txt index f72d1cc..997b044 100644 --- a/ffmpeg-service/requirements.txt +++ b/ffmpeg-service/requirements.txt @@ -6,3 +6,4 @@ pydantic==2.6.0 edge-tts==6.1.12 emoji ffsubsync==0.4.26 +httpx>=0.25.0 diff --git a/server/routes.ts b/server/routes.ts index dd42b18..49c8163 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -420,6 +420,41 @@ export async function registerRoutes(app: Express): Promise { } }); + // Google Gemini API Key (TTS) + app.get("/api/settings/gemini", requireAdmin, async (req, res) => { + try { + const config = await storage.getAppConfig(); + res.json({ configured: !!(config?.geminiApiKey) }); + } catch (error) { + console.error("Error fetching Gemini config:", error); + res.status(500).json({ error: "Erreur lors de la récupération de la configuration" }); + } + }); + + app.post("/api/settings/gemini", requireAdmin, async (req, res) => { + try { + const { apiKey } = req.body; + if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { + return res.status(400).json({ error: "La clé API est requise" }); + } + await storage.upsertAppConfig({ geminiApiKey: apiKey.trim() }); + res.json({ success: true, configured: true }); + } catch (error) { + console.error("Error saving Gemini config:", error); + res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" }); + } + }); + + app.delete("/api/settings/gemini", requireAdmin, async (req, res) => { + try { + await storage.upsertAppConfig({ geminiApiKey: null }); + res.json({ success: true, configured: false }); + } catch (error) { + console.error("Error deleting Gemini config:", error); + res.status(500).json({ error: "Erreur lors de la suppression de la clé" }); + } + }); + // Analytics Routes app.use("/api/analytics", requireAuth, analyticsRouter); diff --git a/server/routes/reels.ts b/server/routes/reels.ts index 82af644..a0413a3 100644 --- a/server/routes/reels.ts +++ b/server/routes/reels.ts @@ -270,6 +270,8 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { musicUrl, overlayText, ttsEnabled, + ttsVoice, + ttsEngine, wordDuration = 0.6, fontSize = 64, musicVolume = 0.25, @@ -318,6 +320,13 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { console.error('Error fetching watermark configuration', e); } + // Get Gemini API key if using Gemini TTS + let geminiApiKey: string | undefined = undefined; + if (ttsEngine === "gemini") { + const appCfg = await storage.getAppConfig(); + geminiApiKey = appCfg?.geminiApiKey ?? undefined; + } + const finalWordDuration = wordDuration; // Traiter la vidéo via FFmpeg @@ -326,6 +335,8 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { musicUrl: finalMusicUrl, ttsEnabled, ttsVoice, + ttsEngine, + geminiApiKey, wordDuration: finalWordDuration, fontSize, musicVolume, @@ -358,13 +369,13 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => { reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => { try { const user = req.user as User; - const { text } = req.body; + const { text, ttsVoice, ttsEngine } = req.body; if (!text) { return res.status(400).json({ error: 'Texte requis' }); } - const result = await ffmpegService.previewTTS(text, voice); + const result = await ffmpegService.previewTTS(text, ttsVoice, ttsEngine, undefined); if (!result.success) { return res.status(500).json({ error: result.error || 'Erreur de génération TTS' }); @@ -455,6 +466,8 @@ async function processReelBackground( overlayText?: string; description?: string; ttsEnabled?: boolean; + ttsVoice?: string; + ttsEngine?: string; pageIds: string[]; scheduledFor?: string; wordDuration?: number; @@ -473,6 +486,8 @@ async function processReelBackground( overlayText, description, ttsEnabled, + ttsVoice, + ttsEngine, pageIds, scheduledFor, wordDuration, @@ -541,15 +556,24 @@ async function processReelBackground( console.error('Error fetching watermark configuration', e); } + // Get Gemini API key if using Gemini TTS + let geminiApiKey: string | undefined = undefined; + if (ttsEngine === "gemini") { + const appCfg = await storage.getAppConfig(); + geminiApiKey = appCfg?.geminiApiKey ?? undefined; + } + const finalWordDuration = wordDuration ?? 0.6; - console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsVoice }); + console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsVoice, ttsEngine }); const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), { text: overlayText, musicUrl: finalMusicUrl, ttsEnabled, ttsVoice, + ttsEngine, + geminiApiKey, wordDuration: finalWordDuration, fontSize, musicVolume, @@ -713,6 +737,8 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => { overlayText, description, ttsEnabled, + ttsVoice, + ttsEngine, pageIds, scheduledFor, wordDuration = 0.6, diff --git a/server/services/ffmpeg.ts b/server/services/ffmpeg.ts index 6bd6cd6..823dddd 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_engine?: string; // Moteur TTS: "edge" ou "gemini" + gemini_api_key?: string; // Clé API Google Gemini pour TTS 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) @@ -72,6 +74,8 @@ export class FFmpegService { musicUrl?: string; ttsEnabled?: boolean; ttsVoice?: string; + ttsEngine?: string; + geminiApiKey?: string; wordDuration?: number; fontSize?: number; musicVolume?: number; @@ -91,6 +95,8 @@ export class FFmpegService { music_url: options.musicUrl, tts_enabled: options.ttsEnabled, tts_voice: options.ttsVoice, + tts_engine: options.ttsEngine, + gemini_api_key: options.geminiApiKey, word_duration: options.wordDuration ?? 0.6, font_size: options.fontSize ?? 64, music_volume: options.musicVolume ?? 0.25, @@ -173,6 +179,8 @@ export class FFmpegService { musicUrl?: string; ttsEnabled?: boolean; ttsVoice?: string; + ttsEngine?: string; + geminiApiKey?: string; wordDuration?: number; fontSize?: number; musicVolume?: number; @@ -192,6 +200,8 @@ export class FFmpegService { music_url: options.musicUrl, tts_enabled: options.ttsEnabled, tts_voice: options.ttsVoice, + tts_engine: options.ttsEngine, + gemini_api_key: options.geminiApiKey, word_duration: options.wordDuration ?? 0.6, font_size: options.fontSize ?? 64, music_volume: options.musicVolume ?? 0.25, @@ -290,7 +300,9 @@ export class FFmpegService { } async previewTTS( text: string, - ttsVoice?: string + ttsVoice?: string, + ttsEngine?: string, + geminiApiKey?: string ): Promise<{ success: boolean; audioBase64?: string; error?: string }> { const config = this.ensureConfigured(); @@ -305,6 +317,8 @@ export class FFmpegService { text, tts_enabled: true, tts_voice: ttsVoice, + tts_engine: ttsEngine, + gemini_api_key: geminiApiKey, }), }); diff --git a/server/storage.ts b/server/storage.ts index 9457e47..9f22de0 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -124,7 +124,7 @@ export interface IStorage { // App Config getAppConfig(): Promise; - upsertAppConfig(data: Partial>): Promise; + upsertAppConfig(data: Partial>): Promise; } export class DatabaseStorage implements IStorage { @@ -571,7 +571,7 @@ export class DatabaseStorage implements IStorage { return config || undefined; } - async upsertAppConfig(data: Partial>): Promise { + async upsertAppConfig(data: Partial>): Promise { const existing = await this.getAppConfig(); if (existing) { const [updated] = await db.update(appConfig) diff --git a/shared/schema.ts b/shared/schema.ts index 507a438..d5b0334 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -209,6 +209,7 @@ export const musicFavorites = pgTable("music_favorites", { export const appConfig = pgTable("app_config", { id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), externalApiKey: text("external_api_key"), + geminiApiKey: text("gemini_api_key"), createdAt: timestamp("created_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(), });