Compare commits

..
Author SHA1 Message Date
LogiFlow 01315bf281 Merge pull request #124 from R0m1k3/claude/practical-newton-aal4xl
Reels : option « Publier aussi en story » sur Facebook
2026-10-05 09:03:19 +02:00
Claude 086ef956e2 Reels : option « Publier aussi en story » sur Facebook
Une case sur les écrans de création (bureau, mobile) et de programmation
d'un Reel double la publication d'une story vidéo sur les pages Facebook
sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas.

- Publication immédiate : la story part juste après le Reel, à partir de la
  vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer).
  Un échec de la story est noté dans l'historique sans faire échouer le Reel.
- Publication programmée : une entrée « story » est planifiée à côté du
  Reel ; le planificateur la publie comme les autres stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
2026-10-05 06:50:33 +00:00
LogiFlow 7806f8bdc8 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
2026-09-29 15:52:21 +02:00
Claude da9a74dbe6 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
2026-09-29 13:32:17 +00:00
LogiFlow e89dd97881 Merge pull request #121 from R0m1k3/claude/dreamy-feynman-jxb0g3
Reels : programmer la publication d'une vidéo déjà montée
2026-09-29 15:20:21 +02:00
Claude 188706f4dc Reels : programmer la publication d'une vidéo déjà montée
Nouvelle page « Programmer un Reel » (/reel/schedule) : envoi ou choix
d'une vidéo, description, pages Facebook/TikTok, date et heure. Le Reel
est publié tel quel par le planificateur, sans passer par le générateur.

