feat(reels): voix Gemini complète, sous-titres mot à mot et rendu de qualité

Service ffmpeg-api réécrit (ffmpeg-service/app/) :
- Appels FFmpeg asynchrones : le service ne se fige plus pendant un rendu
- Vidéo récupérée par téléchargement (GET /files/…) au lieu de base64 en JSON
- Vraies erreurs HTTP ; échec explicite si la voix demandée est impossible
- 30 voix Gemini + ton de lecture (dynamique, chaleureux, promo, calme),
  clé en en-tête, modèle configurable avec repli
- Edge TTS 7 (minutage des mots restauré), secours en voix françaises
- Voix traitée : filtre, compression, niveau constant ; musique bouclée et
  baissée automatiquement sous la voix ; mix final à -14 LUFS
- Sous-titres calés mot à mot (Whisper pour Gemini et la voix d'origine,
  à la place de ffsubsync), style Montserrat, placés hors des boutons Reels
- Vidéo : plus de retouche luminosité forcée, scaling lanczos, HDR iPhone
  converti, BT.709, AAC 48 kHz 192k ; la vidéo s'allonge si la voix dépasse
- Grand logo de fin affiché après la voix ; FFmpeg 7.0.2 épinglé, polices et
  modèle Whisper intégrés à l'image ; tests pytest et ruff

Application :
- Sélecteur de voix partagé (4 pages) : moteur, 30 voix, ton, écoute
- Reel images : minutage réel des mots, interrupteur voix respecté
- sync-info ne génère plus de voix à chaque frappe (estimation locale)
- Stabilisation désactivée par défaut, route /reels/preview inutilisée retirée
- Log « [ReelQueue] Worker démarré » pour vérifier la version déployée

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 12:31:27 +00:00
1 parent d24ab0d4b7
commit 3c6a66bf64
47 files changed
+2206 -2285

No files matched your search

