mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 04:36:32 +02:00
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user