import { useState, useCallback, useRef, useEffect } from "react"; import { Page } from "@/components/layout/app-shell"; import { PageHeader } from "@/components/layout/page-header"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useLocation } from "wouter"; import { Send, Sparkles, Video, Music, Type, Film, Upload, Play, Pause, Volume2, VolumeX, ChevronDown, Loader2, Check, Mic, CheckCircle2, AlertTriangle, Zap, CalendarClock, Link2 } from "lucide-react"; import { Stepper, StepNavigation, type StepDef } from "@/components/stepper"; import { EmptyState } from "@/components/empty-state"; import { PlatformIcon } from "@/components/platform-icon"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { useDropzone } from "react-dropzone"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Switch } from "@/components/ui/switch"; import { StoryToggle } from "@/components/reels/story-toggle"; import { Slider } from "@/components/ui/slider"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker"; import { CaptionStylePicker } from "@/components/reels/caption-style-picker"; import { ReelPreview } from "@/components/reels/reel-preview"; import { SrtUpload, type SrtFile } from "@/components/reels/srt-upload"; import { srtText } from "@shared/srt"; import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions"; 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 { MediaThumbnail } from "@/components/media-thumbnail"; import { DateTimePicker } from "@/components/datetime-picker"; interface MusicTrack { id: string; title: string; artist: string; albumName: string; duration: number; previewUrl: string; downloadUrl: string; imageUrl: string; license: string; } // Étapes du workflow type Step = 'video' | 'music' | 'text' | 'publish'; const STEP_ORDER: Step[] = ['video', 'music', 'text', 'publish']; const REEL_STEPS: StepDef[] = [ { id: 'video', label: 'Vidéo', icon: Video }, { id: 'music', label: 'Musique', icon: Music }, { id: 'text', label: 'Texte & voix', icon: Type }, { id: 'publish', label: 'Publication', icon: Send }, ]; export default function NewReel() { const [, navigate] = useLocation(); const { toast } = useToast(); const audioRef = useRef(null); // État du workflow const [currentStep, setCurrentStep] = useState('video'); const [aiOpen, setAiOpen] = useState(false); const [scheduleMode, setScheduleMode] = useState<'now' | 'later'>('now'); // État des données const [selectedVideoId, setSelectedVideoId] = useState(null); const [selectedVideo, setSelectedVideo] = useState(null); const [selectedTrack, setSelectedTrack] = useState(null); const [overlayText, setOverlayText] = useState(''); // Fichier SRT : remplace le texte libre, la voix suit son minutage const [srtFile, setSrtFile] = useState(null); const [productInfo, setProductInfo] = useState(''); const [generatedVariants, setGeneratedVariants] = useState([]); const [selectedPages, setSelectedPages] = useState([]); const [scheduledDate, setScheduledDate] = useState(undefined); const [musicVolume, setMusicVolume] = useState([25]); const [ttsEnabled, setTtsEnabled] = useState(true); const [drawText, setDrawText] = useState(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 [alsoStory, setAlsoStory] = useState(false); const [enableEndingEffect, setEnableEndingEffect] = useState(true); const [showLogo, setShowLogo] = useState(true); // TTS Sync state const [syncInfo, setSyncInfo] = useState<{ wordDuration: number; audioDuration: number; wordCount: number; isHealthy: boolean; warnings: string[]; } | null>(null); // Voix : moteur, voix et ton de lecture const [voiceSettings, setVoiceSettings] = useState({ engine: 'gemini', voice: DEFAULT_VOICE.gemini, style: DEFAULT_TTS_STYLE, }); const { engine: ttsEngine, voice: ttsVoice, style: ttsStyle } = voiceSettings; const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); // Dernière voix générée : l'aperçu l'utilise tant que texte et réglages n'ont pas changé const [voicePreview, setVoicePreview] = useState(null); const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null; const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] }); // Enable TTS by default on mobile useEffect(() => { const isMobile = window.innerWidth < 768; if (isMobile) { setTtsEnabled(true); } }, []); // Auto-calculate TTS sync when text changes useEffect(() => { if (!ttsEnabled || !overlayText.trim() || srtFile) { setSyncInfo(null); return; } const timer = setTimeout(() => { apiRequest('POST', '/api/reels/sync-info', { text: overlayText, ttsVoice, ttsEngine, }) .then(r => r.json()) .then(data => setSyncInfo(data)) .catch(() => setSyncInfo(null)); }, 800); return () => clearTimeout(timer); }, [overlayText, ttsEnabled, ttsEngine, ttsVoice, srtFile]); // État audio preview const [isPlaying, setIsPlaying] = useState(null); // Récupérer les pages disponibles const { data: pages = [] } = useQuery({ queryKey: ['/api/pages'], }); // Destinations acceptant une vidéo verticale const facebookPages = pages.filter(p => p.platform === 'facebook'); const tiktokAccounts = pages.filter(p => p.platform === 'tiktok'); const renderTargetCheckbox = (page: SocialPage) => { const checked = selectedPages.includes(page.id); return ( ); }; // Récupérer les vidéos disponibles const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'], }); const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12); // --- Added for Internal Audio Tracks --- const { data: internalTracksResponse = [], isLoading: internalTracksLoading } = useQuery({ queryKey: ['/api/audio-tracks'], }); const internalTracks: MusicTrack[] = (internalTracksResponse || []).map(t => ({ id: `internal_${t.id}`, title: t.title, artist: t.fileName || t.title, albumName: "Bibliothèque Interne", duration: t.duration || 0, previewUrl: t.url, downloadUrl: t.url, imageUrl: "", license: "Internal" })); // --- End Internal Tracks --- // Upload vidéo const uploadMutation = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/media/upload", { method: "POST", body: formData, credentials: "include", }); if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/media/upload"); throw new Error("Upload failed"); } return response.json(); }, onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ["/api/media"] }); setSelectedVideoId(data.id); setSelectedVideo(data); toast({ title: "Succès", description: "Vidéo téléchargée avec succès", }); }, onError: () => { toast({ title: "Erreur", description: "Impossible de télécharger la vidéo", variant: "destructive", }); }, }); // Génération de texte IA const generateTextMutation = useMutation({ mutationFn: async (productInfoText: string) => { const response = await apiRequest('POST', '/api/reels/generate-text', { productInfo: productInfoText, }); return response.json(); }, onSuccess: (data: any) => { const variants = data.variants || []; setGeneratedVariants(variants); toast({ title: "Texte généré", description: `${variants.length} variations créées`, }); }, onError: (error: unknown) => { toast({ title: "Erreur", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive", }); }, }); // Création du Reel const createReelMutation = useMutation({ mutationFn: async (data: any) => { const response = await apiRequest('POST', '/api/reels', data); return response.json(); }, onSuccess: async (data) => { await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); toast({ title: "Création du Reel lancée", description: data.queued ? "Un autre Reel est en cours : le vôtre démarrera juste après. Suivez l'avancement sur le tableau de bord." : "Suivez l'avancement sur le tableau de bord.", }); navigate('/'); }, onError: (error: any) => { toast({ title: "Erreur", description: error.message || "Impossible de créer le Reel", variant: "destructive", }); }, }); const onDrop = useCallback((acceptedFiles: File[], fileRejections: any[]) => { if (fileRejections.length > 0) { const rej = fileRejections[0]; const errorMsg = `Erreur: ${rej.errors?.[0]?.message}. Type: ${rej.file.type || 'Inconnu'}. Nom: ${rej.file.name}`; console.error('❌ Fichiers rejetés:', errorMsg); // Debug mobile agressif window.alert("Fichier rejeté !\n" + errorMsg); toast({ title: "Fichier non supporté", description: errorMsg, variant: "destructive", }); return; } if (acceptedFiles.length > 0) { const file = acceptedFiles[0]; console.log('✅ Fichier accepté:', file.type, file.size); // Accepter aussi formats iOS sans type MIME standard if (file.type.startsWith('video/') || file.name.toLowerCase().endsWith('.mov') || file.name.toLowerCase().endsWith('.mp4')) { uploadMutation.mutate(file); } else { toast({ title: "Format invalide", description: `Fichier non reconnu comme vidéo (${file.type || 'sans type'}). Essayez MP4/MOV.`, variant: "destructive", }); } } }, []); const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ onDrop, accept: { 'video/mp4': ['.mp4', '.m4v'], 'video/quicktime': ['.mov', '.qt'], 'video/webm': ['.webm'], 'video/x-msvideo': ['.avi'], }, multiple: true, // Use multiple to bypass iOS Safari automatic compression maxSize: 4 * 1024 * 1024 * 1024, // 4GB Limit noClick: true, noKeyboard: true, }); const handleCameraCapture = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { uploadMutation.mutate(file); e.target.value = ''; } }; const handleSelectVideo = (media: Media) => { setSelectedVideoId(media.id); setSelectedVideo(media); setCurrentStep('music'); }; const handleSelectTrack = (track: MusicTrack) => { setSelectedTrack(track); // Arrêter la preview si en cours if (audioRef.current) { audioRef.current.pause(); } setIsPlaying(null); }; const togglePlayPreview = async (track: MusicTrack) => { if (isPlaying === track.id) { audioRef.current?.pause(); setIsPlaying(null); } else { if (audioRef.current) { console.log('🎵 Playing preview:', track.previewUrl); audioRef.current.src = track.previewUrl; try { await audioRef.current.play(); setIsPlaying(track.id); } catch (error) { console.error('❌ Audio play error:', error); toast({ title: "Erreur de lecture", description: "Impossible de lire la prévisualisation audio. Vérifiez votre connexion.", variant: "destructive", }); setIsPlaying(null); } } } }; const handleGenerateText = () => { if (!productInfo.trim()) { toast({ title: "Information manquante", description: "Veuillez saisir les informations du produit", variant: "destructive", }); return; } generateTextMutation.mutate(productInfo); }; const handleUseVariant = (text: string) => { setOverlayText(text); }; const handleCreateReel = () => { if (!selectedVideoId) { toast({ title: "Vidéo requise", description: "Veuillez sélectionner une vidéo", variant: "destructive", }); return; } if (selectedPages.length === 0) { toast({ title: "Page requise", description: "Veuillez sélectionner au moins une page", variant: "destructive", }); return; } createReelMutation.mutate({ videoMediaId: selectedVideoId, musicTrackId: selectedTrack?.id, overlayText: srtFile ? undefined : overlayText, srtCues: srtFile?.cues, description: srtFile ? srtText(srtFile.cues) : overlayText, pageIds: selectedPages, scheduledFor: scheduledDate?.toISOString(), alsoStory, musicVolume: musicVolume[0] / 100, ttsEnabled, ttsEngine, ttsVoice, ttsStyle, captionStyle, drawText, stabilize: stabilize, enableEndingEffect, showLogo, }); }; const canProceedToMusic = !!selectedVideo; const canProceedToText = canProceedToMusic; // Musique optionnelle const canProceedToPublish = canProceedToText; const canPublish = selectedPages.length > 0 && !!selectedVideo; const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; const stepIndex = STEP_ORDER.indexOf(currentStep); const lockedReason = (index: number): string | null => index > 0 && !selectedVideo ? "Sélectionnez d'abord une vidéo" : null; const goTo = (index: number) => { if (index < 0 || index >= STEP_ORDER.length) return; const reason = lockedReason(index); if (reason) { toast({ title: "Étape incomplète", description: reason, variant: "destructive" }); return; } setCurrentStep(STEP_ORDER[index]); window.scrollTo({ top: 0, behavior: "smooth" }); }; const optionRow = (id: string, label: string, description: string, checked: boolean, onChange: (v: boolean) => void) => (
); return ( <>
{/* Colonne principale */}
{/* ÉTAPE 1 : Vidéo */} {currentStep === 'video' && (
Choisissez une vidéo Une vidéo de la médiathèque ou un nouveau fichier.
{videoList.length === 0 ? ( Importer une vidéo } /> ) : (
{videoList.map((media) => ( ))}
)}
)} {/* ÉTAPE 2 : Musique */} {currentStep === 'music' && ( Ajoutez une musique Facultatif : vous pouvez garder le son d'origine de la vidéo. )} {/* ÉTAPE 3 : Texte & voix */} {currentStep === 'text' && (
Texte affiché sur la vidéo Écrivez un texte, générez-le avec l'IA ou importez des sous-titres (.srt). {srtFile ? (

Le fichier SRT remplace le texte libre. Retirez-le pour écrire un texte.

) : ( <> Générer avec l'IA Décrivez votre produit, l'IA rédige 3 propositions