Merge pull request #122 from R0m1k3/claude/dreamy-feynman-jxb0g3

Retire le générateur vidéo et les badges « New », Reels en échec supprimables
This commit is contained in:
LogiFlow authored and GitHub committed 2026-09-29 15:52:21 +02:00
commit 7806f8bdc8
19 files changed
+64 -1615

No files matched your search

+12 -78
View File
@@ -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.
-6
View File
@@ -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() {
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
<Route path="/reel/schedule">{() => <ProtectedRoute component={ScheduleReel} />}</Route>
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route>
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
+41 -2
View File
@@ -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<OngoingReel[]>(["/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) {
</div>
)}
</div>
{isFailed && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => dismissMutation.mutate(post.id)}
disabled={dismissMutation.isPending && dismissMutation.variables === post.id}
title="Supprimer"
aria-label="Supprimer ce Reel en échec"
data-testid={`button-dismiss-reel-${post.id}`}
>
<X className="w-4 h-4" />
</Button>
)}
</div>
);
})}
+5 -38
View File
@@ -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) {
<div className="overflow-hidden rounded-lg bg-black">
<Player
// Nouvelle instance quand la durée change : le lecteur repart du début
key={`${props.kind}-${composition.total}`}
key={composition.total}
component={composition.component as React.ComponentType<Record<string, unknown>>}
inputProps={composition.inputProps as unknown as Record<string, unknown>}
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
+3 -4
View File
@@ -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 = [
-2
View File
@@ -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" },
-538
View File
@@ -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<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [overlayText, setOverlayText] = useState("");
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
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 [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [showLogo, setShowLogo] = useState(true);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(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<HTMLAudioElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const galleryInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ 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) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`page-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div className="min-h-screen bg-background pb-24">
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* Header */}
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
</Sheet>
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
</div>
<main className="p-4 space-y-4">
{/* 1. Images */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">1. Images</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Input caché pour la caméra */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleCameraCapture}
className="hidden"
/>
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
<input
ref={galleryInputRef}
type="file"
accept="image/*"
multiple
onChange={handleFileChange}
className="hidden"
/>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
onClick={() => galleryInputRef.current?.click()}
disabled={totalSelected >= 4}
>
<ImagePlus className="h-6 w-6 text-primary" />
<span className="text-xs font-medium">Galerie</span>
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
</Button>
<Button
variant="outline"
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
onClick={() => cameraInputRef.current?.click()}
disabled={totalSelected >= 4}
>
<Camera className="h-6 w-6" />
<span className="text-xs">Photo</span>
</Button>
</div>
{imageMediaList.length > 0 && (
<div className="flex overflow-x-auto gap-2 pb-1">
{imageMediaList.map(media => {
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
return (
<div key={media.id} onClick={() => toggleLibraryImage(media)}
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
</div>
);
})}
</div>
)}
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
</CardContent>
</Card>
{/* 2. Texte & Voix */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
<Button variant="secondary" size="sm" className="w-full"
disabled={!productInfo || generateTextMutation.isPending}
onClick={() => generateTextMutation.mutate(productInfo)}>
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
Générer avec l'IA
</Button>
{generatedVariants.length > 0 && (
<div className="space-y-1 max-h-32 overflow-y-auto">
{generatedVariants.map((v: any, i: number) => (
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
{v.text}
</div>
))}
</div>
)}
<Textarea placeholder="Texte overlay…" value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
<div className="space-y-1.5">
<Label className="text-xs font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
</div>
<div className="flex items-center gap-3">
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
</div>
{ttsEnabled && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
</div>
)}
</CardContent>
</Card>
{/* 3. Musique */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="max-h-40 overflow-y-auto space-y-1.5">
{tracksLoading ? (
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
) : audioTracks.length === 0 ? (
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
</div>
) : (
audioTracks.map(track => {
const isSelected = selectedTrack?.id === track.id;
const playing = isPlaying === track.id;
return (
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">{track.title}</p>
<p className="text-[10px] text-muted-foreground">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
);
})
)}
</div>
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
<input type="file" accept="audio/*"
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
</div>
{(musicFile || selectedTrack) && (
<div className="space-y-1">
<Label className="flex items-center gap-2 text-xs">
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
</Label>
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
</div>
)}
</CardContent>
</Card>
{/* 4. Destinations */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : (
<>
{facebookPages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[260px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
{isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
</Button>
{videoUrl && (
<div className="space-y-3">
<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" />
<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>
) : selectedPageIds.length === 0 ? (
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
) : (
<p className="text-xs text-primary font-medium">
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p>
)}
<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>
)}
</main>
</div>
);
}
-490
View File
@@ -1,490 +0,0 @@
import { useEffect, useMemo, useState, useRef } from "react";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
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, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
import { Slider } from "@/components/ui/slider";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateTimePicker } from "@/components/datetime-picker";
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;
}
export default function RemotionVideoPage() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [images, setImages] = useState<File[]>([]);
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
const [overlayText, setOverlayText] = useState("");
const [productInfo, setProductInfo] = useState("");
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
const [ttsEnabled, setTtsEnabled] = useState(true);
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
engine: 'gemini',
voice: DEFAULT_VOICE.gemini,
style: DEFAULT_TTS_STYLE,
});
const [musicFile, setMusicFile] = useState<File | null>(null);
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
const [musicVolume, setMusicVolume] = useState(0.3);
const [isPlaying, setIsPlaying] = useState<string | null>(null);
const [isRendering, setIsRendering] = useState(false);
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 [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
const [showLogo, setShowLogo] = useState(true);
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(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<HTMLAudioElement>(null);
const { toast } = useToast();
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
const { data: socialPages = [] } = useQuery<SocialPage[]>({ 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) => (
<div key={page.id} className="flex items-center gap-3">
<Checkbox
id={`pre-${page.id}`}
checked={selectedPageIds.includes(page.id)}
onCheckedChange={checked =>
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
}
/>
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
{page.pageName}
</label>
</div>
);
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
const totalSelected = images.length + selectedLibraryImages.length;
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", description: "Cliquez pour appliquer." });
},
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
};
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 maximum.", variant: "destructive" }); return prev; }
return [...prev, media];
});
};
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 requises.", 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 !", description: "Votre vidéo est prête à publier." });
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 {
// Fallback for older sync backend
setVideoUrl(data.url);
if (overlayText && !publishDescription) setPublishDescription(overlayText);
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
setIsRendering(false);
}
} catch (err: any) {
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
setIsRendering(false);
}
// Note: removed finally block so isRendering stays true during polling
};
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 (
<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-2xl mx-auto space-y-6">
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
{/* 1. Images */}
<Card>
<CardHeader>
<CardTitle>1. Images</CardTitle>
<CardDescription>Sélectionnez 3 à 4 images (bibliothèque ou upload).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center relative hover:bg-muted/50 transition-colors">
<input type="file" multiple accept="image/*" onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-10 w-10 text-muted-foreground mb-3" />
<p className="font-medium text-center">Glissez ou cliquez</p>
<p className="text-sm text-muted-foreground mt-1">{images.length} uploadée(s)</p>
</div>
{imageMediaList.length > 0 && (
<div className="pt-3 border-t border-border">
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
<div className="grid grid-cols-5 gap-2 max-h-44 overflow-y-auto">
{imageMediaList.map(media => {
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
return (
<div key={media.id} onClick={() => toggleLibraryImage(media)}
className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}>
<img src={media.originalUrl} className="w-full h-full object-cover" />
{isSelected && <div className="absolute top-1 right-1 bg-primary text-white p-0.5 rounded-full"><Check className="w-3 h-3" /></div>}
</div>
);
})}
</div>
</div>
)}
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
</CardContent>
</Card>
{/* 2. Texte + Voix */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
<CardDescription>La voix lira votre texte. Les hashtags s'affichent mais ne sont pas lus.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea placeholder="Décrivez votre produit pour générer un texte IA..." value={productInfo}
onChange={e => setProductInfo(e.target.value)} rows={2} />
<Button variant="secondary" className="w-full" disabled={!productInfo || generateTextMutation.isPending}
onClick={() => generateTextMutation.mutate(productInfo)}>
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-4 h-4 animate-spin" /> : <Sparkles className="mr-2 w-4 h-4" />}
Générer avec l'IA
</Button>
{generatedVariants.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Cliquez pour appliquer :</p>
{generatedVariants.map((v: any, i: number) => (
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}
className={`text-sm p-2 rounded border cursor-pointer hover:bg-accent transition-colors ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
{v.text}
</div>
))}
</div>
)}
<Textarea placeholder="Votre texte overlay..." value={overlayText}
onChange={e => setOverlayText(e.target.value)} rows={3} />
<div className="space-y-1.5">
<Label className="text-sm font-medium">Style des sous-titres</Label>
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
</div>
<div className="flex items-center gap-3">
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
</div>
<div className="flex items-center gap-3">
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
<Label htmlFor="tts">Activer la voix TTS</Label>
</div>
{ttsEnabled && (
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
</div>
)}
</CardContent>
</Card>
{/* 3. Musique */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique de fond</CardTitle>
<CardDescription>Choisissez une musique de la bibliothèque ou uploadez la vôtre.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-h-48 overflow-y-auto space-y-1.5">
{tracksLoading ? (
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
) : audioTracks.length === 0 ? (
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
<p className="text-sm text-muted-foreground">Aucune musique disponible</p>
</div>
) : (
audioTracks.map(track => {
const isSelected = selectedTrack?.id === track.id;
const playing = isPlaying === track.id;
return (
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
</button>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{track.title}</p>
<p className="text-xs text-muted-foreground">
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
</p>
</div>
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
</div>
);
})
)}
</div>
<div className="relative">
<div className="border-2 border-dashed border-border rounded-lg p-3 flex items-center gap-3 hover:bg-muted/50 transition-colors cursor-pointer">
<input type="file" accept="audio/*"
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-5 w-5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground truncate">
{musicFile ? musicFile.name : "Ou uploadez votre propre fichier audio"}
</p>
{musicFile && <Check className="w-4 h-4 text-primary shrink-0" />}
</div>
</div>
{(musicFile || selectedTrack) && (
<div className="space-y-2">
<Label className="flex items-center gap-2 text-sm">
<Volume2 className="w-4 h-4" /> Volume : {Math.round(musicVolume * 100)}%
</Label>
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
</div>
)}
</CardContent>
</Card>
{/* 4. Destinations */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
) : (
<>
{facebookPages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
</div>
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<SiTiktok className="w-4 h-4" />
Comptes TikTok
</div>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Aperçu en direct */}
{previewImages.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aperçu</CardTitle>
</CardHeader>
<CardContent>
<div className="max-w-[300px] mx-auto">
<ReelPreview
kind="images"
images={previewImages}
text={overlayText}
showCaptions
captionStyle={captionStyle}
ttsEnabled={ttsEnabled}
voice={currentVoice}
musicUrl={musicFileUrl ?? selectedTrack?.url}
musicVolume={musicVolume}
logoUrl={reelConfig?.logoUrl}
showWatermark={showLogo}
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
endingEffect
/>
</div>
</CardContent>
</Card>
)}
{/* Générer */}
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
{isRendering
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
</Button>
{/* Vidéo générée */}
{videoUrl && (
<>
<div>
<p className="font-semibold text-green-600 mb-2">Vidéo prête :</p>
<video src={videoUrl} controls className="w-full rounded-lg shadow-lg" />
</div>
{/* Publication */}
<Card className="border-primary/40">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Send className="w-4 h-4" /> Publier sur Facebook
</CardTitle>
<CardDescription>Sélectionnez la page et planifiez (optionnel).</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{selectedPageIds.length === 0 ? (
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
) : (
<p className="text-sm text-primary font-medium">
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
</p>
)}
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
onChange={e => setPublishDescription(e.target.value)} />
<div className="space-y-1">
<Label className="text-sm text-muted-foreground">Date de publication</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>
</main>
</div>
);
}
-122
View File
@@ -1,122 +0,0 @@
import React from "react";
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
import type { CaptionStyle, TimedWord } from "@shared/captions";
import { Captions } from "./components/Captions";
import { Outro, Watermark } from "./components/Branding";
export type ImageCompositionProps = {
images: string[];
totalDuration: number;
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
words: TimedWord[];
captionStyle: CaptionStyle;
audioUrl?: string;
musicUrl?: string;
musicVolume?: number;
logoUrl?: string;
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
showWatermark?: boolean;
storeName?: string;
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
endingSeconds?: number;
};
/**
* Ken Burns presets — deterministic per image index.
* Each entry: starting transform → ending transform over the slide duration.
* Scale > 1 to avoid black edges when translating.
*/
const KB_PRESETS = [
// zoom in, center
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// zoom in + drift right
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
// zoom in + drift left
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
// zoom out, center
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
// pan up + slight zoom
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
// pan down + zoom in
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
// diagonal drift bottom-right
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
// diagonal drift top-left + zoom out
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
];
/**
* ImageSlide with Ken Burns effect (slow zoom / pan).
* The effect index is derived from the image index for deterministic behaviour in Remotion.
*/
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
return (
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
<Img
src={src}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
transformOrigin: "center center",
willChange: "transform",
}}
/>
</AbsoluteFill>
);
};
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
export const ImageComposition: React.FC<ImageCompositionProps> = ({
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
}) => {
const { fps, durationInFrames } = useVideoConfig();
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
const contentFrames = durationInFrames - endingFrames;
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
const endingStart = contentFrames / fps;
return (
<AbsoluteFill style={{ backgroundColor: "black" }}>
{images.map((imgUrl, index) => (
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
<ImageSlide src={imgUrl} effectIndex={index} />
</Sequence>
))}
{audioUrl && <Html5Audio src={audioUrl} />}
{musicUrl && (
<Html5Audio
src={musicUrl}
loop
volume={(frame) =>
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
? musicVolume * 0.35
: musicVolume) *
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
})
}
/>
)}
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
</AbsoluteFill>
);
};
-16
View File
@@ -1,6 +1,5 @@
import { Composition, type CalculateMetadataFunction } from "remotion";
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
/** La durée vient des propriétés : chaque Reel a la sienne. */
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
captionStyle: DEFAULT_CAPTION_STYLE,
} as ReelVideoProps}
/>
<Composition
id="ImageVideo"
component={ImageComposition}
fps={REEL_FPS}
width={REEL_WIDTH}
height={REEL_HEIGHT}
durationInFrames={REEL_FPS * 25}
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
defaultProps={{
images: ["https://placehold.co/1080x1920?text=Image+1"],
totalDuration: 25,
words: [],
captionStyle: DEFAULT_CAPTION_STYLE,
} as ImageCompositionProps}
/>
</>
);
};
-2
View File
@@ -16,7 +16,6 @@ import type { User, InsertUser, ScheduledPost } from "@shared/schema";
import { analyticsRouter } from "./routes/analytics";
import { normalizeQwenUrl } from "./services/reels/assets";
import { reelsRouter } from "./routes/reels";
import { remotionRouter } from "./routes/remotion";
import { externalRouter } from "./routes/external";
import { tiktokRouter } from "./routes/tiktok";
import { facebookRouter } from "./routes/facebook";
@@ -554,7 +553,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter);
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
-150
View File
@@ -1,150 +0,0 @@
import { Router } from "express";
import multer from "multer";
import path from "path";
import fs from "fs";
import type { User } from "@shared/schema";
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
import { storage as dbStorage } from "../storage";
import { enqueueReelJob, getReelJob } from "../services/reels/queue";
import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
import { resolveStoreName } from "../services/reels/assets";
import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
export const remotionRouter = Router();
const uploadDir = REMOTION_TEMP_DIR;
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const upload = multer({
storage: multer.diskStorage({
destination: uploadDir,
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
},
}),
});
/**
* GET /api/remotion/render/status/:jobId
* État d'un rendu, lu dans la file persistante (survit à un redémarrage).
*/
remotionRouter.get("/render/status/:jobId", async (req, res) => {
const user = req.user as User;
const job = await getReelJob(req.params.jobId);
if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
return res.status(404).json({ error: "Job introuvable" });
}
if (job.status === "completed") {
const result = job.result as ImagesReelResult;
return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
}
if (job.status === "failed") {
return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
}
res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
});
/**
* POST /api/remotion/render
* Met en file le rendu d'un Reel à partir d'images (1 à 4).
*/
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
const fields = (req.files ?? {}) as Record<string, Express.Multer.File[]>;
const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
try {
const user = req.user as User;
const existing = req.body.existingImageUrls;
const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
const musicFile = fields["music"]?.[0];
const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
const parsed = imagesReelParamsSchema.safeParse({
imageUrls: [...libraryUrls, ...uploadedImageUrls],
overlayText: req.body.overlayText,
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
ttsEnabled: req.body.ttsEnabled !== "false",
ttsEngine: req.body.ttsEngine || undefined,
ttsVoice: req.body.ttsVoice,
ttsStyle: req.body.ttsStyle || undefined,
captionStyle: req.body.captionStyle || undefined,
showLogo: req.body.showLogo !== "false",
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
tempFiles: uploaded.map((f) => f.path),
});
if (!parsed.success) {
await removeFiles(uploaded);
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
}
const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
} catch (err) {
console.error("❌ Remotion pre-render error:", err);
await removeFiles(uploaded);
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
}
});
async function removeFiles(files: Express.Multer.File[]): Promise<void> {
await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
}
/**
* POST /api/remotion/publish
* Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
*/
remotionRouter.post("/publish", async (req, res) => {
try {
const user = req.user as User;
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" });
// Chemin local du rendu à partir de son URL temporaire
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é ?)" });
}
const videoBuffer = await fs.promises.readFile(localPath);
const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
const post = await dbStorage.createPost({
userId: user.id,
content: description || "",
aiGenerated: "false",
status: scheduledFor ? "scheduled" : "draft",
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
});
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
const results = await publishReelToPages({
postId: post.id,
pageIds,
videoBuffer,
description: description || "",
scheduledFor,
});
res.json({ success: results.some((r) => r.success), results, postId: post.id });
} catch (err) {
console.error("❌ Remotion publish error:", err);
res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
}
});
-105
View File
@@ -1,105 +0,0 @@
/**
* Rendu d'un Reel à partir d'images avec Remotion.
* Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
* déclenchée par l'utilisateur après l'aperçu.
*/
import fs from "fs";
import path from "path";
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
import { ffmpegService } from "../ffmpeg";
import { generateVideoThumbnail } from "../thumbnail";
import type { JobContext } from "./queue";
import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
import { renderReelComposition } from "./remotionRenderer";
export { REMOTION_TEMP_DIR };
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
const params = imagesReelParamsSchema.parse(job.params);
const jobTempFiles: string[] = [];
try {
await progress(5, "prepare");
const images = await Promise.all(params.imageUrls.map(toDataUrl));
const logoPath = await resolveLogoPath();
const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
const { overlayText, storeName } = params;
// --- Voix et minutage réel des mots ---
let audioUrl: string | undefined;
let words: { text: string; start: number; end: number }[] = [];
let audioDuration = 0;
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
if (params.ttsEnabled && spokenText) {
await progress(15, "voice");
const voice = await ffmpegService.previewVoice(spokenText, {
voice: params.ttsVoice,
engine: params.ttsEngine,
style: params.ttsStyle,
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
qwen: await resolveQwenTarget(params.ttsEngine),
});
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
await fs.promises.writeFile(audioPath, voice.audio);
jobTempFiles.push(audioPath);
audioUrl = tempFileUrl(audioPath);
audioDuration = voice.duration;
words = voice.words;
}
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
const { total, endingSeconds } = computeImagesTiming({
imageCount: images.length,
voiceDuration: audioDuration,
hasEnding: Boolean(logoUrl || storeName),
});
await progress(25, "render");
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
let lastReported = 25;
await renderReelComposition({
compositionId: "ImageVideo",
outputLocation,
inputProps: {
images,
totalDuration: total,
words,
captionStyle: params.captionStyle,
audioUrl,
musicUrl,
musicVolume: params.musicVolume,
logoUrl,
showWatermark: params.showLogo,
storeName,
endingSeconds,
},
onProgress: (ratio) => {
const pct = 25 + Math.floor(ratio * 70);
if (pct >= lastReported + 5) {
lastReported = pct;
progress(pct, "render").catch(() => { /* non bloquant */ });
}
},
});
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
return {
url: `/uploads/temp/${path.basename(outputLocation)}`,
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
};
} finally {
// Images, musique importée et voix ne servent plus une fois le rendu fini
for (const file of [...params.tempFiles, ...jobTempFiles]) {
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
}
}
}
-2
View File
@@ -5,10 +5,8 @@
import { registerReelJobHandler, startReelWorker } from "./queue";
import { runVideoReelJob } from "./videoPipeline";
import { runImagesReelJob } from "./imagesPipeline";
export function startReelProcessing(): Promise<void> {
registerReelJobHandler("video", runVideoReelJob);
registerReelJobHandler("images", runImagesReelJob);
return startReelWorker();
}
+1 -1
View File
@@ -45,7 +45,7 @@ function renderConcurrency(): number {
}
export async function renderReelComposition(options: {
compositionId: "ReelVideo" | "ImageVideo";
compositionId: "ReelVideo";
inputProps: Record<string, unknown>;
outputLocation: string;
onProgress?: (ratio: number) => void;
-9
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
cleanCaptionText,
computeImagesTiming,
computeReelTiming,
groupLines,
offsetWords,
@@ -37,14 +36,6 @@ describe("fondu final", () => {
});
});
describe("computeImagesTiming", () => {
it("garde un Reel d'images entre 25 et 30 s", () => {
expect(computeImagesTiming({ imageCount: 3, hasEnding: true })).toEqual({ total: 25, endingSeconds: 3 });
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 40, hasEnding: true }).total).toBe(30);
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 24, hasEnding: false }).total).toBe(25);
});
});
describe("groupLines", () => {
const words = (texts: string[]) => texts.map((text, i) => ({ text, start: i, end: i + 0.5 }));
-16
View File
@@ -129,19 +129,3 @@ export function spreadWords(text: string, start: number, end: number): TimedWord
export function offsetWords(words: TimedWord[], offset: number): TimedWord[] {
return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset }));
}
const IMAGES_ENDING_SECONDS = 3;
const IMAGES_MIN_CONTENT = 22;
const IMAGES_MAX_CONTENT = 27;
const SECONDS_PER_IMAGE = 3;
/** Reel d'images : 25 à 30 s, diapositive de fin comprise. */
export function computeImagesTiming(input: { imageCount: number; voiceDuration?: number; hasEnding: boolean }): {
total: number;
endingSeconds: number;
} {
const natural = Math.max((input.voiceDuration ?? 0) + 1, input.imageCount * SECONDS_PER_IMAGE);
const content = Math.min(Math.max(natural, IMAGES_MIN_CONTENT), IMAGES_MAX_CONTENT);
const endingSeconds = input.hasEnding ? IMAGES_ENDING_SECONDS : 0;
return { total: content + endingSeconds, endingSeconds };
}
+1 -7
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
import { ttsPreviewSchema, videoReelParamsSchema } from "./reel";
describe("videoReelParamsSchema", () => {
it("exige une vidéo et une page, avec des messages lisibles", () => {
@@ -22,12 +22,6 @@ describe("videoReelParamsSchema", () => {
});
});
describe("imagesReelParamsSchema", () => {
it("limite à 4 images", () => {
expect(imagesReelParamsSchema.safeParse({ imageUrls: ["a", "b", "c", "d", "e"] }).success).toBe(false);
});
});
describe("ttsPreviewSchema", () => {
it("refuse un texte vide", () => {
expect(ttsPreviewSchema.safeParse({ text: " " }).success).toBe(false);
+1 -27
View File
@@ -48,24 +48,6 @@ export const videoReelParamsSchema = z.object({
export type VideoReelParams = z.infer<typeof videoReelParamsSchema>;
/** Reel construit à partir d'images (rendu Remotion). */
export const imagesReelParamsSchema = z.object({
// Chemins relatifs /uploads/... ou URL absolues
imageUrls: z.array(z.string().min(1)).min(1, "Aucune image fournie").max(4),
overlayText: optionalText,
musicUrl: optionalText,
musicVolume: z.number().min(0).max(2).default(0.3),
ttsEnabled: z.boolean().default(true),
ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsVoice: optionalText,
ttsStyle: z.enum(TTS_STYLES).optional(),
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
showLogo: z.boolean().default(true),
storeName: z.string().optional(),
// Fichiers temporaires à supprimer une fois le rendu terminé
tempFiles: z.array(z.string()).default([]),
});
/** Aperçu de la voix. */
export const ttsPreviewSchema = z.object({
text: z.string({ required_error: "Texte requis" }).trim().min(1, "Texte requis").max(2000),
@@ -74,14 +56,6 @@ export const ttsPreviewSchema = z.object({
ttsStyle: z.enum(TTS_STYLES).optional(),
});
export type ImagesReelParams = z.infer<typeof imagesReelParamsSchema>;
export type ReelJobKind = "video" | "images";
export type ReelJobKind = "video";
export type ReelJobStatus = "pending" | "processing" | "completed" | "failed";
/** Résultat d'un rendu d'images, consulté par le client pour l'aperçu. */
export interface ImagesReelResult {
url: string;
thumbnailUrl: string | null;
}