- POST /api/posts valide les Reels (une vidéo, pages Facebook/TikTok)
- vignette extraite à l'envoi des vidéos pour l'historique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
2026-09-29 13:16:06 +00:00
LogiFlow 2c732c11b9 Merge pull request #120 from R0m1k3/claude/trusting-hawking-47qpzb
Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
2026-09-29 14:02:00 +02:00
26 changed files with 567 additions and 1620 deletions

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.
+5 -4
View File
@@ -81,9 +81,10 @@ const NewReel = createResponsiveRoute(
() => import("@/pages/mobile/new-reel") // Uses same component for now
);
const RemotionVideo = createResponsiveRoute(
() => import("@/pages/remotion-video"),
() => import("@/pages/mobile/remotion-video")
// Reel déjà monté : même page responsive sur mobile et desktop
const ScheduleReel = createResponsiveRoute(
() => import("@/pages/schedule-reel"),
() => import("@/pages/schedule-reel")
);
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
@@ -136,7 +137,7 @@ function Router() {
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route>
<Route path="/reel/schedule">{() => <ProtectedRoute component={ScheduleReel} />}</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))}
@@ -0,0 +1,29 @@
import { Switch } from "@/components/ui/switch";
interface StoryToggleProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
/**
* Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a
* pas de stories : l'option ne le concerne pas.
*/
export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
import { Home, PlusCircle, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
import { Link, useLocation } from "wouter";
import { useQuery, useMutation } from "@tanstack/react-query";
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
@@ -70,12 +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: 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 -2
View File
@@ -13,7 +13,7 @@ import {
Users,
ChevronRight,
Sparkles,
Film
CalendarClock
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -29,7 +29,7 @@ export default function DashboardMobile() {
];
const secondaryMenu = [
{ icon: Film, label: "Générateur Vidéo", href: "/remotion" },
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" },
{ icon: Images, label: "Médiathèque", href: "/media" },
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor" },
{ icon: Clock, label: "Historique", href: "/history" },
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
stabilize: stabilize,
drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
-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>
);
}
+4
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider";
import {
Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</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>
);
}
+300
View File
@@ -0,0 +1,300 @@
import { useState, useCallback } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useLocation } from "wouter";
import { CalendarClock, Upload, Loader2, Check, Video } from "lucide-react";
import { useDropzone } from "react-dropzone";
import Sidebar from "@/components/sidebar";
import TopBar from "@/components/topbar";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { StoryToggle } from "@/components/reels/story-toggle";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
import type { SocialPage, Media } from "@shared/schema";
import { SiFacebook, SiTiktok } from "react-icons/si";
import { MediaThumbnail } from "@/components/media-thumbnail";
import { DateTimePicker } from "@/components/datetime-picker";
/**
* Programmation d'un Reel déjà monté : pas de rendu, la vidéo envoyée est
* publiée telle quelle par le planificateur à la date choisie.
*/
export default function ScheduleReel() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [, navigate] = useLocation();
const { toast } = useToast();
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
const [description, setDescription] = useState("");
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [alsoStory, setAlsoStory] = useState(false);
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
// Destinations acceptant un Reel
const facebookPages = pages.filter((p) => p.platform === "facebook");
const tiktokAccounts = pages.filter((p) => p.platform === "tiktok");
const videoList = allMedia.filter((m) => m.type === "video").slice(0, 12);
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/media/upload", {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
if (response.status === 401) handleUnauthorized("/api/media/upload");
throw new Error("Upload failed");
}
return response.json() as Promise<Media>;
},
onSuccess: (media) => {
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
setSelectedVideo(media);
toast({ title: "Vidéo envoyée", description: media.fileName });
},
onError: () => {
toast({ title: "Erreur", description: "Impossible d'envoyer la vidéo", variant: "destructive" });
},
});
const onDrop = useCallback((files: File[]) => {
if (files[0]) uploadMutation.mutate(files[0]);
}, [uploadMutation]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { "video/*": [".mp4", ".mov", ".webm"] },
maxFiles: 1,
disabled: uploadMutation.isPending,
});
const scheduleMutation = useMutation({
mutationFn: async () => {
const response = await apiRequest("POST", "/api/posts", {
content: description,
postType: "reel",
mediaIds: [selectedVideo!.id],
pageIds: selectedPages,
scheduledFor: scheduledDate!.toISOString(),
alsoStory,
});
return response.json();
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"], refetchType: "all" });
await queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
toast({ title: "Reel programmé", description: "Il sera publié à la date choisie." });
navigate("/calendar");
},
onError: (error: unknown) => {
toast({
title: "Erreur",
description: getErrorMessage(error, "Impossible de programmer le Reel"),
variant: "destructive",
});
},
});
const isPast = !!scheduledDate && scheduledDate.getTime() <= Date.now();
const canSchedule = !!selectedVideo && selectedPages.length > 0 && !!scheduledDate && !isPast;
const togglePage = (id: string, checked: boolean) => {
setSelectedPages((prev) => (checked ? [...prev, id] : prev.filter((p) => p !== id)));
};
const renderTargetCheckbox = (page: SocialPage) => (
<div key={page.id} className="flex items-center space-x-2">
<Checkbox
id={`schedule-reel-page-${page.id}`}
checked={selectedPages.includes(page.id)}
onCheckedChange={(checked) => togglePage(page.id, checked === true)}
/>
<label htmlFor={`schedule-reel-page-${page.id}`} className="text-sm font-medium leading-none flex-1">
{page.pageName}
</label>
</div>
);
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-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground flex items-center gap-2">
<CalendarClock className="w-8 h-8 text-primary" />
Programmer un Reel
</h1>
<p className="text-muted-foreground mt-2">
Publiez une vidéo déjà montée, telle quelle, à la date et l'heure choisies
</p>
</div>
{/* 1. Vidéo */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video className="w-5 h-5" /> Vidéo
</CardTitle>
<CardDescription>Format vertical 9:16 recommandé</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${
isDragActive ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
}`}
data-testid="dropzone-schedule-reel"
>
<input {...getInputProps()} />
{uploadMutation.isPending ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" /> Envoi en cours...
</div>
) : (
<div className="space-y-1">
<Upload className="w-8 h-8 mx-auto text-muted-foreground" />
<p className="font-medium">Déposez votre Reel ici ou cliquez pour choisir</p>
<p className="text-xs text-muted-foreground">MP4, MOV ou WebM</p>
</div>
)}
</div>
{videoList.length > 0 && (
<div className="space-y-2">
<Label>Ou choisissez une vidéo de la médiathèque</Label>
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
{videoList.map((media) => {
const selected = selectedVideo?.id === media.id;
return (
<button
key={media.id}
type="button"
onClick={() => setSelectedVideo(media)}
className={`relative aspect-[9/16] rounded-lg overflow-hidden border-2 ${
selected ? "border-primary" : "border-transparent"
}`}
>
<MediaThumbnail
src={media.originalUrl}
alt={media.fileName}
thumbnailUrl={media.thumbnailUrl ?? undefined}
type="video"
/>
{selected && (
<div className="absolute inset-0 bg-primary/30 flex items-center justify-center">
<Check className="w-6 h-6 text-white" />
</div>
)}
</button>
);
})}
</div>
</div>
)}
{selectedVideo && (
<p className="text-sm text-muted-foreground">
Sélectionnée : <span className="font-medium text-foreground">{selectedVideo.fileName}</span>
</p>
)}
</CardContent>
</Card>
{/* 2. Description */}
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Texte publié avec le Reel (optionnel)"
rows={4}
data-testid="textarea-schedule-reel-description"
/>
</CardContent>
</Card>
{/* 3. Destinations et date */}
<Card>
<CardHeader>
<CardTitle>Publication</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
<p className="text-sm text-muted-foreground">Aucune page Facebook ou compte TikTok connecté.</p>
)}
{facebookPages.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
</Label>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
{tiktokAccounts.length > 0 && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<SiTiktok className="w-4 h-4" /> Comptes TikTok
</Label>
{tiktokAccounts.map(renderTargetCheckbox)}
</div>
)}
<div className="space-y-2">
<Label>Date et heure de parution</Label>
<DateTimePicker
value={scheduledDate}
onChange={setScheduledDate}
placeholder="Choisir la date et l'heure"
/>
{isPast && (
<p className="text-sm text-destructive">La date doit être dans le futur.</p>
)}
</div>
<Button
className="w-full"
size="lg"
disabled={!canSchedule || scheduleMutation.isPending}
onClick={() => scheduleMutation.mutate()}
data-testid="button-schedule-reel"
>
{scheduleMutation.isPending ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CalendarClock className="w-4 h-4 mr-2" />
)}
Programmer le Reel
</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}
/>
</>
);
};
+40 -5
View File
@@ -16,11 +16,11 @@ 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";
import { legalRouter } from "./routes/legal";
import { createVideoThumbnail } from "./services/thumbnail";
import { insertAudioTrackSchema } from "@shared/schema";
import * as musicMetadata from "music-metadata";
@@ -553,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
@@ -721,13 +720,20 @@ export async function registerRoutes(app: Express): Promise<Server> {
req.file.mimetype
);
// Détection basique basée sur le mimetype ou l'extension
const isVideo = req.file.mimetype.startsWith("video/") || /\.(mp4|mov|avi|mkv|webm)$/i.test(req.file.originalname);
// Vignette extraite tant que la vidéo est sur le disque : elle survit à la
// suppression du fichier après publication (historique illustré)
const thumbnailUrl = isVideo ? await createVideoThumbnail(req.file.path) : null;
// Clean up temp file immediately after upload to free disk space
await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err));
const mediaItem = await storage.createMedia({
userId,
// Détection basique basée sur le mimetype ou le résultat Cloudinary
type: req.file.mimetype.startsWith("video/") || req.file.originalname.match(/\.(mp4|mov|avi|mkv)$/i) ? "video" : "image",
type: isVideo ? "video" : "image",
thumbnailUrl,
cloudinaryPublicId: uploadResult.publicId,
originalUrl: uploadResult.originalUrl,
facebookFeedUrl: uploadResult.facebookFeedUrl,
@@ -1262,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -1297,6 +1303,23 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" });
}
// Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok
if (postType === 'reel') {
if (finalMediaItems.length !== 1) {
return res.status(400).json({ error: "Un Reel nécessite exactement une vidéo" });
}
const reelMedia = await storage.getMediaById(finalMediaItems[0].mediaId);
if (!reelMedia || reelMedia.type !== 'video' || (user.role !== 'admin' && reelMedia.userId !== userId)) {
return res.status(400).json({ error: "Le média d'un Reel doit être une de vos vidéos" });
}
for (const pageId of Array.isArray(pageIds) ? pageIds : []) {
const page = await storage.getSocialPage(pageId);
if (!page || (page.platform !== 'facebook' && page.platform !== 'tiktok')) {
return res.status(400).json({ error: "Les Reels ne peuvent être publiés que sur Facebook ou TikTok" });
}
}
}
// Security: Verify user has access to all specified pages (unless admin)
if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
const accessiblePages = await storage.getUserAccessiblePages(userId);
@@ -1372,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
postType: finalPostType,
scheduledAt,
});
// Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas
if (finalPostType === 'reel' && alsoStory === true) {
const page = await storage.getSocialPage(pageId);
if (page?.platform === 'facebook') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'story',
scheduledAt,
});
}
}
}
}
}
-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) });
}
});
+55
View File
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId;
}
/**
* Publie une vidéo en story Facebook en envoyant directement les données
* binaires (start → upload → finish sur /video_stories). Sert à doubler un
* Reel d'une story : la vidéo rendue est déjà en mémoire.
*/
async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const accessToken = page.accessToken;
const fileSize = videoBuffer.length;
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
method: 'POST',
});
if (!startRes.ok) {
const error = await startRes.json() as FacebookError;
throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: new Blob([videoBuffer], { type: 'video/mp4' }),
});
if (!uploadRes.ok) {
throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
}
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
method: 'POST',
});
if (!finishRes.ok) {
const error = await finishRes.json() as FacebookError;
throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishRes.json() as { success: boolean; post_id?: string };
console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
-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();
}
+59 -3
View File
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media } from "@shared/schema";
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
@@ -49,6 +51,9 @@ export async function storeRenderedVideo(
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor } = options;
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
+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;
+1
View File
@@ -84,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
-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 };
}
+2 -8
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", () => {
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true, alsoStory: false });
});
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
@@ -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);
+3 -27
View File
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText,
// Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok)
alsoStory: z.boolean().default(false),
// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64),
@@ -48,24 +50,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 +58,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;
}