diff --git a/client/src/App.tsx b/client/src/App.tsx index b6a4c51..2d375b4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -83,7 +83,7 @@ const NewReel = createResponsiveRoute( const RemotionVideo = createResponsiveRoute( () => import("@/pages/remotion-video"), - () => import("@/pages/remotion-video") + () => import("@/pages/mobile/remotion-video") ); function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) { diff --git a/client/src/pages/mobile/remotion-video.tsx b/client/src/pages/mobile/remotion-video.tsx new file mode 100644 index 0000000..861b734 --- /dev/null +++ b/client/src/pages/mobile/remotion-video.tsx @@ -0,0 +1,231 @@ +import { useState, useMemo } from "react"; +import { Player } from "@remotion/player"; +import { ImageComposition } from "@/remotion/ImageComposition"; +import { useToast } from "@/hooks/use-toast"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { UploadCloud, Video, Loader2, Menu, Check } from "lucide-react"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import Sidebar from "@/components/sidebar"; +import { useQuery } from "@tanstack/react-query"; +import type { Media } from "@shared/schema"; + +export default function MobileRemotionVideoPage() { + const [images, setImages] = useState([]); + const [selectedLibraryImages, setSelectedLibraryImages] = useState([]); + const [isRendering, setIsRendering] = useState(false); + const [videoUrl, setVideoUrl] = useState(null); + const { toast } = useToast(); + + const { data: allMedia = [] } = useQuery({ + queryKey: ['/api/media'], + }); + const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + const remainingSlots = 4 - selectedLibraryImages.length; + if (files.length > remainingSlots) { + toast({ title: "Limite atteinte", description: `Vous ne pouvez ajouter que ${remainingSlots} image(s) supplémentaire(s).`, variant: "destructive" }); + } + setImages(files.slice(0, remainingSlots)); + } + }; + + const toggleLibraryImage = (media: Media) => { + setSelectedLibraryImages(prev => { + const isSelected = prev.find(m => m.id === media.id); + if (isSelected) { + return prev.filter(m => m.id !== media.id); + } + if (images.length + prev.length >= 4) { + toast({ title: "Limite", description: "Vous pouvez sélectionner 4 images maximum au total.", variant: "destructive" }); + return prev; + } + return [...prev, media]; + }); + }; + + const combinedUrls = useMemo(() => { + const urls: string[] = []; + images.forEach(f => urls.push(URL.createObjectURL(f))); + selectedLibraryImages.forEach(m => urls.push(m.originalUrl)); + return urls; + }, [images, selectedLibraryImages]); + + const totalSelected = images.length + selectedLibraryImages.length; + + const handleGenerate = async () => { + if (totalSelected < 3) { + toast({ + title: "Pas assez d'images", + description: "Veuillez sélectionner au moins 3 images.", + variant: "destructive" + }); + return; + } + + setIsRendering(true); + setVideoUrl(null); + + try { + const formData = new FormData(); + images.forEach(img => formData.append("images", img)); + selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); + + const response = await fetch("/api/remotion/render", { + method: "POST", + body: formData, + }); + + if (!response.ok) { + throw new Error("Erreur de génération"); + } + + const data = await response.json(); + setVideoUrl(data.url); + + toast({ + title: "Vidéo générée !", + description: "Votre vidéo Remotion est prête.", + }); + + } catch (err) { + toast({ + title: "Erreur", + description: "Impossible de créer la vidéo.", + variant: "destructive" + }); + } finally { + setIsRendering(false); + } + }; + + // 3s per image, 30fps + const totalFrames = combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; + + return ( +
+ {/* Header Mobile */} +
+
+ + + + + + + + +

Générateur Remotion

+
+
+ +
+

Créez une vidéo à partir de vos images (version mobile).

+ + + + 1. Sélectionner des Images + Choisissez entre 3 et 4 images. + + +
+ + +

Appuyez pour choisir

+

{images.length} image(s)

