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
This commit is contained in:
Michael committed 2026-05-18 11:55:07 +02:00
1 parent 9932561050
commit b7ef4940f7
9 files changed
+364 -29

No files matched your search

+47 -9
View File
@@ -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() {
<div className="mt-4 space-y-2 ml-12 p-4 bg-muted/30 rounded-lg border border-border/50">
<Label className="flex items-center gap-2">
<Mic className="w-4 h-4" />
Moteur TTS
</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => { setTtsProvider("edge_tts"); setTtsVoice("fr-FR-VivienneMultilingualNeural"); }}
className={`p-2 rounded-md border-2 text-sm font-medium transition-all ${ttsProvider === "edge_tts" ? "border-primary bg-primary/10" : "border-transparent bg-muted/30 hover:bg-accent"}`}
>
Edge TTS (local)
</button>
<button
type="button"
onClick={() => { setTtsProvider("minimax"); setTtsVoice("French_Female_News"); }}
className={`p-2 rounded-md border-2 text-sm font-medium transition-all ${ttsProvider === "minimax" ? "border-primary bg-primary/10" : "border-transparent bg-muted/30 hover:bg-accent"}`}
>
Minimax (premium)
</button>
</div>
<Label className="flex items-center gap-2 mt-3">
Voix du narrateur
</Label>
<div className="grid grid-cols-2 gap-3">
{[
{(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) => (
<div
key={voice.id}
onClick={() => 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'
}`}
>
<div className="font-semibold flex items-center justify-between">
{voice.label}
{(ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural')) && <Check className="w-3 h-3 text-primary" />}
{ttsVoice === voice.id && <Check className="w-3 h-3 text-primary" />}
</div>
<div className="text-xs text-muted-foreground">{voice.type}</div>
</div>
@@ -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() {
</Button>
</div>
{syncInfo && (
{ttsProvider === "minimax" && (
<div className="p-3 rounded-lg border mt-3 bg-blue-500/10 border-blue-500/30">
<p className="text-xs text-blue-600 dark:text-blue-400">
Minimax utilise ffsubsync pour la synchronisation des sous-titres. Configurez votre clé API dans Paramètres.
</p>
</div>
)}
{ttsProvider === "edge_tts" && syncInfo && (
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Sync texte/voix</span>
+83 -1
View File
@@ -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() {
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<Mic className="w-5 h-5" />
Configuration Minimax Speech (TTS)
</CardTitle>
<CardDescription>
Configurez l'API Minimax pour générer des voix françaises premium dans les Reels
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="minimaxApiKey">Clé API Minimax</Label>
<Input
id="minimaxApiKey"
type="password"
value={minimaxApiKey}
onChange={(e) => setMinimaxApiKey(e.target.value)}
placeholder={hasExistingMinimaxConfig ? "••••••••••••••••" : "eyJhbGci..."}
data-testid="input-minimax-api-key"
/>
<p className="text-xs text-muted-foreground">
{hasExistingMinimaxConfig ? (
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée - Laissez vide pour conserver</span>
) : (
"Votre clé API depuis platform.minimaxi.com"
)}
</p>
</div>
<div className="rounded-lg bg-muted/50 p-3 space-y-1">
<p className="text-xs font-medium">Voix françaises disponibles :</p>
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
<span>• French_Female_News</span>
<span>• French_Male_Speech_New</span>
<span>• French_MovieLeadFemale</span>
<span>• French_CasualMan</span>
<span>• French_FemaleAnchor</span>
<span>• French_MaleNarrator</span>
</div>
</div>
<Button
onClick={() => saveMinimaxMutation.mutate()}
disabled={saveMinimaxMutation.isPending || (!minimaxApiKey.trim() && !hasExistingMinimaxConfig)}
data-testid="button-save-minimax"
className="w-full"
>
{saveMinimaxMutation.isPending ? "Enregistrement..." : "Enregistrer Minimax"}
</Button>
</CardContent>
</Card>
{isAdmin && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">