feat: Introduce Remotion video creation and publishing page with a media thumbnail component and backend routes.

This commit is contained in:
Michael committed 2026-03-27 09:45:38 +01:00
1 parent e82d73c4e1
commit 0eca57efab
3 files changed
+196 -22

No files matched your search

+122 -12
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { ImageIcon, VideoIcon } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { ImageIcon, VideoIcon, Play } from 'lucide-react';
interface MediaThumbnailProps {
src: string;
alt: string;
type?: 'image' | 'video';
className?: string;
thumbnailUrl?: string; // Optional pre-generated thumbnail
}
function isVideoUrl(url: string): boolean {
@@ -14,15 +15,85 @@ function isVideoUrl(url: string): boolean {
/**
* 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
* - 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 [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);
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 (
<div className={`flex items-center justify-center bg-muted ${className.replace('object-cover', '')}`}>
{isVideo
@@ -33,18 +104,57 @@ export function MediaThumbnail({ src, alt, type, className = "w-full h-full obje
);
}
// Video with generated thumbnail
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 (
<video
src={src}
className={className}
preload="metadata"
muted
onError={() => setError(true)}
/>
<div className="relative">
<video
ref={videoRef}
src={src}
className={className}
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 (
<img
src={src}
+39 -9
View File
@@ -6,7 +6,7 @@ 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 } 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 { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import Sidebar from "@/components/sidebar";
@@ -51,6 +51,7 @@ export default function MobileRemotionVideoPage() {
const [isPublishing, setIsPublishing] = useState(false);
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'] });
@@ -67,7 +68,23 @@ export default function MobileRemotionVideoPage() {
});
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>) => {
@@ -184,6 +201,7 @@ export default function MobileRemotionVideoPage() {
<CardTitle className="text-base">1. Images</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Input caché pour la caméra */}
<input
ref={cameraInputRef}
type="file"
@@ -192,14 +210,26 @@ export default function MobileRemotionVideoPage() {
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">
<div className="flex-1 border-2 border-dashed border-border rounded-lg p-4 flex flex-col items-center relative bg-muted/30">
<input type="file" multiple accept="image/*" onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
<UploadCloud className="h-6 w-6 text-muted-foreground mb-1" />
<p className="text-xs font-medium">Galerie</p>
<p className="text-xs text-muted-foreground">{images.length} choisie(s)</p>
</div>
<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"