feat: Add Facebook Reels with music and text overlay - Add ffmpeg.ts service for Docker FFmpeg API integration - Add jamendo.ts service for royalty-free music search - Add publishReel methods to facebook.ts - Add reels API routes (music search, AI text generation, create/preview) - Add new-reel.tsx frontend with 4-step workflow - Add 'reel' type to postTypeEnum - Update navigation with Nouveau Reel link

This commit is contained in:
Michael committed 2026-01-22 11:57:27 +01:00
1 parent 7b39b34afb
commit 3d494d4056
10 files changed
+2069 -60

No files matched your search

+7
View File
@@ -65,6 +65,12 @@ const Analytics = createResponsiveRoute(
() => import("@/pages/mobile/analytics")
);
// NewReel uses desktop version for now (mobile can be added later)
const NewReel = createResponsiveRoute(
() => import("@/pages/new-reel"),
() => import("@/pages/new-reel") // Uses same component for now
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
const [, setLocation] = useLocation();
@@ -114,6 +120,7 @@ function Router() {
<Route path="/login" component={Login} />
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
+2 -1
View File
@@ -1,4 +1,4 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3 } from "lucide-react";
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, Wand2, BarChart3, Video } from "lucide-react";
import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient } from "@/lib/queryClient";
@@ -61,6 +61,7 @@ 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: 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 },
+832
View File
@@ -0,0 +1,832 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import {
Send, Sparkles, Video, Music, Type, Calendar,
Upload, Camera, Play, Pause, Volume2, VolumeX,
ChevronRight, Loader2, Check
} from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
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 { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
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';
export default function NewReel() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const cameraInputRef = useRef<HTMLInputElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
// État du workflow
const [currentStep, setCurrentStep] = useState<Step>('video');
// État des données
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [selectedTrack, setSelectedTrack] = useState<MusicTrack | null>(null);
const [overlayText, setOverlayText] = useState('');
const [productInfo, setProductInfo] = useState('');
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [musicVolume, setMusicVolume] = useState([25]);
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [musicOffset, setMusicOffset] = useState(0);
// Récupérer les pages disponibles
const { data: pages = [] } = useQuery<SocialPage[]>({
queryKey: ['/api/pages'],
});
// Récupérer les vidéos disponibles
const { data: allMedia = [] } = useQuery<Media[]>({
queryKey: ['/api/media'],
});
const videoList = allMedia.filter(m => m.type === 'video').slice(0, 12);
// Recherche de musiques par durée
const { data: musicData, isLoading: musicLoading } = useQuery<{ tracks: MusicTrack[] }>({
queryKey: ['/api/music/search', selectedVideo?.id],
queryFn: async () => {
// Estimer la durée vidéo (on prend une marge de ±10 secondes)
const minDuration = 10;
const maxDuration = 120; // On prend toutes les musiques courtes
const response = await fetch(
`/api/music/search?minDuration=${minDuration}&maxDuration=${maxDuration}&limit=10`
);
return response.json();
},
enabled: currentStep === 'music' && !!selectedVideo,
});
// Mutation pour voir plus de musiques
const loadMoreMusicMutation = useMutation({
mutationFn: async (offset: number) => {
const response = await fetch(
`/api/music/more?minDuration=10&maxDuration=120&limit=10&offset=${offset}`
);
return response.json();
},
onSuccess: (data) => {
setMusicOffset(prev => prev + 10);
// Vous pourriez ajouter les nouveaux tracks à une liste
},
});
// 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,
});
if (!response.ok) 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: () => {
toast({
title: "Erreur",
description: "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: data.success ? "Reel créé !" : "Reel créé avec avertissements",
description: data.success
? "Votre Reel a été publié avec succès"
: "Certaines pages ont échoué",
});
navigate('/');
},
onError: (error: any) => {
toast({
title: "Erreur",
description: error.message || "Impossible de créer le Reel",
variant: "destructive",
});
},
});
const onDrop = useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
const file = acceptedFiles[0];
if (file.type.startsWith('video/')) {
uploadMutation.mutate(file);
} else {
toast({
title: "Format invalide",
description: "Veuillez sélectionner un fichier vidéo",
variant: "destructive",
});
}
}
}, []);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
onDrop,
accept: {
"video/*": [".mp4", ".mov", ".webm", ".3gp", ".3gpp", ".mkv", ".avi"],
},
maxSize: 52428800,
noClick: true,
noKeyboard: true,
});
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
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 = (track: MusicTrack) => {
if (isPlaying === track.id) {
audioRef.current?.pause();
setIsPlaying(null);
} else {
if (audioRef.current) {
audioRef.current.src = track.previewUrl;
audioRef.current.play();
setIsPlaying(track.id);
}
}
};
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);
toast({
title: "Texte sélectionné",
description: "Le texte a été ajouté à votre Reel",
});
};
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: overlayText,
description: overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
musicVolume: musicVolume[0] / 100,
});
};
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')}`;
};
return (
<div className="flex h-screen overflow-hidden bg-background">
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<div className={`
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
</div>
<main className="flex-1 overflow-y-auto">
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
<div className="p-4 sm:p-6 lg:p-8 max-w-6xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
<Video className="w-8 h-8 text-primary" />
Créer un Reel
</h1>
<p className="text-muted-foreground mt-2">
Créez un Reel Facebook avec musique et texte overlay
</p>
</div>
{/* Stepper */}
<div className="flex items-center justify-between mb-8 px-4">
{(['video', 'music', 'text', 'publish'] as Step[]).map((step, index) => (
<div key={step} className="flex items-center">
<button
onClick={() => {
if (step === 'video') setCurrentStep(step);
else if (step === 'music' && canProceedToMusic) setCurrentStep(step);
else if (step === 'text' && canProceedToText) setCurrentStep(step);
else if (step === 'publish' && canProceedToPublish) setCurrentStep(step);
}}
className={`flex items-center gap-2 px-4 py-2 rounded-full transition-all ${currentStep === step
? 'bg-primary text-primary-foreground'
: step === 'video' ||
(step === 'music' && canProceedToMusic) ||
(step === 'text' && canProceedToText) ||
(step === 'publish' && canProceedToPublish)
? 'bg-muted hover:bg-accent cursor-pointer'
: 'bg-muted/50 text-muted-foreground cursor-not-allowed'
}`}
>
{step === 'video' && <Video className="w-4 h-4" />}
{step === 'music' && <Music className="w-4 h-4" />}
{step === 'text' && <Type className="w-4 h-4" />}
{step === 'publish' && <Send className="w-4 h-4" />}
<span className="hidden sm:inline capitalize">{step === 'video' ? 'Vidéo' : step === 'music' ? 'Musique' : step === 'text' ? 'Texte' : 'Publier'}</span>
</button>
{index < 3 && <ChevronRight className="w-5 h-5 text-muted-foreground mx-2" />}
</div>
))}
</div>
{/* Contenu par étape */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Colonne principale */}
<div className="lg:col-span-2 space-y-6">
{/* ÉTAPE 1: Vidéo */}
{currentStep === 'video' && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video className="w-5 h-5" />
Sélectionnez une Vidéo
</CardTitle>
<CardDescription>
Choisissez une vidéo existante ou capturez-en une nouvelle
</CardDescription>
</CardHeader>
<CardContent>
<input
ref={cameraInputRef}
type="file"
accept="video/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
<div {...getRootProps()} className={`${isDragActive ? 'bg-primary/5 border-primary' : ''}`}>
<input {...getInputProps()} />
<div className="flex gap-2 mb-4">
<Button
onClick={() => cameraInputRef.current?.click()}
disabled={uploadMutation.isPending}
variant="outline"
className="lg:hidden"
>
<Camera className="w-4 h-4 mr-2" />
Capturer
</Button>
<Button
onClick={open}
disabled={uploadMutation.isPending}
variant="outline"
>
<Upload className="w-4 h-4 mr-2" />
{uploadMutation.isPending ? 'Upload...' : 'Uploader'}
</Button>
</div>
{videoList.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg">
<Video className="w-12 h-12 mx-auto text-muted-foreground mb-2" />
<p className="text-muted-foreground">
{isDragActive ? "Déposez votre vidéo ici" : "Aucune vidéo disponible"}
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{videoList.map((media) => (
<button
key={media.id}
onClick={() => handleSelectVideo(media)}
className={`relative aspect-video rounded-lg overflow-hidden border-2 transition-all ${selectedVideoId === media.id
? 'border-primary ring-2 ring-primary'
: 'border-transparent hover:border-muted-foreground'
}`}
>
<video
src={media.originalUrl}
className="w-full h-full object-cover"
muted
playsInline
preload="metadata"
/>
{selectedVideoId === media.id && (
<div className="absolute inset-0 bg-primary/20 flex items-center justify-center">
<Check className="w-8 h-8 text-primary" />
</div>
)}
</button>
))}
</div>
)}
</div>
{selectedVideo && (
<div className="mt-4 flex justify-end">
<Button onClick={() => setCurrentStep('music')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* ÉTAPE 2: Musique */}
{currentStep === 'music' && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Music className="w-5 h-5" />
Choisissez une Musique
</CardTitle>
<CardDescription>
Sélectionnez une musique libre de droits (optionnel)
</CardDescription>
</CardHeader>
<CardContent>
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{musicLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<>
<div className="space-y-2">
{musicData?.tracks?.map((track) => (
<div
key={track.id}
onClick={() => handleSelectTrack(track)}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${selectedTrack?.id === track.id
? 'bg-primary/10 border border-primary'
: 'bg-muted/50 hover:bg-muted'
}`}
>
<button
onClick={(e) => {
e.stopPropagation();
togglePlayPreview(track);
}}
className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30"
>
{isPlaying === track.id ? (
<Pause className="w-5 h-5" />
) : (
<Play className="w-5 h-5 ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.title}</p>
<p className="text-sm text-muted-foreground truncate">{track.artist}</p>
</div>
<span className="text-sm text-muted-foreground">
{formatDuration(track.duration)}
</span>
{selectedTrack?.id === track.id && (
<Check className="w-5 h-5 text-primary" />
)}
</div>
))}
</div>
{selectedTrack && (
<div className="mt-4 space-y-4">
<div>
<Label className="flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Volume musique: {musicVolume[0]}%
</Label>
<Slider
value={musicVolume}
onValueChange={setMusicVolume}
max={100}
step={5}
className="mt-2"
/>
</div>
</div>
)}
<div className="mt-4 flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep('video')}>
Retour
</Button>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => {
setSelectedTrack(null);
setCurrentStep('text');
}}>
Passer
</Button>
<Button onClick={() => setCurrentStep('text')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* ÉTAPE 3: Texte */}
{currentStep === 'text' && (
<>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
Générer avec l'IA
</CardTitle>
<CardDescription>
Décrivez votre produit pour générer des textes
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={productInfo}
onChange={(e) => setProductInfo(e.target.value)}
placeholder="Décrivez votre produit : nom, caractéristiques, prix, etc."
rows={4}
/>
<Button
onClick={handleGenerateText}
disabled={generateTextMutation.isPending}
className="w-full"
>
<Sparkles className="w-4 h-4 mr-2" />
{generateTextMutation.isPending ? 'Génération...' : 'Générer 3 variations'}
</Button>
</CardContent>
</Card>
{generatedVariants.length > 0 && (
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Variations générées</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{generatedVariants.map((variant, index) => (
<div
key={index}
className="p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="text-xs font-semibold text-muted-foreground mb-2">
{variant.variant || `Version ${index + 1}`}
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{variant.text}
</p>
</div>
<Button
size="sm"
onClick={() => handleUseVariant(variant.text)}
>
Utiliser
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Type className="w-5 h-5" />
Texte Overlay
</CardTitle>
<CardDescription>
Ce texte s'affichera au centre de votre Reel (style TikTok)
</CardDescription>
</CardHeader>
<CardContent>
<Textarea
value={overlayText}
onChange={(e) => setOverlayText(e.target.value)}
placeholder="Écrivez le texte qui apparaîtra sur votre Reel..."
rows={4}
/>
<div className="mt-4 flex justify-between">
<Button variant="outline" onClick={() => setCurrentStep('music')}>
Retour
</Button>
<Button onClick={() => setCurrentStep('publish')}>
Continuer
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</CardContent>
</Card>
</>
)}
{/* ÉTAPE 4: Publication */}
{currentStep === 'publish' && (
<>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Pages cibles</CardTitle>
<CardDescription>Sélectionnez les pages où publier</CardDescription>
</CardHeader>
<CardContent>
{pages.filter(p => p.platform === 'facebook').length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground mb-2">
Aucune page Facebook connectée
</p>
<Button variant="link" onClick={() => navigate('/pages')}>
Ajouter des pages
</Button>
</div>
) : (
<div className="space-y-3">
{pages.filter(p => p.platform === 'facebook').map((page) => (
<div key={page.id} className="flex items-center space-x-2">
<Checkbox
id={`page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPages([...selectedPages, page.id]);
} else {
setSelectedPages(selectedPages.filter(id => id !== page.id));
}
}}
/>
<label
htmlFor={`page-${page.id}`}
className="text-sm font-medium leading-none flex-1"
>
{page.pageName}
</label>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5" />
Planification
</CardTitle>
<CardDescription>
Programmez la publication (optionnel)
</CardDescription>
</CardHeader>
<CardContent>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
occupiedDates={[]}
placeholder="Publier immédiatement"
/>
</CardContent>
</Card>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setCurrentStep('text')}
className="flex-1"
>
Retour
</Button>
<Button
onClick={handleCreateReel}
disabled={!canPublish || createReelMutation.isPending}
className="flex-1 bg-gradient-to-r from-primary to-secondary hover:opacity-90"
>
{createReelMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Création...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Publier le Reel
</>
)}
</Button>
</div>
</>
)}
</div>
{/* Colonne de prévisualisation */}
<div className="space-y-6">
<Card className="rounded-2xl border-border/50 shadow-lg sticky top-4">
<CardHeader>
<CardTitle>Aperçu</CardTitle>
</CardHeader>
<CardContent>
{selectedVideo ? (
<div className="relative aspect-[9/16] bg-black rounded-lg overflow-hidden">
<video
src={selectedVideo.originalUrl}
className="w-full h-full object-cover"
muted
playsInline
loop
autoPlay
/>
{overlayText && (
<div className="absolute inset-0 flex items-center justify-center p-4">
<p className="text-white text-center text-lg font-bold drop-shadow-lg">
{overlayText}
</p>
</div>
)}
{selectedTrack && (
<div className="absolute bottom-4 left-4 right-4 bg-black/50 rounded-lg p-2 flex items-center gap-2">
<Music className="w-4 h-4 text-white" />
<span className="text-white text-sm truncate">
{selectedTrack.title} - {selectedTrack.artist}
</span>
</div>
)}
</div>
) : (
<div className="aspect-[9/16] bg-muted rounded-lg flex items-center justify-center">
<div className="text-center text-muted-foreground">
<Video className="w-12 h-12 mx-auto mb-2" />
<p>Sélectionnez une vidéo</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Résumé */}
<Card className="rounded-2xl border-border/50 shadow-lg">
<CardHeader>
<CardTitle>Résumé</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Vidéo</span>
<span>{selectedVideo ? '✓' : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Musique</span>
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Texte</span>
<span>{overlayText ? '✓' : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Pages</span>
<span>{selectedPages.length} sélectionnée(s)</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</main>
</div>
);
}
+4
View File
@@ -11,6 +11,7 @@ import { cloudinaryService } from "./services/cloudinary";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { reelsRouter } from "./routes/reels";
// Types MIME autorisés pour les uploads
const ALLOWED_MIME_TYPES = [
@@ -59,6 +60,9 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Analytics Routes
app.use("/api/analytics", requireAuth, analyticsRouter);
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
// Routes d'authentification
app.post("/api/auth/login", (req, res, next) => {
passport.authenticate("local", (err: any, user: User | false, info: any) => {
+404
View File
@@ -0,0 +1,404 @@
/**
* Routes API pour les Reels Facebook et la musique Jamendo
*/
import { Router, Request, Response } from 'express';
import type { User, Media, SocialPage } from '@shared/schema';
import { storage } from '../storage';
import { jamendoService, type MusicTrack } from '../services/jamendo';
import { ffmpegService } from '../services/ffmpeg';
import { facebookService } from '../services/facebook';
import { cloudinaryService } from '../services/cloudinary';
import { openRouterService } from '../services/openrouter';
export const reelsRouter = Router();
// ============================================
// ROUTES MUSIQUE JAMENDO
// ============================================
/**
* Recherche de musiques par durée
* GET /api/music/search?minDuration=10&maxDuration=60&genre=pop&limit=10
*/
reelsRouter.get('/music/search', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
const tracks = await jamendoService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
},
});
} catch (error) {
console.error('❌ Error searching music:', error);
res.status(500).json({ error: 'Erreur lors de la recherche de musique' });
}
});
/**
* Voir plus de musiques (pagination)
* GET /api/music/more?offset=10&minDuration=10&maxDuration=60
*/
reelsRouter.get('/music/more', async (req: Request, res: Response) => {
try {
const {
minDuration = '10',
maxDuration = '120',
genre,
limit = '10',
offset = '0',
search,
} = req.query;
const tracks = await jamendoService.searchMusicByDuration({
minDuration: parseInt(minDuration as string),
maxDuration: parseInt(maxDuration as string),
genre: genre as string | undefined,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
search: search as string | undefined,
});
res.json({
tracks,
pagination: {
offset: parseInt(offset as string),
limit: parseInt(limit as string),
hasMore: tracks.length === parseInt(limit as string),
},
});
} catch (error) {
console.error('❌ Error loading more music:', error);
res.status(500).json({ error: 'Erreur lors du chargement des musiques' });
}
});
/**
* Obtenir les tracks populaires
* GET /api/music/popular
*/
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 10;
const tracks = await jamendoService.getPopularTracks(limit);
res.json({ tracks });
} catch (error) {
console.error('❌ Error fetching popular music:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des musiques populaires' });
}
});
/**
* Détails d'un track
* GET /api/music/:trackId
*/
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
try {
const { trackId } = req.params;
const track = await jamendoService.getMusicDetails(trackId);
if (!track) {
return res.status(404).json({ error: 'Musique non trouvée' });
}
res.json(track);
} catch (error) {
console.error('❌ Error fetching music details:', error);
res.status(500).json({ error: 'Erreur lors de la récupération des détails' });
}
});
// ============================================
// ROUTES REELS
// ============================================
/**
* Générer des textes IA pour un Reel (utilise le même service que les posts)
* POST /api/reels/generate-text
*/
reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const { productInfo, model } = req.body;
if (!productInfo) {
return res.status(400).json({ error: 'Informations produit requises' });
}
const generatedTexts = await openRouterService.generatePostText(productInfo, user.id, model);
// Sauvegarder la génération
await storage.createAiGeneration({
userId: user.id,
productInfo,
generatedTexts,
});
res.json({ variants: generatedTexts });
} catch (error) {
console.error('❌ Error generating Reel text:', error);
res.status(500).json({ error: 'Erreur lors de la génération du texte' });
}
});
/**
* Prévisualiser un Reel (traitement sans publication)
* POST /api/reels/preview
*/
reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
wordDuration = 0.6,
fontSize = 60,
musicVolume = 0.25,
} = req.body;
// Récupérer le média vidéo
const media = await storage.getMediaById(videoMediaId);
if (!media) {
return res.status(404).json({ error: 'Vidéo non trouvée' });
}
if (media.type !== 'video') {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await jamendoService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
// Traiter la vidéo via FFmpeg
const result = await ffmpegService.processReelFromUrl(media.originalUrl, {
text: overlayText,
musicUrl: finalMusicUrl,
wordDuration,
fontSize,
musicVolume,
});
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de traitement vidéo' });
}
// Retourner la vidéo en base64 pour prévisualisation
res.json({
success: true,
videoBase64: result.videoBase64,
duration: result.duration,
});
} catch (error) {
console.error('❌ Error previewing Reel:', error);
res.status(500).json({ error: 'Erreur lors de la prévisualisation du Reel' });
}
});
/**
* Créer et publier un Reel
* POST /api/reels
*/
reelsRouter.post('/reels', async (req: Request, res: Response) => {
try {
const user = req.user as User;
const {
videoMediaId,
musicTrackId,
musicUrl,
overlayText,
description,
pageIds,
scheduledFor,
wordDuration = 0.6,
fontSize = 60,
musicVolume = 0.25,
} = req.body;
// Validation
if (!videoMediaId) {
return res.status(400).json({ error: 'Vidéo requise' });
}
if (!pageIds || pageIds.length === 0) {
return res.status(400).json({ error: 'Au moins une page requise' });
}
// Récupérer le média vidéo
const media = await storage.getMediaById(videoMediaId);
if (!media) {
return res.status(404).json({ error: 'Vidéo non trouvée' });
}
if (media.type !== 'video') {
return res.status(400).json({ error: 'Le média doit être une vidéo' });
}
// Récupérer l'URL de la musique si trackId fourni
let finalMusicUrl = musicUrl;
if (musicTrackId && !musicUrl) {
const track = await jamendoService.getMusicDetails(musicTrackId);
if (track) {
finalMusicUrl = track.downloadUrl;
}
}
console.log('🎬 Processing Reel:', {
videoMediaId,
hasMusic: !!finalMusicUrl,
hasText: !!overlayText,
pageCount: pageIds.length,
});
// Traiter la vidéo via FFmpeg
const ffmpegResult = await ffmpegService.processReelFromUrl(media.originalUrl, {
text: overlayText,
musicUrl: finalMusicUrl,
wordDuration,
fontSize,
musicVolume,
});
if (!ffmpegResult.success || !ffmpegResult.videoBase64) {
return res.status(500).json({
error: ffmpegResult.error || 'Erreur de traitement vidéo',
});
}
// Upload la vidéo traitée sur Cloudinary
const videoBuffer = Buffer.from(ffmpegResult.videoBase64, 'base64');
const cloudinaryResult = await cloudinaryService.uploadMedia(
videoBuffer,
`reel-${Date.now()}.mp4`,
user.id,
'video/mp4'
);
console.log('✅ Reel uploaded to Cloudinary:', cloudinaryResult.originalUrl);
// Créer le post en base
const post = await storage.createPost({
userId: user.id,
content: description || overlayText || '',
aiGenerated: 'false',
status: scheduledFor ? 'scheduled' : 'draft',
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
});
// Publier sur chaque page sélectionnée
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
for (const pageId of pageIds) {
try {
const page = await storage.getSocialPage(pageId);
if (!page) {
results.push({ pageId, success: false, error: 'Page non trouvée' });
continue;
}
if (page.platform !== 'facebook') {
results.push({ pageId, success: false, error: 'Seules les pages Facebook sont supportées pour les Reels' });
continue;
}
// Créer l'entrée scheduled_post
const scheduledPost = await storage.createScheduledPost({
postId: post.id,
pageId: page.id,
postType: 'reel',
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
// Si pas de planification, publier immédiatement
if (!scheduledFor) {
const reelId = await facebookService.publishReel(
page,
cloudinaryResult.originalUrl,
description
);
// Mettre à jour le scheduled_post avec l'ID externe
await storage.updateScheduledPost(scheduledPost.id, {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
} else {
results.push({ pageId, success: true, reelId: 'scheduled' });
}
} catch (pageError) {
console.error(`❌ Error publishing Reel to page ${pageId}:`, pageError);
results.push({
pageId,
success: false,
error: pageError instanceof Error ? pageError.message : 'Erreur inconnue',
});
}
}
// Mettre à jour le statut du post
const allSuccess = results.every(r => r.success);
await storage.updatePost(post.id, {
status: scheduledFor ? 'scheduled' : (allSuccess ? 'published' : 'failed'),
});
res.json({
success: allSuccess,
postId: post.id,
results,
videoUrl: cloudinaryResult.originalUrl,
});
} catch (error) {
console.error('❌ Error creating Reel:', error);
res.status(500).json({
error: error instanceof Error ? error.message : 'Erreur lors de la création du Reel',
});
}
});
/**
* Obtenir la configuration des services Reels
* GET /api/reels/config
*/
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
try {
const jamendoConfigured = await jamendoService.testConnection().catch(() => false);
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
res.json({
jamendo: { configured: jamendoConfigured },
ffmpeg: { configured: ffmpegConfigured },
});
} catch (error) {
console.error('❌ Error fetching Reels config:', error);
res.status(500).json({ error: 'Erreur lors de la récupération de la configuration' });
}
});
+247 -28
View File
@@ -43,27 +43,27 @@ export class FacebookService {
if (postType === 'both') {
throw new Error('postType "both" is not supported. Posts should be split into separate feed and story entries at creation.');
}
if (postType === 'story') {
// Publish as story - one story per media
if (mediaList.length === 0) {
throw new Error('Stories require media (photo or video)');
}
// Publish each media as a separate story
const storyIds: string[] = [];
for (const media of mediaList) {
const storyId = await this.publishStory(post, page, media);
storyIds.push(storyId);
}
// Return all story IDs joined together
return storyIds.join(',');
} else {
// Default to feed
const imageMedia = mediaList.filter(m => m.type === 'image');
const videoMedia = mediaList.filter(m => m.type === 'video');
if (imageMedia.length > 1) {
// Multi-photo carousel (images only)
return await this.publishMultiPhotoPost(post, page, mediaList);
@@ -135,7 +135,7 @@ export class FacebookService {
// Check if this is a Cloudinary URL
const cloudinaryPattern = /https:\/\/res\.cloudinary\.com\/([^\/]+)\/video\/upload\//;
const match = originalUrl.match(cloudinaryPattern);
if (!match) {
// Not a Cloudinary URL, return as-is (may fail on Facebook)
console.warn('Video URL is not from Cloudinary, cannot apply transformations:', originalUrl);
@@ -144,20 +144,20 @@ export class FacebookService {
const [fullMatch, cloudName] = match;
const videoPath = originalUrl.replace(fullMatch, '');
// Apply transformations for Facebook compatibility:
// - vc_h264: Video codec H.264
// - ac_aac: Audio codec AAC
// - f_mp4: Force MP4 format
// - q_auto: Automatic quality optimization
const transformations = 'vc_h264,ac_aac,f_mp4,q_auto';
const transformedUrl = `https://res.cloudinary.com/${cloudName}/video/upload/${transformations}/${videoPath}`;
console.log('🎬 Transforming video for Facebook compatibility:');
console.log(' Original:', originalUrl);
console.log(' Transformed:', transformedUrl);
return transformedUrl;
}
@@ -193,7 +193,7 @@ export class FacebookService {
if (imageMedia.length === 0) {
throw new Error('Multi-photo posts require at least one image');
}
if (imageMedia.length !== mediaList.length) {
console.warn(`Filtered out ${mediaList.length - imageMedia.length} non-image media from multi-photo post`);
}
@@ -204,7 +204,7 @@ export class FacebookService {
for (const media of imageMedia) {
// Use original URL - Facebook handles the cropping for carousel
const photoUrl = media.originalUrl;
if (!photoUrl) {
console.warn(`Skipping media ${media.id} - no valid URL found`);
continue;
@@ -230,7 +230,7 @@ export class FacebookService {
const uploadData = await uploadResponse.json() as FacebookPhotoResponse;
photoIds.push(uploadData.id);
}
if (photoIds.length === 0) {
throw new Error('No photos were successfully uploaded');
}
@@ -347,7 +347,7 @@ export class FacebookService {
if (media.type === 'video') {
return this.publishVideoStory(post, page, media);
}
// Stories require a 2-step process:
// Step 1: Upload photo as unpublished to get photo_id
// Step 2: Publish the photo as a story using the photo_id
@@ -372,7 +372,7 @@ export class FacebookService {
});
const uploadUrl = `${this.baseUrl}/${page.pageId}/photos?${uploadParams.toString()}`;
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
});
@@ -411,31 +411,31 @@ export class FacebookService {
// 1. START phase - Initialize the upload and get upload_url
// 2. UPLOAD phase - Upload the video to upload_url using file_url header
// 3. FINISH phase - Finalize and publish the story
const videoUrl = media.originalUrl;
const accessToken = page.accessToken!;
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload the video using the upload_url
// IMPORTANT: file_url must be passed as a header, not a query parameter
const uploadResponse = await fetch(uploadUrl, {
@@ -445,35 +445,254 @@ export class FacebookService {
'file_url': videoUrl,
},
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH - Finalize and publish the story
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishUrl = `${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
// Return the post_id from the finish response
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
* Utilise l'endpoint /{page-id}/video_reels avec un processus en 3 phases :
* 1. START - Initialise l'upload et récupère l'upload_url
* 2. UPLOAD - Upload la vidéo vers rupload.facebook.com
* 3. FINISH - Finalise et publie le Reel
*
* Spécifications vidéo requises :
* - Format : MP4, H.264
* - Ratio : 9:16 (vertical)
* - Durée : 3-90 secondes
* - Résolution recommandée : 1080x1920
*
* @param page - Page Facebook cible
* @param videoUrl - URL de la vidéo à publier
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReel(
page: SocialPage,
videoUrl: string,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
const accessToken = page.accessToken;
console.log('🎬 Publishing Reel to Facebook:', {
pageId: page.pageId,
pageName: page.pageName,
hasDescription: !!description,
});
// Phase 1: START - Initialize the upload
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
console.log('📤 Phase 1: START - Initializing Reel upload');
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
console.log('✅ START phase complete, video_id:', videoId);
// Phase 2: UPLOAD - Upload the video to rupload.facebook.com
// IMPORTANT: file_url must be passed as a header for URL-based uploads
console.log('📤 Phase 2: UPLOAD - Uploading video to:', uploadUrl);
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'file_url': videoUrl,
},
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
console.error('❌ UPLOAD phase failed:', errorText);
throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
}
console.log('✅ UPLOAD phase complete');
// Phase 3: FINISH - Finalize and publish the Reel
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
// Add description if provided
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
console.log('📤 Phase 3: FINISH - Publishing Reel');
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
if (!finishData.success) {
throw new Error('Facebook Reel publishing failed - success was false');
}
const reelId = finishData.post_id || videoId;
console.log('✅ Reel published successfully! ID:', reelId);
return reelId;
}
/**
* Publie une vidéo comme Reel Facebook en uploadant directement les données binaires
* Utile quand on a la vidéo en base64 (après traitement FFmpeg)
*
* @param page - Page Facebook cible
* @param videoBuffer - Buffer de la vidéo
* @param description - Description du Reel (optionnel)
* @returns ID du Reel publié
*/
async publishReelFromBuffer(
page: SocialPage,
videoBuffer: ArrayBuffer,
description?: string
): Promise<string> {
if (page.platform !== 'facebook') {
throw new Error('This method only supports Facebook pages');
}
if (!page.accessToken) {
throw new Error('No access token found for this page');
}
const accessToken = page.accessToken;
const fileSize = videoBuffer.byteLength;
console.log('🎬 Publishing Reel from buffer:', {
pageId: page.pageId,
fileSize: `${(fileSize / 1024 / 1024).toFixed(2)} MB`,
});
// Phase 1: START
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startUrl = `${this.baseUrl}/${page.pageId}/video_reels?${startParams.toString()}`;
const startResponse = await fetch(startUrl, {
method: 'POST',
});
if (!startResponse.ok) {
const error = await startResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const startData = await startResponse.json() as { video_id: string; upload_url: string };
const videoId = startData.video_id;
const uploadUrl = startData.upload_url;
// Phase 2: UPLOAD - Upload binary data directly
const videoBlob = new Blob([videoBuffer], { type: 'video/mp4' });
const uploadResponse = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: videoBlob,
});
if (!uploadResponse.ok) {
const errorText = await uploadResponse.text();
throw new Error(`Facebook Reel API error (UPLOAD phase): ${errorText}`);
}
// Phase 3: FINISH
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
video_state: 'PUBLISHED',
});
if (description) {
finishParams.append('description', description);
}
const finishUrl = `${this.baseUrl}/${page.pageId}/video_reels?${finishParams.toString()}`;
const finishResponse = await fetch(finishUrl, {
method: 'POST',
});
if (!finishResponse.ok) {
const error = await finishResponse.json() as FacebookError;
throw new Error(`Facebook Reel API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishResponse.json() as { success: boolean; post_id?: string };
return finishData.post_id || videoId;
}
}
export const facebookService = new FacebookService();
+245
View File
@@ -0,0 +1,245 @@
/**
* FFmpeg Docker API Service
*
* Intégration avec l'API FFmpeg Docker locale pour le traitement vidéo des Reels.
* L'API attend une vidéo en base64 et retourne la vidéo traitée en base64.
*/
interface FFmpegReelRequest {
video_base64?: string; // Vidéo source en base64
video_url?: string; // OU URL de la vidéo source
text?: string; // Texte overlay style TikTok
music_id?: string; // ID de la musique (catalogue FFmpeg)
music_url?: string; // OU URL directe de la musique
word_duration?: number; // Durée par mot (default: 0.6s)
font_size?: number; // Taille police (default: 60)
music_volume?: number; // Volume musique (default: 0.25)
}
interface FFmpegReelResponse {
success: boolean;
output_base64?: string; // Vidéo traitée en base64
duration?: number; // Durée de la vidéo en secondes
detail?: string; // Message d'erreur si échec
}
interface FFmpegConfig {
apiUrl: string;
apiKey: string;
}
export class FFmpegService {
private config: FFmpegConfig | null = null;
/**
* Configure le service avec l'URL et la clé API
*/
configure(apiUrl: string, apiKey: string): void {
this.config = { apiUrl, apiKey };
console.log('🎬 FFmpeg Service configured:', apiUrl);
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): FFmpegConfig {
if (!this.config) {
throw new Error('FFmpeg Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Traite une vidéo pour créer un Reel avec musique et texte overlay
*
* @param videoBase64 - Vidéo source encodée en base64
* @param options - Options de traitement (texte, musique, etc.)
* @returns Vidéo traitée en base64
*/
async processReelVideo(
videoBase64: string,
options: {
text?: string;
musicId?: string;
musicUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
const requestBody: FFmpegReelRequest = {
video_base64: videoBase64,
text: options.text,
music_id: options.musicId,
music_url: options.musicUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 60,
music_volume: options.musicVolume ?? 0.25,
};
// Remove undefined values
Object.keys(requestBody).forEach(key => {
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
delete requestBody[key as keyof FFmpegReelRequest];
}
});
console.log('🎬 Processing Reel video:', {
hasVideo: !!videoBase64,
hasText: !!options.text,
hasMusicId: !!options.musicId,
hasMusicUrl: !!options.musicUrl,
});
try {
const response = await fetch(`${config.apiUrl}/process-reel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ FFmpeg API error:', response.status, errorText);
return {
success: false,
error: `FFmpeg API error: ${response.status} - ${errorText}`,
};
}
const data = await response.json() as FFmpegReelResponse;
if (!data.success) {
console.error('❌ FFmpeg processing failed:', data.detail);
return {
success: false,
error: data.detail || 'Unknown FFmpeg processing error',
};
}
console.log('✅ Reel video processed successfully, duration:', data.duration);
return {
success: true,
videoBase64: data.output_base64,
duration: data.duration,
};
} catch (error) {
console.error('❌ FFmpeg Service error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Traite une vidéo depuis une URL (télécharge, traite, retourne base64)
*/
async processReelFromUrl(
videoUrl: string,
options: {
text?: string;
musicId?: string;
musicUrl?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
const requestBody: FFmpegReelRequest = {
video_url: videoUrl,
text: options.text,
music_id: options.musicId,
music_url: options.musicUrl,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 60,
music_volume: options.musicVolume ?? 0.25,
};
// Remove undefined values
Object.keys(requestBody).forEach(key => {
if (requestBody[key as keyof FFmpegReelRequest] === undefined) {
delete requestBody[key as keyof FFmpegReelRequest];
}
});
console.log('🎬 Processing Reel from URL:', {
videoUrl,
hasText: !!options.text,
hasMusicId: !!options.musicId,
hasMusicUrl: !!options.musicUrl,
});
try {
const response = await fetch(`${config.apiUrl}/process-reel`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': config.apiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ FFmpeg API error:', response.status, errorText);
return {
success: false,
error: `FFmpeg API error: ${response.status} - ${errorText}`,
};
}
const data = await response.json() as FFmpegReelResponse;
if (!data.success) {
console.error('❌ FFmpeg processing failed:', data.detail);
return {
success: false,
error: data.detail || 'Unknown FFmpeg processing error',
};
}
console.log('✅ Reel video processed successfully from URL');
return {
success: true,
videoBase64: data.output_base64,
duration: data.duration,
};
} catch (error) {
console.error('❌ FFmpeg Service error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Vérifie la santé de l'API FFmpeg
*/
async healthCheck(): Promise<boolean> {
try {
const config = this.ensureConfigured();
const response = await fetch(`${config.apiUrl}/health`, {
method: 'GET',
headers: {
'X-API-Key': config.apiKey,
},
});
return response.ok;
} catch {
return false;
}
}
}
export const ffmpegService = new FFmpegService();
+296
View File
@@ -0,0 +1,296 @@
/**
* Jamendo Music API Service
*
* Intégration avec l'API Jamendo pour rechercher et récupérer des musiques
* libres de droits pour les Reels.
*
* Documentation: https://developer.jamendo.com/v3.0
*/
interface JamendoTrack {
id: string;
name: string;
duration: number; // en secondes
artist_id: string;
artist_name: string;
album_name: string;
album_id: string;
license_ccurl: string;
position: number;
releasedate: string;
album_image: string;
audio: string; // URL du fichier audio complet
audiodownload: string; // URL de téléchargement
prourl: string;
shorturl: string;
shareurl: string;
waveform: string;
image: string;
audiodownload_allowed: boolean;
}
interface JamendoResponse {
headers: {
status: string;
code: number;
error_message: string;
warnings: string;
results_count: number;
};
results: JamendoTrack[];
}
export interface MusicTrack {
id: string;
title: string;
artist: string;
albumName: string;
duration: number; // en secondes
previewUrl: string; // URL audio streaming
downloadUrl: string; // URL téléchargement
imageUrl: string; // Pochette album
license: string;
}
interface MusicSearchParams {
minDuration?: number; // Durée min en secondes
maxDuration?: number; // Durée max en secondes
genre?: string; // Genre musical (ex: "pop", "rock", "electronic")
mood?: string; // Ambiance (ex: "happy", "sad", "energetic")
limit?: number; // Nombre de résultats (default: 10)
offset?: number; // Offset pour pagination
search?: string; // Recherche texte libre
}
interface JamendoConfig {
clientId: string;
}
const JAMENDO_API_BASE = 'https://api.jamendo.com/v3.0';
// Mapping des genres Jamendo
const GENRE_MAPPING: Record<string, string> = {
'pop': 'pop',
'rock': 'rock',
'electronic': 'electronic',
'hiphop': 'hiphop',
'jazz': 'jazz',
'classical': 'classical',
'ambient': 'ambient',
'chill': 'chillout',
'dance': 'dance',
'indie': 'indie',
};
export class JamendoService {
private config: JamendoConfig | null = null;
/**
* Configure le service avec le Client ID Jamendo
*/
configure(clientId: string): void {
this.config = { clientId };
console.log('🎵 Jamendo Service configured');
}
/**
* Vérifie que le service est configuré
*/
private ensureConfigured(): JamendoConfig {
if (!this.config) {
throw new Error('Jamendo Service not configured. Call configure() first.');
}
return this.config;
}
/**
* Convertit un track Jamendo en format MusicTrack unifié
*/
private mapTrack(track: JamendoTrack): MusicTrack {
return {
id: track.id,
title: track.name,
artist: track.artist_name,
albumName: track.album_name,
duration: track.duration,
previewUrl: track.audio,
downloadUrl: track.audiodownload,
imageUrl: track.album_image || track.image,
license: track.license_ccurl,
};
}
/**
* Recherche des musiques par durée
*
* @param params - Paramètres de recherche
* @returns Liste de tracks correspondants
*/
async searchMusicByDuration(params: MusicSearchParams = {}): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const {
minDuration = 10,
maxDuration = 120,
genre,
limit = 10,
offset = 0,
search,
} = params;
// Construction de l'URL avec les paramètres
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
offset: offset.toString(),
include: 'musicinfo',
audioformat: 'mp32', // MP3 320kbps
durationbetween: `${minDuration}_${maxDuration}`,
order: 'popularity_total',
});
// Ajouter le genre si spécifié
if (genre && GENRE_MAPPING[genre.toLowerCase()]) {
queryParams.append('tags', GENRE_MAPPING[genre.toLowerCase()]);
} else if (genre) {
queryParams.append('tags', genre.toLowerCase());
}
// Ajouter la recherche texte si spécifiée
if (search) {
queryParams.append('namesearch', search);
}
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
console.log('🎵 Searching Jamendo:', {
duration: `${minDuration}-${maxDuration}s`,
genre,
limit,
offset,
});
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
const tracks = data.results.map(track => this.mapTrack(track));
console.log(`✅ Found ${tracks.length} tracks on Jamendo`);
return tracks;
} catch (error) {
console.error('❌ Jamendo search error:', error);
throw error;
}
}
/**
* Récupère les détails d'un track par son ID
*/
async getMusicDetails(trackId: string): Promise<MusicTrack | null> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
id: trackId,
include: 'musicinfo',
audioformat: 'mp32',
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0 || data.results.length === 0) {
return null;
}
return this.mapTrack(data.results[0]);
} catch (error) {
console.error('❌ Jamendo getMusicDetails error:', error);
return null;
}
}
/**
* Récupère des tracks populaires (utilisé comme fallback ou découverte)
*/
async getPopularTracks(limit: number = 10): Promise<MusicTrack[]> {
const config = this.ensureConfigured();
const queryParams = new URLSearchParams({
client_id: config.clientId,
format: 'json',
limit: limit.toString(),
include: 'musicinfo',
audioformat: 'mp32',
order: 'popularity_week',
durationbetween: '15_90', // Durée idéale pour Reels
});
const url = `${JAMENDO_API_BASE}/tracks/?${queryParams.toString()}`;
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Jamendo API error: ${response.status}`);
}
const data = await response.json() as JamendoResponse;
if (data.headers.code !== 0) {
throw new Error(`Jamendo API error: ${data.headers.error_message}`);
}
return data.results.map(track => this.mapTrack(track));
} catch (error) {
console.error('❌ Jamendo getPopularTracks error:', error);
return [];
}
}
/**
* Télécharge l'URL du fichier audio (avec redirection si nécessaire)
* Note: Jamendo fournit directement les URLs de téléchargement
*/
getDownloadUrl(track: MusicTrack): string {
// L'URL de téléchargement Jamendo est directement utilisable
return track.downloadUrl;
}
/**
* Vérifie la validité de la configuration
*/
async testConnection(): Promise<boolean> {
try {
const tracks = await this.getPopularTracks(1);
return tracks.length > 0;
} catch {
return false;
}
}
}
export const jamendoService = new JamendoService();
+1 -1
View File
@@ -5,7 +5,7 @@ import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
export const platformEnum = pgEnum("platform", ["facebook", "instagram"]);
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both"]);
export const postTypeEnum = pgEnum("post_type", ["feed", "story", "both", "reel"]);
export const postStatusEnum = pgEnum("post_status", ["draft", "scheduled", "published", "failed"]);
export const mediaTypeEnum = pgEnum("media_type", ["image", "video"]);
export const userRoleEnum = pgEnum("user_role", ["admin", "user"]);
+31 -30
View File
@@ -1,50 +1,51 @@
# Task: Socialflow - Analytics & Token Management
# Task: Socialflow - Facebook Reels avec Musique et Texte
## Context
Implementation of a robust Facebook Page Token management system (automatic renewal) and a new Facebook Analytics module (ingestion & dashboard) to improve platform reliability and value.
Ajout d'une fonctionnalité complète de création de Reels Facebook permettant de capturer une vidéo, ajouter une musique libre de droits (Jamendo API), superposer du texte style TikTok, et publier via l'API Graph Facebook. Le traitement vidéo sera effectué via une API FFmpeg Docker locale.
## Current Focus
Planning and Architecture Definition.
**Phase: COMPLETED** - Implémentation terminée, prêt pour configuration et tests.
## Master Plan
### Phase 1: Foundation & Data
### Phase 0: Clarifications ✅
- [x] Requirements & Architecture Analysis
- [x] **Review Implementation Plan**
- [x] [BACKEND] Update Database Schema (Analytics tables, SocialPages extension)
- [ ] [BACKEND] Run Drizzle Migration (Pending DB connectivity)
- [x] Confirmer source de musique → Jamendo API
- [x] Confirmer URL et format API FFmpeg Docker → `/process-reel`, base64, X-API-Key
- [x] Style texte → TikTok (animation mot par mot)
### Phase 2: Core Services
### Phase 1: Backend Services ✅
- [x] [NEW] `server/services/token_manager.ts` (Encryption + Refresh Logic)
- [x] [NEW] `server/services/analytics.ts` (Graph API Fetch, DB Store)
- [x] [NEW] `server/utils/graph_client.ts` (Typed Graph API Client)
- [x] [MODIFY] `server/routes.ts` - Register analytics routes
- [x] [NEW] `server/cron.ts` - Setup Daily Token Check
- [x] `server/services/ffmpeg.ts` - Intégration API FFmpeg Docker
- [x] `server/services/jamendo.ts` - Service recherche musique Jamendo
- [x] `server/services/facebook.ts` - Méthodes `publishReel()` et `publishReelFromBuffer()`
### Phase 3: Frontend
### Phase 2: Database & Routes ✅
- [x] [NEW] `client/src/components/analytics/AnalyticsDashboard.tsx`
- [x] [NEW] `client/src/components/analytics/MetricsCard.tsx`
- [x] [NEW] `client/src/pages/AnalyticsPage.tsx` & Mobile Version
- [x] [MODIFY] `client/src/App.tsx` - Add /analytics route
- [x] [MODIFY] `client/src/components/sidebar.tsx` - Add navigation link
- [x] `shared/schema.ts` - Ajout type "reel" à postTypeEnum
- [x] `server/routes/reels.ts` - Routes API complètes
- [x] `server/routes.ts` - Enregistrement du reelsRouter
### Phase 4: Verification
### Phase 3: Frontend ✅
### Phase 4: Verification & Stabilization
- [x] `client/src/pages/new-reel.tsx` - Page création Reel (workflow 4 étapes)
- [x] `client/src/App.tsx` - Route `/reel`
- [x] `client/src/components/sidebar.tsx` - Lien navigation "Nouveau Reel"
- [x] [TEST] Verify implementation builds correctly (`npm run build`)
- [x] [FIX] **Deployment**: Implemented automated safe DB migration on startup
- [x] [FIX] **Encryption**: Unified TokenManager encryption with storage service (Fixed Invalid Token error)
- [x] [FIX] **Resilience**: Added auto-expiration for invalid tokens in `AnalyticsService`
- [x] [FIX] **Feedback**: Improved error reporting in `AnalyticsService`
- [ ] [MANUAL] Validate Analytics Data accuracy through UI
### Phase 4: Configuration Requise ⏳
- [ ] Configurer Jamendo Client ID (variable d'environnement)
- [ ] Configurer FFmpeg API URL et clé (variable d'environnement)
- [ ] Ajouter la valeur 'reel' à l'enum post_type en base de données
## Progress Log
- **20 Jan 2026** - Architecture defined: Service-based approach, Encryption for tokens, Node-cron for scheduling.
- **20 Jan 2026** - **Build Fixed**: Resolved circular dependencies (`db` import) and frontend type errors.
- **22 Jan 2026** - Analyse complète de l'application Socialflow effectuée
- **22 Jan 2026** - PRD créé avec architecture proposée
- **22 Jan 2026** - Spécifications confirmées : Jamendo API, FFmpeg Docker, style TikTok
- **22 Jan 2026** - Backend complet : ffmpeg.ts, jamendo.ts, facebook.ts (publishReel)
- **22 Jan 2026** - Routes API créées dans server/routes/reels.ts
- **22 Jan 2026** - Frontend créé : new-reel.tsx avec workflow 4 étapes
- **22 Jan 2026** - Navigation mise à jour : route et lien sidebar