+
+ + {imageMediaList.length > 0 && ( +
+

Bibliothèque :

+
+ {imageMediaList.map((media) => { + const isSelected = selectedLibraryImages.some(m => m.id === media.id); + return ( +
toggleLibraryImage(media)} + className={`flex-shrink-0 w-20 h-20 relative rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`} + > + + {isSelected && ( +
+ +
+ )} +
+ ) + })} +
+
+ )} + +
+ Total sélectionné : {totalSelected} / 4 +
+ + {totalSelected > 0 && ( + + )} + + {videoUrl && ( +
+

Vidéo prête :

+
+ )} +
+
+ + + + 2. Aperçu + + + {combinedUrls.length > 0 ? ( + + ) : ( +
+ Sélectionnez des images pour prévisualiser. +
+ )} +
+
+
+
+ ); +} diff --git a/client/src/pages/remotion-video.tsx b/client/src/pages/remotion-video.tsx index 223f870..17dc4be 100644 --- a/client/src/pages/remotion-video.tsx +++ b/client/src/pages/remotion-video.tsx @@ -1,30 +1,61 @@ -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Player } from "@remotion/player"; import { ImageComposition } from "@/remotion/ImageComposition"; import { useToast } from "@/hooks/use-toast"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { UploadCloud, Video, Loader2 } from "lucide-react"; +import { UploadCloud, Video, Loader2, Check } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import type { Media } from "@shared/schema"; export default function RemotionVideoPage() { const [images, setImages] = useState([]); - const [imageUrls, setImageUrls] = useState([]); + const [selectedLibraryImages, setSelectedLibraryImages] = useState([]); const [isRendering, setIsRendering] = useState(false); const [videoUrl, setVideoUrl] = useState(null); const { toast } = useToast(); + const { data: allMedia = [] } = useQuery({ + queryKey: ['/api/media'], + }); + const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20); + const handleFileChange = (e: React.ChangeEvent) => { if (e.target.files) { - const files = Array.from(e.target.files).slice(0, 4); - setImages(files); - - const ObjectUrls = files.map(file => URL.createObjectURL(file)); - setImageUrls(ObjectUrls); + const files = Array.from(e.target.files); + const remainingSlots = 4 - selectedLibraryImages.length; + if (files.length > remainingSlots) { + toast({ title: "Limite atteinte", description: `Vous ne pouvez ajouter que ${remainingSlots} image(s) supplémentaire(s).`, variant: "destructive" }); + } + setImages(files.slice(0, remainingSlots)); } }; + const toggleLibraryImage = (media: Media) => { + setSelectedLibraryImages(prev => { + const isSelected = prev.find(m => m.id === media.id); + if (isSelected) { + return prev.filter(m => m.id !== media.id); + } + if (images.length + prev.length >= 4) { + toast({ title: "Limite", description: "Vous pouvez sélectionner 4 images maximum au total.", variant: "destructive" }); + return prev; + } + return [...prev, media]; + }); + }; + + const combinedUrls = useMemo(() => { + const urls: string[] = []; + images.forEach(f => urls.push(URL.createObjectURL(f))); + selectedLibraryImages.forEach(m => urls.push(m.originalUrl)); + return urls; + }, [images, selectedLibraryImages]); + + const totalSelected = images.length + selectedLibraryImages.length; + const handleGenerate = async () => { - if (images.length < 3) { + if (totalSelected < 3) { toast({ title: "Pas assez d'images", description: "Veuillez sélectionner au moins 3 images.", @@ -39,6 +70,7 @@ export default function RemotionVideoPage() { try { const formData = new FormData(); images.forEach(img => formData.append("images", img)); + selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl)); const response = await fetch("/api/remotion/render", { method: "POST", @@ -69,7 +101,7 @@ export default function RemotionVideoPage() { }; // 3s per image, 30fps - const totalFrames = imageUrls.length > 0 ? imageUrls.length * 3 * 30 : 300; + const totalFrames = combinedUrls.length > 0 ? combinedUrls.length * 3 * 30 : 300; return (
@@ -93,11 +125,40 @@ export default function RemotionVideoPage() { />

Glissez vos images ici ou cliquez

-

{images.length} image(s) sélectionnée(s)

+

{images.length} image(s) uploadée(s)

- {images.length > 0 && ( -