@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { Loader2, Play, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, getErrorMessage } from "@/lib/queryClient";
import {
DEFAULT_VOICE,
TTS_STYLE_OPTIONS,
voicesFor,
type TtsEngine,
type TtsStyle,
} from "@shared/voices";
export interface VoiceSettings {
engine: TtsEngine;
voice: string;
style: TtsStyle;
}
interface VoicePickerProps {
value: VoiceSettings;
onChange: (value: VoiceSettings) => void;
/** Texte lu par le bouton « Tester la voix ». */
sampleText?: string;
compact?: boolean;
}
const FALLBACK_SAMPLE = "Découvrez nos nouveautés en magasin, on vous attend !";
/**
* Choix du moteur, de la voix et du ton, avec écoute de l'aperçu.
* Partagé par les pages Reel (vidéo et images, bureau et mobile).
*/
export function VoicePicker({ value, onChange, sampleText, compact = false }: VoicePickerProps) {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => () => audioRef.current?.pause(), []);
const voices = voicesFor(value.engine);
const groups = [
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
];
const setEngine = (engine: TtsEngine) => {
if (engine !== value.engine) onChange({ ...value, engine, voice: DEFAULT_VOICE[engine] });
};
const stop = () => {
audioRef.current?.pause();
setPlaying(false);
};
const preview = async () => {
if (playing) return stop();
setLoading(true);
try {
const response = await apiRequest("POST", "/api/reels/tts-preview", {
text: sampleText?.trim() || FALLBACK_SAMPLE,
ttsEngine: value.engine,
ttsVoice: value.voice,
ttsStyle: value.style,
});
const data = await response.json();
for (const warning of data.warnings ?? []) {
toast({ title: "Voix de secours utilisée", description: warning });
}
const audio = new Audio(`data:audio/mpeg;base64,${data.audioBase64}`);
audioRef.current?.pause();
audioRef.current = audio;
audio.onended = () => setPlaying(false);
await audio.play();
setPlaying(true);
} catch (error) {
toast({
title: "Impossible de tester la voix",
description: getErrorMessage(error, "La voix n'a pas pu être générée."),
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const labelClass = compact ? "text-xs font-medium" : "text-sm font-medium";
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "gemini" ? "default" : "outline"}
onClick={() => setEngine("gemini")}
>
Gemini (naturelle)
</Button>
<Button
type="button"
size="sm"
className={compact ? "h-7 text-xs" : undefined}
variant={value.engine === "edge" ? "default" : "outline"}
onClick={() => setEngine("edge")}
>
Edge (gratuite)
</Button>
</div>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Voix</Label>
<Select value={value.voice} onValueChange={(voice) => onChange({ ...value, voice })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue placeholder="Choisir une voix" />
</SelectTrigger>
<SelectContent className="max-h-72">
{groups.map((group) => (
<SelectGroup key={group.label}>
<SelectLabel>{group.label}</SelectLabel>
{group.items.map((voice) => (
<SelectItem key={voice.id} value={voice.id}>
{voice.label}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label className={`${labelClass} w-14 shrink-0`}>Ton</Label>
<Select value={value.style} onValueChange={(style) => onChange({ ...value, style: style as TtsStyle })}>
<SelectTrigger className={compact ? "h-8 text-xs flex-1" : "flex-1"}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TTS_STYLE_OPTIONS.map((style) => (
<SelectItem key={style.id} value={style.id}>
{style.label} <span className="text-muted-foreground">— {style.description}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
variant="secondary"
className={compact ? "w-full h-8 text-xs" : "w-full"}
disabled={loading}
onClick={(e) => {
e.stopPropagation();
preview();
}}
>
{loading ? (
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
) : playing ? (
<Square className="w-3 h-3 mr-2" />
) : (
<Play className="w-3 h-3 mr-2" />
)}
{loading ? "Génération de la voix…" : playing ? "Arrêter" : "Tester la voix"}
</Button>
</div>
);
}
+17 -93
View File
@@ -16,6 +16,8 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
@@ -59,27 +61,17 @@ export default function MobileNewReel() {
// État TTS
const [ttsEnabled, setTtsEnabled] = useState(false);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// 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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [stabilize, setStabilize] = useState(true); // Activé par défaut pour les Reels
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
// TTS Sync state
@@ -271,6 +263,7 @@ export default function MobileNewReel() {
ttsEnabled,
ttsEngine,
ttsVoice,
ttsStyle,
enableEndingEffect,
});
};
@@ -492,82 +485,13 @@ export default function MobileNewReel() {
<span>TTS — voix activée</span>
</div>
{/* 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-B');
}}
>
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
size="sm"
variant="secondary"
className="w-full h-8 text-xs"
onClick={async (e) => {
e.stopPropagation();
const textToTest = overlayText || "Ceci est un test de voix.";
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`);
audio.play();
}
} catch (err) {
toast({ title: "Erreur", description: "Impossible de lire la voix", variant: "destructive" });
}
}}
>
<Play className="w-3 h-3 mr-1" /> Tester la voix
</Button>
<VoicePicker
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={overlayText}
compact
/>
</div>
{syncInfo && (
+12 -64
View File
@@ -1,5 +1,7 @@
import { useState, useRef } from "react";
import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@@ -33,23 +35,11 @@ export default function MobileRemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// 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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -143,16 +133,6 @@ export default function MobileRemotionVideoPage() {
const totalSelected = images.length + selectedLibraryImages.length;
const handleTtsPreview = async () => {
const ttsText = stripForTTS(overlayText);
if (!ttsText) return;
try {
const r = await apiRequest('POST', '/api/reels/tts-preview', { text: ttsText, ttsEngine, ttsVoice });
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" }); }
};
const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
@@ -168,8 +148,10 @@ 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);
formData.append("ttsEngine", ttsEngine);
formData.append("ttsVoice", ttsVoice);
formData.append("ttsEnabled", String(ttsEnabled));
formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style);
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)); }
@@ -354,41 +336,7 @@ export default function MobileRemotionVideoPage() {
<div className="space-y-2">
<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-B'); }}>
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}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} compact />
</div>
)}
</CardContent>
+18 -101
View File
@@ -25,6 +25,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
@@ -67,7 +69,8 @@ export default function NewReel() {
const [musicVolume, setMusicVolume] = useState([25]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [drawText, setDrawText] = useState(true);
const [stabilize, setStabilize] = useState(true); // default to true
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
// TTS Sync state
@@ -79,24 +82,13 @@ export default function NewReel() {
warnings: string[];
} | null>(null);
// TTS Engine & Voice
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// 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' },
];
// Voix : moteur, voix et ton de lecture
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings;
// Enable TTS by default on mobile
useEffect(() => {
@@ -413,6 +405,7 @@ export default function NewReel() {
ttsEnabled,
ttsEngine,
ttsVoice,
ttsStyle,
drawText,
stabilize: stabilize,
enableEndingEffect,
@@ -819,88 +812,12 @@ export default function NewReel() {
<span>TTS — voix activée</span>
</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-B');
}}
>
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">
<Button
size="sm"
variant="secondary"
className="w-full"
onClick={async (e) => {
e.stopPropagation();
const textToTest = overlayText || "Ceci est un test de voix pour votre vidéo.";
try {
const response = await apiRequest('POST', '/api/reels/tts-preview', {
text: textToTest,
ttsEngine,
ttsVoice,
});
const data = await response.json();
if (data.success && data.audioBase64) {
const audio = new Audio(`data:audio/mp3;base64,${data.audioBase64}`);
audio.play();
}
} catch (err) {
toast({
title: "Erreur",
description: "Impossible de tester la voix",
variant: "destructive"
});
}
}}
>
<Play className="w-3 h-3 mr-2" />
Tester la voix
</Button>
<div className="mt-3">
<VoicePicker
value={voiceSettings}
onChange={setVoiceSettings}
sampleText={overlayText}
/>
</div>
{syncInfo && (
+12 -64
View File
@@ -2,6 +2,8 @@ import { useState, useRef } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { useToast } from "@/hooks/use-toast";
import { VoicePicker, type VoiceSettings } from "@/components/reels/voice-picker";
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
@@ -34,23 +36,11 @@ export default function RemotionVideoPage() {
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsEngine, setTtsEngine] = useState<'edge' | 'gemini'>('edge');
const [ttsVoice, setTtsVoice] = useState('fr-FR-VivienneMultilingualNeural');
// Gemini native TTS voices (Charon = homme, Kore = femme)
const geminiVoices = [
{ label: 'Charon - Voix Homme', value: 'fr-FR-Standard-B' },
{ label: 'Kore - Voix Femme', value: 'fr-FR-Standard-A' },
];
// 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 [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
@@ -109,16 +99,6 @@ export default function RemotionVideoPage() {
});
};
const handleTtsPreview = async () => {
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, ttsEngine, ttsVoice });
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" }); }
};
const togglePlayPreview = (track: AudioTrack) => {
if (!audioRef.current) return;
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
@@ -134,8 +114,10 @@ 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);
formData.append("ttsEngine", ttsEngine);
formData.append("ttsVoice", ttsVoice);
formData.append("ttsEnabled", String(ttsEnabled));
formData.append("ttsEngine", voiceSettings.engine);
formData.append("ttsVoice", voiceSettings.voice);
formData.append("ttsStyle", voiceSettings.style);
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)); }
@@ -294,41 +276,7 @@ export default function RemotionVideoPage() {
<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>
{/* 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-B'); }}>
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}>
<Volume2 className="mr-2 w-3 h-3" /> Écouter la voix
</Button>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} />
</div>
)}
</CardContent>