refactor(tts): replace Minimax+Freesound with Piper TTS

Remove Minimax Speech API and Freesound integrations entirely.
Add Piper TTS as the sole TTS provider via configurable HTTP URL.

- Add piper_config DB table (url field, per-user)
- Add GET/POST /api/piper/config routes
- Python: replace generate_tts_minimax with generate_tts_piper (GET ?text=, WAV→MP3)
- Simplify generate_tts_with_subs: piper_url param replaces tts_provider+minimax fields
- Drop ttsVoice/ttsProvider from all UI, API, and background job params
- Settings page: replace Minimax+Freesound cards with single Piper URL input
- Remove freeSoundService init from server startup and CSP headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Sonnet 4.6 committed 2026-05-19 09:15:26 +02:00
1 parent 0209c3eacf
commit 797e12dde5
14 files changed
+152 -725

No files matched your search

+4 -31
View File
@@ -58,7 +58,6 @@ export default function MobileNewReel() {
// État TTS
const [ttsEnabled, setTtsEnabled] = useState(false);
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
@@ -83,14 +82,14 @@ export default function MobileNewReel() {
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
voice: ttsVoice,
voice: "fr-FR-VivienneMultilingualNeural",
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsVoice, ttsEnabled]);
}, [overlayText, ttsEnabled]);
const { data: pages = [] } = useQuery<SocialPage[]>({
@@ -231,7 +230,6 @@ export default function MobileNewReel() {
stabilize: stabilize,
drawText: true,
ttsEnabled,
ttsVoice,
enableEndingEffect,
});
};
@@ -448,33 +446,9 @@ export default function MobileNewReel() {
{ttsEnabled && (
<div className="mt-4 space-y-2 ml-1 p-3 bg-muted/30 rounded-lg border border-border/50">
<Label className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mic className="w-4 h-4" />
Voix du narrateur
</Label>
<div className="grid grid-cols-2 gap-3">
{[
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme", 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", style: "bg-purple-500/10 border-purple-500/50" },
{ id: "fr-FR-HenriNeural", label: "Henri", type: "Homme", 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) => (
<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')
? `border-primary ${voice.style}`
: 'border-transparent bg-muted/30'
}`}
>
<div className="font-semibold flex items-center justify-between text-sm">
{voice.label}
{(ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural')) && <Check className="w-3 h-3 text-primary" />}
</div>
<div className="text-[10px] text-muted-foreground">{voice.type}</div>
</div>
))}
<span>Piper TTS — voix configurée sur le serveur</span>
</div>
<div className="mt-3">
<Button
@@ -487,7 +461,6 @@ export default function MobileNewReel() {
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
voice: ttsVoice
});
const data = await response.json();
if (data.success && data.audioBase64) {
+2 -19
View File
@@ -18,13 +18,6 @@ import type { Media, SocialPage } from "@shared/schema";
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
const TTS_VOICES = [
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme" },
{ id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme" },
{ id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme" },
{ id: "fr-FR-HenriNeural", label: "Henri", type: "Homme" },
{ id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant" },
];
function stripForTTS(text: string) {
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
@@ -38,7 +31,6 @@ export default function MobileRemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -119,7 +111,7 @@ export default function MobileRemotionVideoPage() {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, voice: ttsVoice });
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
@@ -140,7 +132,6 @@ export default function MobileRemotionVideoPage() {
images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText);
if (ttsEnabled && overlayText) formData.append("ttsVoice", ttsVoice);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -320,15 +311,7 @@ export default function MobileRemotionVideoPage() {
</div>
{ttsEnabled && (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-1.5">
{TTS_VOICES.map(v => (
<div key={v.id} onClick={() => setTtsVoice(v.id)}
className={`cursor-pointer p-1.5 rounded border-2 text-center ${ttsVoice === v.id ? 'border-primary bg-primary/10' : 'border-transparent bg-muted/30'}`}>
<div className="font-semibold text-xs">{v.label}</div>
<div className="text-[10px] text-muted-foreground">{v.type}</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground">Piper TTS — voix configurée sur le serveur</p>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
+7 -72
View File
@@ -65,8 +65,6 @@ export default function NewReel() {
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
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);
@@ -88,23 +86,23 @@ export default function NewReel() {
}
}, []);
// Auto-calculate TTS sync when text or voice changes (Edge TTS only)
// Auto-calculate TTS sync when text changes (edge_tts fallback)
useEffect(() => {
if (!ttsEnabled || !overlayText.trim() || ttsProvider !== "edge_tts") {
if (!ttsEnabled || !overlayText.trim()) {
setSyncInfo(null);
return;
}
const timer = setTimeout(() => {
apiRequest('POST', '/api/reels/sync-info', {
text: overlayText,
voice: ttsVoice,
voice: "fr-FR-VivienneMultilingualNeural",
})
.then(r => r.json())
.then(data => setSyncInfo(data))
.catch(() => setSyncInfo(null));
}, 800);
return () => clearTimeout(timer);
}, [overlayText, ttsVoice, ttsEnabled, ttsProvider]);
}, [overlayText, ttsEnabled]);
// État audio preview
@@ -362,8 +360,6 @@ export default function NewReel() {
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsVoice,
ttsProvider,
drawText,
stabilize: stabilize,
enableEndingEffect,
@@ -764,60 +760,9 @@ export default function NewReel() {
{ttsEnabled && (
<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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<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" }
] : [
{ 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
? `border-primary ${voice.style}`
: 'border-transparent bg-muted/30'
}`}
>
<div className="font-semibold flex items-center justify-between">
{voice.label}
{ttsVoice === voice.id && <Check className="w-3 h-3 text-primary" />}
</div>
<div className="text-xs text-muted-foreground">{voice.type}</div>
</div>
))}
<span>Piper TTS — voix configurée sur le serveur</span>
</div>
<div className="mt-3 flex gap-2">
@@ -831,8 +776,6 @@ export default function NewReel() {
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
voice: ttsVoice,
ttsProvider,
});
const data = await response.json();
if (data.success && data.audioBase64) {
@@ -853,15 +796,7 @@ export default function NewReel() {
</Button>
</div>
{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 && (
{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>
+2 -19
View File
@@ -15,13 +15,6 @@ import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/datetime-picker";
import type { Media, SocialPage } from "@shared/schema";
const TTS_VOICES = [
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme" },
{ id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme" },
{ id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme" },
{ id: "fr-FR-HenriNeural", label: "Henri", type: "Homme" },
{ id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant" },
];
interface AudioTrack {
id: string;
@@ -39,7 +32,6 @@ export default function RemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -86,7 +78,7 @@ export default function RemotionVideoPage() {
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, voice: ttsVoice });
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
const data = await r.json();
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
@@ -107,7 +99,6 @@ export default function RemotionVideoPage() {
images.forEach(img => formData.append("images", img));
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
if (overlayText) formData.append("overlayText", overlayText);
if (ttsEnabled && overlayText) formData.append("ttsVoice", ttsVoice);
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
@@ -261,15 +252,7 @@ export default function RemotionVideoPage() {
</div>
{ttsEnabled && (
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<div className="grid grid-cols-3 gap-2">
{TTS_VOICES.map(v => (
<div key={v.id} onClick={() => setTtsVoice(v.id)}
className={`cursor-pointer p-2 rounded border-2 text-center transition-all ${ttsVoice === v.id ? 'border-primary bg-primary/10' : 'border-transparent bg-muted/30'}`}>
<div className="font-semibold text-sm">{v.label}</div>
<div className="text-xs text-muted-foreground">{v.type}</div>
</div>
))}
</div>
<p className="text-sm text-muted-foreground">Piper TTS — voix configurée sur le serveur</p>
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
+29 -161
View File
@@ -30,8 +30,7 @@ export default function Settings() {
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
const [externalApiKey, setExternalApiKey] = useState("");
const [minimaxApiKey, setMinimaxApiKey] = useState("");
const [minimaxGroupId, setMinimaxGroupId] = useState("");
const [piperUrl, setPiperUrl] = useState("");
const { toast } = useToast();
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
@@ -62,8 +61,8 @@ export default function Settings() {
enabled: isAdmin,
});
const { data: minimaxConfig } = useQuery({
queryKey: ['/api/minimax/config'],
const { data: piperConfig } = useQuery({
queryKey: ['/api/piper/config'],
});
useEffect(() => {
@@ -79,50 +78,11 @@ export default function Settings() {
}
}, [cloudinaryConfig]);
const { data: freesoundConfig } = useQuery({
queryKey: ['/api/freesound/config'],
});
const [freesoundClientId, setFreesoundClientId] = useState("");
const [freesoundClientSecret, setFreesoundClientSecret] = useState("");
useEffect(() => {
if (freesoundConfig) {
setFreesoundClientId((freesoundConfig as any).clientId || "");
setFreesoundClientSecret((freesoundConfig as any).clientSecret || ""); // Usually masked
if (piperConfig) {
setPiperUrl((piperConfig as any).url || "");
}
}, [freesoundConfig]);
const hasExistingFreesoundConfig = !!freesoundConfig;
const saveFreesoundMutation = useMutation({
mutationFn: () => {
const payload: any = {
clientId: freesoundClientId,
};
if (freesoundClientSecret && freesoundClientSecret.trim() !== "" && freesoundClientSecret !== "********") {
payload.clientSecret = freesoundClientSecret;
}
return apiRequest('POST', '/api/freesound/config', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/freesound/config'] });
toast({
title: "Configuration sauvegardée",
description: "Vos paramètres Freesound ont été enregistrés",
});
// On garde l'affichage tel quel ou on remet masked si retourné
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration Freesound",
variant: "destructive",
});
},
});
}, [piperConfig]);
useEffect(() => {
if (openrouterConfig) {
@@ -264,33 +224,23 @@ export default function Settings() {
});
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
const hasExistingMinimaxConfig = !!(minimaxConfig as any)?.apiKey;
const hasExistingMinimaxGroupId = !!(minimaxConfig as any)?.groupId;
const hasExistingPiperConfig = !!(piperConfig as any)?.url;
const saveMinimaxMutation = useMutation({
const savePiperMutation = useMutation({
mutationFn: () => {
const payload: any = {};
if (minimaxApiKey && minimaxApiKey.trim() !== "") {
payload.apiKey = minimaxApiKey;
}
if (minimaxGroupId && minimaxGroupId.trim() !== "") {
payload.groupId = minimaxGroupId;
}
return apiRequest('POST', '/api/minimax/config', payload);
return apiRequest('POST', '/api/piper/config', { url: piperUrl });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/minimax/config'] });
setMinimaxApiKey("");
setMinimaxGroupId("");
queryClient.invalidateQueries({ queryKey: ['/api/piper/config'] });
toast({
title: "Configuration sauvegardée",
description: "Votre clé API Minimax a été enregistrée",
description: "L'URL du serveur Piper TTS a été enregistrée",
});
},
onError: () => {
toast({
title: "Erreur",
description: "Impossible de sauvegarder la configuration Minimax",
description: "Impossible de sauvegarder la configuration Piper",
variant: "destructive",
});
},
@@ -546,53 +496,39 @@ export default function Settings() {
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader className="p-6">
<CardTitle className="flex items-center gap-2">
<SettingsIcon className="w-5 h-5" />
Configuration Freesound (Musique)
<Mic className="w-5 h-5" />
Configuration Piper TTS (Voix)
</CardTitle>
<CardDescription>
Configurez l'API Freesound pour la recherche de musique dans les Reels
Configurez l'URL de votre serveur Piper TTS pour la synthèse vocale des Reels
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="freesoundClientId">Client ID</Label>
<Label htmlFor="piperUrl">URL du serveur Piper</Label>
<Input
id="freesoundClientId"
type="text"
value={freesoundClientId}
onChange={(e) => setFreesoundClientId(e.target.value)}
placeholder="Votre Client ID Freesound"
data-testid="input-freesound-client-id"
id="piperUrl"
type="url"
value={piperUrl}
onChange={(e) => setPiperUrl(e.target.value)}
placeholder="http://piper-server:5000/api/tts"
data-testid="input-piper-url"
/>
<p className="text-xs text-muted-foreground">
Disponible dans vos paramètres d'API Freesound
</p>
</div>
<div className="space-y-2">
<Label htmlFor="freesoundClientSecret">Client Secret</Label>
<Input
id="freesoundClientSecret"
type="password"
value={freesoundClientSecret}
onChange={(e) => setFreesoundClientSecret(e.target.value)}
placeholder={hasExistingFreesoundConfig ? "••••••••" : "Votre Client Secret"}
data-testid="input-freesound-client-secret"
/>
<p className="text-xs text-muted-foreground">
{hasExistingFreesoundConfig ? (
<span className="text-green-600 dark:text-green-400">✓ Configuré (Laissez vide pour conserver)</span>
{hasExistingPiperConfig ? (
<span className="text-green-600 dark:text-green-400">✓ Configuré</span>
) : (
"Votre clé secrète ne sera jamais exposée"
"URL de l'endpoint HTTP GET de votre serveur Piper (ex: http://piper:5000/api/tts)"
)}
</p>
</div>
<Button
onClick={() => saveFreesoundMutation.mutate()}
disabled={saveFreesoundMutation.isPending || !freesoundClientId}
data-testid="button-save-freesound"
onClick={() => savePiperMutation.mutate()}
disabled={savePiperMutation.isPending || !piperUrl.trim()}
data-testid="button-save-piper"
className="w-full"
>
{saveFreesoundMutation.isPending ? "Enregistrement..." : "Enregistrer Freesound"}
{savePiperMutation.isPending ? "Enregistrement..." : "Enregistrer Piper TTS"}
</Button>
</CardContent>
</Card>
@@ -657,74 +593,6 @@ 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="space-y-2">
<Label htmlFor="minimaxGroupId">Group ID Minimax</Label>
<Input
id="minimaxGroupId"
type="text"
value={minimaxGroupId}
onChange={(e) => setMinimaxGroupId(e.target.value)}
placeholder={hasExistingMinimaxGroupId ? "••••••••••••••••" : "1234567890"}
data-testid="input-minimax-group-id"
/>
<p className="text-xs text-muted-foreground">
{hasExistingMinimaxGroupId ? (
<span className="text-green-600 dark:text-green-400">✓ Group ID configuré - Laissez vide pour conserver</span>
) : (
"Requis pour les quotas corrects — trouvez-le sur platform.minimaxi.com dans votre profil"
)}
</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() && !minimaxGroupId.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">
+31 -102
View File
@@ -158,10 +158,7 @@ class ReelRequest(BaseModel):
font_size: int = 64
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
minimax_group_id: Optional[str] = None
piper_url: Optional[str] = None
draw_text: bool = True
stabilize: bool = False # Stabilisation vidéo via vidstab
enable_ending_effect: bool = True
@@ -195,82 +192,44 @@ 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, group_id: Optional[str] = None) -> None:
"""Generate TTS audio using Minimax Speech API (T2A v2)."""
url = f"https://api.minimax.io/v1/t2a_v2?GroupId={group_id}" if group_id else "https://api.minimax.io/v1/t2a_v2"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": "speech-02-hd",
"text": text,
"voice_setting": {
"voice_id": voice_id,
"speed": 1.0,
"pitch": 0,
"vol": 1.0,
},
"audio_setting": {
"format": "mp3",
"sample_rate": 32000,
"bitrate": 128000,
"channel": 1,
},
}
print(f"🔊 Minimax API request: voice_id={voice_id}, model=speech-02-hd, text_len={len(text)}")
response = requests.post(url, headers=headers, json=payload, timeout=60)
print(f"🔊 Minimax API response: status={response.status_code}, content_type={response.headers.get('Content-Type', '?')}")
def generate_tts_piper(text: str, piper_url: str, audio_path: Path) -> None:
"""Generate TTS audio using Piper TTS HTTP API."""
print(f"🔊 Piper TTS request: url={piper_url}, text_len={len(text)}")
response = requests.get(piper_url, params={"text": text}, timeout=60)
print(f"🔊 Piper API response: status={response.status_code}, content_type={response.headers.get('Content-Type', '?')}")
response.raise_for_status()
content_type = response.headers.get("Content-Type", "")
if "audio" in content_type:
# Direct binary audio response
audio_bytes = response.content
print(f"🔊 Minimax returned binary audio directly ({len(audio_bytes)} bytes)")
else:
data = response.json()
print(f"🔊 Minimax JSON response keys: {list(data.keys())}")
tmp_path = audio_path.with_suffix(".tmp")
with open(tmp_path, "wb") as f:
f.write(response.content)
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_raw = data.get("data", {}).get("audio") or data.get("audio")
if not audio_raw:
raise Exception(f"Minimax API: no audio field in response. Keys: {list(data.keys())}")
audio_bytes = bytes.fromhex(audio_raw)
with open(audio_path, "wb") as f:
f.write(audio_bytes)
print(f"✅ Minimax TTS audio saved: {audio_path.stat().st_size} bytes")
subprocess.run([
"ffmpeg", "-y", "-i", str(tmp_path),
"-codec:a", "libmp3lame", "-b:a", "128k",
str(audio_path)
], check=True, capture_output=True)
tmp_path.unlink(missing_ok=True)
print(f"✅ Piper TTS audio saved: {audio_path.stat().st_size} bytes")
async def generate_tts_with_subs(
text: str,
voice: str,
audio_path: Path,
ass_path: Path,
display_text: Optional[str] = None,
delay: float = 0.0,
tts_provider: str = "edge_tts",
minimax_api_key: Optional[str] = None,
minimax_group_id: Optional[str] = None,
piper_url: 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).
Uses Piper TTS when piper_url is set, otherwise falls back to edge_tts.
Piper uses ffsubsync for subtitle sync (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")
# Piper branch: no WordBoundary events, use ffsubsync for subtitle sync
if piper_url:
text_to_display = display_text if display_text else text
print(f"🔊 Minimax TTS with voice: {voice}, group_id={bool(minimax_group_id)}")
generate_tts_minimax(text, voice, minimax_api_key, audio_path, group_id=minimax_group_id)
generate_tts_piper(text, piper_url, audio_path)
audio_duration = None
try:
@@ -280,18 +239,21 @@ async def generate_tts_with_subs(
]
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")
print(f"⏱️ Piper TTS Duration: {audio_duration:.2f}s")
except Exception as e:
print(f"⚠️ Could not measure Minimax TTS duration: {e}")
print(f"⚠️ Could not measure Piper 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")
print("✅ Piper TTS synchronisation completed with ffsubsync")
return
# edge_tts fallback branch
voice = "fr-FR-VivienneMultilingualNeural"
# Determine gender of requested voice to choose appropriate fallbacks
is_male = any(name in voice for name in ["Remi", "Henri", "Paul"])
@@ -880,35 +842,18 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
try:
# Clean text for TTS (remove hashtags/emojis)
tts_clean_text = clean_text_for_tts(request.text)
print(f"🔊 TTS enabled. Provider: {request.tts_provider}, Voice: {request.tts_voice}")
print(f"🔊 TTS has minimax_api_key: {bool(request.minimax_api_key)}, group_id: {bool(request.minimax_group_id)}")
print(f"🔊 TTS enabled. Piper URL: {bool(request.piper_url)}")
print(f"🔊 TTS cleaned: '{tts_clean_text[:80]}...'")
# Check for male/female voice map (edge_tts only)
voice = request.tts_voice
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}")
if tts_clean_text:
print(f"🔊 Generating TTS audio to: {tts_audio_path}")
# Pass original text (with emojis) for subtitles, cleaned text for audio
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,
tts_provider=request.tts_provider,
minimax_api_key=request.minimax_api_key,
minimax_group_id=request.minimax_group_id,
piper_url=request.piper_url,
)
# Verify files were created
@@ -1281,28 +1226,12 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
clean_text = clean_text_for_tts(request.text)
# Determine voice (reuse logic)
voice = request.tts_voice
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.
# Adding delay=0.0 here explicitly since it's just audio preview, or delay=2.0 if previewing video directly.
await generate_tts_with_subs(
clean_text,
voice,
tts_audio_path,
tts_srt_path,
display_text=clean_text_for_display(request.text),
tts_provider=request.tts_provider,
minimax_api_key=request.minimax_api_key,
minimax_group_id=request.minimax_group_id,
piper_url=request.piper_url,
)
if not tts_audio_path.exists():
+2 -30
View File
@@ -15,7 +15,6 @@ import { ensureAdminUserExists } from "./init-admin";
import { startTokenCron } from "./cron";
import { migrate } from "./migrate";
import { jamendoService } from "./services/jamendo";
import { freeSoundService } from "./services/freesound";
import { ffmpegService } from "./services/ffmpeg";
const app = express();
@@ -34,8 +33,8 @@ app.use(helmet({
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "https://freesound.org", "wss:", "ws:"],
mediaSrc: ["'self'", "https://cdn.freesound.org", "https:", "data:"],
connectSrc: ["'self'", "https://graph.facebook.com", "https://openrouter.ai", "https://res.cloudinary.com", "wss:", "ws:"],
mediaSrc: ["'self'", "https:", "data:"],
}
},
crossOriginEmbedderPolicy: false,
@@ -209,33 +208,6 @@ app.use((req, res, next) => {
const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49';
jamendoService.configure(jamendoClientId);
// Configurer le service FreeSound pour les Reels
// 1. Essayer de charger depuis la DB (suppose un utilisateur admin ou premier utilisateur)
// Note: Dans un contexte multi-utilisateur, cela devrait être fait par requête.
// Pour l'instant, on charge la config du premier user trouvé ou via variable d'env.
let freesoundConfigured = false;
try {
const { storage } = await import("./storage");
const allUsers = await storage.getAllUsers();
if (allUsers.length > 0) {
const dbConfig = await storage.getFreesoundConfig(allUsers[0].id);
if (dbConfig && dbConfig.clientId && dbConfig.clientSecret) {
freeSoundService.configure(dbConfig.clientId, dbConfig.clientSecret);
freesoundConfigured = true;
log('🎵 FreeSound configured from Database');
}
}
} catch (err) {
console.warn('Failed to load Freesound config from DB:', err);
}
if (!freesoundConfigured && process.env.FREESOUND_CLIENT_ID && process.env.FREESOUND_CLIENT_SECRET) {
freeSoundService.configure(process.env.FREESOUND_CLIENT_ID, process.env.FREESOUND_CLIENT_SECRET);
log('🎵 FreeSound configured from Environment Variables');
} else if (!freesoundConfigured) {
log('⚠️ FreeSound credentials not found - music search will be disabled');
}
// Configurer le service FFmpeg pour le traitement vidéo (si configuré)
if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) {
+4 -10
View File
@@ -152,24 +152,18 @@ export async function migrate() {
);
`);
// minimax_config
// piper_config
await client.query(`
CREATE TABLE IF NOT EXISTS "minimax_config" (
CREATE TABLE IF NOT EXISTS "piper_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,
"url" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT minimax_config_user_id_unique UNIQUE ("user_id")
CONSTRAINT piper_config_user_id_unique UNIQUE ("user_id")
);
`);
// minimax_config.group_id
await client.query(`
ALTER TABLE "minimax_config"
ADD COLUMN IF NOT EXISTS "group_id" text;
`);
console.log("[Migration] Safe migration completed.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
+15 -78
View File
@@ -10,9 +10,8 @@ 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, updateMinimaxConfigSchema, insertMinimaxConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema";
import { freeSoundService } from "./services/freesound";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertPiperConfigSchema, updatePiperConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
@@ -424,101 +423,39 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// Freesound Configuration Routes
app.get("/api/freesound/config", requireAuth, async (req, res) => {
// Piper TTS Configuration Routes
app.get("/api/piper/config", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const config = await storage.getFreesoundConfig(user.id);
const config = await storage.getPiperConfig(user.id);
if (!config) {
return res.json(null);
}
return res.json({
...config,
clientSecret: config.clientSecret ? "********" : "",
});
return res.json(config);
} catch (error) {
console.error("Error fetching Freesound config:", error);
console.error("Error fetching Piper config:", error);
res.status(500).json({ error: "Failed to fetch configuration" });
}
});
app.post("/api/freesound/config", requireAuth, async (req, res) => {
app.post("/api/piper/config", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const data = updateFreesoundConfigSchema.parse(req.body);
let config = await storage.getFreesoundConfig(user.id);
const updateData: any = { ...data };
if (!updateData.clientSecret || updateData.clientSecret === "********") {
delete updateData.clientSecret;
}
const data = updatePiperConfigSchema.parse(req.body);
let config = await storage.getPiperConfig(user.id);
if (config) {
config = await storage.updateFreesoundConfig(user.id, updateData);
config = await storage.updatePiperConfig(user.id, data);
} else {
if (!data.clientId || !data.clientSecret) {
return res.status(400).json({ error: "Client ID and Secret are required" });
if (!data.url) {
return res.status(400).json({ error: "URL is required" });
}
config = await storage.createFreesoundConfig({
...data as any,
userId: user.id
});
}
if (config && config.clientId && config.clientSecret) {
freeSoundService.configure(config.clientId, config.clientSecret);
config = await storage.createPiperConfig({ ...data as any, userId: user.id });
}
res.json({ success: true });
} catch (error) {
console.error("Error saving Freesound config:", error);
res.status(500).json({ error: "Failed to save configuration" });
}
});
// 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);
console.error("Error saving Piper config:", error);
res.status(500).json({ error: "Failed to save configuration" });
}
});
+23 -98
View File
@@ -5,7 +5,6 @@
import { Router, Request, Response } from 'express';
import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { freeSoundService, type MusicTrack } from '../services/freesound';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { minioService as cloudinaryService, buildMinioUrl, resolveInternalUrl } from '../services/minio';
@@ -36,21 +35,12 @@ reelsRouter.get('/music/search', async (req: Request, res: Response) => {
search,
} = req.query;
const tracks = await freeSoundService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
@@ -74,21 +64,12 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => {
search,
} = req.query;
const tracks = await freeSoundService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
tracks: [],
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
hasMore: false,
},
});
} catch (error) {
@@ -103,9 +84,7 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => {
*/
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const tracks = await freeSoundService.getPopularTracks(limit);
res.json({ tracks });
res.json({ tracks: [] });
} catch (error) {
console.error('❌ Error fetching popular music:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
@@ -229,13 +208,7 @@ reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Respon
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
try {
const { trackId } = req.params;
const track = await freeSoundService.getMusicDetails(trackId);
if (!track) {
return res.status(404).json({ error: 'Musique non trouvée' });
}
res.json(track);
return res.status(404).json({ error: 'Musique non trouvée' });
} catch (error) {
console.error('❌ Error fetching music details:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
@@ -297,8 +270,6 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
musicUrl,
overlayText,
ttsEnabled,
ttsVoice,
ttsProvider,
wordDuration = 0.6,
fontSize = 64,
musicVolume = 0.25,
@@ -334,11 +305,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
console.log(`🎵 Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} else {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
}
}
let watermarkUrl: string | undefined = undefined;
@@ -353,15 +320,10 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
const finalWordDuration = wordDuration;
let minimaxApiKey: string | undefined;
let minimaxGroupId: 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;
minimaxGroupId = minimaxCfg.groupId ?? undefined;
let piperUrl: string | undefined;
if (ttsEnabled) {
const piperCfg = await storage.getPiperConfig(user.id);
piperUrl = piperCfg?.url ?? undefined;
}
// Traiter la vidéo via FFmpeg
@@ -369,10 +331,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsProvider,
minimaxApiKey,
minimaxGroupId,
piperUrl,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
@@ -405,24 +364,14 @@ 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, voice, ttsProvider } = req.body;
const { text } = req.body;
if (!text) {
return res.status(400).json({ error: 'Texte requis' });
}
let minimaxApiKey: string | undefined;
let minimaxGroupId: 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;
minimaxGroupId = minimaxCfg.groupId ?? undefined;
}
const result = await ffmpegService.previewTTS(text, voice, ttsProvider, minimaxApiKey, minimaxGroupId);
const piperCfg = await storage.getPiperConfig(user.id);
const result = await ffmpegService.previewTTS(text, piperCfg?.url);
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
@@ -513,8 +462,6 @@ async function processReelBackground(
overlayText?: string;
description?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
pageIds: string[];
scheduledFor?: string;
wordDuration?: number;
@@ -533,8 +480,6 @@ async function processReelBackground(
overlayText,
description,
ttsEnabled,
ttsVoice,
ttsProvider,
pageIds,
scheduledFor,
wordDuration,
@@ -579,15 +524,6 @@ async function processReelBackground(
console.log(`🎵 [Background] Audio URL for ffmpeg: ${finalMusicUrl}`);
}
} catch (e) { console.error('Error fetching internal track', e); }
} else {
try {
const track = await freeSoundService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
} catch (e) {
console.error(`⚠️ [Background] Failed to fetch music details for ${musicTrackId}`, e);
}
}
}
@@ -614,28 +550,19 @@ async function processReelBackground(
const finalWordDuration = wordDuration ?? 0.6;
let minimaxApiKeyBg: string | undefined;
let minimaxGroupIdBg: string | undefined;
if (ttsEnabled && ttsProvider === 'minimax') {
const minimaxCfg = await storage.getMinimaxConfig(userId);
if (minimaxCfg?.apiKey) {
minimaxApiKeyBg = minimaxCfg.apiKey;
minimaxGroupIdBg = minimaxCfg.groupId ?? undefined;
} else {
console.error('❌ [Background] Minimax API key not found in DB for user:', userId);
}
let piperUrlBg: string | undefined;
if (ttsEnabled) {
const piperCfg = await storage.getPiperConfig(userId);
piperUrlBg = piperCfg?.url ?? undefined;
}
console.log('🔊 [Background] TTS config:', { ttsEnabled, ttsProvider, ttsVoice, hasMinimaxKey: !!minimaxApiKeyBg, hasGroupId: !!minimaxGroupIdBg });
console.log('🔊 [Background] TTS config:', { ttsEnabled, hasPiperUrl: !!piperUrlBg });
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsProvider,
minimaxApiKey: minimaxApiKeyBg,
minimaxGroupId: minimaxGroupIdBg,
piperUrl: piperUrlBg,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
@@ -799,7 +726,6 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
overlayText,
description,
ttsEnabled,
ttsVoice,
pageIds,
scheduledFor,
wordDuration = 0.6,
@@ -889,12 +815,11 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
*/
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try {
const freesoundConfigured = await freeSoundService.testConnection().catch(() => false);
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
res.json({
jamendo: { configured: freesoundConfigured }, // Keep legacy key for frontend compatibility
freesound: { configured: freesoundConfigured },
jamendo: { configured: false },
freesound: { configured: false },
ffmpeg: { configured: ffmpegConfigured },
});
} catch (error) {
+1 -2
View File
@@ -199,7 +199,6 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
? req.body.overlayText.trim()
: undefined;
const ttsVoice: string = req.body.ttsVoice || "fr-FR-VivienneMultilingualNeural";
const musicVolume: number = parseFloat(req.body.musicVolume ?? "0.3");
// Music: HTTP URLs are fine for audio (Remotion uses Web Audio API, not <Img>)
@@ -248,7 +247,7 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
if (ttsText) {
console.log("🎙️ Generating TTS for:", ttsText);
try {
const ttsResult = await ffmpegService.previewTTS(ttsText, ttsVoice);
const ttsResult = await ffmpegService.previewTTS(ttsText);
if (ttsResult.success && ttsResult.audioBase64) {
const audioFilename = `tts-${Date.now()}.mp3`;
const audioPath = path.join(uploadDir, audioFilename);
+9 -30
View File
@@ -12,10 +12,7 @@ interface FFmpegReelRequest {
music_id?: string; // ID de la musique (catalogue FFmpeg)
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)
minimax_group_id?: string; // Group ID Minimax (requis pour quotas corrects)
piper_url?: string; // URL du serveur Piper 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)
@@ -74,10 +71,7 @@ export class FFmpegService {
musicId?: string;
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
minimaxApiKey?: string;
minimaxGroupId?: string;
piperUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
@@ -96,10 +90,7 @@ export class FFmpegService {
music_id: options.musicId,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled,
tts_voice: options.ttsVoice,
tts_provider: options.ttsProvider,
minimax_api_key: options.minimaxApiKey,
minimax_group_id: options.minimaxGroupId,
piper_url: options.piperUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
@@ -181,10 +172,7 @@ export class FFmpegService {
musicId?: string;
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
minimaxApiKey?: string;
minimaxGroupId?: string;
piperUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
@@ -203,10 +191,7 @@ export class FFmpegService {
music_id: options.musicId,
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled,
tts_voice: options.ttsVoice,
tts_provider: options.ttsProvider,
minimax_api_key: options.minimaxApiKey,
minimax_group_id: options.minimaxGroupId,
piper_url: options.piperUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
@@ -231,12 +216,11 @@ export class FFmpegService {
hasMusicId: !!options.musicId,
hasMusicUrl: !!options.musicUrl,
ttsEnabled: options.ttsEnabled,
ttsVoice: options.ttsVoice,
hasPiperUrl: !!options.piperUrl,
drawText: options.drawText,
});
const debugBody = { ...requestBody };
if (debugBody.minimax_api_key) debugBody.minimax_api_key = '***';
console.log('📤 Sending to FFmpeg API:', JSON.stringify({ ...debugBody, text: debugBody.text ? `[${debugBody.text.length} chars]` : undefined }));
try {
@@ -307,10 +291,7 @@ export class FFmpegService {
}
async previewTTS(
text: string,
voice: string,
ttsProvider?: string,
minimaxApiKey?: string,
minimaxGroupId?: string
piperUrl?: string
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
const config = this.ensureConfigured();
@@ -323,10 +304,8 @@ export class FFmpegService {
},
body: JSON.stringify({
text,
tts_voice: voice,
tts_provider: ttsProvider,
minimax_api_key: minimaxApiKey,
minimax_group_id: minimaxGroupId,
tts_enabled: true,
piper_url: piperUrl,
}),
});
+15 -37
View File
@@ -31,17 +31,14 @@ import {
musicFavorites,
type MusicFavorite,
type InsertMusicFavorite,
freesoundConfig,
type FreesoundConfig,
type InsertFreesoundConfig,
piperConfig,
type PiperConfig,
type InsertPiperConfig,
audioTracks,
type AudioTrack,
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";
@@ -127,10 +124,10 @@ export interface IStorage {
createAudioTrack(track: InsertAudioTrack): Promise<AudioTrack>;
deleteAudioTrack(id: string): Promise<void>;
// Freesound Config
getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined>;
createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig>;
updateFreesoundConfig(userId: string, config: Partial<InsertFreesoundConfig>): Promise<FreesoundConfig>;
// Piper Config
getPiperConfig(userId: string): Promise<PiperConfig | undefined>;
createPiperConfig(config: InsertPiperConfig): Promise<PiperConfig>;
updatePiperConfig(userId: string, config: Partial<InsertPiperConfig>): Promise<PiperConfig>;
// App Config
getAppConfig(): Promise<AppConfig | undefined>;
@@ -575,25 +572,6 @@ export class DatabaseStorage implements IStorage {
await db.delete(audioTracks).where(eq(audioTracks.id, id));
}
// Freesound Config
async getFreesoundConfig(userId: string): Promise<FreesoundConfig | undefined> {
const [config] = await db.select().from(freesoundConfig).where(eq(freesoundConfig.userId, userId));
return config || undefined;
}
async createFreesoundConfig(config: InsertFreesoundConfig): Promise<FreesoundConfig> {
const [newConfig] = await db.insert(freesoundConfig).values(config).returning();
return newConfig;
}
async updateFreesoundConfig(userId: string, config: Partial<InsertFreesoundConfig>): Promise<FreesoundConfig> {
const [updated] = await db.update(freesoundConfig)
.set({ ...config, updatedAt: new Date() })
.where(eq(freesoundConfig.userId, userId))
.returning();
return updated;
}
// App Config
async getAppConfig(): Promise<AppConfig | undefined> {
const [config] = await db.select().from(appConfig).limit(1);
@@ -612,21 +590,21 @@ export class DatabaseStorage implements IStorage {
const [created] = await db.insert(appConfig).values(data).returning();
return created;
}
// Minimax Config
async getMinimaxConfig(userId: string): Promise<MinimaxConfig | undefined> {
const [config] = await db.select().from(minimaxConfig).where(eq(minimaxConfig.userId, userId));
// Piper Config
async getPiperConfig(userId: string): Promise<PiperConfig | undefined> {
const [config] = await db.select().from(piperConfig).where(eq(piperConfig.userId, userId));
return config || undefined;
}
async createMinimaxConfig(config: InsertMinimaxConfig): Promise<MinimaxConfig> {
const [newConfig] = await db.insert(minimaxConfig).values(config).returning();
async createPiperConfig(config: InsertPiperConfig): Promise<PiperConfig> {
const [newConfig] = await db.insert(piperConfig).values(config).returning();
return newConfig;
}
async updateMinimaxConfig(userId: string, config: Partial<InsertMinimaxConfig>): Promise<MinimaxConfig> {
const [updated] = await db.update(minimaxConfig)
async updatePiperConfig(userId: string, config: Partial<InsertPiperConfig>): Promise<PiperConfig> {
const [updated] = await db.update(piperConfig)
.set({ ...config, updatedAt: new Date() })
.where(eq(minimaxConfig.userId, userId))
.where(eq(piperConfig.userId, userId))
.returning();
return updated;
}
+8 -36
View File
@@ -133,20 +133,10 @@ export const openrouterConfig = pgTable("openrouter_config", {
updatedAt: timestamp("updated_at").defaultNow(),
});
export const freesoundConfig = pgTable("freesound_config", {
export const piperConfig = pgTable("piper_config", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
clientId: text("client_id").notNull(),
clientSecret: text("client_secret").notNull(),
createdAt: timestamp("created_at").defaultNow(),
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(),
groupId: text("group_id"),
url: text("url").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
@@ -291,7 +281,6 @@ export const usersRelations = relations(users, ({ many, one }) => ({
pagePermissions: many(userPagePermissions),
cloudinaryConfig: one(cloudinaryConfig),
openrouterConfig: one(openrouterConfig),
freesoundConfig: one(freesoundConfig),
}));
// Pas de relation User pour CloudinaryConfig (maintenant global)
@@ -481,39 +470,22 @@ export type InsertMusicFavorite = z.infer<typeof insertMusicFavoriteSchema>;
export type AudioTrack = typeof audioTracks.$inferSelect;
export type InsertAudioTrack = z.infer<typeof insertAudioTrackSchema>;
export const insertFreesoundConfigSchema = createInsertSchema(freesoundConfig).omit({
export const insertPiperConfigSchema = createInsertSchema(piperConfig).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const updateFreesoundConfigSchema = insertFreesoundConfigSchema.omit({
export const updatePiperConfigSchema = insertPiperConfigSchema.omit({
userId: true
}).partial({
clientId: true,
clientSecret: true,
url: true,
});
export type FreesoundConfig = typeof freesoundConfig.$inferSelect;
export type PiperConfig = typeof piperConfig.$inferSelect;
export type InsertPiperConfig = z.infer<typeof insertPiperConfigSchema>;
export type UpdatePiperConfig = z.infer<typeof updatePiperConfigSchema>;
export type AppConfig = typeof appConfig.$inferSelect;
export type InsertFreesoundConfig = z.infer<typeof insertFreesoundConfigSchema>;
export type UpdateFreesoundConfig = z.infer<typeof updateFreesoundConfigSchema>;
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<typeof insertMinimaxConfigSchema>;
export type UpdateMinimaxConfig = z.infer<typeof updateMinimaxConfigSchema>;