mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 23:18:12 +02:00
feat: add publication scheduling to Remotion video generator
- New POST /api/remotion/publish endpoint: uploads MP4 to Cloudinary, creates post/media records, publishes or schedules per-page as a Reel - Desktop and mobile pages show page selector + DateTimePicker + description after video generation, matching the Reels publication workflow Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
32c308c2af
commit
4072f8954f
3 files changed
+282
-12
No files matched your search
@@ -8,12 +8,14 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause } from "lucide-react";
|
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import type { Media } from "@shared/schema";
|
import type { Media, SocialPage } from "@shared/schema";
|
||||||
|
|
||||||
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
||||||
|
|
||||||
@@ -56,18 +58,23 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
|
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [publishDescription, setPublishDescription] = useState("");
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||||
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({ queryKey: ['/api/cloudinary/config'] });
|
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({ queryKey: ['/api/cloudinary/config'] });
|
||||||
const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({ queryKey: ['/api/pages'] });
|
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||||
|
|
||||||
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
||||||
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined;
|
const previewStoreName = socialPages[0]?.pageName as string | undefined;
|
||||||
|
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||||
|
|
||||||
const generateTextMutation = useMutation({
|
const generateTextMutation = useMutation({
|
||||||
@@ -143,6 +150,27 @@ export default function MobileRemotionVideoPage() {
|
|||||||
} finally { setIsRendering(false); }
|
} finally { 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.",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||||
|
} finally { setIsPublishing(false); }
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background pb-24">
|
<div className="min-h-screen bg-background pb-24">
|
||||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||||
@@ -340,12 +368,54 @@ export default function MobileRemotionVideoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
||||||
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
||||||
<Button variant="outline" className="w-full" asChild>
|
<Button variant="outline" className="w-full" asChild>
|
||||||
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
|
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Card className="border-primary/40">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{facebookPages.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{facebookPages.map(page => (
|
||||||
|
<div key={page.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`mpub-${page.id}`}
|
||||||
|
checked={selectedPageIds.includes(page.id)}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setSelectedPageIds(prev =>
|
||||||
|
checked ? [...prev, page.id] : prev.filter(id => id !== page.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`mpub-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||||
|
{page.pageName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||||
|
onChange={e => setPublishDescription(e.target.value)} />
|
||||||
|
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||||
|
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||||
|
<Button className="w-full" onClick={handlePublish}
|
||||||
|
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||||
|
{isPublishing
|
||||||
|
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||||
|
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { useToast } from "@/hooks/use-toast";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause } from "lucide-react";
|
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import type { Media } from "@shared/schema";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { DateTimePicker } from "@/components/datetime-picker";
|
||||||
|
import type { Media, SocialPage } from "@shared/schema";
|
||||||
|
|
||||||
const TTS_VOICES = [
|
const TTS_VOICES = [
|
||||||
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme" },
|
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme" },
|
||||||
@@ -65,6 +67,10 @@ export default function RemotionVideoPage() {
|
|||||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||||
const [isRendering, setIsRendering] = useState(false);
|
const [isRendering, setIsRendering] = useState(false);
|
||||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
|
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [publishDescription, setPublishDescription] = useState("");
|
||||||
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -75,14 +81,15 @@ export default function RemotionVideoPage() {
|
|||||||
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({
|
const { data: cloudinaryConfig } = useQuery<{ cloudName?: string; logoPublicId?: string } | null>({
|
||||||
queryKey: ['/api/cloudinary/config'],
|
queryKey: ['/api/cloudinary/config'],
|
||||||
});
|
});
|
||||||
const { data: socialPages = [] } = useQuery<{ pageName: string }[]>({
|
const { data: socialPages = [] } = useQuery<SocialPage[]>({
|
||||||
queryKey: ['/api/pages'],
|
queryKey: ['/api/pages'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
const previewLogoUrl = cloudinaryConfig?.cloudName && cloudinaryConfig?.logoPublicId
|
||||||
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
? `https://res.cloudinary.com/${cloudinaryConfig.cloudName}/image/upload/${cloudinaryConfig.logoPublicId}`
|
||||||
: undefined;
|
: undefined;
|
||||||
const previewStoreName = (socialPages[0] as any)?.pageName as string | undefined;
|
const previewStoreName = socialPages[0]?.pageName as string | undefined;
|
||||||
|
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||||
|
|
||||||
const generateTextMutation = useMutation({
|
const generateTextMutation = useMutation({
|
||||||
@@ -184,6 +191,32 @@ export default function RemotionVideoPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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')}`
|
||||||
|
: "Votre vidéo a été publiée sur Facebook.",
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||||
|
} finally {
|
||||||
|
setIsPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-8">
|
<div className="container mx-auto p-6 space-y-8">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Générateur Remotion</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Générateur Remotion</h1>
|
||||||
@@ -362,12 +395,76 @@ export default function RemotionVideoPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{videoUrl && (
|
{videoUrl && (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<p className="font-semibold mb-2 text-green-600">Vidéo prête :</p>
|
<p className="font-semibold text-green-600">Vidéo prête :</p>
|
||||||
<video src={videoUrl} controls className="w-full rounded-md shadow" />
|
<video src={videoUrl} controls className="w-full rounded-md shadow" />
|
||||||
<Button variant="outline" className="w-full mt-2" asChild>
|
<Button variant="outline" className="w-full" asChild>
|
||||||
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
|
<a href={videoUrl} download="remotion-video.mp4">Télécharger</a>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Publication */}
|
||||||
|
<Card className="border-primary/40">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Choisissez la page et programmez (optionnel).</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{facebookPages.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Aucune page Facebook connectée.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{facebookPages.map(page => (
|
||||||
|
<div key={page.id} className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
id={`pub-page-${page.id}`}
|
||||||
|
checked={selectedPageIds.includes(page.id)}
|
||||||
|
onCheckedChange={checked =>
|
||||||
|
setSelectedPageIds(prev =>
|
||||||
|
checked ? [...prev, page.id] : prev.filter(id => id !== page.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`pub-page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||||
|
{page.pageName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm">Description (optionnel)</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={2}
|
||||||
|
placeholder="Texte de la publication..."
|
||||||
|
value={publishDescription}
|
||||||
|
onChange={e => setPublishDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-sm">Planifier (optionnel)</Label>
|
||||||
|
<DateTimePicker
|
||||||
|
value={scheduledDate}
|
||||||
|
onChange={setScheduledDate}
|
||||||
|
occupiedDates={[]}
|
||||||
|
placeholder="Publier immédiatement"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={isPublishing || selectedPageIds.length === 0}
|
||||||
|
>
|
||||||
|
{isPublishing
|
||||||
|
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||||
|
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { bundle } from "@remotion/bundler";
|
|||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
import { ffmpegService } from "../services/ffmpeg";
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
import { storage as dbStorage } from "../storage";
|
import { storage as dbStorage } from "../storage";
|
||||||
|
import { cloudinaryService } from "../services/cloudinary";
|
||||||
|
import { facebookService } from "../services/facebook";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
export const remotionRouter = Router();
|
export const remotionRouter = Router();
|
||||||
@@ -240,3 +242,104 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
res.status(500).json({ error: "Erreur lors du rendu: " + err.message });
|
res.status(500).json({ error: "Erreur lors du rendu: " + err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/remotion/publish
|
||||||
|
* Uploads the rendered MP4 to Cloudinary and publishes (or schedules) it as a Reel.
|
||||||
|
*/
|
||||||
|
remotionRouter.post("/publish", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as any;
|
||||||
|
if (!user?.id) return res.status(401).json({ error: "Non authentifié" });
|
||||||
|
|
||||||
|
const { videoUrl, pageIds, scheduledFor, description } = req.body as {
|
||||||
|
videoUrl: string;
|
||||||
|
pageIds: string[];
|
||||||
|
scheduledFor?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
|
||||||
|
if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
|
||||||
|
|
||||||
|
// Resolve the local file path from the temp URL
|
||||||
|
const filename = path.basename(videoUrl.split("?")[0]);
|
||||||
|
const localPath = path.join(uploadDir, filename);
|
||||||
|
if (!fs.existsSync(localPath)) {
|
||||||
|
return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("☁️ Uploading Remotion video to Cloudinary...");
|
||||||
|
const cloudinaryResult = await cloudinaryService.uploadMedia(
|
||||||
|
localPath,
|
||||||
|
filename,
|
||||||
|
user.id,
|
||||||
|
"video/mp4"
|
||||||
|
);
|
||||||
|
console.log("✅ Cloudinary upload:", cloudinaryResult.originalUrl);
|
||||||
|
|
||||||
|
// Create a Post record to track this publication
|
||||||
|
const post = await dbStorage.createPost({
|
||||||
|
userId: user.id,
|
||||||
|
content: description || "",
|
||||||
|
aiGenerated: "false",
|
||||||
|
status: scheduledFor ? "scheduled" : "published",
|
||||||
|
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a Media record for the processed video
|
||||||
|
const mediaRecord = await dbStorage.createMedia({
|
||||||
|
userId: user.id,
|
||||||
|
type: "video",
|
||||||
|
cloudinaryPublicId: cloudinaryResult.publicId,
|
||||||
|
originalUrl: cloudinaryResult.originalUrl,
|
||||||
|
facebookFeedUrl: null,
|
||||||
|
instagramFeedUrl: null,
|
||||||
|
instagramStoryUrl: null,
|
||||||
|
fileName: filename,
|
||||||
|
fileSize: fs.statSync(localPath).size,
|
||||||
|
});
|
||||||
|
|
||||||
|
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
|
||||||
|
|
||||||
|
const results: { pageId: string; success: boolean; reelId?: string; error?: string }[] = [];
|
||||||
|
|
||||||
|
for (const pageId of pageIds) {
|
||||||
|
try {
|
||||||
|
const page = await dbStorage.getSocialPage(pageId);
|
||||||
|
if (!page) { results.push({ pageId, success: false, error: "Page introuvable" }); continue; }
|
||||||
|
if (page.platform !== "facebook") { results.push({ pageId, success: false, error: "Seules les pages Facebook sont supportées" }); continue; }
|
||||||
|
|
||||||
|
const scheduledPost = await dbStorage.createScheduledPost({
|
||||||
|
postId: post.id,
|
||||||
|
pageId: page.id,
|
||||||
|
postType: "reel",
|
||||||
|
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!scheduledFor) {
|
||||||
|
console.log(`🚀 Publishing to ${page.pageName}...`);
|
||||||
|
const reelId = await facebookService.publishReel(page, cloudinaryResult.originalUrl, description || "");
|
||||||
|
await dbStorage.updateScheduledPost(scheduledPost.id, { publishedAt: new Date(), externalPostId: reelId });
|
||||||
|
results.push({ pageId, success: true, reelId });
|
||||||
|
} else {
|
||||||
|
results.push({ pageId, success: true, reelId: "scheduled" });
|
||||||
|
}
|
||||||
|
} catch (pageErr: any) {
|
||||||
|
console.error(`❌ Error publishing to page ${pageId}:`, pageErr);
|
||||||
|
results.push({ pageId, success: false, error: pageErr.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const anySuccess = results.some(r => r.success);
|
||||||
|
if (!anySuccess) {
|
||||||
|
await dbStorage.updatePost(post.id, { status: "failed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: anySuccess, results, postId: post.id });
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("❌ Remotion publish error:", err);
|
||||||
|
res.status(500).json({ error: "Erreur lors de la publication: " + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user