mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
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
This commit is contained in:
1 parent
59bc56aedc
commit
72b13b3e42
12 files changed
+605
-29
No files matched your search
@@ -58,6 +58,29 @@ export default function MobileNewReel() {
|
|||||||
|
|
||||||
// État TTS
|
// État TTS
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(false);
|
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
|
// État audio preview
|
||||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||||
@@ -82,14 +105,15 @@ export default function MobileNewReel() {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
apiRequest('POST', '/api/reels/sync-info', {
|
apiRequest('POST', '/api/reels/sync-info', {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
voice: "fr-FR-VivienneMultilingualNeural",
|
ttsEngine,
|
||||||
|
ttsVoice,
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => setSyncInfo(data))
|
.then(data => setSyncInfo(data))
|
||||||
.catch(() => setSyncInfo(null));
|
.catch(() => setSyncInfo(null));
|
||||||
}, 800);
|
}, 800);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [overlayText, ttsEnabled]);
|
}, [overlayText, ttsEnabled, ttsEngine, ttsVoice]);
|
||||||
|
|
||||||
|
|
||||||
const { data: pages = [] } = useQuery<SocialPage[]>({
|
const { data: pages = [] } = useQuery<SocialPage[]>({
|
||||||
@@ -230,6 +254,8 @@ export default function MobileNewReel() {
|
|||||||
stabilize: stabilize,
|
stabilize: stabilize,
|
||||||
drawText: true,
|
drawText: true,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
|
ttsEngine,
|
||||||
|
ttsVoice,
|
||||||
enableEndingEffect,
|
enableEndingEffect,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -450,7 +476,58 @@ export default function MobileNewReel() {
|
|||||||
<Mic className="w-4 h-4" />
|
<Mic className="w-4 h-4" />
|
||||||
<span>TTS — voix activée</span>
|
<span>TTS — voix activée</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
|
||||||
|
{/* TTS Engine Selector */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Label className="text-xs font-medium">Moteur:</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setTtsEngine('edge');
|
||||||
|
setTtsVoice('fr-FR-VivienneMultilingualNeural');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edge
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
|
||||||
|
className="h-7 text-xs px-2"
|
||||||
|
onClick={() => {
|
||||||
|
setTtsEngine('gemini');
|
||||||
|
setTtsVoice('fr-FR-Standard-A');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gemini
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Selector */}
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||||
|
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||||
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ttsEngine === 'edge' ? (
|
||||||
|
edgeVoices.map(v => (
|
||||||
|
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
geminiVoices.map(v => (
|
||||||
|
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -461,6 +538,8 @@ export default function MobileNewReel() {
|
|||||||
try {
|
try {
|
||||||
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
||||||
text: textToTest,
|
text: textToTest,
|
||||||
|
ttsEngine,
|
||||||
|
ttsVoice,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.audioBase64) {
|
if (data.success && data.audioBase64) {
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const [productInfo, setProductInfo] = useState("");
|
const [productInfo, setProductInfo] = useState("");
|
||||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
|
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' },
|
||||||
|
];
|
||||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||||
@@ -111,7 +134,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const ttsText = stripForTTS(overlayText);
|
const ttsText = stripForTTS(overlayText);
|
||||||
if (!ttsText) return;
|
if (!ttsText) return;
|
||||||
try {
|
try {
|
||||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
|
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
||||||
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
||||||
@@ -312,6 +335,39 @@ export default function MobileRemotionVideoPage() {
|
|||||||
{ttsEnabled && (
|
{ttsEnabled && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||||
|
|
||||||
|
{/* TTS Engine Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs font-medium">Moteur:</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs px-2"
|
||||||
|
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
|
||||||
|
Edge
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs px-2"
|
||||||
|
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
|
||||||
|
Gemini
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||||
|
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||||
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ttsEngine === 'edge' ? (
|
||||||
|
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||||
|
) : (
|
||||||
|
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
||||||
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -78,6 +78,31 @@ export default function NewReel() {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
} | null>(null);
|
} | 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
|
// Enable TTS by default on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isMobile = window.innerWidth < 768;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!ttsEnabled || !overlayText.trim()) {
|
if (!ttsEnabled || !overlayText.trim()) {
|
||||||
setSyncInfo(null);
|
setSyncInfo(null);
|
||||||
@@ -95,14 +120,14 @@ export default function NewReel() {
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
apiRequest('POST', '/api/reels/sync-info', {
|
apiRequest('POST', '/api/reels/sync-info', {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
voice: "fr-FR-VivienneMultilingualNeural",
|
voice: ttsVoice,
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => setSyncInfo(data))
|
.then(data => setSyncInfo(data))
|
||||||
.catch(() => setSyncInfo(null));
|
.catch(() => setSyncInfo(null));
|
||||||
}, 800);
|
}, 800);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [overlayText, ttsEnabled]);
|
}, [overlayText, ttsEnabled, ttsVoice]);
|
||||||
|
|
||||||
|
|
||||||
// État audio preview
|
// État audio preview
|
||||||
@@ -360,6 +385,8 @@ export default function NewReel() {
|
|||||||
scheduledFor: scheduledDate?.toISOString(),
|
scheduledFor: scheduledDate?.toISOString(),
|
||||||
musicVolume: musicVolume[0] / 100,
|
musicVolume: musicVolume[0] / 100,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
|
ttsEngine,
|
||||||
|
ttsVoice,
|
||||||
drawText,
|
drawText,
|
||||||
stabilize: stabilize,
|
stabilize: stabilize,
|
||||||
enableEndingEffect,
|
enableEndingEffect,
|
||||||
@@ -765,6 +792,57 @@ export default function NewReel() {
|
|||||||
<span>TTS — voix activée</span>
|
<span>TTS — voix activée</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TTS Engine Selector */}
|
||||||
|
<div className="flex items-center gap-4 mt-3">
|
||||||
|
<Label className="text-sm font-medium">Moteur:</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ttsEngine === 'edge' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setTtsEngine('edge');
|
||||||
|
setTtsVoice('fr-FR-VivienneMultilingualNeural');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edge TTS
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={ttsEngine === 'gemini' ? 'default' : 'outline'}
|
||||||
|
onClick={() => {
|
||||||
|
setTtsEngine('gemini');
|
||||||
|
setTtsVoice('fr-FR-Standard-A');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Gemini TTS
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Selector */}
|
||||||
|
<div className="flex items-center gap-3 mt-3">
|
||||||
|
<Label className="text-sm font-medium shrink-0">Voix:</Label>
|
||||||
|
<Select
|
||||||
|
value={ttsVoice}
|
||||||
|
onValueChange={setTtsVoice}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ttsEngine === 'edge' ? (
|
||||||
|
edgeVoices.map(v => (
|
||||||
|
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
geminiVoices.map(v => (
|
||||||
|
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -776,6 +854,8 @@ export default function NewReel() {
|
|||||||
try {
|
try {
|
||||||
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
||||||
text: textToTest,
|
text: textToTest,
|
||||||
|
ttsEngine,
|
||||||
|
ttsVoice,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.audioBase64) {
|
if (data.success && data.audioBase64) {
|
||||||
|
|||||||
@@ -32,6 +32,29 @@ export default function RemotionVideoPage() {
|
|||||||
const [productInfo, setProductInfo] = useState("");
|
const [productInfo, setProductInfo] = useState("");
|
||||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
|
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' },
|
||||||
|
];
|
||||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||||
@@ -78,7 +101,7 @@ export default function RemotionVideoPage() {
|
|||||||
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
const ttsText = overlayText.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
if (!ttsText) return;
|
if (!ttsText) return;
|
||||||
try {
|
try {
|
||||||
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText });
|
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
if (data.success && data.audioBase64) new window.Audio(`data:audio/mp3;base64,${data.audioBase64}`).play();
|
||||||
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
} catch { toast({ title: "Erreur prévisualisation voix", variant: "destructive" }); }
|
||||||
@@ -253,6 +276,39 @@ export default function RemotionVideoPage() {
|
|||||||
{ttsEnabled && (
|
{ttsEnabled && (
|
||||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||||
|
|
||||||
|
{/* TTS Engine Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs font-medium">Moteur:</Label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant={ttsEngine === 'edge' ? 'default' : 'outline'} className="h-7 text-xs"
|
||||||
|
onClick={() => { setTtsEngine('edge'); setTtsVoice('fr-FR-VivienneMultilingualNeural'); }}>
|
||||||
|
Edge
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={ttsEngine === 'gemini' ? 'default' : 'outline'} className="h-7 text-xs"
|
||||||
|
onClick={() => { setTtsEngine('gemini'); setTtsVoice('fr-FR-Standard-A'); }}>
|
||||||
|
Gemini
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Selector */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label className="text-xs font-medium shrink-0">Voix:</Label>
|
||||||
|
<Select value={ttsVoice} onValueChange={setTtsVoice}>
|
||||||
|
<SelectTrigger className="h-7 text-xs flex-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ttsEngine === 'edge' ? (
|
||||||
|
edgeVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||||
|
) : (
|
||||||
|
geminiVoices.map(v => <SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
<Button size="sm" variant="outline" className="w-full" onClick={handleTtsPreview} disabled={!overlayText}>
|
||||||
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default function Settings() {
|
|||||||
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
||||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||||
const [externalApiKey, setExternalApiKey] = useState("");
|
const [externalApiKey, setExternalApiKey] = useState("");
|
||||||
|
const [geminiApiKey, setGeminiApiKey] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||||
@@ -60,6 +61,11 @@ export default function Settings() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: geminiConfig } = useQuery({
|
||||||
|
queryKey: ['/api/settings/gemini'],
|
||||||
|
enabled: isAdmin,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cloudinaryConfig) {
|
if (cloudinaryConfig) {
|
||||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
@@ -86,9 +92,16 @@ export default function Settings() {
|
|||||||
}
|
}
|
||||||
}, [ffmpegConfig]);
|
}, [ffmpegConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (geminiConfig) {
|
||||||
|
// Don't pre-fill - user must enter key manually
|
||||||
|
}
|
||||||
|
}, [geminiConfig]);
|
||||||
|
|
||||||
// Vérifier si une config OpenRouter existe déjà
|
// Vérifier si une config OpenRouter existe déjà
|
||||||
const hasExistingConfig = !!openrouterConfig;
|
const hasExistingConfig = !!openrouterConfig;
|
||||||
const hasExistingFfmpegConfig = !!ffmpegConfig;
|
const hasExistingFfmpegConfig = !!ffmpegConfig;
|
||||||
|
const hasExistingGeminiConfig = !!(geminiConfig as any)?.configured;
|
||||||
|
|
||||||
const saveCloudinaryMutation = useMutation({
|
const saveCloudinaryMutation = useMutation({
|
||||||
mutationFn: () => {
|
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({
|
const uploadLogoMutation = useMutation({
|
||||||
mutationFn: async (file: File) => {
|
mutationFn: async (file: File) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -521,6 +559,52 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Google Gemini TTS */}
|
||||||
|
{isAdmin && (
|
||||||
|
<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" />
|
||||||
|
Google Gemini TTS
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configurez votre clé API Google pour utiliser Gemini TTS dans les Reels
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="geminiApiKey">Clé API Google Gemini</Label>
|
||||||
|
<Input
|
||||||
|
id="geminiApiKey"
|
||||||
|
type="password"
|
||||||
|
value={geminiApiKey}
|
||||||
|
onChange={(e) => setGeminiApiKey(e.target.value)}
|
||||||
|
placeholder={hasExistingGeminiConfig ? "••••••••••••••••" : "AIza..."}
|
||||||
|
data-testid="input-gemini-api-key"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasExistingGeminiConfig ? (
|
||||||
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée</span>
|
||||||
|
) : (
|
||||||
|
"Obtenez une clé API sur console.cloud.google.com → APIs & Services → Credentials"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveGeminiMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
saveGeminiMutation.isPending ||
|
||||||
|
(!hasExistingGeminiConfig && !geminiApiKey)
|
||||||
|
}
|
||||||
|
data-testid="button-save-gemini"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveGeminiMutation.isPending ? "Enregistrement..." : "Enregistrer Gemini TTS"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
|
|||||||
+159
-15
@@ -159,6 +159,8 @@ class ReelRequest(BaseModel):
|
|||||||
music_volume: float = 0.25
|
music_volume: float = 0.25
|
||||||
tts_enabled: bool = False
|
tts_enabled: bool = False
|
||||||
tts_voice: str = "fr-FR-VivienneMultilingualNeural"
|
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
|
draw_text: bool = True
|
||||||
stabilize: bool = False # Stabilisation vidéo via vidstab
|
stabilize: bool = False # Stabilisation vidéo via vidstab
|
||||||
enable_ending_effect: bool = True
|
enable_ending_effect: bool = True
|
||||||
@@ -192,6 +194,123 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
return " ".join(text.split())
|
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(
|
async def generate_tts_with_subs(
|
||||||
text: str,
|
text: str,
|
||||||
voice: str,
|
voice: str,
|
||||||
@@ -807,14 +926,27 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
|
|
||||||
if tts_clean_text:
|
if tts_clean_text:
|
||||||
print(f"🔊 Generating TTS audio to: {tts_audio_path}")
|
print(f"🔊 Generating TTS audio to: {tts_audio_path}")
|
||||||
await generate_tts_with_subs(
|
engine = request.tts_engine or "edge"
|
||||||
tts_clean_text,
|
|
||||||
voice,
|
if engine == "gemini" and request.gemini_api_key:
|
||||||
tts_audio_path,
|
await generate_tts_gemini(
|
||||||
tts_ass_path,
|
tts_clean_text,
|
||||||
display_text=clean_text_for_display(request.text),
|
voice,
|
||||||
delay=2.0,
|
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
|
# Verify files were created
|
||||||
if tts_audio_path.exists() and tts_audio_path.stat().st_size > 0:
|
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:
|
elif not voice or "Neural" not in voice:
|
||||||
voice = "fr-FR-VivienneMultilingualNeural"
|
voice = "fr-FR-VivienneMultilingualNeural"
|
||||||
|
|
||||||
await generate_tts_with_subs(
|
engine = request.tts_engine or "edge"
|
||||||
clean_text,
|
|
||||||
voice,
|
if engine == "gemini" and request.gemini_api_key:
|
||||||
tts_audio_path,
|
await generate_tts_gemini(
|
||||||
tts_srt_path,
|
clean_text,
|
||||||
display_text=clean_text_for_display(request.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():
|
if not tts_audio_path.exists():
|
||||||
raise Exception("TTS generation failed (file missing)")
|
raise Exception("TTS generation failed (file missing)")
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ pydantic==2.6.0
|
|||||||
edge-tts==6.1.12
|
edge-tts==6.1.12
|
||||||
emoji
|
emoji
|
||||||
ffsubsync==0.4.26
|
ffsubsync==0.4.26
|
||||||
|
httpx>=0.25.0
|
||||||
@@ -420,6 +420,41 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Analytics Routes
|
||||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||||
|
|
||||||
|
|||||||
+29
-3
@@ -270,6 +270,8 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
musicUrl,
|
musicUrl,
|
||||||
overlayText,
|
overlayText,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
|
ttsVoice,
|
||||||
|
ttsEngine,
|
||||||
wordDuration = 0.6,
|
wordDuration = 0.6,
|
||||||
fontSize = 64,
|
fontSize = 64,
|
||||||
musicVolume = 0.25,
|
musicVolume = 0.25,
|
||||||
@@ -318,6 +320,13 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
console.error('Error fetching watermark configuration', e);
|
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;
|
const finalWordDuration = wordDuration;
|
||||||
|
|
||||||
// Traiter la vidéo via FFmpeg
|
// Traiter la vidéo via FFmpeg
|
||||||
@@ -326,6 +335,8 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
musicUrl: finalMusicUrl,
|
musicUrl: finalMusicUrl,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsEngine,
|
||||||
|
geminiApiKey,
|
||||||
wordDuration: finalWordDuration,
|
wordDuration: finalWordDuration,
|
||||||
fontSize,
|
fontSize,
|
||||||
musicVolume,
|
musicVolume,
|
||||||
@@ -358,13 +369,13 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
const { text } = req.body;
|
const { text, ttsVoice, ttsEngine } = req.body;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return res.status(400).json({ error: 'Texte requis' });
|
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) {
|
if (!result.success) {
|
||||||
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
||||||
@@ -455,6 +466,8 @@ async function processReelBackground(
|
|||||||
overlayText?: string;
|
overlayText?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
|
ttsVoice?: string;
|
||||||
|
ttsEngine?: string;
|
||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
scheduledFor?: string;
|
scheduledFor?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
@@ -473,6 +486,8 @@ async function processReelBackground(
|
|||||||
overlayText,
|
overlayText,
|
||||||
description,
|
description,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
|
ttsVoice,
|
||||||
|
ttsEngine,
|
||||||
pageIds,
|
pageIds,
|
||||||
scheduledFor,
|
scheduledFor,
|
||||||
wordDuration,
|
wordDuration,
|
||||||
@@ -541,15 +556,24 @@ async function processReelBackground(
|
|||||||
console.error('Error fetching watermark configuration', e);
|
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;
|
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), {
|
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
musicUrl: finalMusicUrl,
|
musicUrl: finalMusicUrl,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsEngine,
|
||||||
|
geminiApiKey,
|
||||||
wordDuration: finalWordDuration,
|
wordDuration: finalWordDuration,
|
||||||
fontSize,
|
fontSize,
|
||||||
musicVolume,
|
musicVolume,
|
||||||
@@ -713,6 +737,8 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
|||||||
overlayText,
|
overlayText,
|
||||||
description,
|
description,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
|
ttsVoice,
|
||||||
|
ttsEngine,
|
||||||
pageIds,
|
pageIds,
|
||||||
scheduledFor,
|
scheduledFor,
|
||||||
wordDuration = 0.6,
|
wordDuration = 0.6,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface FFmpegReelRequest {
|
|||||||
music_url?: string; // OU URL directe de la musique
|
music_url?: string; // OU URL directe de la musique
|
||||||
tts_enabled?: boolean; // Activation du TTS
|
tts_enabled?: boolean; // Activation du TTS
|
||||||
tts_voice?: string; // Voix TTS (ex: fr-FR-VivienneNeural)
|
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)
|
word_duration?: number; // Durée par mot (default: 0.6s)
|
||||||
font_size?: number; // Taille police (default: 24)
|
font_size?: number; // Taille police (default: 24)
|
||||||
music_volume?: number; // Volume musique (default: 0.25)
|
music_volume?: number; // Volume musique (default: 0.25)
|
||||||
@@ -72,6 +74,8 @@ export class FFmpegService {
|
|||||||
musicUrl?: string;
|
musicUrl?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
ttsVoice?: string;
|
ttsVoice?: string;
|
||||||
|
ttsEngine?: string;
|
||||||
|
geminiApiKey?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
@@ -91,6 +95,8 @@ export class FFmpegService {
|
|||||||
music_url: options.musicUrl,
|
music_url: options.musicUrl,
|
||||||
tts_enabled: options.ttsEnabled,
|
tts_enabled: options.ttsEnabled,
|
||||||
tts_voice: options.ttsVoice,
|
tts_voice: options.ttsVoice,
|
||||||
|
tts_engine: options.ttsEngine,
|
||||||
|
gemini_api_key: options.geminiApiKey,
|
||||||
word_duration: options.wordDuration ?? 0.6,
|
word_duration: options.wordDuration ?? 0.6,
|
||||||
font_size: options.fontSize ?? 64,
|
font_size: options.fontSize ?? 64,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
@@ -173,6 +179,8 @@ export class FFmpegService {
|
|||||||
musicUrl?: string;
|
musicUrl?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
ttsVoice?: string;
|
ttsVoice?: string;
|
||||||
|
ttsEngine?: string;
|
||||||
|
geminiApiKey?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
@@ -192,6 +200,8 @@ export class FFmpegService {
|
|||||||
music_url: options.musicUrl,
|
music_url: options.musicUrl,
|
||||||
tts_enabled: options.ttsEnabled,
|
tts_enabled: options.ttsEnabled,
|
||||||
tts_voice: options.ttsVoice,
|
tts_voice: options.ttsVoice,
|
||||||
|
tts_engine: options.ttsEngine,
|
||||||
|
gemini_api_key: options.geminiApiKey,
|
||||||
word_duration: options.wordDuration ?? 0.6,
|
word_duration: options.wordDuration ?? 0.6,
|
||||||
font_size: options.fontSize ?? 64,
|
font_size: options.fontSize ?? 64,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
@@ -290,7 +300,9 @@ export class FFmpegService {
|
|||||||
}
|
}
|
||||||
async previewTTS(
|
async previewTTS(
|
||||||
text: string,
|
text: string,
|
||||||
ttsVoice?: string
|
ttsVoice?: string,
|
||||||
|
ttsEngine?: string,
|
||||||
|
geminiApiKey?: string
|
||||||
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||||
const config = this.ensureConfigured();
|
const config = this.ensureConfigured();
|
||||||
|
|
||||||
@@ -305,6 +317,8 @@ export class FFmpegService {
|
|||||||
text,
|
text,
|
||||||
tts_enabled: true,
|
tts_enabled: true,
|
||||||
tts_voice: ttsVoice,
|
tts_voice: ttsVoice,
|
||||||
|
tts_engine: ttsEngine,
|
||||||
|
gemini_api_key: geminiApiKey,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -124,7 +124,7 @@ export interface IStorage {
|
|||||||
|
|
||||||
// App Config
|
// App Config
|
||||||
getAppConfig(): Promise<AppConfig | undefined>;
|
getAppConfig(): Promise<AppConfig | undefined>;
|
||||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): Promise<AppConfig>;
|
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@@ -571,7 +571,7 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return config || undefined;
|
return config || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey'>>): Promise<AppConfig> {
|
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
||||||
const existing = await this.getAppConfig();
|
const existing = await this.getAppConfig();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await db.update(appConfig)
|
const [updated] = await db.update(appConfig)
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export const musicFavorites = pgTable("music_favorites", {
|
|||||||
export const appConfig = pgTable("app_config", {
|
export const appConfig = pgTable("app_config", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
externalApiKey: text("external_api_key"),
|
externalApiKey: text("external_api_key"),
|
||||||
|
geminiApiKey: text("gemini_api_key"),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user