mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Montage (étape 3) : - Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles : Impact, Surligné, Épuré), logo, effet de fin et fondu, en React - Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière image figée) et la piste son finale (/prepare-reel) ; Remotion compose - Reels d'images sur les mêmes composants, avec le vrai minutage de la voix - Police Montserrat embarquée (plus de dépendance à Google Fonts) - REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours - Script de pré-bundle réparé (échouait en silence : require en ESM) Interface (étape 4) : - Aperçu en direct avec @remotion/player, identique au rendu final ; la voix testée cale les sous-titres, sinon minutage estimé - Choix du style de sous-titres sur les 4 pages Reel - Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur cause ; fin de la barre simulée et du faux « publié avec succès » Outillage (étape 5) : - Tests vitest (minutage identique à Python, validation, sécurité) et pytest ; CI GitHub Actions (tsc, tests, build, ruff) - Captures d'écran, out.mp4 et scripts de test retirés de la racine Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
218 lines
6.9 KiB
TypeScript
218 lines
6.9 KiB
TypeScript
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";
|
|
import type { TimedWord } from "@shared/captions";
|
|
|
|
export interface VoiceSettings {
|
|
engine: TtsEngine;
|
|
voice: string;
|
|
style: TtsStyle;
|
|
}
|
|
|
|
/** Voix générée par « Tester la voix », réutilisée par l'aperçu vidéo. */
|
|
export interface VoicePreviewResult {
|
|
audioUrl: string;
|
|
duration: number;
|
|
words: TimedWord[];
|
|
/** Texte et réglages ayant servi : l'aperçu n'est valable que s'ils n'ont pas changé. */
|
|
text: string;
|
|
settings: VoiceSettings;
|
|
}
|
|
|
|
export function isVoicePreviewCurrent(
|
|
preview: VoicePreviewResult | null,
|
|
text: string,
|
|
settings: VoiceSettings,
|
|
): preview is VoicePreviewResult {
|
|
return (
|
|
!!preview &&
|
|
preview.text === text.trim() &&
|
|
preview.settings.engine === settings.engine &&
|
|
preview.settings.voice === settings.voice &&
|
|
preview.settings.style === settings.style
|
|
);
|
|
}
|
|
|
|
interface VoicePickerProps {
|
|
value: VoiceSettings;
|
|
onChange: (value: VoiceSettings) => void;
|
|
/** Texte lu par le bouton « Tester la voix ». */
|
|
sampleText?: string;
|
|
compact?: boolean;
|
|
/** Appelé avec la voix générée (audio et minutage des mots). */
|
|
onPreview?: (result: VoicePreviewResult) => void;
|
|
}
|
|
|
|
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, onPreview }: 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 text = sampleText?.trim() || FALLBACK_SAMPLE;
|
|
const response = await apiRequest("POST", "/api/reels/tts-preview", {
|
|
text,
|
|
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 audioUrl = `data:audio/mpeg;base64,${data.audioBase64}`;
|
|
if (sampleText?.trim()) {
|
|
onPreview?.({ audioUrl, duration: data.duration, words: data.words ?? [], text, settings: value });
|
|
}
|
|
const audio = new Audio(audioUrl);
|
|
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>
|
|
);
|
|
}
|