mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
feat: Introduce Remotion video creation and publishing page with a media thumbnail component and backend routes.
This commit is contained in:
1 parent
e82d73c4e1
commit
0eca57efab
3 files changed
+196
-22
No files matched your search
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ImageIcon, VideoIcon } from 'lucide-react';
|
import { ImageIcon, VideoIcon, Play } from 'lucide-react';
|
||||||
|
|
||||||
interface MediaThumbnailProps {
|
interface MediaThumbnailProps {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
type?: 'image' | 'video';
|
type?: 'image' | 'video';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
thumbnailUrl?: string; // Optional pre-generated thumbnail
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVideoUrl(url: string): boolean {
|
function isVideoUrl(url: string): boolean {
|
||||||
@@ -14,15 +15,85 @@ function isVideoUrl(url: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a media thumbnail that handles:
|
* Renders a media thumbnail that handles:
|
||||||
* - Local video files: uses <video preload="metadata"> to show first frame
|
* - Local video files: captures first frame as thumbnail
|
||||||
* - Images: standard <img> with lazy loading
|
* - Images: standard <img> with lazy loading
|
||||||
* - Broken/unavailable URLs: shows a placeholder icon
|
* - Broken/unavailable URLs: shows a placeholder icon
|
||||||
*/
|
*/
|
||||||
export function MediaThumbnail({ src, alt, type, className = "w-full h-full object-cover" }: MediaThumbnailProps) {
|
export function MediaThumbnail({ src, alt, type, className = "w-full h-full object-cover", thumbnailUrl }: MediaThumbnailProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [thumbnail, setThumbnail] = useState<string | null>(thumbnailUrl || null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const isVideo = type === 'video' || isVideoUrl(src);
|
const isVideo = type === 'video' || isVideoUrl(src);
|
||||||
|
|
||||||
if (error || !src) {
|
// Generate thumbnail from video first frame
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVideo || thumbnail || error || !src) return;
|
||||||
|
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.crossOrigin = 'anonymous';
|
||||||
|
video.muted = true;
|
||||||
|
video.preload = 'metadata';
|
||||||
|
|
||||||
|
const handleLoadedData = () => {
|
||||||
|
// Seek to 1 second or 10% of duration (whichever is smaller)
|
||||||
|
const seekTime = Math.min(1, video.duration * 0.1);
|
||||||
|
video.currentTime = seekTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeeked = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth || 320;
|
||||||
|
canvas.height = video.videoHeight || 180;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
|
||||||
|
setThumbnail(dataUrl);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to generate video thumbnail:', e);
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
video.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = () => {
|
||||||
|
console.warn('Video thumbnail generation failed for:', src);
|
||||||
|
setError(true);
|
||||||
|
video.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('loadeddata', handleLoadedData);
|
||||||
|
video.addEventListener('seeked', handleSeeked);
|
||||||
|
video.addEventListener('error', handleError);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
video.src = src;
|
||||||
|
video.load();
|
||||||
|
|
||||||
|
// Timeout fallback
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!thumbnail) {
|
||||||
|
setError(true);
|
||||||
|
video.remove();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
video.removeEventListener('loadeddata', handleLoadedData);
|
||||||
|
video.removeEventListener('seeked', handleSeeked);
|
||||||
|
video.removeEventListener('error', handleError);
|
||||||
|
video.remove();
|
||||||
|
};
|
||||||
|
}, [src, isVideo, thumbnail, error]);
|
||||||
|
|
||||||
|
// Show placeholder if error or no src
|
||||||
|
if ((error && !thumbnail) || !src) {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center justify-center bg-muted ${className.replace('object-cover', '')}`}>
|
<div className={`flex items-center justify-center bg-muted ${className.replace('object-cover', '')}`}>
|
||||||
{isVideo
|
{isVideo
|
||||||
@@ -33,18 +104,57 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Video with generated thumbnail
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
if (thumbnail) {
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className.replace('object-cover', '')}`}>
|
||||||
|
<img
|
||||||
|
src={thumbnail}
|
||||||
|
alt={alt}
|
||||||
|
className={className}
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<Play className="w-5 h-5 text-white fill-white ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state while generating thumbnail
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center bg-muted animate-pulse ${className.replace('object-cover', '')}`}>
|
||||||
|
<VideoIcon className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to video element
|
||||||
return (
|
return (
|
||||||
<video
|
<div className="relative">
|
||||||
src={src}
|
<video
|
||||||
className={className}
|
ref={videoRef}
|
||||||
preload="metadata"
|
src={src}
|
||||||
muted
|
className={className}
|
||||||
onError={() => setError(true)}
|
preload="metadata"
|
||||||
/>
|
muted
|
||||||
|
playsInline
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<Play className="w-5 h-5 text-white fill-white ml-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera } from "lucide-react";
|
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 { MediaThumbnail } from "@/components/media-thumbnail";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
@@ -51,6 +51,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
const [isPublishing, setIsPublishing] = useState(false);
|
const [isPublishing, setIsPublishing] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||||
@@ -67,7 +68,23 @@ export default function MobileRemotionVideoPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
|
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 handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -184,6 +201,7 @@ export default function MobileRemotionVideoPage() {
|
|||||||
<CardTitle className="text-base">1. Images</CardTitle>
|
<CardTitle className="text-base">1. Images</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
|
{/* Input caché pour la caméra */}
|
||||||
<input
|
<input
|
||||||
ref={cameraInputRef}
|
ref={cameraInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -192,14 +210,26 @@ export default function MobileRemotionVideoPage() {
|
|||||||
onChange={handleCameraCapture}
|
onChange={handleCameraCapture}
|
||||||
className="hidden"
|
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">
|
<div className="flex gap-2">
|
||||||
<div className="flex-1 border-2 border-dashed border-border rounded-lg p-4 flex flex-col items-center relative bg-muted/30">
|
<Button
|
||||||
<input type="file" multiple accept="image/*" onChange={handleFileChange}
|
variant="outline"
|
||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||||
<UploadCloud className="h-6 w-6 text-muted-foreground mb-1" />
|
onClick={() => galleryInputRef.current?.click()}
|
||||||
<p className="text-xs font-medium">Galerie</p>
|
disabled={totalSelected >= 4}
|
||||||
<p className="text-xs text-muted-foreground">{images.length} choisie(s)</p>
|
>
|
||||||
</div>
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Router } from "express";
|
|||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
import { bundle } from "@remotion/bundler";
|
import { bundle } from "@remotion/bundler";
|
||||||
import { renderMedia, selectComposition } from "@remotion/renderer";
|
import { renderMedia, selectComposition } from "@remotion/renderer";
|
||||||
import { ffmpegService } from "../services/ffmpeg";
|
import { ffmpegService } from "../services/ffmpeg";
|
||||||
@@ -10,6 +12,25 @@ import { minioService as cloudinaryService, buildMinioUrl } from "../services/mi
|
|||||||
import { facebookService } from "../services/facebook";
|
import { facebookService } from "../services/facebook";
|
||||||
import * as musicMetadata from "music-metadata";
|
import * as musicMetadata from "music-metadata";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a thumbnail from a video file using FFmpeg
|
||||||
|
* @param videoPath Path to the video file
|
||||||
|
* @param outputPath Path to save the thumbnail
|
||||||
|
* @param seekTime Time in seconds to extract the frame (default: 1s)
|
||||||
|
*/
|
||||||
|
async function generateVideoThumbnail(videoPath: string, outputPath: string, seekTime: number = 1): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const cmd = `ffmpeg -y -ss ${seekTime} -i "${videoPath}" -vframes 1 -q:v 2 "${outputPath}"`;
|
||||||
|
await execAsync(cmd);
|
||||||
|
return fs.existsSync(outputPath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Failed to generate video thumbnail:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const remotionRouter = Router();
|
export const remotionRouter = Router();
|
||||||
|
|
||||||
const uploadDir = path.join(process.cwd(), "uploads", "temp");
|
const uploadDir = path.join(process.cwd(), "uploads", "temp");
|
||||||
@@ -291,8 +312,21 @@ remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ Render completed:", outputFilename);
|
console.log("✅ Render completed:", outputFilename);
|
||||||
|
|
||||||
|
// Generate thumbnail for the video
|
||||||
|
const thumbnailFilename = outputFilename.replace('.mp4', '-thumb.jpg');
|
||||||
|
const thumbnailPath = path.join(uploadDir, thumbnailFilename);
|
||||||
|
const thumbnailGenerated = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||||
|
|
||||||
|
if (thumbnailGenerated) {
|
||||||
|
console.log("🖼️ Thumbnail generated:", thumbnailFilename);
|
||||||
|
}
|
||||||
|
|
||||||
// Return relative URL so any client (mobile, desktop, Docker) can access it
|
// Return relative URL so any client (mobile, desktop, Docker) can access it
|
||||||
res.json({ url: `/uploads/temp/${outputFilename}` });
|
res.json({
|
||||||
|
url: `/uploads/temp/${outputFilename}`,
|
||||||
|
thumbnailUrl: thumbnailGenerated ? `/uploads/temp/${thumbnailFilename}` : null,
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("❌ Remotion render error:", err);
|
console.error("❌ Remotion render error:", err);
|
||||||
|
|||||||
Reference in new issue
Block a user