mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-24 07:28:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0b4d3f20 | ||
|
|
7806f8bdc8 | ||
|
|
da9a74dbe6 | ||
|
|
e89dd97881 | ||
|
|
188706f4dc | ||
|
|
2c732c11b9 |
No files matched your search
@@ -42,13 +42,12 @@ La clé externe peut être configurée par un administrateur depuis l'interface
|
||||
5. [Gestion de l'Audio (Musique de fond)](#5-gestion-de-laudio-musique-de-fond)
|
||||
6. [Publications & Planification](#6-publications--planification)
|
||||
7. [Reels & Génération Vidéo (FFmpeg)](#7-reels--génération-vidéo-ffmpeg)
|
||||
8. [Génération Vidéo Avancée (Remotion)](#8-génération-vidéo-avancée-remotion)
|
||||
9. [IA & Modèles (OpenRouter)](#9-ia--modèles-openrouter)
|
||||
10. [Statistiques & Paramètres Système](#10-statistiques--paramètres-système)
|
||||
11. [Console SQL (Admin uniquement)](#11-console-sql-admin-uniquement)
|
||||
12. [API d'Analyse (Analytics)](#12-api-danalyse-analytics)
|
||||
13. [API Développeur Externe (v1)](#13-api-développeur-externe-v1)
|
||||
14. [Microservice Interne FFmpeg (Python)](#14-microservice-interne-ffmpeg-python)
|
||||
8. [IA & Modèles (OpenRouter)](#8-ia--modèles-openrouter)
|
||||
9. [Statistiques & Paramètres Système](#9-statistiques--paramètres-système)
|
||||
10. [Console SQL (Admin uniquement)](#10-console-sql-admin-uniquement)
|
||||
11. [API d'Analyse (Analytics)](#11-api-danalyse-analytics)
|
||||
12. [API Développeur Externe (v1)](#12-api-développeur-externe-v1)
|
||||
13. [Microservice Interne FFmpeg (Python)](#13-microservice-interne-ffmpeg-python)
|
||||
|
||||
---
|
||||
|
||||
@@ -727,72 +726,7 @@ GET /api/reels/status/:postId
|
||||
|
||||
---
|
||||
|
||||
## 8. Génération Vidéo Avancée (Remotion)
|
||||
|
||||
Remotion permet d'assembler des diaporamas complexes et des vidéos à partir d'images à l'aide d'un navigateur Chromium sans tête (headless).
|
||||
|
||||
### Lancer un rendu de vidéo diaporama Remotion
|
||||
```http
|
||||
POST /api/remotion/render
|
||||
```
|
||||
**Body (Multipart Form) :**
|
||||
- `existingImageUrls` : (Facultatif) Tableau d'URL d'images déjà enregistrées en base (converties directement en base64 pour contourner les limitations de requêtes réseau Chromium sous Docker).
|
||||
- `images` : Fichiers images téléversés en direct.
|
||||
- `music` : Fichier de musique téléversé.
|
||||
- `musicTrackUrl` : (Facultatif) URL de musique existante.
|
||||
- `overlayText` : Texte de sous-titre pour la synthèse vocale intégrée.
|
||||
- `musicVolume` : Volume sonore (ex: `0.3`).
|
||||
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"jobId": "remotion_job_171691",
|
||||
"message": "Rendu démarré en arrière-plan"
|
||||
}
|
||||
```
|
||||
|
||||
### Vérifier le statut du rendu Remotion
|
||||
```http
|
||||
GET /api/remotion/render/status/:jobId
|
||||
```
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"url": "/uploads/temp/remotion-171691.mp4",
|
||||
"thumbnailUrl": "/uploads/temp/remotion-171691-thumb.jpg",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Publier le diaporama généré comme Reel
|
||||
Téléverse le fichier MP4 finalisé vers le stockage puis publie la vidéo sur les pages cibles.
|
||||
```http
|
||||
POST /api/remotion/publish
|
||||
```
|
||||
**Body (JSON) :**
|
||||
```json
|
||||
{
|
||||
"videoUrl": "/uploads/temp/remotion-171691.mp4",
|
||||
"pageIds": ["page_999"],
|
||||
"description": "Mon diaporama animé créé avec Remotion !",
|
||||
"scheduledFor": "2026-06-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"postId": "pst_remotion_999",
|
||||
"results": [
|
||||
{ "pageId": "page_999", "success": true, "reelId": "scheduled" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. IA & Modèles (OpenRouter)
|
||||
## 8. IA & Modèles (OpenRouter)
|
||||
|
||||
### Liste des modèles d'écriture IA disponibles
|
||||
```http
|
||||
@@ -839,7 +773,7 @@ GET /api/ai/generations
|
||||
|
||||
---
|
||||
|
||||
## 10. Statistiques & Paramètres Système
|
||||
## 9. Statistiques & Paramètres Système
|
||||
|
||||
### Récupérer les statistiques du tableau de bord
|
||||
Calcule les publications planifiées, le nombre de pages connectées, le total de textes générés, l'espace de stockage des médias et les tendances d'évolution.
|
||||
@@ -888,7 +822,7 @@ GET /api/stats
|
||||
|
||||
---
|
||||
|
||||
## 11. Console SQL (Admin uniquement)
|
||||
## 10. Console SQL (Admin uniquement)
|
||||
|
||||
Ces outils exclusifs aux administrateurs permettent de déboguer ou de faire des opérations de maintenance directement sur la base de données PostgreSQL.
|
||||
|
||||
@@ -931,7 +865,7 @@ GET /api/sql/tables
|
||||
|
||||
---
|
||||
|
||||
## 12. API d'Analyse (Analytics)
|
||||
## 11. API d'Analyse (Analytics)
|
||||
|
||||
Récupération et mise à jour des statistiques de performance des publications et des pages (portée, clics, croissance d'abonnés).
|
||||
|
||||
@@ -1006,13 +940,47 @@ 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`.
|
||||
|
||||
### Publier un message avec téléchargement d'image
|
||||
Crée une publication en téléchargeant automatiquement l'image depuis une URL publique fournie (ex: depuis votre ERP ou flux e-commerce), et programme sa publication.
|
||||
Les requêtes JSON sous `/api/v1` peuvent peser jusqu'à 15 Mo (contre 100 Ko ailleurs), pour transporter une image en base64.
|
||||
|
||||
### Envoyer une image dans la médiathèque
|
||||
Envoie une image directement, sans hébergeur public intermédiaire. L'`id` renvoyé se passe en `mediaId` à `/publish` ou à `PATCH /posts/:id`.
|
||||
Formats acceptés : JPEG, PNG, WebP, GIF (reconnus par leur contenu, pas par l'extension) — 10 Mo maximum.
|
||||
```http
|
||||
POST /api/v1/media
|
||||
```
|
||||
**Body (multipart/form-data) :** champ `file` contenant l'image.
|
||||
```bash
|
||||
curl -X POST https://socialflow.example/api/v1/media \
|
||||
-H "X-API-Key: $SOCIALFLOW_API_KEY" \
|
||||
-F "file=@galette-chaise.jpg"
|
||||
```
|
||||
**Ou body (JSON) :** l'image en base64, brute ou en data URL.
|
||||
```json
|
||||
{ "imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }
|
||||
```
|
||||
**Réponse (201 Created) :**
|
||||
```json
|
||||
{
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg",
|
||||
"type": "image",
|
||||
"fileName": "external-1791141548234.jpg",
|
||||
"fileSize": 2483120
|
||||
}
|
||||
```
|
||||
**Erreurs :** `400` (aucune image, format non reconnu), `401` (clé absente ou invalide), `413` (image de plus de 10 Mo).
|
||||
|
||||
### Publier un message avec image
|
||||
Crée une publication et programme sa publication. L'image peut être fournie de quatre façons (une seule par requête) :
|
||||
- `mediaId` : image déjà envoyée via `POST /api/v1/media` ;
|
||||
- `imageData` : image en base64 (ou data URL) ;
|
||||
- `imageUrl` : URL publique que Socialflow télécharge lui-même ;
|
||||
- en multipart/form-data, un fichier dans le champ `image` (les autres champs en texte, `pageIds` en tableau JSON ou séparés par des virgules).
|
||||
```http
|
||||
POST /api/v1/publish
|
||||
```
|
||||
@@ -1020,7 +988,7 @@ POST /api/v1/publish
|
||||
```json
|
||||
{
|
||||
"content": "Découvrez cet article exceptionnel !",
|
||||
"imageUrl": "https://mon-site.com/images/produit.jpg",
|
||||
"mediaId": "med_ext_123",
|
||||
"pageIds": ["page_999"],
|
||||
"scheduledAt": "2026-06-02T08:00:00.000Z",
|
||||
"postType": "feed"
|
||||
@@ -1040,8 +1008,8 @@ POST /api/v1/publish
|
||||
{ "pageId": "page_999", "pageName": "Ma Page", "scheduledPostId": "sch_ext_001" }
|
||||
],
|
||||
"media": {
|
||||
"id": "med_ext_999",
|
||||
"url": "/uploads/media/external-17169.jpg"
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1052,7 @@ PATCH /api/v1/posts/:id
|
||||
"imageUrl": "https://mon-site.com/images/produit-rectifie.jpg"
|
||||
}
|
||||
```
|
||||
*Note : Tous les champs sont facultatifs. L'envoi d'une nouvelle URL d'image écrase et remplace le média précédent.*
|
||||
*Note : Tous les champs sont facultatifs. Une nouvelle image (`imageUrl`, `imageData`, `mediaId` ou fichier multipart `image`) remplace le média précédent.*
|
||||
|
||||
**Réponse (200 OK) :**
|
||||
*(Retourne la publication modifiée avec sa liste de planification actualisée).*
|
||||
@@ -1103,7 +1071,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
@@ -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>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Post } from "@shared/schema";
|
||||
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
@@ -35,6 +38,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Un Reel en échec n'a rien à publier : le supprimer retire aussi la notification
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: async (postId: string) => (await apiRequest("DELETE", `/api/reels/${postId}`)).json(),
|
||||
onSuccess: (_data, postId) => {
|
||||
queryClient.setQueryData<OngoingReel[]>(["/api/reels/ongoing"], (posts) =>
|
||||
posts?.filter((p) => p.id !== postId),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/reels/ongoing"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de supprimer la notification"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Nothing to show — render nothing (not even a card)
|
||||
if (ongoingPosts.length === 0) {
|
||||
return null;
|
||||
@@ -112,6 +136,21 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFailed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => dismissMutation.mutate(post.id)}
|
||||
disabled={dismissMutation.isPending && dismissMutation.variables === post.id}
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer ce Reel en échec"
|
||||
data-testid={`button-dismiss-reel-${post.id}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from "@shared/captions";
|
||||
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
@@ -40,9 +38,7 @@ interface CommonProps {
|
||||
srtCues?: SrtCue[] | null;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string };
|
||||
|
||||
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
|
||||
function useVideoDuration(url: string | undefined): number | null {
|
||||
@@ -75,12 +71,12 @@ function estimatedVoiceDuration(text: string): number {
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
const videoDuration = useVideoDuration(props.videoUrl);
|
||||
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
|
||||
props;
|
||||
const logoUrl = props.logoUrl ?? undefined;
|
||||
const storeName = endingEffect ? props.storeName || undefined : undefined;
|
||||
const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null;
|
||||
const srtCues = props.srtCues?.length ? props.srtCues : null;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = srtCues
|
||||
? ttsEnabled ? srtEnd(srtCues) : 0
|
||||
@@ -88,34 +84,6 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
@@ -153,8 +121,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
props.videoUrl,
|
||||
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
|
||||
]);
|
||||
@@ -172,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
key={composition.total}
|
||||
component={composition.component as React.ComponentType<Record<string, unknown>>}
|
||||
inputProps={composition.inputProps as unknown as Record<string, unknown>}
|
||||
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
||||
|
||||
|
||||
function stripForTTS(text: string) {
|
||||
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
|
||||
export default function MobileRemotionVideoPage() {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
|
||||
// Corriger les noms de fichiers pour les fichiers venant de la galerie mobile
|
||||
const processedFiles = filesArray.slice(0, remaining).map(file => {
|
||||
if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type });
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length));
|
||||
}
|
||||
// Reset input pour permettre de resélectionner les mêmes fichiers
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
if (remaining <= 0) {
|
||||
toast({ title: "4 images max.", variant: "destructive" });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
let fileToAdd = file;
|
||||
if (!file.name || file.name === 'blob' || file.name === '') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type });
|
||||
}
|
||||
setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-24">
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
|
||||
</div>
|
||||
|
||||
<main className="p-4 space-y-4">
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">1. Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Input caché pour la caméra */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
|
||||
<input
|
||||
ref={galleryInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => galleryInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<ImagePlus className="h-6 w-6 text-primary" />
|
||||
<span className="text-xs font-medium">Galerie</span>
|
||||
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Photo</span>
|
||||
</Button>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="flex overflow-x-auto gap-2 pb-1">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
|
||||
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte & Voix */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
|
||||
<Button variant="secondary" size="sm" className="w-full"
|
||||
disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
|
||||
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{track.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
|
||||
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
||||
</Button>
|
||||
|
||||
{videoUrl && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
||||
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{facebookPages.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
||||
) : selectedPageIds.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
||||
) : (
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
|
||||
interface AudioTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default function RemotionVideoPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`pre-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => {
|
||||
setGeneratedVariants(data.variants || []);
|
||||
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
|
||||
},
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images maximum.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
// Fallback for older sync backend
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
// Note: removed finally block so isRendering stays true during polling
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<div className={`fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Images</CardTitle>
|
||||
<CardDescription>Sélectionnez 3 à 4 images (bibliothèque ou upload).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center relative hover:bg-muted/50 transition-colors">
|
||||
<input type="file" multiple accept="image/*" onChange={handleFileChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="font-medium text-center">Glissez ou cliquez</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{images.length} uploadée(s)</p>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-44 overflow-y-auto">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<img src={media.originalUrl} className="w-full h-full object-cover" />
|
||||
{isSelected && <div className="absolute top-1 right-1 bg-primary text-white p-0.5 rounded-full"><Check className="w-3 h-3" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte + Voix */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
<CardDescription>La voix lira votre texte. Les hashtags s'affichent mais ne sont pas lus.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea placeholder="Décrivez votre produit pour générer un texte IA..." value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} />
|
||||
<Button variant="secondary" className="w-full" disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-4 h-4 animate-spin" /> : <Sparkles className="mr-2 w-4 h-4" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Cliquez pour appliquer :</p>
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}
|
||||
className={`text-sm p-2 rounded border cursor-pointer hover:bg-accent transition-colors ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={3} />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique de fond</CardTitle>
|
||||
<CardDescription>Choisissez une musique de la bibliothèque ou uploadez la vôtre.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-sm text-muted-foreground">Aucune musique disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-3 flex items-center gap-3 hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{musicFile ? musicFile.name : "Ou uploadez votre propre fichier audio"}
|
||||
</p>
|
||||
{musicFile && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
<Volume2 className="w-4 h-4" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
||||
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[300px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
|
||||
</Button>
|
||||
|
||||
{/* Vidéo générée */}
|
||||
{videoUrl && (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-semibold text-green-600 mb-2">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-lg shadow-lg" />
|
||||
</div>
|
||||
|
||||
{/* Publication */}
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
<CardDescription>Sélectionnez la page et planifiez (optionnel).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedPageIds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
|
||||
) : (
|
||||
<p className="text-sm text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">Date de publication</Label>
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
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 { 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 { 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(),
|
||||
});
|
||||
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)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
totalDuration: number;
|
||||
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
audioUrl?: string;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
|
||||
showWatermark?: boolean;
|
||||
storeName?: string;
|
||||
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
|
||||
endingSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ken Burns presets — deterministic per image index.
|
||||
* Each entry: starting transform → ending transform over the slide duration.
|
||||
* Scale > 1 to avoid black edges when translating.
|
||||
*/
|
||||
const KB_PRESETS = [
|
||||
// zoom in, center
|
||||
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift right
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift left
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
|
||||
// zoom out, center
|
||||
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// pan up + slight zoom
|
||||
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
|
||||
// pan down + zoom in
|
||||
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
|
||||
// diagonal drift bottom-right
|
||||
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
|
||||
// diagonal drift top-left + zoom out
|
||||
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
|
||||
];
|
||||
|
||||
/**
|
||||
* ImageSlide with Ken Burns effect (slow zoom / pan).
|
||||
* The effect index is derived from the image index for deterministic behaviour in Remotion.
|
||||
*/
|
||||
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
|
||||
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
|
||||
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
|
||||
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
|
||||
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
|
||||
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
|
||||
<Img
|
||||
src={src}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
|
||||
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
|
||||
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
|
||||
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
|
||||
|
||||
/** La durée vient des propriétés : chaque Reel a la sienne. */
|
||||
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ReelVideoProps}
|
||||
/>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+9
-5
@@ -1,4 +1,5 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import type { IncomingMessage } from "http";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import session from "express-session";
|
||||
@@ -87,11 +88,14 @@ declare module 'http' {
|
||||
rawBody: unknown
|
||||
}
|
||||
}
|
||||
app.use(express.json({
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
}));
|
||||
const keepRawBody = (req: IncomingMessage, _res: unknown, buf: Buffer) => {
|
||||
req.rawBody = buf;
|
||||
};
|
||||
// L'API externe accepte des images en base64 (imageData) : 10 MB d'image font
|
||||
// ~13,4 MB une fois encodés. Ce parseur passe avant le parseur global (100 KB),
|
||||
// qui ignore ensuite un corps déjà lu.
|
||||
app.use('/api/v1', express.json({ limit: '15mb', verify: keepRawBody }));
|
||||
app.use(express.json({ verify: keepRawBody }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Validation renforcée du SESSION_SECRET
|
||||
|
||||
+27
-4
@@ -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,
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "net";
|
||||
import type { Server } from "http";
|
||||
|
||||
const API_KEY = "cle-de-test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storage: {
|
||||
getAppConfig: vi.fn(),
|
||||
getAllUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
getSocialPage: vi.fn(),
|
||||
getMediaById: vi.fn(),
|
||||
createMedia: vi.fn(),
|
||||
createPost: vi.fn(),
|
||||
createScheduledPost: vi.fn(),
|
||||
},
|
||||
insertValues: vi.fn(),
|
||||
uploadMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage", () => ({ storage: mocks.storage }));
|
||||
vi.mock("../db", () => ({ db: { insert: () => ({ values: mocks.insertValues }) } }));
|
||||
vi.mock("../services/minio", () => ({ minioService: { uploadMedia: mocks.uploadMedia } }));
|
||||
|
||||
const { externalRouter } = await import("./external");
|
||||
|
||||
// Un PNG valide (signature) de la taille voulue
|
||||
function png(size: number): Buffer {
|
||||
const header = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
return Buffer.concat([header, Buffer.alloc(Math.max(0, size - header.length))]);
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Même montage que server/index.ts : parseur large pour /api/v1, puis global
|
||||
const app = express();
|
||||
app.use("/api/v1", express.json({ limit: "15mb" }));
|
||||
app.use(express.json());
|
||||
app.use("/api/v1", externalRouter);
|
||||
server = app.listen(0);
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.storage.getAppConfig.mockResolvedValue({ externalApiKey: API_KEY });
|
||||
mocks.storage.getAllUsers.mockResolvedValue([{ id: "admin-1", role: "admin" }]);
|
||||
mocks.storage.getSocialPage.mockImplementation(async (id: string) =>
|
||||
id === "page-1" ? { id, pageName: "Ma Page" } : undefined
|
||||
);
|
||||
mocks.uploadMedia.mockImplementation(async (_buffer: Buffer, fileName: string) => ({
|
||||
publicId: `media/${fileName}`,
|
||||
originalUrl: `/uploads/media/${fileName}`,
|
||||
facebookFeedUrl: null,
|
||||
instagramFeedUrl: null,
|
||||
instagramStoryUrl: null,
|
||||
}));
|
||||
mocks.storage.createMedia.mockImplementation(async (data: Record<string, unknown>) => ({ id: "media-1", ...data }));
|
||||
mocks.storage.createPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: "post-1", ...data }));
|
||||
mocks.storage.createScheduledPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: `sp-${data.postType}`, ...data }));
|
||||
});
|
||||
|
||||
function form(fields: Record<string, string>, file?: { field: string; buffer: Buffer; name: string; type?: string }) {
|
||||
const body = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) body.append(key, value);
|
||||
if (file) body.append(file.field, new Blob([file.buffer], { type: file.type ?? "image/png" }), file.name);
|
||||
return body;
|
||||
}
|
||||
|
||||
describe("POST /api/v1/media", () => {
|
||||
it("refuse sans clé API", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, { method: "POST", body: form({}, { field: "file", buffer: png(100), name: "a.png" }) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepte un fichier multipart et le range dans la médiathèque de l'admin", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: png(3 * 1024 * 1024), name: "galette.png", type: "application/octet-stream" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ id: "media-1", type: "image", fileSize: 3 * 1024 * 1024 });
|
||||
expect(body.url).toMatch(/^\/uploads\/media\/external-\d+\.png$/);
|
||||
expect(mocks.storage.createMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: "admin-1" }));
|
||||
});
|
||||
|
||||
it("accepte une image base64 bien au-delà de 100 KB", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageData: `data:image/png;base64,${png(2 * 1024 * 1024).toString("base64")}` }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(mocks.uploadMedia.mock.calls[0][0].length).toBe(2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("renvoie 413 au-delà de 10 MB", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: png(10 * 1024 * 1024 + 1), name: "enorme.png" }),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuse un fichier qui n'est pas une image", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: Buffer.from("<html></html>"), name: "page.png" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/Format/);
|
||||
});
|
||||
|
||||
it("refuse une requête sans image", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/publish", () => {
|
||||
it("programme un post avec un mediaId déjà envoyé", async () => {
|
||||
mocks.storage.getMediaById.mockResolvedValue({ id: "media-9", originalUrl: "/uploads/media/x.png", type: "image" });
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", pageIds: ["page-1"], scheduledAt: "2026-10-05T12:15:00+02:00", postType: "both" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.post.media).toEqual({ id: "media-9", url: "/uploads/media/x.png" });
|
||||
expect(body.post.pages).toHaveLength(2);
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith({ postId: "post-1", mediaId: "media-9", displayOrder: 0 });
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepte l'image en multipart avec pageIds en texte", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({ content: "Bonjour", pageIds: "page-1", scheduledAt: "2026-10-05T10:15:00Z" }, { field: "image", buffer: png(500_000), name: "chaise.png" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.post.media.id).toBe("media-1");
|
||||
expect(body.post.pages).toEqual([{ pageId: "page-1", pageName: "Ma Page", scheduledPostId: "sp-feed" }]);
|
||||
});
|
||||
|
||||
it("refuse un mediaId inconnu sans créer de post", async () => {
|
||||
mocks.storage.getMediaById.mockResolvedValue(undefined);
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "absent", pageIds: ["page-1"] }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(mocks.storage.createPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuse plusieurs sources d'image", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", imageUrl: "https://exemple.fr/a.png", pageIds: ["page-1"] }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(mocks.storage.createPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exige une image pour une story", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", pageIds: ["page-1"], postType: "story" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
+215
-73
@@ -1,20 +1,60 @@
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response as ExpressResponse, type NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import path from "path";
|
||||
import multer from "multer";
|
||||
import { storage } from "../storage";
|
||||
import { db } from "../db";
|
||||
import { postMedia } from "@shared/schema";
|
||||
import { postMedia, type Media } from "@shared/schema";
|
||||
import { minioService } from "../services/minio";
|
||||
import { requireApiKey } from "../middleware/apiKey";
|
||||
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, validateImage } from "../services/imageData";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireApiKey);
|
||||
|
||||
/** Erreur imputable à la requête : renvoyée telle quelle avec son code HTTP. */
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier gardé en mémoire : 10 MB max, le contenu est validé par sa signature
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_EXTERNAL_IMAGE_SIZE, files: 1 },
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepte un fichier image en multipart/form-data sous le champ `field`.
|
||||
* Sans effet sur les requêtes JSON. Les erreurs de multer deviennent des
|
||||
* réponses JSON explicites au lieu de remonter au gestionnaire global.
|
||||
*/
|
||||
function acceptImageFile(field: string) {
|
||||
const middleware = imageUpload.single(field);
|
||||
return (req: Request, res: ExpressResponse, next: NextFunction) => {
|
||||
middleware(req, res, (err: unknown) => {
|
||||
if (!err) return next();
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
return res.status(413).json({ error: "Image trop volumineuse (max 10 MB)" });
|
||||
}
|
||||
if (err.code === "LIMIT_UNEXPECTED_FILE") {
|
||||
return res.status(400).json({ error: `Fichier attendu dans le champ « ${field} »` });
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Requête multipart invalide";
|
||||
return res.status(400).json({ error: message });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const publishSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu est requis"),
|
||||
imageUrl: z.string().url("URL d'image invalide").optional(),
|
||||
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
|
||||
imageData: z.string().min(1).optional(),
|
||||
mediaId: z.string().min(1).optional(),
|
||||
pageIds: z.preprocess(normalizePageIds, z.array(z.string()).min(1, "Au moins une page est requise")),
|
||||
scheduledAt: z.string().datetime({ offset: true }).optional(),
|
||||
postType: z.enum(["feed", "story", "both"]).default("feed"),
|
||||
userId: z.string().optional(),
|
||||
@@ -59,6 +99,151 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
|
||||
return { buffer, ext, mimeType: contentType };
|
||||
}
|
||||
|
||||
/** Enregistre une image dans la médiathèque de `ownerId`. */
|
||||
async function storeImage(buffer: Buffer, ownerId: string, prefix: string): Promise<Media> {
|
||||
let detected: { mimeType: string; ext: string };
|
||||
try {
|
||||
detected = validateImage(buffer);
|
||||
} catch (error) {
|
||||
const tooLarge = buffer.length > MAX_EXTERNAL_IMAGE_SIZE;
|
||||
throw new ApiError(tooLarge ? 413 : 400, error instanceof Error ? error.message : "Image invalide");
|
||||
}
|
||||
|
||||
const fileName = `${prefix}-${Date.now()}${detected.ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, detected.mimeType);
|
||||
return storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: "image",
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
interface ImageSources {
|
||||
file?: Express.Multer.File;
|
||||
imageUrl?: string;
|
||||
imageData?: string;
|
||||
mediaId?: string;
|
||||
}
|
||||
|
||||
function countImageSources({ file, imageUrl, imageData, mediaId }: ImageSources): number {
|
||||
return [file, imageUrl, imageData, mediaId].filter((source) => source !== undefined).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'image d'une publication, quelle que soit la façon dont elle a été
|
||||
* transmise : fichier multipart, base64 (`imageData`), média déjà envoyé via
|
||||
* POST /api/v1/media (`mediaId`) ou URL publique à télécharger (`imageUrl`).
|
||||
*/
|
||||
async function resolveImage(sources: ImageSources, ownerId: string, prefix: string): Promise<Media | null> {
|
||||
if (countImageSources(sources) > 1) {
|
||||
throw new ApiError(400, "Une seule source d'image à la fois : fichier, imageData, mediaId ou imageUrl");
|
||||
}
|
||||
for (const key of ["imageUrl", "imageData", "mediaId"] as const) {
|
||||
if (sources[key] !== undefined && typeof sources[key] !== "string") {
|
||||
throw new ApiError(400, `${key} doit être une chaîne`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.mediaId !== undefined) {
|
||||
const media = await storage.getMediaById(sources.mediaId);
|
||||
if (!media) {
|
||||
throw new ApiError(400, `Média introuvable: ${sources.mediaId}`);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
if (sources.file) {
|
||||
return storeImage(sources.file.buffer, ownerId, prefix);
|
||||
}
|
||||
if (sources.imageData !== undefined) {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decodeImageData(sources.imageData);
|
||||
} catch (error) {
|
||||
throw new ApiError(400, error instanceof Error ? error.message : "imageData invalide");
|
||||
}
|
||||
return storeImage(buffer, ownerId, prefix);
|
||||
}
|
||||
if (sources.imageUrl !== undefined) {
|
||||
const { buffer } = await downloadImage(sources.imageUrl);
|
||||
return storeImage(buffer, ownerId, prefix);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Propriétaire des médias et posts créés : `userId` fourni, sinon le premier admin. */
|
||||
async function resolveOwnerId(userId: string | undefined): Promise<string> {
|
||||
if (userId) {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) {
|
||||
throw new ApiError(400, `Utilisateur introuvable: ${userId}`);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
throw new ApiError(500, "Aucun utilisateur admin trouvé");
|
||||
}
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
function sendError(res: ExpressResponse, error: unknown, context: string) {
|
||||
if (error instanceof ApiError) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
console.error(`[external API] ${context} error:`, error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/media
|
||||
*
|
||||
* Envoie une image dans la médiathèque, sans passer par un hébergeur public.
|
||||
* Renvoie un `id` à passer en `mediaId` à /publish ou PATCH /posts/:id.
|
||||
*
|
||||
* Deux formats :
|
||||
* multipart/form-data champ `file` (+ `userId` optionnel)
|
||||
* application/json { "imageData": "<base64 ou data URL>", "userId"?: "..." }
|
||||
*
|
||||
* Formats : JPEG, PNG, WebP, GIF — 10 MB max.
|
||||
*/
|
||||
router.post("/media", acceptImageFile("file"), async (req, res) => {
|
||||
try {
|
||||
const { imageData, userId } = (req.body ?? {}) as { imageData?: unknown; userId?: unknown };
|
||||
if (imageData !== undefined && typeof imageData !== "string") {
|
||||
return res.status(400).json({ error: "imageData doit être une chaîne base64" });
|
||||
}
|
||||
if (!req.file && imageData === undefined) {
|
||||
return res.status(400).json({
|
||||
error: "Aucune image reçue : envoyez un fichier (multipart, champ « file ») ou imageData (base64)",
|
||||
});
|
||||
}
|
||||
if (req.file && imageData !== undefined) {
|
||||
return res.status(400).json({ error: "Envoyez soit un fichier, soit imageData, pas les deux" });
|
||||
}
|
||||
|
||||
const ownerId = await resolveOwnerId(typeof userId === "string" && userId ? userId : undefined);
|
||||
const media = await resolveImage({ file: req.file, imageData }, ownerId, "external");
|
||||
|
||||
return res.status(201).json({
|
||||
id: media!.id,
|
||||
url: media!.originalUrl,
|
||||
type: media!.type,
|
||||
fileName: media!.fileName,
|
||||
fileSize: media!.fileSize,
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error, "POST /media");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/publish
|
||||
*
|
||||
@@ -71,12 +256,18 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
|
||||
* Body:
|
||||
* content string Texte de la publication
|
||||
* imageUrl string? URL publique de l'image à télécharger
|
||||
* imageData string? Image en base64 (ou data URL)
|
||||
* mediaId string? Image déjà envoyée via POST /api/v1/media
|
||||
* pageIds string[] IDs des pages cibles (social_pages.id)
|
||||
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
|
||||
* postType feed|story|both Type de publication (défaut: feed)
|
||||
* userId string? ID utilisateur propriétaire (défaut: premier admin)
|
||||
*
|
||||
* Accepte aussi multipart/form-data : mêmes champs en texte (pageIds en
|
||||
* tableau JSON ou séparés par des virgules) et l'image dans le champ `image`.
|
||||
* Une seule source d'image par requête.
|
||||
*/
|
||||
router.post("/publish", async (req, res) => {
|
||||
router.post("/publish", acceptImageFile("image"), async (req, res) => {
|
||||
try {
|
||||
const parsed = publishSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -86,23 +277,10 @@ router.post("/publish", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
const { content, imageUrl, imageData, mediaId, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
const imageSources: ImageSources = { file: req.file, imageUrl, imageData, mediaId };
|
||||
|
||||
// Résoudre l'utilisateur propriétaire
|
||||
let ownerId = bodyUserId;
|
||||
if (!ownerId) {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
ownerId = admin.id;
|
||||
} else {
|
||||
const user = await storage.getUser(ownerId);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
|
||||
}
|
||||
}
|
||||
const ownerId = await resolveOwnerId(bodyUserId);
|
||||
|
||||
// Vérifier que toutes les pages existent
|
||||
const resolvedPages: Array<{ id: string; pageName: string }> = [];
|
||||
@@ -115,31 +293,11 @@ router.post("/publish", async (req, res) => {
|
||||
}
|
||||
|
||||
// Validation story → image obligatoire
|
||||
if ((postType === "story" || postType === "both") && !imageUrl) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
|
||||
if ((postType === "story" || postType === "both") && countImageSources(imageSources) === 0) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (fichier, imageData, mediaId ou imageUrl)" });
|
||||
}
|
||||
|
||||
// Télécharger l'image si fournie
|
||||
let mediaRecord: { id: string; originalUrl: string } | null = null;
|
||||
if (imageUrl) {
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
|
||||
const fileName = `external-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
}
|
||||
const mediaRecord = await resolveImage(imageSources, ownerId, "external");
|
||||
|
||||
// Créer le post
|
||||
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
|
||||
@@ -209,9 +367,7 @@ router.post("/publish", async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
return sendError(res, error, "POST /publish");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -363,11 +519,15 @@ router.get("/posts", async (req, res) => {
|
||||
* content string? Nouveau texte
|
||||
* scheduledAt ISO8601? Nouvelle date/heure de publication
|
||||
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
|
||||
* imageData string? Nouvelle image en base64 (remplace l'existante)
|
||||
* mediaId string? Image déjà envoyée via POST /api/v1/media
|
||||
*
|
||||
* Accepte aussi multipart/form-data avec l'image dans le champ `image`.
|
||||
*/
|
||||
router.patch("/posts/:id", async (req, res) => {
|
||||
router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, scheduledAt, imageUrl } = req.body;
|
||||
const { content, scheduledAt, imageUrl, imageData, mediaId } = req.body ?? {};
|
||||
|
||||
// Vérifier que le post existe et est modifiable
|
||||
const post = await storage.getPost(id);
|
||||
@@ -378,6 +538,9 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
|
||||
}
|
||||
|
||||
// Image résolue d'abord : une image refusée ne laisse pas le post à moitié modifié
|
||||
const mediaRecord = await resolveImage({ file: req.file, imageUrl, imageData, mediaId }, post.userId, "external-edit");
|
||||
|
||||
// Mettre à jour le contenu si fourni
|
||||
if (content !== undefined) {
|
||||
await storage.updatePost(id, { content });
|
||||
@@ -397,27 +560,8 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Remplacer le média si une nouvelle imageUrl est fournie
|
||||
if (imageUrl !== undefined) {
|
||||
// Résoudre le userId pour le stockage du média
|
||||
const ownerId = post.userId;
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
const fileName = `external-edit-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
const mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
|
||||
// Remplacer les médias existants par le nouveau
|
||||
// Remplacer les médias existants par la nouvelle image
|
||||
if (mediaRecord) {
|
||||
await storage.updatePostMedia(id, [mediaRecord.id]);
|
||||
}
|
||||
|
||||
@@ -461,9 +605,7 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] PATCH /posts/:id error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
return sendError(res, error, "PATCH /posts/:id");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, sniffImageMime, validateImage } from "./imageData";
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
|
||||
const GIF = Buffer.from("GIF89a\0\0", "latin1");
|
||||
const WEBP = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBPVP8 ")]);
|
||||
|
||||
describe("sniffImageMime", () => {
|
||||
it("reconnaît les formats acceptés par leur signature", () => {
|
||||
expect(sniffImageMime(PNG)).toBe("image/png");
|
||||
expect(sniffImageMime(JPEG)).toBe("image/jpeg");
|
||||
expect(sniffImageMime(GIF)).toBe("image/gif");
|
||||
expect(sniffImageMime(WEBP)).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("refuse le reste", () => {
|
||||
expect(sniffImageMime(Buffer.from("<html></html>"))).toBeNull();
|
||||
expect(sniffImageMime(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeImageData", () => {
|
||||
it("décode le base64 brut et les data URL", () => {
|
||||
const base64 = PNG.toString("base64");
|
||||
expect(decodeImageData(base64).equals(PNG)).toBe(true);
|
||||
expect(decodeImageData(`data:image/png;base64,${base64}`).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolère les retours à la ligne du base64", () => {
|
||||
const base64 = JPEG.toString("base64");
|
||||
expect(decodeImageData(`${base64.slice(0, 4)}\n${base64.slice(4)}`).equals(JPEG)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuse ce qui n'est pas du base64", () => {
|
||||
expect(() => decodeImageData("https://exemple.fr/image.jpg")).toThrow(/base64/);
|
||||
expect(() => decodeImageData("")).toThrow(/base64/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImage", () => {
|
||||
it("renvoie type et extension", () => {
|
||||
expect(validateImage(JPEG)).toEqual({ mimeType: "image/jpeg", ext: ".jpg" });
|
||||
});
|
||||
|
||||
it("refuse une image au-delà de 10 MB", () => {
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(MAX_EXTERNAL_IMAGE_SIZE)]);
|
||||
expect(() => validateImage(big)).toThrow(/trop volumineuse/);
|
||||
});
|
||||
|
||||
it("refuse un format non reconnu", () => {
|
||||
expect(() => validateImage(Buffer.from("pas une image"))).toThrow(/Format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePageIds", () => {
|
||||
it("laisse un tableau intact", () => {
|
||||
expect(normalizePageIds(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("accepte un tableau JSON ou une liste séparée par des virgules", () => {
|
||||
expect(normalizePageIds('["a","b"]')).toEqual(["a", "b"]);
|
||||
expect(normalizePageIds("a, b,")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Décodage et identification des images reçues par l'API externe : le type
|
||||
// annoncé par le client (multipart, data URL) n'est pas fiable, iOS et bien des
|
||||
// scripts envoient « application/octet-stream », donc on lit les octets.
|
||||
|
||||
export const MAX_EXTERNAL_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
export const IMAGE_EXTENSIONS: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
};
|
||||
|
||||
/** Type MIME d'après la signature du fichier, ou null si ce n'est pas une image acceptée. */
|
||||
export function sniffImageMime(buffer: Buffer): string | null {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
return "image/png";
|
||||
}
|
||||
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString("latin1"))) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode une image transmise en base64, brute ou sous forme de data URL
|
||||
* (`data:image/png;base64,...`).
|
||||
*/
|
||||
export function decodeImageData(data: string): Buffer {
|
||||
const match = /^data:[^;,]*;base64,([\s\S]*)$/.exec(data.trim());
|
||||
const base64 = (match ? match[1] : data).replace(/\s+/g, "");
|
||||
|
||||
if (base64.length === 0 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64)) {
|
||||
throw new Error("imageData doit être une image encodée en base64 (ou une data URL)");
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/** Vérifie taille et format ; renvoie le type MIME et l'extension détectés. */
|
||||
export function validateImage(buffer: Buffer): { mimeType: string; ext: string } {
|
||||
if (buffer.length > MAX_EXTERNAL_IMAGE_SIZE) {
|
||||
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
|
||||
}
|
||||
const mimeType = sniffImageMime(buffer);
|
||||
if (!mimeType) {
|
||||
throw new Error("Format d'image non reconnu. Formats acceptés : JPEG, PNG, WebP, GIF");
|
||||
}
|
||||
return { mimeType, ext: IMAGE_EXTENSIONS[mimeType] };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pageIds` arrive en tableau en JSON, mais en texte en multipart :
|
||||
* on accepte un tableau JSON (`["a","b"]`) ou une liste séparée par des virgules.
|
||||
*/
|
||||
export function normalizePageIds(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return trimmed.split(",").map((id) => id.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Rendu d'un Reel à partir d'images avec Remotion.
|
||||
* Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
|
||||
* déclenchée par l'utilisateur après l'aperçu.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { ffmpegService } from "../ffmpeg";
|
||||
import { generateVideoThumbnail } from "../thumbnail";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
|
||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
export { REMOTION_TEMP_DIR };
|
||||
|
||||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||||
const params = imagesReelParamsSchema.parse(job.params);
|
||||
const jobTempFiles: string[] = [];
|
||||
|
||||
try {
|
||||
await progress(5, "prepare");
|
||||
const images = await Promise.all(params.imageUrls.map(toDataUrl));
|
||||
const logoPath = await resolveLogoPath();
|
||||
const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
|
||||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||||
const { overlayText, storeName } = params;
|
||||
|
||||
// --- Voix et minutage réel des mots ---
|
||||
let audioUrl: string | undefined;
|
||||
let words: { text: string; start: number; end: number }[] = [];
|
||||
let audioDuration = 0;
|
||||
|
||||
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
|
||||
if (params.ttsEnabled && spokenText) {
|
||||
await progress(15, "voice");
|
||||
const voice = await ffmpegService.previewVoice(spokenText, {
|
||||
voice: params.ttsVoice,
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||
qwen: await resolveQwenTarget(params.ttsEngine),
|
||||
});
|
||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||
|
||||
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
|
||||
await fs.promises.writeFile(audioPath, voice.audio);
|
||||
jobTempFiles.push(audioPath);
|
||||
audioUrl = tempFileUrl(audioPath);
|
||||
audioDuration = voice.duration;
|
||||
words = voice.words;
|
||||
}
|
||||
|
||||
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: images.length,
|
||||
voiceDuration: audioDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
|
||||
await progress(25, "render");
|
||||
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
|
||||
let lastReported = 25;
|
||||
await renderReelComposition({
|
||||
compositionId: "ImageVideo",
|
||||
outputLocation,
|
||||
inputProps: {
|
||||
images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle: params.captionStyle,
|
||||
audioUrl,
|
||||
musicUrl,
|
||||
musicVolume: params.musicVolume,
|
||||
logoUrl,
|
||||
showWatermark: params.showLogo,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
},
|
||||
onProgress: (ratio) => {
|
||||
const pct = 25 + Math.floor(ratio * 70);
|
||||
if (pct >= lastReported + 5) {
|
||||
lastReported = pct;
|
||||
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
|
||||
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||
|
||||
return {
|
||||
url: `/uploads/temp/${path.basename(outputLocation)}`,
|
||||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
|
||||
};
|
||||
} finally {
|
||||
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
||||
for (const file of [...params.tempFiles, ...jobTempFiles]) {
|
||||
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
import { registerReelJobHandler, startReelWorker } from "./queue";
|
||||
import { runVideoReelJob } from "./videoPipeline";
|
||||
import { runImagesReelJob } from "./imagesPipeline";
|
||||
|
||||
export function startReelProcessing(): Promise<void> {
|
||||
registerReelJobHandler("video", runVideoReelJob);
|
||||
registerReelJobHandler("images", runImagesReelJob);
|
||||
return startReelWorker();
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function renderConcurrency(): number {
|
||||
}
|
||||
|
||||
export async function renderReelComposition(options: {
|
||||
compositionId: "ReelVideo" | "ImageVideo";
|
||||
compositionId: "ReelVideo";
|
||||
inputProps: Record<string, unknown>;
|
||||
outputLocation: string;
|
||||
onProgress?: (ratio: number) => void;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
groupLines,
|
||||
offsetWords,
|
||||
@@ -37,14 +36,6 @@ describe("fondu final", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeImagesTiming", () => {
|
||||
it("garde un Reel d'images entre 25 et 30 s", () => {
|
||||
expect(computeImagesTiming({ imageCount: 3, hasEnding: true })).toEqual({ total: 25, endingSeconds: 3 });
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 40, hasEnding: true }).total).toBe(30);
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 24, hasEnding: false }).total).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupLines", () => {
|
||||
const words = (texts: string[]) => texts.map((text, i) => ({ text, start: i, end: i + 0.5 }));
|
||||
|
||||
|
||||
@@ -129,19 +129,3 @@ export function spreadWords(text: string, start: number, end: number): TimedWord
|
||||
export function offsetWords(words: TimedWord[], offset: number): TimedWord[] {
|
||||
return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset }));
|
||||
}
|
||||
|
||||
const IMAGES_ENDING_SECONDS = 3;
|
||||
const IMAGES_MIN_CONTENT = 22;
|
||||
const IMAGES_MAX_CONTENT = 27;
|
||||
const SECONDS_PER_IMAGE = 3;
|
||||
|
||||
/** Reel d'images : 25 à 30 s, diapositive de fin comprise. */
|
||||
export function computeImagesTiming(input: { imageCount: number; voiceDuration?: number; hasEnding: boolean }): {
|
||||
total: number;
|
||||
endingSeconds: number;
|
||||
} {
|
||||
const natural = Math.max((input.voiceDuration ?? 0) + 1, input.imageCount * SECONDS_PER_IMAGE);
|
||||
const content = Math.min(Math.max(natural, IMAGES_MIN_CONTENT), IMAGES_MAX_CONTENT);
|
||||
const endingSeconds = input.hasEnding ? IMAGES_ENDING_SECONDS : 0;
|
||||
return { total: content + endingSeconds, endingSeconds };
|
||||
}
|
||||
+1
-7
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||
import { ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||
|
||||
describe("videoReelParamsSchema", () => {
|
||||
it("exige une vidéo et une page, avec des messages lisibles", () => {
|
||||
@@ -22,12 +22,6 @@ describe("videoReelParamsSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("imagesReelParamsSchema", () => {
|
||||
it("limite à 4 images", () => {
|
||||
expect(imagesReelParamsSchema.safeParse({ imageUrls: ["a", "b", "c", "d", "e"] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ttsPreviewSchema", () => {
|
||||
it("refuse un texte vide", () => {
|
||||
expect(ttsPreviewSchema.safeParse({ text: " " }).success).toBe(false);
|
||||
|
||||
+1
-27
@@ -48,24 +48,6 @@ export const videoReelParamsSchema = z.object({
|
||||
|
||||
export type VideoReelParams = z.infer<typeof videoReelParamsSchema>;
|
||||
|
||||
/** Reel construit à partir d'images (rendu Remotion). */
|
||||
export const imagesReelParamsSchema = z.object({
|
||||
// Chemins relatifs /uploads/... ou URL absolues
|
||||
imageUrls: z.array(z.string().min(1)).min(1, "Aucune image fournie").max(4),
|
||||
overlayText: optionalText,
|
||||
musicUrl: optionalText,
|
||||
musicVolume: z.number().min(0).max(2).default(0.3),
|
||||
ttsEnabled: z.boolean().default(true),
|
||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||
ttsVoice: optionalText,
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||
showLogo: z.boolean().default(true),
|
||||
storeName: z.string().optional(),
|
||||
// Fichiers temporaires à supprimer une fois le rendu terminé
|
||||
tempFiles: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Aperçu de la voix. */
|
||||
export const ttsPreviewSchema = z.object({
|
||||
text: z.string({ required_error: "Texte requis" }).trim().min(1, "Texte requis").max(2000),
|
||||
@@ -74,14 +56,6 @@ export const ttsPreviewSchema = z.object({
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
});
|
||||
|
||||
export type ImagesReelParams = z.infer<typeof imagesReelParamsSchema>;
|
||||
|
||||
export type ReelJobKind = "video" | "images";
|
||||
export type ReelJobKind = "video";
|
||||
|
||||
export type ReelJobStatus = "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** Résultat d'un rendu d'images, consulté par le client pour l'aperçu. */
|
||||
export interface ImagesReelResult {
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
Reference in new issue
Block a user