diff --git a/SOCIALFLOW_API_DOCUMENTATION.md b/SOCIALFLOW_API_DOCUMENTATION.md index 6c1ed19..263da7e 100644 --- a/SOCIALFLOW_API_DOCUMENTATION.md +++ b/SOCIALFLOW_API_DOCUMENTATION.md @@ -42,13 +42,12 @@ La clé externe peut être configurée par un administrateur depuis l'interface 5. [Gestion de l'Audio (Musique de fond)](#5-gestion-de-laudio-musique-de-fond) 6. [Publications & Planification](#6-publications--planification) 7. [Reels & Génération Vidéo (FFmpeg)](#7-reels--génération-vidéo-ffmpeg) -8. [Génération Vidéo Avancée (Remotion)](#8-génération-vidéo-avancée-remotion) -9. [IA & Modèles (OpenRouter)](#9-ia--modèles-openrouter) -10. [Statistiques & Paramètres Système](#10-statistiques--paramètres-système) -11. [Console SQL (Admin uniquement)](#11-console-sql-admin-uniquement) -12. [API d'Analyse (Analytics)](#12-api-danalyse-analytics) -13. [API Développeur Externe (v1)](#13-api-développeur-externe-v1) -14. [Microservice Interne FFmpeg (Python)](#14-microservice-interne-ffmpeg-python) +8. [IA & Modèles (OpenRouter)](#8-ia--modèles-openrouter) +9. [Statistiques & Paramètres Système](#9-statistiques--paramètres-système) +10. [Console SQL (Admin uniquement)](#10-console-sql-admin-uniquement) +11. [API d'Analyse (Analytics)](#11-api-danalyse-analytics) +12. [API Développeur Externe (v1)](#12-api-développeur-externe-v1) +13. [Microservice Interne FFmpeg (Python)](#13-microservice-interne-ffmpeg-python) --- @@ -727,72 +726,7 @@ GET /api/reels/status/:postId --- -## 8. Génération Vidéo Avancée (Remotion) - -Remotion permet d'assembler des diaporamas complexes et des vidéos à partir d'images à l'aide d'un navigateur Chromium sans tête (headless). - -### Lancer un rendu de vidéo diaporama Remotion -```http -POST /api/remotion/render -``` -**Body (Multipart Form) :** -- `existingImageUrls` : (Facultatif) Tableau d'URL d'images déjà enregistrées en base (converties directement en base64 pour contourner les limitations de requêtes réseau Chromium sous Docker). -- `images` : Fichiers images téléversés en direct. -- `music` : Fichier de musique téléversé. -- `musicTrackUrl` : (Facultatif) URL de musique existante. -- `overlayText` : Texte de sous-titre pour la synthèse vocale intégrée. -- `musicVolume` : Volume sonore (ex: `0.3`). - -**Réponse (200 OK) :** -```json -{ - "jobId": "remotion_job_171691", - "message": "Rendu démarré en arrière-plan" -} -``` - -### Vérifier le statut du rendu Remotion -```http -GET /api/remotion/render/status/:jobId -``` -**Réponse (200 OK) :** -```json -{ - "status": "done", - "url": "/uploads/temp/remotion-171691.mp4", - "thumbnailUrl": "/uploads/temp/remotion-171691-thumb.jpg", - "error": null -} -``` - -### Publier le diaporama généré comme Reel -Téléverse le fichier MP4 finalisé vers le stockage puis publie la vidéo sur les pages cibles. -```http -POST /api/remotion/publish -``` -**Body (JSON) :** -```json -{ - "videoUrl": "/uploads/temp/remotion-171691.mp4", - "pageIds": ["page_999"], - "description": "Mon diaporama animé créé avec Remotion !", - "scheduledFor": "2026-06-01T12:00:00Z" -} -``` -**Réponse (200 OK) :** -```json -{ - "success": true, - "postId": "pst_remotion_999", - "results": [ - { "pageId": "page_999", "success": true, "reelId": "scheduled" } - ] -} -``` - ---- - -## 9. IA & Modèles (OpenRouter) +## 8. IA & Modèles (OpenRouter) ### Liste des modèles d'écriture IA disponibles ```http @@ -839,7 +773,7 @@ GET /api/ai/generations --- -## 10. Statistiques & Paramètres Système +## 9. Statistiques & Paramètres Système ### Récupérer les statistiques du tableau de bord Calcule les publications planifiées, le nombre de pages connectées, le total de textes générés, l'espace de stockage des médias et les tendances d'évolution. @@ -888,7 +822,7 @@ GET /api/stats --- -## 11. Console SQL (Admin uniquement) +## 10. Console SQL (Admin uniquement) Ces outils exclusifs aux administrateurs permettent de déboguer ou de faire des opérations de maintenance directement sur la base de données PostgreSQL. @@ -931,7 +865,7 @@ GET /api/sql/tables --- -## 12. API d'Analyse (Analytics) +## 11. API d'Analyse (Analytics) Récupération et mise à jour des statistiques de performance des publications et des pages (portée, clics, croissance d'abonnés). @@ -1006,7 +940,7 @@ POST /api/analytics/tokens/check --- -## 13. API Développeur Externe (v1) +## 12. API Développeur Externe (v1) Ces routes sont optimisées pour les appels de scripts d'intégration tierce. Toutes les requêtes doivent contenir le header `X-API-Key`. @@ -1103,7 +1037,7 @@ DELETE /api/v1/posts/:id --- -## 14. Microservice Interne FFmpeg (Python) +## 13. Microservice Interne FFmpeg (Python) Ce service s'exécute sur le port `8000` et gère le pipeline de rendu lourd FFmpeg. diff --git a/client/src/App.tsx b/client/src/App.tsx index 69356b1..e2c1323 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -87,11 +87,6 @@ const ScheduleReel = createResponsiveRoute( () => import("@/pages/schedule-reel") ); -const RemotionVideo = createResponsiveRoute( - () => import("@/pages/remotion-video"), - () => import("@/pages/mobile/remotion-video") -); - function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) { const [, setLocation] = useLocation(); @@ -143,7 +138,6 @@ function Router() { {() => } {() => } {() => } - {() => } {() => } {() => } {() => } diff --git a/client/src/components/ongoing-reels.tsx b/client/src/components/ongoing-reels.tsx index f90b972..5399521 100644 --- a/client/src/components/ongoing-reels.tsx +++ b/client/src/components/ongoing-reels.tsx @@ -1,7 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; -import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react"; +import { Clapperboard, Loader2, CheckCircle2, XCircle, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient, getErrorMessage } from "@/lib/queryClient"; import type { Post } from "@shared/schema"; type OngoingReel = Post & { generationStep?: string | null }; @@ -35,6 +38,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) { (query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000, }); + const { toast } = useToast(); + + // Un Reel en échec n'a rien à publier : le supprimer retire aussi la notification + const dismissMutation = useMutation({ + mutationFn: async (postId: string) => (await apiRequest("DELETE", `/api/reels/${postId}`)).json(), + onSuccess: (_data, postId) => { + queryClient.setQueryData(["/api/reels/ongoing"], (posts) => + posts?.filter((p) => p.id !== postId), + ); + queryClient.invalidateQueries({ queryKey: ["/api/reels/ongoing"] }); + queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"] }); + }, + onError: (error: unknown) => { + toast({ + title: "Erreur", + description: getErrorMessage(error, "Impossible de supprimer la notification"), + variant: "destructive", + }); + }, + }); + // Nothing to show — render nothing (not even a card) if (ongoingPosts.length === 0) { return null; @@ -112,6 +136,21 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) { )} + + {isFailed && ( + + )} ); })} diff --git a/client/src/components/reels/reel-preview.tsx b/client/src/components/reels/reel-preview.tsx index 4d0b508..d298798 100644 --- a/client/src/components/reels/reel-preview.tsx +++ b/client/src/components/reels/reel-preview.tsx @@ -7,7 +7,6 @@ import { REEL_WIDTH, VOICE_DELAY, cleanCaptionText, - computeImagesTiming, computeReelTiming, offsetWords, spreadWords, @@ -16,7 +15,6 @@ import { } from "@shared/captions"; import { srtEnd, srtWords, type SrtCue } from "@shared/srt"; import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo"; -import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition"; import type { VoicePreviewResult } from "./voice-picker"; /** Débit moyen d'une voix de synthèse française (estimation avant génération). */ @@ -40,9 +38,7 @@ interface CommonProps { srtCues?: SrtCue[] | null; } -type ReelPreviewProps = - | (CommonProps & { kind: "video"; videoUrl: string }) - | (CommonProps & { kind: "images"; images: string[] }); +type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string }; /** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */ function useVideoDuration(url: string | undefined): number | null { @@ -75,12 +71,12 @@ function estimatedVoiceDuration(text: string): number { * Sans voix générée, le minutage des mots est estimé. */ export function ReelPreview(props: ReelPreviewProps) { - const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined); + const videoDuration = useVideoDuration(props.videoUrl); const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } = props; const logoUrl = props.logoUrl ?? undefined; const storeName = endingEffect ? props.storeName || undefined : undefined; - const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null; + const srtCues = props.srtCues?.length ? props.srtCues : null; const cleanText = cleanCaptionText(text); const voiceDuration = srtCues ? ttsEnabled ? srtEnd(srtCues) : 0 @@ -88,34 +84,6 @@ export function ReelPreview(props: ReelPreviewProps) { const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice; const composition = useMemo(() => { - if (props.kind === "images") { - if (props.images.length === 0) return null; - const { total, endingSeconds } = computeImagesTiming({ - imageCount: props.images.length, - voiceDuration, - hasEnding: Boolean(logoUrl || storeName), - }); - const contentEnd = total - endingSeconds; - let words: TimedWord[] = []; - if (showCaptions && cleanText) { - words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8)); - } - const inputProps: ImageCompositionProps = { - images: props.images, - totalDuration: total, - words, - captionStyle, - audioUrl: voice?.audioUrl, - musicUrl, - musicVolume, - logoUrl, - showWatermark, - storeName, - endingSeconds, - }; - return { component: ImageComposition, inputProps, total }; - } - if (videoDuration == null) return null; const timing = computeReelTiming({ videoDuration, @@ -153,8 +121,7 @@ export function ReelPreview(props: ReelPreviewProps) { return { component: ReelVideo, inputProps, total: timing.total }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - props.kind, - props.kind === "video" ? props.videoUrl : props.images.join("|"), + props.videoUrl, videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration, musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect, ]); @@ -172,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) {
>} inputProps={composition.inputProps as unknown as Record} durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))} diff --git a/client/src/components/sidebar.tsx b/client/src/components/sidebar.tsx index 7cf0735..18b6678 100644 --- a/client/src/components/sidebar.tsx +++ b/client/src/components/sidebar.tsx @@ -70,13 +70,12 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) { const navItems = [ { icon: Home, label: "Tableau de bord", href: "/", badge: null }, { icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null }, - { icon: Video, label: "Nouveau Reel", href: "/reel", badge: "New" }, - { icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: "New" }, - { icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" }, + { icon: Video, label: "Nouveau Reel", href: "/reel", badge: null }, + { icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null }, { icon: Calendar, label: "Calendrier", href: "/calendar", badge: null }, { icon: Images, label: "Médiathèque", href: "/media", badge: null }, { icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null }, - { icon: BarChart3, label: "Analytics", href: "/analytics", badge: "New" }, + { icon: BarChart3, label: "Analytics", href: "/analytics", badge: null }, ]; const statsItems = [ diff --git a/client/src/pages/mobile/dashboard.tsx b/client/src/pages/mobile/dashboard.tsx index c232396..7c0d4ac 100644 --- a/client/src/pages/mobile/dashboard.tsx +++ b/client/src/pages/mobile/dashboard.tsx @@ -13,7 +13,6 @@ import { Users, ChevronRight, Sparkles, - Film, CalendarClock } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -31,7 +30,6 @@ export default function DashboardMobile() { const secondaryMenu = [ { icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" }, - { icon: Film, label: "Générateur Vidéo", href: "/remotion" }, { icon: Images, label: "Médiathèque", href: "/media" }, { icon: Wand2, label: "Éditeur d'images", href: "/image-editor" }, { icon: Clock, label: "Historique", href: "/history" }, diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx deleted file mode 100644 index d0a4a51..0000000 --- a/client/src/pages/mobile/remotion-video.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { useEffect, useMemo, useState, useRef } from "react"; -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 { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions"; -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"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Slider } from "@/components/ui/slider"; -import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react"; -import { MediaThumbnail } from "@/components/media-thumbnail"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; -import Sidebar from "@/components/sidebar"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { DateTimePicker } from "@/components/datetime-picker"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient"; -import type { Media, SocialPage } from "@shared/schema"; -import { SiFacebook, SiTiktok } from "react-icons/si"; - -interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; } - - -function stripForTTS(text: string) { - return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim(); -} - - -export default function MobileRemotionVideoPage() { - const [images, setImages] = useState([]); - const [selectedLibraryImages, setSelectedLibraryImages] = useState([]); - const [overlayText, setOverlayText] = useState(""); - const [productInfo, setProductInfo] = useState(""); - const [generatedVariants, setGeneratedVariants] = useState([]); - const [ttsEnabled, setTtsEnabled] = useState(true); - const [voiceSettings, setVoiceSettings] = useState({ - engine: 'gemini', - voice: DEFAULT_VOICE.gemini, - style: DEFAULT_TTS_STYLE, - }); - const [musicFile, setMusicFile] = useState(null); - const [selectedTrack, setSelectedTrack] = useState(null); - const [musicVolume, setMusicVolume] = useState(0.3); - const [isPlaying, setIsPlaying] = useState(null); - const [isRendering, setIsRendering] = useState(false); - const [videoUrl, setVideoUrl] = useState(null); - const [selectedPageIds, setSelectedPageIds] = useState([]); - const [scheduledDate, setScheduledDate] = useState(undefined); - const [publishDescription, setPublishDescription] = useState(""); - const [isPublishing, setIsPublishing] = useState(false); - const [captionStyle, setCaptionStyle] = useState(DEFAULT_CAPTION_STYLE); - const [showLogo, setShowLogo] = useState(true); - const [voicePreview, setVoicePreview] = useState(null); - const [renderProgress, setRenderProgress] = useState(0); - const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null; - const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] }); - const audioRef = useRef(null); - const cameraInputRef = useRef(null); - const galleryInputRef = useRef(null); - const { toast } = useToast(); - - const { data: allMedia = [] } = useQuery({ queryKey: ['/api/media'] }); - const { data: audioTracks = [], isLoading: tracksLoading } = useQuery({ queryKey: ['/api/audio-tracks'] }); - const { data: socialPages = [] } = useQuery({ queryKey: ['/api/pages'] }); - - // Aperçu : URL locales des fichiers choisis, libérées quand ils changent - const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]); - useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]); - const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]); - useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]); - const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls]; - - const facebookPages = socialPages.filter(p => p.platform === 'facebook'); - const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok'); - - const renderTargetCheckbox = (page: SocialPage) => ( -
- - setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id)) - } - /> - -
- ); - - const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); - - const generateTextMutation = useMutation({ - mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(), - onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); }, - onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }), - }); - - const handleFileChange = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - const filesArray = Array.from(e.target.files); - const remaining = 4 - selectedLibraryImages.length - images.length; - - // Corriger les noms de fichiers pour les fichiers venant de la galerie mobile - const processedFiles = filesArray.slice(0, remaining).map(file => { - if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') { - const ext = file.type.split('/')[1] || 'jpg'; - return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type }); - } - return file; - }); - - setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length)); - } - // Reset input pour permettre de resélectionner les mêmes fichiers - e.target.value = ''; - }; - - const handleCameraCapture = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - const remaining = 4 - selectedLibraryImages.length - images.length; - if (remaining <= 0) { - toast({ title: "4 images max.", variant: "destructive" }); - e.target.value = ''; - return; - } - let fileToAdd = file; - if (!file.name || file.name === 'blob' || file.name === '') { - const ext = file.type.split('/')[1] || 'jpg'; - fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type }); - } - setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length)); - e.target.value = ''; - }; - - const toggleLibraryImage = (media: Media) => { - setSelectedLibraryImages(prev => { - if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id); - if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; } - return [...prev, media]; - }); - }; - - const totalSelected = images.length + selectedLibraryImages.length; - - const togglePlayPreview = (track: AudioTrack) => { - if (!audioRef.current) return; - if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); } - else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); } - }; - - const handleGenerate = async () => { - if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; } - setIsRendering(true); - setRenderProgress(0); - setVideoUrl(null); - try { - const formData = new FormData(); - images.forEach(img => formData.append("images", img)); - selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); - if (overlayText) formData.append("overlayText", overlayText); - formData.append("ttsEnabled", String(ttsEnabled)); - formData.append("ttsEngine", voiceSettings.engine); - formData.append("ttsVoice", voiceSettings.voice); - formData.append("ttsStyle", voiceSettings.style); - formData.append("captionStyle", captionStyle); - formData.append("showLogo", String(showLogo)); - 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)); } - const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" }); - if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); } - const data = await response.json(); - - if (data.jobId) { - toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." }); - - const checkStatus = async () => { - try { - const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" }); - if (!res.ok) { - if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`); - throw new Error("Erreur de suivi du rendu"); - } - const job = await res.json(); - - if (job.status === 'done') { - setVideoUrl(job.url); - if (overlayText && !publishDescription) setPublishDescription(overlayText); - toast({ title: "Vidéo générée !" }); - setIsRendering(false); - } else if (job.status === 'error') { - toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" }); - setIsRendering(false); - } else { - setRenderProgress(job.progress ?? 0); - setTimeout(checkStatus, 3000); - } - } catch (e: any) { - toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" }); - setIsRendering(false); - } - }; - setTimeout(checkStatus, 3000); - } else { - setVideoUrl(data.url); - if (overlayText && !publishDescription) setPublishDescription(overlayText); - toast({ title: "Vidéo générée !" }); - setIsRendering(false); - } - } catch (err: any) { - toast({ title: "Erreur", description: err.message, variant: "destructive" }); - setIsRendering(false); - } - }; - - const handlePublish = async () => { - if (!videoUrl) return; - if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; } - setIsPublishing(true); - try { - const response = await apiRequest('POST', '/api/remotion/publish', { - videoUrl, pageIds: selectedPageIds, - scheduledFor: scheduledDate?.toISOString(), - description: publishDescription || undefined, - }); - const data = await response.json(); - if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur"); - toast({ - title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !", - description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.", - }); - await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' }); - } catch (err: any) { - toast({ title: "Erreur de publication", description: err.message, variant: "destructive" }); - } finally { setIsPublishing(false); } - }; - - return ( -
-