mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
Retire le générateur vidéo, les badges « New » et permet d'effacer les Reels en échec
- Suppression de la page « Générateur Vidéo » (/remotion) : routes /api/remotion, pipeline de rendu d'images, composition ImageVideo et aperçu associé. Le générateur de Reels est conservé. - Plus de badge « New » à côté des menus. - Tableau de bord : bouton pour supprimer un Reel en échec et sa notification. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
This commit is contained in:
19 files changed
+64
-1615
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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))}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -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 */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user